feat: Vision Support + New UI (#1203)

* feat: add timer duration to showToast, show toast for preset selection

* refactor: replace old /chat/ route with /c/. e2e tests will fail here

* refactor: move typedefs to root of /api/ and add a few to assistant types in TS

* refactor: reorganize data-provider imports, fix dependency cycle, strategize new plan to separate react dependent packages

* feat: add dataService for uploading images

* feat(data-provider): add mutation keys

* feat: file resizing and upload

* WIP: initial API image handling

* fix: catch JSON.parse of localStorage tools

* chore: experimental: use module-alias for absolute imports

* refactor: change temp_file_id strategy

* fix: updating files state by using Map and defining react query callbacks in a way that keeps them during component unmount, initial delete handling

* feat: properly handle file deletion

* refactor: unexpose complete filepath and resize from server for higher fidelity

* fix: make sure resized height, width is saved, catch bad requests

* refactor: use absolute imports

* fix: prevent setOptions from being called more than once for OpenAIClient, made note to fix for PluginsClient

* refactor: import supportsFiles and models vars from schemas

* fix: correctly replace temp file id

* refactor(BaseClient): use absolute imports, pass message 'opts' to buildMessages method, count tokens for nested objects/arrays

* feat: add validateVisionModel to determine if model has vision capabilities

* chore(checkBalance): update jsdoc

* feat: formatVisionMessage: change message content format dependent on role and image_urls passed

* refactor: add usage to File schema, make create and updateFile, correctly set and remove TTL

* feat: working vision support
TODO: file size, type, amount validations, making sure they are styled right, and making sure you can add images from the clipboard/dragging

* feat: clipboard support for uploading images

* feat: handle files on drop to screen, refactor top level view code to Presentation component so the useDragHelpers hook  has ChatContext

* fix(Images): replace uploaded images in place

* feat: add filepath validation to protect sensitive files

* fix: ensure correct file_ids are push and not the Map key values

* fix(ToastContext): type issue

* feat: add basic file validation

* fix(useDragHelpers): correct context issue with `files` dependency

* refactor: consolidate setErrors logic to setError

* feat: add dialog Image overlay on image click

* fix: close endpoints menu on click

* chore: set detail to auto, make note for configuration

* fix: react warning (button desc. of button)

* refactor: optimize filepath handling, pass file_ids to images for easier re-use

* refactor: optimize image file handling, allow re-using files in regen, pass more file metadata in messages

* feat: lazy loading images including use of upload preview

* fix: SetKeyDialog closing, stopPropagation on Dialog content click

* style(EndpointMenuItem): tighten up the style, fix dark theme showing in lightmode, make menu more ux friendly

* style: change maxheight of all settings textareas to 138px from 300px

* style: better styling for textarea and enclosing buttons

* refactor(PresetItems): swap back edit and delete icons

* feat: make textarea placeholder dynamic to endpoint

* style: show user hover buttons only on hover when message is streaming

* fix: ordered list not going past 9, fix css

* feat: add User/AI labels; style: hide loading spinner

* feat: add back custom footer, change original footer text

* feat: dynamic landing icons based on endpoint

* chore: comment out assistants route

* fix: autoScroll to newest on /c/ view

* fix: Export Conversation on new UI

* style: match message style of official more closely

* ci: fix api jest unit tests, comment out e2e tests for now as they will fail until addressed

* feat: more file validation and use blob in preview field, not filepath, to fix temp deletion

* feat: filefilter for multer

* feat: better AI labels based on custom name, model, and endpoint instead of  `ChatGPT`
This commit is contained in:
Danny Avila 2023-11-21 20:12:48 -05:00 committed by GitHub
parent 345f4b2e85
commit 317cdd3f77
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
113 changed files with 2680 additions and 675 deletions

View file

@ -65,7 +65,7 @@ const AuthContextProvider = ({
loginUser.mutate(data, {
onSuccess: (data: TLoginResponse) => {
const { user, token } = data;
setUserContext({ token, isAuthenticated: true, user, redirect: '/chat/new' });
setUserContext({ token, isAuthenticated: true, user, redirect: '/c/new' });
},
onError: (error: TResError | unknown) => {
const resError = error as TResError;

View file

@ -1,5 +1,6 @@
import { createContext, useRef, useContext, RefObject } from 'react';
import { toCanvas } from 'html-to-image';
import { ThemeContext } from '~/hooks/ThemeContext';
type ScreenshotContextType = {
ref?: RefObject<HTMLDivElement>;
@ -9,14 +10,21 @@ const ScreenshotContext = createContext<ScreenshotContextType>({});
export const useScreenshot = () => {
const { ref } = useContext(ScreenshotContext);
const { theme } = useContext(ThemeContext);
const takeScreenShot = async (node: HTMLElement) => {
if (!node) {
throw new Error('You should provide correct html node.');
}
let isDark = theme === 'dark';
if (theme === 'system') {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
const backgroundColor = isDark ? '#343541' : 'white';
const canvas = await toCanvas(node);
const croppedCanvas = document.createElement('canvas');
const croppedCanvasContext = croppedCanvas.getContext('2d');
const croppedCanvasContext = croppedCanvas.getContext('2d') as CanvasRenderingContext2D;
// init data
const cropPositionTop = 0;
const cropPositionLeft = 0;
@ -26,6 +34,9 @@ export const useScreenshot = () => {
croppedCanvas.width = cropWidth;
croppedCanvas.height = cropHeight;
croppedCanvasContext.fillStyle = backgroundColor;
croppedCanvasContext?.fillRect(0, 0, cropWidth, cropHeight);
croppedCanvasContext?.drawImage(canvas, cropPositionLeft, cropPositionTop);
const base64Image = croppedCanvas.toDataURL('image/png', 1);

View file

@ -22,16 +22,12 @@ import useUserKey from './useUserKey';
import store from '~/store';
// this to be set somewhere else
export default function useChatHelpers(index = 0, paramId) {
export default function useChatHelpers(index = 0, paramId: string | undefined) {
const [files, setFiles] = useState(new Map<string, ExtendedFile>());
const [filesLoading, setFilesLoading] = useState(false);
const queryClient = useQueryClient();
const { isAuthenticated } = useAuthContext();
// const tempConvo = {
// endpoint: null,
// conversationId: null,
// jailbreak: false,
// examples: [],
// tools: [],
// };
const { newConversation } = useNewConvo(index);
const { useCreateConversationAtom } = store;
@ -40,10 +36,6 @@ export default function useChatHelpers(index = 0, paramId) {
const queryParam = paramId === 'new' ? paramId : conversationId ?? paramId ?? '';
// if (!queryParam && paramId && paramId !== 'new') {
// }
/* Messages: here simply to fetch, don't export and use `getMessages()` instead */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { data: _messages } = useGetMessagesByConvoId(conversationId ?? '', {
@ -97,10 +89,6 @@ export default function useChatHelpers(index = 0, paramId) {
[queryClient],
);
// const getConvos = useCallback(() => {
// return queryClient.getQueryData<TGetConversationsResponse>([QueryKeys.allConversations, { pageNumber: '1', active: true }]);
// }, [queryClient]);
const invalidateConvos = useCallback(() => {
queryClient.invalidateQueries([QueryKeys.allConversations, { active: true }]);
}, [queryClient]);
@ -195,6 +183,24 @@ export default function useChatHelpers(index = 0, paramId) {
error: false,
};
const parentMessage = currentMessages?.find(
(msg) => msg.messageId === latestMessage?.parentMessageId,
);
const reuseFiles = isRegenerate && parentMessage?.files;
if (reuseFiles && parentMessage.files?.length) {
currentMsg.files = parentMessage.files;
setFiles(new Map());
} else if (files.size > 0) {
currentMsg.files = Array.from(files.values()).map((file) => ({
file_id: file.file_id,
filepath: file.filepath,
type: file.type || '', // Ensure type is not undefined
height: file.height,
width: file.width,
}));
setFiles(new Map());
}
// construct the placeholder response message
const generation = editedText ?? latestMessage?.text ?? '';
const responseText = isEditOrContinue
@ -311,7 +317,6 @@ export default function useChatHelpers(index = 0, paramId) {
);
const [showPopover, setShowPopover] = useRecoilState(store.showPopoverFamily(index));
const [abortScroll, setAbortScroll] = useRecoilState(store.abortScrollFamily(index));
const [autoScroll, setAutoScroll] = useRecoilState(store.autoScrollFamily(index));
const [preset, setPreset] = useRecoilState(store.presetByIndex(index));
const [textareaHeight, setTextareaHeight] = useRecoilState(store.textareaHeightFamily(index));
const [optionSettings, setOptionSettings] = useRecoilState(store.optionSettingsFamily(index));
@ -319,8 +324,6 @@ export default function useChatHelpers(index = 0, paramId) {
store.showAgentSettingsFamily(index),
);
const [files, setFiles] = useState<ExtendedFile[]>([]);
return {
newConversation,
conversation,
@ -347,8 +350,6 @@ export default function useChatHelpers(index = 0, paramId) {
setShowPopover,
abortScroll,
setAbortScroll,
autoScroll,
setAutoScroll,
showBingToneSetting,
setShowBingToneSetting,
preset,
@ -362,5 +363,7 @@ export default function useChatHelpers(index = 0, paramId) {
files,
setFiles,
invalidateConvos,
filesLoading,
setFilesLoading,
};
}

View file

@ -1,79 +1,38 @@
import { useDrop } from 'react-dnd';
import { NativeTypes } from 'react-dnd-html5-backend';
import type { DropTargetMonitor } from 'react-dnd';
import type { ExtendedFile } from '~/common';
import useFileHandling from './useFileHandling';
export default function useDragHelpers(
setFiles: React.Dispatch<React.SetStateAction<ExtendedFile[]>>,
) {
const addFile = (newFile: ExtendedFile) => {
setFiles((currentFiles) => [...currentFiles, newFile]);
};
export default function useDragHelpers() {
const { files, handleFiles } = useFileHandling();
const [{ canDrop, isOver }, drop] = useDrop(
() => ({
accept: [NativeTypes.FILE],
drop(item: { files: File[] }) {
console.log('drop', item.files);
handleFiles(item.files);
},
canDrop() {
// console.log('canDrop', item.files, item.items);
return true;
},
// hover() {
// // console.log('hover', item.files, item.items);
// },
collect: (monitor: DropTargetMonitor) => {
// const item = monitor.getItem() as File[];
// if (item) {
// console.log('collect', item.files, item.items);
// }
const replaceFile = (newFile: ExtendedFile) => {
setFiles((currentFiles) =>
currentFiles.map((f) => (f.preview === newFile.preview ? newFile : f)),
);
};
const handleFiles = (files: FileList | File[]) => {
Array.from(files).forEach((originalFile) => {
if (!originalFile.type.startsWith('image/')) {
// TODO: showToast('Only image files are supported');
// TODO: handle other file types
return;
}
const preview = URL.createObjectURL(originalFile);
const extendedFile: ExtendedFile = {
file: originalFile,
preview,
progress: 0,
};
addFile(extendedFile);
// async processing
if (originalFile.type.startsWith('image/')) {
const img = new Image();
img.onload = () => {
extendedFile.width = img.width;
extendedFile.height = img.height;
extendedFile.progress = 1; // Update loading status
replaceFile(extendedFile);
URL.revokeObjectURL(preview); // Clean up the object URL
return {
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
};
img.src = preview;
} else {
// TODO: non-image files
// extendedFile.progress = false;
// replaceFile(extendedFile);
}
});
};
const [{ canDrop, isOver }, drop] = useDrop(() => ({
accept: [NativeTypes.FILE],
drop(item: { files: File[] }) {
console.log('drop', item.files);
handleFiles(item.files);
},
canDrop() {
// console.log('canDrop', item.files, item.items);
return true;
},
// hover() {
// // console.log('hover', item.files, item.items);
// },
collect: (monitor: DropTargetMonitor) => {
// const item = monitor.getItem() as File[];
// if (item) {
// console.log('collect', item.files, item.items);
// }
return {
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
};
},
}));
},
}),
[files],
);
return {
canDrop,

View file

@ -1,56 +1,255 @@
import { v4 } from 'uuid';
import debounce from 'lodash/debounce';
import { useState, useEffect, useCallback } from 'react';
import type { ExtendedFile } from '~/common';
import { useToastContext } from '~/Providers/ToastContext';
import { useChatContext } from '~/Providers/ChatContext';
import { useUploadImageMutation } from '~/data-provider';
import { NotificationSeverity } from '~/common';
const sizeMB = 20;
const maxSize = 25;
const fileLimit = 10;
const sizeLimit = sizeMB * 1024 * 1024; // 20 MB
const totalSizeLimit = maxSize * 1024 * 1024; // 25 MB
const supportedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
const useFileHandling = () => {
const { files, setFiles } = useChatContext();
const { showToast } = useToastContext();
const [errors, setErrors] = useState<string[]>([]);
const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]);
const { files, setFiles, setFilesLoading } = useChatContext();
const displayToast = useCallback(() => {
if (errors.length > 1) {
const errorList = Array.from(new Set(errors))
.map((e, i) => `${i > 0 ? '• ' : ''}${e}\n`)
.join('');
showToast({
message: errorList,
severity: NotificationSeverity.ERROR,
duration: 5000,
});
} else if (errors.length === 1) {
showToast({
message: errors[0],
severity: NotificationSeverity.ERROR,
duration: 5000,
});
}
setErrors([]);
}, [errors, showToast]);
const debouncedDisplayToast = debounce(displayToast, 250);
useEffect(() => {
if (errors.length > 0) {
debouncedDisplayToast();
}
return () => debouncedDisplayToast.cancel();
}, [errors, debouncedDisplayToast]);
const addFile = (newFile: ExtendedFile) => {
setFiles((currentFiles) => [...currentFiles, newFile]);
setFiles((currentFiles) => {
const updatedFiles = new Map(currentFiles);
updatedFiles.set(newFile.file_id, newFile);
return updatedFiles;
});
};
const replaceFile = (newFile: ExtendedFile) => {
setFiles((currentFiles) =>
currentFiles.map((f) => (f.preview === newFile.preview ? newFile : f)),
);
setFiles((currentFiles) => {
const updatedFiles = new Map(currentFiles);
updatedFiles.set(newFile.file_id, newFile);
return updatedFiles;
});
};
const handleFiles = (files: FileList | File[]) => {
Array.from(files).forEach((originalFile) => {
if (!originalFile.type.startsWith('image/')) {
// TODO: showToast('Only image files are supported');
// TODO: handle other file types
return;
const updateFileById = (fileId: string, updates: Partial<ExtendedFile>) => {
setFiles((currentFiles) => {
if (!currentFiles.has(fileId)) {
console.warn(`File with id ${fileId} not found.`);
return currentFiles;
}
const preview = URL.createObjectURL(originalFile);
const extendedFile: ExtendedFile = {
file: originalFile,
preview,
progress: 0,
};
addFile(extendedFile);
// async processing
if (originalFile.type.startsWith('image/')) {
const updatedFiles = new Map(currentFiles);
const currentFile = updatedFiles.get(fileId);
if (!currentFile) {
console.warn(`File with id ${fileId} not found.`);
return currentFiles;
}
updatedFiles.set(fileId, { ...currentFile, ...updates });
return updatedFiles;
});
};
const deleteFileById = (fileId: string) => {
setFiles((currentFiles) => {
const updatedFiles = new Map(currentFiles);
if (updatedFiles.has(fileId)) {
updatedFiles.delete(fileId);
} else {
console.warn(`File with id ${fileId} not found.`);
}
return updatedFiles;
});
};
const uploadImage = useUploadImageMutation({
onSuccess: (data) => {
console.log('upload success', data);
updateFileById(data.temp_file_id, {
progress: 0.9,
filepath: data.filepath,
});
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,
filename: data.filename,
});
}, 300);
},
onError: (error, body) => {
console.log('upload error', error);
deleteFileById(body.file_id);
setError('An error occurred while uploading the file.');
},
});
const uploadFile = async (extendedFile: ExtendedFile) => {
const formData = new FormData();
formData.append('file', extendedFile.file);
formData.append('file_id', extendedFile.file_id);
if (extendedFile.width) {
formData.append('width', extendedFile.width?.toString());
}
if (extendedFile.height) {
formData.append('height', extendedFile.height?.toString());
}
uploadImage.mutate({ formData, file_id: extendedFile.file_id });
};
const validateFiles = (fileList: File[]) => {
const existingFiles = Array.from(files.values());
const incomingTotalSize = fileList.reduce((total, file) => total + file.size, 0);
const currentTotalSize = existingFiles.reduce((total, file) => total + file.size, 0);
if (fileList.length + files.size > fileLimit) {
setError(`You can only upload up to ${fileLimit} files at a time.`);
return false;
}
for (let i = 0; i < fileList.length; i++) {
const originalFile = fileList[i];
if (!supportedTypes.includes(originalFile.type)) {
setError('Currently, only JPEG, JPG, PNG, and WEBP files are supported.');
return false;
}
if (originalFile.size >= sizeLimit) {
setError(`File size exceeds ${sizeMB} MB.`);
return false;
}
}
if (currentTotalSize + incomingTotalSize > totalSizeLimit) {
setError(`The total size of the files cannot exceed ${maxSize} MB.`);
return false;
}
const combinedFilesInfo = [
...existingFiles.map(
(file) => `${file.file.name}-${file.size}-${file.type?.split('/')[0] ?? 'file'}`,
),
...fileList.map((file) => `${file.name}-${file.size}-${file.type?.split('/')[0] ?? 'file'}`),
];
const uniqueFilesSet = new Set(combinedFilesInfo);
if (uniqueFilesSet.size !== combinedFilesInfo.length) {
setError('Duplicate file detected.');
return false;
}
return true;
};
const handleFiles = async (_files: FileList | File[]) => {
const fileList = Array.from(_files);
/* Validate files */
let filesAreValid: boolean;
try {
filesAreValid = validateFiles(fileList);
} catch (error) {
console.error('file validation error', error);
setError('An error occurred while validating the file.');
return;
}
if (!filesAreValid) {
setFilesLoading(false);
return;
}
/* Process files */
fileList.forEach((originalFile) => {
const file_id = v4();
try {
const preview = URL.createObjectURL(originalFile);
let extendedFile: ExtendedFile = {
file_id,
file: originalFile,
preview,
progress: 0.2,
size: originalFile.size,
};
addFile(extendedFile);
// async processing
const img = new Image();
img.onload = () => {
img.onload = async () => {
extendedFile.width = img.width;
extendedFile.height = img.height;
extendedFile.progress = 1; // Update loading status
extendedFile = {
...extendedFile,
progress: 0.6,
};
replaceFile(extendedFile);
URL.revokeObjectURL(preview); // Clean up the object URL
await uploadFile(extendedFile);
// This gets cleaned up in the Image component, after receiving the server image
// URL.revokeObjectURL(preview);
};
img.src = preview;
} else {
// TODO: non-image files
// extendedFile.progress = false;
// replaceFile(extendedFile);
} catch (error) {
deleteFileById(file_id);
console.log('file handling error', error);
setError('An error occurred while processing the file.');
}
});
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
event.stopPropagation();
if (event.target.files) {
setFilesLoading(true);
handleFiles(event.target.files);
// reset the input
event.target.value = '';
}
};

View file

@ -0,0 +1,209 @@
import { v4 } from 'uuid';
// import { useState } from 'react';
import ImageBlobReduce from 'image-blob-reduce';
import type { ExtendedFile } from '~/common';
import { useUploadImageMutation } from '~/data-provider';
import { useChatContext } from '~/Providers/ChatContext';
const reducer = new ImageBlobReduce();
const resolution = 'high';
const useFileHandling = () => {
// const [errors, setErrors] = useState<unknown[]>([]);
const { files, setFiles, setFilesLoading } = useChatContext();
const addFile = (newFile: ExtendedFile) => {
setFiles((currentFiles) => {
const updatedFiles = new Map(currentFiles);
updatedFiles.set(newFile.file_id, newFile);
return updatedFiles;
});
};
const replaceFile = (newFile: ExtendedFile) => {
setFiles((currentFiles) => {
const updatedFiles = new Map(currentFiles);
updatedFiles.set(newFile.file_id, newFile);
return updatedFiles;
});
};
const updateFileById = (fileId: string, updates: Partial<ExtendedFile>) => {
setFiles((currentFiles) => {
if (!currentFiles.has(fileId)) {
console.warn(`File with id ${fileId} not found.`);
return currentFiles;
}
const updatedFiles = new Map(currentFiles);
const currentFile = updatedFiles.get(fileId);
updatedFiles.set(fileId, { ...currentFile, ...updates });
return updatedFiles;
});
};
// const deleteFile = (fileId: string) => {
// setFiles((currentFiles) => {
// const updatedFiles = new Map(currentFiles);
// updatedFiles.delete(fileId);
// return updatedFiles;
// });
// };
const deleteFileById = (fileId: string) => {
setFiles((currentFiles) => {
const updatedFiles = new Map(currentFiles);
if (updatedFiles.has(fileId)) {
updatedFiles.delete(fileId);
} else {
console.warn(`File with id ${fileId} not found.`);
}
return updatedFiles;
});
};
const uploadImage = useUploadImageMutation({
onSuccess: (data) => {
console.log('upload success', data);
updateFileById(data.temp_file_id, {
progress: 0.9,
filepath: data.filepath,
});
setTimeout(() => {
updateFileById(data.temp_file_id, {
progress: 1,
filepath: data.filepath,
});
}, 300);
},
onError: (error, body) => {
console.log('upload error', error);
deleteFileById(body.file_id);
},
});
const uploadFile = async (extendedFile: ExtendedFile) => {
const formData = new FormData();
formData.append('file', extendedFile.file);
formData.append('file_id', extendedFile.file_id);
if (extendedFile.width) {
formData.append('width', extendedFile.width?.toString());
}
if (extendedFile.height) {
formData.append('height', extendedFile.height?.toString());
}
uploadImage.mutate({ formData, file_id: extendedFile.file_id });
};
const handleFiles = async (files: FileList | File[]) => {
Array.from(files).forEach((originalFile) => {
if (!originalFile.type.startsWith('image/')) {
// TODO: showToast('Only image files are supported');
// TODO: handle other file types
return;
}
// todo: Set File is loading
try {
const preview = URL.createObjectURL(originalFile);
let extendedFile: ExtendedFile = {
file_id: v4(),
file: originalFile,
preview,
progress: 0.2,
};
addFile(extendedFile);
// async processing
const img = new Image();
img.onload = async () => {
extendedFile.width = img.width;
extendedFile.height = img.height;
let max = 512;
if (resolution === 'high') {
max = extendedFile.height > extendedFile.width ? 768 : 2000;
}
const reducedBlob = await reducer.toBlob(originalFile, {
max,
});
const resizedFile = new File([reducedBlob], originalFile.name, {
type: originalFile.type,
});
const resizedPreview = URL.createObjectURL(resizedFile);
extendedFile = {
...extendedFile,
file: resizedFile,
};
const resizedImg = new Image();
resizedImg.onload = async () => {
extendedFile = {
...extendedFile,
file: resizedFile,
width: resizedImg.width,
height: resizedImg.height,
progress: 0.6,
};
replaceFile(extendedFile);
URL.revokeObjectURL(resizedPreview); // Clean up the object URL
await uploadFile(extendedFile);
};
resizedImg.src = resizedPreview;
URL.revokeObjectURL(preview); // Clean up the original object URL
/* TODO: send to backend server /api/files
use React Query Mutation to upload file (TypeScript), we need to make the CommonJS api endpoint (expressjs) to accept file upload
server needs the image file, which the server will convert to base64 to send to external API
server will then employ a 'saving' or 'caching' strategy based on admin configuration (can be local, CDN, etc.)
the expressjs server needs the following:
name,
size,
type,
width,
height,
use onSuccess, onMutate handlers to update the file progress
we need the full api handling for this, including the server-side
*/
};
img.src = preview;
} catch (error) {
console.log('file handling error', error);
}
});
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
event.stopPropagation();
if (event.target.files) {
setFilesLoading(true);
handleFiles(event.target.files);
// reset the input
event.target.value = '';
}
};
return {
handleFileChange,
handleFiles,
files,
setFiles,
};
};
export default useFileHandling;

View file

@ -272,6 +272,7 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
}
if (data.created) {
message = {
...message,
...data.message,
overrideParentMessageId: message?.overrideParentMessageId,
};

View file

@ -1,6 +1,8 @@
import { useEffect, useRef } from 'react';
import { TEndpointOption, getResponseSender } from 'librechat-data-provider';
import type { KeyboardEvent } from 'react';
import { useChatContext } from '~/Providers/ChatContext';
import useFileHandling from './useFileHandling';
type KeyEvent = KeyboardEvent<HTMLTextAreaElement>;
@ -12,9 +14,11 @@ export default function useTextarea({ setText, submitMessage }) {
setShowBingToneSetting,
textareaHeight,
setTextareaHeight,
setFilesLoading,
} = useChatContext();
const isComposing = useRef(false);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const { handleFiles } = useFileHandling();
const isNotAppendable = (latestMessage?.unfinished && !isSubmitting) || latestMessage?.error;
const { conversationId, jailbreak } = conversation || {};
@ -88,7 +92,9 @@ export default function useTextarea({ setText, submitMessage }) {
return 'Edit your message or Regenerate.';
}
return 'Message ChatGPT…';
const sender = getResponseSender(conversation as TEndpointOption);
return `Message ${sender ? sender : 'ChatGPT'}`;
};
const onHeightChange = (height: number) => {
@ -101,10 +107,19 @@ export default function useTextarea({ setText, submitMessage }) {
}
};
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (e.clipboardData && e.clipboardData.files.length > 0) {
e.preventDefault();
setFilesLoading(true);
handleFiles(e.clipboardData.files);
}
};
return {
inputRef,
handleKeyDown,
handleKeyUp,
handlePaste,
handleCompositionStart,
handleCompositionEnd,
placeholder: getPlaceholderText(),

View file

@ -4,14 +4,18 @@ import type { TShowToast } from '~/common';
import { NotificationSeverity } from '~/common';
import store from '~/store';
export default function useToast(timeoutDuration = 100) {
export default function useToast(showDelay = 100) {
const [toast, setToast] = useRecoilState(store.toastState);
const timerRef = useRef<number | null>(null);
const showTimerRef = useRef<number | null>(null);
const hideTimerRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
if (showTimerRef.current !== null) {
clearTimeout(showTimerRef.current);
}
if (hideTimerRef.current !== null) {
clearTimeout(hideTimerRef.current);
}
};
}, []);
@ -20,14 +24,24 @@ export default function useToast(timeoutDuration = 100) {
message,
severity = NotificationSeverity.SUCCESS,
showIcon = true,
duration = 3000, // default duration for the toast to be visible
}: TShowToast) => {
setToast({ ...toast, open: false });
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
// Clear existing timeouts
if (showTimerRef.current !== null) {
clearTimeout(showTimerRef.current);
}
timerRef.current = window.setTimeout(() => {
if (hideTimerRef.current !== null) {
clearTimeout(hideTimerRef.current);
}
// Timeout to show the toast
showTimerRef.current = window.setTimeout(() => {
setToast({ open: true, message, severity, showIcon });
}, timeoutDuration);
// Hides the toast after the specified duration
hideTimerRef.current = window.setTimeout(() => {
setToast((prevToast) => ({ ...prevToast, open: false }));
}, duration);
}, showDelay);
};
return {