mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-06 00:00:18 +01:00
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:
parent
345f4b2e85
commit
317cdd3f77
113 changed files with 2680 additions and 675 deletions
|
|
@ -1,6 +1,6 @@
|
|||
// Container Component
|
||||
const Container = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="text-message peer flex min-h-[20px] flex-col items-start gap-3 overflow-x-auto break-words peer-[.text-message]:mt-5">
|
||||
<div className="text-message flex min-h-[20px] flex-col items-start gap-3 overflow-x-auto [.text-message+&]:mt-5">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
42
client/src/components/Chat/Messages/Content/DialogImage.tsx
Normal file
42
client/src/components/Chat/Messages/Content/DialogImage.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
|
||||
export default function DialogImage({ src = '', width = 1920, height = 1080 }) {
|
||||
return (
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
className="radix-state-open:animate-show fixed inset-0 z-[100] flex items-center justify-center overflow-hidden bg-black/90 dark:bg-black/80"
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
className="absolute right-4 top-4 text-gray-50 transition hover:text-gray-200"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-5 w-5"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
<Dialog.Content
|
||||
className="radix-state-open:animate-contentShow relative max-h-[85vh] max-w-[90vw] shadow-xl focus:outline-none"
|
||||
tabIndex={-1}
|
||||
style={{ pointerEvents: 'auto', aspectRatio: height > width ? 1 / 1.75 : 1.75 / 1 }}
|
||||
>
|
||||
<img src={src} alt="Uploaded image" className="h-full w-full object-contain" />
|
||||
</Dialog.Content>
|
||||
</Dialog.Overlay>
|
||||
</Dialog.Portal>
|
||||
);
|
||||
}
|
||||
85
client/src/components/Chat/Messages/Content/Image.tsx
Normal file
85
client/src/components/Chat/Messages/Content/Image.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import React, { useState, useEffect, useRef, memo } from 'react';
|
||||
import { LazyLoadImage } from 'react-lazy-load-image-component';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import DialogImage from './DialogImage';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Image = ({
|
||||
imagePath,
|
||||
altText,
|
||||
height,
|
||||
width,
|
||||
}: // n,
|
||||
// i,
|
||||
{
|
||||
imagePath: string;
|
||||
altText: string;
|
||||
height: number;
|
||||
width: number;
|
||||
// n: number;
|
||||
// i: number;
|
||||
}) => {
|
||||
const prevImagePathRef = useRef<string | null>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const handleImageLoad = () => setIsLoaded(true);
|
||||
const [minDisplayTimeElapsed, setMinDisplayTimeElapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (isLoaded) {
|
||||
timer = setTimeout(() => setMinDisplayTimeElapsed(true), 150);
|
||||
}
|
||||
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';
|
||||
|
||||
return (
|
||||
<Dialog.Root>
|
||||
<div className="">
|
||||
<div className="relative mt-1 flex h-auto w-full max-w-lg items-center justify-center overflow-hidden bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400">
|
||||
<Dialog.Trigger asChild>
|
||||
<button type="button" aria-haspopup="dialog" aria-expanded="false">
|
||||
<LazyLoadImage
|
||||
// loading="lazy"
|
||||
alt={altText}
|
||||
onLoad={handleImageLoad}
|
||||
className={cn(
|
||||
'max-h-[900px] max-w-full opacity-100 transition-opacity duration-300',
|
||||
// n >= 3 && i < 2 ? 'aspect-square object-cover' : '',
|
||||
isLoaded && minDisplayTimeElapsed ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
src={imagePath}
|
||||
style={{
|
||||
height: isLoaded && minDisplayTimeElapsed ? 'auto' : placeholderHeight,
|
||||
width,
|
||||
color: 'transparent',
|
||||
}}
|
||||
placeholder={
|
||||
<div
|
||||
style={{
|
||||
height: isLoaded && minDisplayTimeElapsed ? 'auto' : placeholderHeight,
|
||||
width,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
<DialogImage src={imagePath} height={height} width={width} />
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Image);
|
||||
|
|
@ -9,6 +9,7 @@ import EditMessage from './EditMessage';
|
|||
import Container from './Container';
|
||||
import Markdown from './Markdown';
|
||||
import { cn } from '~/utils';
|
||||
import Image from './Image';
|
||||
|
||||
const ErrorMessage = ({ text }: TText) => {
|
||||
const { logout } = useAuthContext();
|
||||
|
|
@ -27,22 +28,39 @@ const ErrorMessage = ({ text }: TText) => {
|
|||
};
|
||||
|
||||
// Display Message Component
|
||||
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => (
|
||||
<Container>
|
||||
<div
|
||||
className={cn(
|
||||
'markdown prose dark:prose-invert light w-full break-words',
|
||||
isCreatedByUser ? 'whitespace-pre-wrap dark:text-gray-20' : 'dark:text-gray-70',
|
||||
)}
|
||||
>
|
||||
{!isCreatedByUser ? (
|
||||
<Markdown content={text} message={message} showCursor={showCursor} />
|
||||
) : (
|
||||
<>{text}</>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => {
|
||||
const imageFiles = message?.files
|
||||
? message.files.filter((file) => file.type.startsWith('image/'))
|
||||
: null;
|
||||
return (
|
||||
<Container>
|
||||
{imageFiles &&
|
||||
imageFiles.map((file, i) => (
|
||||
<Image
|
||||
key={file.file_id}
|
||||
imagePath={file.preview ?? file.filepath ?? ''}
|
||||
height={file.height ?? 1920}
|
||||
width={file.width ?? 1080}
|
||||
altText={file.filename ?? 'Uploaded Image'}
|
||||
// n={imageFiles.length}
|
||||
// i={i}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={cn(
|
||||
'markdown prose dark:prose-invert light w-full break-words',
|
||||
isCreatedByUser ? 'whitespace-pre-wrap dark:text-gray-20' : 'dark:text-gray-70',
|
||||
)}
|
||||
>
|
||||
{!isCreatedByUser ? (
|
||||
<Markdown content={text} message={message} showCursor={showCursor} />
|
||||
) : (
|
||||
<>{text}</>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
// Unfinished Message Component
|
||||
const UnfinishedMessage = () => (
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export default function HoverButtons({
|
|||
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-400 lg:justify-start">
|
||||
<button
|
||||
className={cn(
|
||||
'hover-button rounded-md p-1 pl-0 text-gray-400 hover:text-gray-950 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible',
|
||||
'hover-button rounded-md p-1 pl-0 text-gray-400 hover:text-gray-950 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
|
||||
isCreatedByUser ? '' : 'active',
|
||||
hideEditButton ? 'opacity-0' : '',
|
||||
isEditing ? 'active bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200' : '',
|
||||
|
|
@ -68,8 +68,8 @@ export default function HoverButtons({
|
|||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
'hover-button ml-0 flex items-center gap-1.5 rounded-md p-1 pl-0 text-xs hover:text-gray-950 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible',
|
||||
isCreatedByUser ? '' : 'active',
|
||||
'ml-0 flex items-center gap-1.5 rounded-md p-1 pl-0 text-xs hover:text-gray-950 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
|
||||
isSubmitting && isCreatedByUser ? 'md:opacity-0 md:group-hover:opacity-100' : '',
|
||||
)}
|
||||
onClick={() => copyToClipboard(setIsCopied)}
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useEffect } from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Plugin } from '~/components/Messages/Content';
|
||||
import MessageContent from './Content/MessageContent';
|
||||
import { Icon } from '~/components/Endpoints';
|
||||
|
|
@ -11,9 +12,11 @@ import { useChatContext } from '~/Providers';
|
|||
import MultiMessage from './MultiMessage';
|
||||
import HoverButtons from './HoverButtons';
|
||||
import SubRow from './SubRow';
|
||||
// import { cn } from '~/utils';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function Message(props: TMessageProps) {
|
||||
const autoScroll = useRecoilValue(store.autoScroll);
|
||||
const {
|
||||
message,
|
||||
scrollToBottom,
|
||||
|
|
@ -27,7 +30,6 @@ export default function Message(props: TMessageProps) {
|
|||
const {
|
||||
ask,
|
||||
regenerate,
|
||||
autoScroll,
|
||||
abortScroll,
|
||||
isSubmitting,
|
||||
conversation,
|
||||
|
|
@ -121,8 +123,8 @@ export default function Message(props: TMessageProps) {
|
|||
onWheel={handleScroll}
|
||||
onTouchMove={handleScroll}
|
||||
>
|
||||
<div className="m-auto justify-center p-4 py-2 text-base md:gap-6 md:py-6">
|
||||
<div className="final-completion group mx-auto flex flex-1 gap-3 text-base md:max-w-3xl md:gap-6 md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
|
||||
<div className="m-auto justify-center p-4 py-2 text-base md:gap-6 ">
|
||||
<div className="} group mx-auto flex flex-1 gap-3 text-base md:max-w-3xl md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
|
||||
<div className="relative flex flex-shrink-0 flex-col items-end">
|
||||
<div>
|
||||
<div className="pt-0.5">
|
||||
|
|
@ -136,7 +138,12 @@ export default function Message(props: TMessageProps) {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="agent-turn relative flex w-[calc(100%-50px)] w-full flex-col lg:w-[calc(100%-36px)]">
|
||||
<div
|
||||
className={cn('relative flex w-full flex-col', isCreatedByUser ? '' : 'agent-turn')}
|
||||
>
|
||||
<div className="select-none font-semibold">
|
||||
{isCreatedByUser ? 'You' : message.sender}
|
||||
</div>
|
||||
<div className="flex-col gap-1 md:gap-3">
|
||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||
{/* Legacy Plugins */}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import { useLayoutEffect, useState, useRef, useCallback } from 'react';
|
|||
import type { ReactNode } from 'react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
|
||||
import { useScreenshot, useScrollToRef } from '~/hooks';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import MultiMessage from './MultiMessage';
|
||||
import { useScrollToRef } from '~/hooks';
|
||||
|
||||
export default function MessagesView({
|
||||
messagesTree: _messagesTree,
|
||||
|
|
@ -21,8 +21,7 @@ export default function MessagesView({
|
|||
const { conversation, showPopover, setAbortScroll } = useChatContext();
|
||||
const { conversationId } = conversation ?? {};
|
||||
|
||||
// TODO: screenshot target ref
|
||||
// const { screenshotTargetRef } = useScreenshot();
|
||||
const { screenshotTargetRef } = useScreenshot();
|
||||
|
||||
const checkIfAtBottom = useCallback(() => {
|
||||
if (!scrollableRef.current) {
|
||||
|
|
@ -82,26 +81,28 @@ export default function MessagesView({
|
|||
) : (
|
||||
<>
|
||||
{Header && Header}
|
||||
<MultiMessage
|
||||
key={conversationId} // avoid internal state mixture
|
||||
messageId={conversationId ?? null}
|
||||
messagesTree={_messagesTree}
|
||||
scrollToBottom={scrollToBottom}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
currentEditId={currentEditId ?? null}
|
||||
/>
|
||||
<CSSTransition
|
||||
in={showScrollButton}
|
||||
timeout={400}
|
||||
classNames="scroll-down"
|
||||
unmountOnExit={false}
|
||||
// appear
|
||||
>
|
||||
{() =>
|
||||
showScrollButton &&
|
||||
!showPopover && <ScrollToBottom scrollHandler={handleSmoothToRef} />
|
||||
}
|
||||
</CSSTransition>
|
||||
<div ref={screenshotTargetRef}>
|
||||
<MultiMessage
|
||||
key={conversationId} // avoid internal state mixture
|
||||
messageId={conversationId ?? null}
|
||||
messagesTree={_messagesTree}
|
||||
scrollToBottom={scrollToBottom}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
currentEditId={currentEditId ?? null}
|
||||
/>
|
||||
<CSSTransition
|
||||
in={showScrollButton}
|
||||
timeout={400}
|
||||
classNames="scroll-down"
|
||||
unmountOnExit={false}
|
||||
// appear
|
||||
>
|
||||
{() =>
|
||||
showScrollButton &&
|
||||
!showPopover && <ScrollToBottom scrollHandler={handleSmoothToRef} />
|
||||
}
|
||||
</CSSTransition>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue