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

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

View 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>
);
}

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

View file

@ -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 = () => (

View file

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

View file

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

View file

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