WIP: Update UI to match Official Style; Vision and Assistants 👷🏽 (#1190)

* wip: initial client side code

* wip: initial api code

* refactor: export query keys from own module, export assistant hooks

* refactor(SelectDropDown): more customization via props

* feat: create Assistant and render real Assistants

* refactor: major refactor of UI components to allow multi-chat, working alongside CreationPanel

* refactor: move assistant routes to own directory

* fix(CreationHeader): state issue with assistant select

* refactor: style changes for form, fix setSiblingIdx from useChatHelpers to use latestMessageParentId, fix render issue with ChatView and change location

* feat: parseCompactConvo: begin refactor of slimmer JSON payloads between client/api

* refactor(endpoints): add assistant endpoint, also use EModelEndpoint as much as possible

* refactor(useGetConversationsQuery): use object to access query data easily

* fix(MultiMessage): react warning of bad state set, making use of effect during render (instead of useEffect)

* fix(useNewConvo): use correct atom key (index instead of convoId) for reset latestMessageFamily

* refactor: make routing navigation/conversation change simpler

* chore: add removeNullishValues for smaller payloads, remove unused fields, setup frontend pinging of assistant endpoint

* WIP: initial complete assistant run handling

* fix: CreationPanel form correctly setting internal state

* refactor(api/assistants/chat): revise functions to working run handling strategy

* refactor(UI): initial major refactor of ChatForm and options

* feat: textarea hook

* refactor: useAuthRedirect hook and change directory name

* feat: add ChatRoute (/c/), make optionsBar absolute and change on textarea height, add temp header

* feat: match new toggle Nav open button to ChatGPT's

* feat: add OpenAI custom classnames

* feat: useOriginNavigate

* feat: messages loading view

* fix: conversation navigation and effects

* refactor: make toggle change nav opacity

* WIP: new endpoint menu

* feat: NewEndpointsMenu complete

* fix: ensure set key dialog shows on endpoint change, and new conversation resets messages

* WIP: textarea styling fix, add temp footer, create basic file handling component

* feat: image file handling (UI)

* feat: PopOver and ModelSelect in Header, remove GenButtons

* feat: drop file handling

* refactor: bug fixes
use SSE at route level
add opts to useOriginNavigate
delay render of unfinishedMessage to avoid flickering
pass params (convoId) to chatHelpers to set messages query data based on param when the route is new (fixes can't continue convo on /new/)
style(MessagesView): matches height to official
fix(SSE): pass paramId and invalidate convos
style(Message): make bg uniform

* refactor(useSSE): setStorage within setConversation updates

* feat: conversationKeysAtom, allConversationsSelector, update convos query data on created message (if new), correctly handle convo deletion (individual)

* feat: add popover select dropdowns to allow options in header while allowing horizontal scroll for mobile

* style(pluginsSelect): styling changes

* refactor(NewEndpointsMenu): make UI components modular

* feat: Presets complete

* fix: preset editing, make by index

* fix: conversations not setting on inital navigation, fix getMessages() based on query param

* fix: changing preset no longer resets latestMessage

* feat: useOnClickOutside for OptionsPopover and fix bug that causes selection of preset when deleting

* fix: revert /chat/ switchToConvo, also use NewDeleteButton in Convo

* fix: Popover correctly closes on close Popover button using custom condition for useOnClickOutside

* style: new message and nav styling

* style: hover/sibling buttons and preset menu scrolling

* feat: new convo header button

* style(Textarea): minor style changes to textarea buttons

* feat: stop/continue generating and hide hoverbuttons when submitting

* feat: compact AI Provider schemas to make json payloads and db saves smaller

* style: styling changes for consistency on chat route

* fix: created usePresetIndexOptions to prevent bugs between /c/ and /chat/ routes when editing presets, removed redundant code from the new dialog

* chore: make /chat/ route default for now since we still lack full image support
This commit is contained in:
Danny Avila 2023-11-16 10:42:24 -05:00 committed by GitHub
parent adbeb46399
commit bac1fb67d2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
171 changed files with 8380 additions and 468 deletions

View file

@ -2,19 +2,31 @@ export * from './AuthContext';
export * from './ThemeContext';
export * from './ScreenshotContext';
export * from './ApiErrorBoundaryContext';
export { default as useSSE } from './useSSE';
export { default as useToast } from './useToast';
export { default as useTimeout } from './useTimeout';
export { default as useUserKey } from './useUserKey';
export { default as useNewConvo } from './useNewConvo';
export { default as useDebounce } from './useDebounce';
export { default as useTextarea } from './useTextarea';
export { default as useLocalize } from './useLocalize';
export { default as useMediaQuery } from './useMediaQuery';
export { default as useSetOptions } from './useSetOptions';
export { default as useSetStorage } from './useSetStorage';
export { default as useChatHelpers } from './useChatHelpers';
export { default as useGenerations } from './useGenerations';
export { default as useDragHelpers } from './useDragHelpers';
export { default as useScrollToRef } from './useScrollToRef';
export { default as useLocalStorage } from './useLocalStorage';
export { default as useConversation } from './useConversation';
export { default as useDefaultConvo } from './useDefaultConvo';
export { default as useServerStream } from './useServerStream';
export { default as useFileHandling } from './useFileHandling';
export { default as useConversations } from './useConversations';
export { default as useDelayedRender } from './useDelayedRender';
export { default as useOnClickOutside } from './useOnClickOutside';
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 useGenerationsByLatest } from './useGenerationsByLatest';

View file

@ -0,0 +1,366 @@
import { v4 } from 'uuid';
import { useCallback, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useRecoilState, useResetRecoilState, useSetRecoilState } from 'recoil';
import {
QueryKeys,
parseCompactConvo,
getResponseSender,
useGetMessagesByConvoId,
} from 'librechat-data-provider';
import type {
TMessage,
TSubmission,
TEndpointOption,
TConversation,
TGetConversationsResponse,
} from 'librechat-data-provider';
import type { TAskFunction, ExtendedFile } from '~/common';
import { useAuthContext } from './AuthContext';
import useNewConvo from './useNewConvo';
import useUserKey from './useUserKey';
import store from '~/store';
// this to be set somewhere else
export default function useChatHelpers(index = 0, paramId) {
const queryClient = useQueryClient();
const { isAuthenticated } = useAuthContext();
// const tempConvo = {
// endpoint: null,
// conversationId: null,
// jailbreak: false,
// examples: [],
// tools: [],
// };
const { newConversation } = useNewConvo(index);
const { useCreateConversationAtom } = store;
const { conversation, setConversation } = useCreateConversationAtom(index);
const { conversationId, endpoint } = conversation ?? {};
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 ?? '', {
enabled: isAuthenticated,
});
const resetLatestMessage = useResetRecoilState(store.latestMessageFamily(index));
const [isSubmitting, setIsSubmitting] = useRecoilState(store.isSubmittingFamily(index));
const [latestMessage, setLatestMessage] = useRecoilState(store.latestMessageFamily(index));
const setSiblingIdx = useSetRecoilState(
store.messagesSiblingIdxFamily(latestMessage?.parentMessageId ?? null),
);
const setMessages = useCallback(
(messages: TMessage[]) => {
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, queryParam], messages);
},
// [conversationId, queryClient],
[queryParam, queryClient],
);
const addConvo = useCallback(
(convo: TConversation) => {
const convoData = queryClient.getQueryData<TGetConversationsResponse>([
QueryKeys.allConversations,
{ pageNumber: '1', active: true },
]) ?? { conversations: [] as TConversation[], pageNumber: '1', pages: 1, pageSize: 14 };
let { conversations: convos, pageSize = 14 } = convoData;
pageSize = Number(pageSize);
convos = convos.filter((c) => c.conversationId !== convo.conversationId);
convos = convos.length < pageSize ? convos : convos.slice(0, -1);
const conversations = [
{
...convo,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
...convos,
];
queryClient.setQueryData<TGetConversationsResponse>(
[QueryKeys.allConversations, { pageNumber: '1', active: true }],
{
...convoData,
conversations,
},
);
},
[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]);
const getMessages = useCallback(() => {
return queryClient.getQueryData<TMessage[]>([QueryKeys.messages, queryParam]);
}, [queryParam, queryClient]);
/* Conversation */
// const setActiveConvos = useSetRecoilState(store.activeConversations);
// const setConversation = useCallback(
// (convoUpdate: TConversation) => {
// _setConversation(prev => {
// const { conversationId: convoId } = prev ?? { conversationId: null };
// const { conversationId: currentId } = convoUpdate;
// if (currentId && convoId && convoId !== 'new' && convoId !== currentId) {
// // for now, we delete the prev convoId from activeConversations
// const newActiveConvos = { [currentId]: true };
// setActiveConvos(newActiveConvos);
// }
// return convoUpdate;
// });
// },
// [_setConversation, setActiveConvos],
// );
const { getExpiry } = useUserKey(endpoint ?? '');
const setSubmission = useSetRecoilState(store.submissionByIndex(index));
const ask: TAskFunction = (
{ text, parentMessageId = null, conversationId = null, messageId = null },
{
editedText = null,
editedMessageId = null,
isRegenerate = false,
isContinued = false,
isEdited = false,
} = {},
) => {
if (!!isSubmitting || text === '') {
return;
}
if (endpoint === null) {
console.error('No endpoint available');
return;
}
conversationId = conversationId ?? conversation?.conversationId ?? null;
if (conversationId == 'search') {
console.error('cannot send any message under search view!');
return;
}
if (isContinued && !latestMessage) {
console.error('cannot continue AI message without latestMessage!');
return;
}
const isEditOrContinue = isEdited || isContinued;
// set the endpoint option
const convo = parseCompactConvo(endpoint, conversation ?? {});
const endpointOption = {
...convo,
endpoint,
key: getExpiry(),
} as TEndpointOption;
const responseSender = getResponseSender(endpointOption);
let currentMessages: TMessage[] | null = getMessages() ?? [];
// construct the query message
// this is not a real messageId, it is used as placeholder before real messageId returned
text = text.trim();
const fakeMessageId = v4();
parentMessageId =
parentMessageId || latestMessage?.messageId || '00000000-0000-0000-0000-000000000000';
if (conversationId == 'new') {
parentMessageId = '00000000-0000-0000-0000-000000000000';
currentMessages = [];
conversationId = null;
}
const currentMsg: TMessage = {
text,
sender: 'User',
isCreatedByUser: true,
parentMessageId,
conversationId,
messageId: isContinued && messageId ? messageId : fakeMessageId,
error: false,
};
// construct the placeholder response message
const generation = editedText ?? latestMessage?.text ?? '';
const responseText = isEditOrContinue
? generation
: '<span className="result-streaming">█</span>';
const responseMessageId = editedMessageId ?? latestMessage?.messageId ?? null;
const initialResponse: TMessage = {
sender: responseSender,
text: responseText,
parentMessageId: isRegenerate ? messageId : fakeMessageId,
messageId: responseMessageId ?? `${isRegenerate ? messageId : fakeMessageId}_`,
conversationId,
unfinished: false,
submitting: true,
isCreatedByUser: false,
isEdited: isEditOrContinue,
error: false,
};
if (isContinued) {
currentMessages = currentMessages.filter((msg) => msg.messageId !== responseMessageId);
}
const submission: TSubmission = {
conversation: {
...conversation,
conversationId,
},
endpointOption,
message: {
...currentMsg,
generation,
responseMessageId,
overrideParentMessageId: isRegenerate ? messageId : null,
},
messages: currentMessages,
isEdited: isEditOrContinue,
isContinued,
isRegenerate,
initialResponse,
};
if (isRegenerate) {
setMessages([...submission.messages, initialResponse]);
} else {
setMessages([...submission.messages, currentMsg, initialResponse]);
}
setLatestMessage(initialResponse);
setSubmission(submission);
};
const regenerate = ({ parentMessageId }) => {
const messages = getMessages();
const parentMessage = messages?.find((element) => element.messageId == parentMessageId);
if (parentMessage && parentMessage.isCreatedByUser) {
ask({ ...parentMessage }, { isRegenerate: true });
} else {
console.error(
'Failed to regenerate the message: parentMessage not found or not created by user.',
);
}
};
const continueGeneration = () => {
if (!latestMessage) {
console.error('Failed to regenerate the message: latestMessage not found.');
return;
}
const messages = getMessages();
const parentMessage = messages?.find(
(element) => element.messageId == latestMessage.parentMessageId,
);
if (parentMessage && parentMessage.isCreatedByUser) {
ask({ ...parentMessage }, { isContinued: true, isRegenerate: true, isEdited: true });
} else {
console.error(
'Failed to regenerate the message: parentMessage not found, or not created by user.',
);
}
};
const stopGenerating = () => {
setSubmission(null);
};
const handleStopGenerating = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
stopGenerating();
};
const handleRegenerate = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
const parentMessageId = latestMessage?.parentMessageId;
if (!parentMessageId) {
console.error('Failed to regenerate the message: parentMessageId not found.');
return;
}
regenerate({ parentMessageId });
};
const handleContinue = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
continueGeneration();
setSiblingIdx(0);
};
const [showBingToneSetting, setShowBingToneSetting] = useRecoilState(
store.showBingToneSettingFamily(index),
);
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));
const [showAgentSettings, setShowAgentSettings] = useRecoilState(
store.showAgentSettingsFamily(index),
);
const [files, setFiles] = useState<ExtendedFile[]>([]);
return {
newConversation,
conversation,
setConversation,
addConvo,
// getConvos,
// setConvos,
isSubmitting,
setIsSubmitting,
getMessages,
setMessages,
setSiblingIdx,
latestMessage,
setLatestMessage,
resetLatestMessage,
ask,
index,
regenerate,
stopGenerating,
handleStopGenerating,
handleRegenerate,
handleContinue,
showPopover,
setShowPopover,
abortScroll,
setAbortScroll,
autoScroll,
setAutoScroll,
showBingToneSetting,
setShowBingToneSetting,
preset,
setPreset,
optionSettings,
setOptionSettings,
showAgentSettings,
setShowAgentSettings,
textareaHeight,
setTextareaHeight,
files,
setFiles,
invalidateConvos,
};
}

View file

@ -1,5 +1,4 @@
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSetRecoilState, useResetRecoilState, useRecoilCallback } from 'recoil';
import { useGetEndpointsQuery } from 'librechat-data-provider';
import type {
@ -10,10 +9,11 @@ import type {
TModelsConfig,
} from 'librechat-data-provider';
import { buildDefaultConvo, getDefaultEndpoint } from '~/utils';
import useOriginNavigate from './useOriginNavigate';
import store from '~/store';
const useConversation = () => {
const navigate = useNavigate();
const navigate = useOriginNavigate();
const setConversation = useSetRecoilState(store.conversation);
const setMessages = useSetRecoilState<TMessagesAtom>(store.messages);
const setSubmission = useSetRecoilState<TSubmission | null>(store.submission);
@ -52,7 +52,7 @@ const useConversation = () => {
resetLatestMessage();
if (conversation.conversationId === 'new' && !modelsData) {
navigate('/chat/new');
navigate('new');
}
},
[endpointsConfig],

View file

@ -0,0 +1,14 @@
import { useState, useEffect } from 'react';
import type { ReactNode } from 'react';
const useDelayedRender = (delay: number) => {
const [delayed, setDelayed] = useState(true);
useEffect(() => {
const timeout = setTimeout(() => setDelayed(false), delay);
return () => clearTimeout(timeout);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (fn: () => ReactNode) => !delayed && fn();
};
export default useDelayedRender;

View file

@ -0,0 +1,83 @@
import { useDrop } from 'react-dnd';
import { NativeTypes } from 'react-dnd-html5-backend';
import type { DropTargetMonitor } from 'react-dnd';
import type { ExtendedFile } from '~/common';
export default function useDragHelpers(
setFiles: React.Dispatch<React.SetStateAction<ExtendedFile[]>>,
) {
const addFile = (newFile: ExtendedFile) => {
setFiles((currentFiles) => [...currentFiles, newFile]);
};
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
};
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(),
};
},
}));
return {
canDrop,
isOver,
drop,
};
}

View file

@ -0,0 +1,65 @@
import type { ExtendedFile } from '~/common';
import { useChatContext } from '~/Providers/ChatContext';
const useFileHandling = () => {
const { files, setFiles } = useChatContext();
const addFile = (newFile: ExtendedFile) => {
setFiles((currentFiles) => [...currentFiles, newFile]);
};
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
};
img.src = preview;
} else {
// TODO: non-image files
// extendedFile.progress = false;
// replaceFile(extendedFile);
}
});
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files) {
handleFiles(event.target.files);
}
};
return {
handleFileChange,
handleFiles,
files,
setFiles,
};
};
export default useFileHandling;

View file

@ -1,4 +1,5 @@
import type { TMessage } from 'librechat-data-provider';
import { EModelEndpoint } from 'librechat-data-provider';
import { useRecoilValue } from 'recoil';
import store from '~/store';
@ -7,6 +8,7 @@ type TUseGenerations = {
message: TMessage;
isSubmitting: boolean;
isEditing?: boolean;
latestMessage?: TMessage | null;
};
export default function useGenerations({
@ -14,13 +16,19 @@ export default function useGenerations({
message,
isSubmitting,
isEditing = false,
latestMessage: _latestMessage,
}: TUseGenerations) {
const latestMessage = useRecoilValue(store.latestMessage);
const latestMessage = useRecoilValue(store.latestMessage) ?? _latestMessage;
const { error, messageId, searchResult, finish_reason, isCreatedByUser } = message ?? {};
const isEditableEndpoint = !!['azureOpenAI', 'openAI', 'gptPlugins', 'anthropic'].find(
(e) => e === endpoint,
);
const isEditableEndpoint = !![
EModelEndpoint.azureOpenAI,
EModelEndpoint.openAI,
EModelEndpoint.assistant,
EModelEndpoint.gptPlugins,
EModelEndpoint.anthropic,
EModelEndpoint.anthropic,
].find((e) => e === endpoint);
const continueSupported =
latestMessage?.messageId === messageId &&
@ -33,13 +41,13 @@ export default function useGenerations({
const branchingSupported =
// 5/21/23: Bing is allowing editing and Message regenerating
!![
'azureOpenAI',
'openAI',
'chatGPTBrowser',
'google',
'bingAI',
'gptPlugins',
'anthropic',
EModelEndpoint.azureOpenAI,
EModelEndpoint.openAI,
EModelEndpoint.chatGPTBrowser,
EModelEndpoint.google,
EModelEndpoint.bingAI,
EModelEndpoint.gptPlugins,
EModelEndpoint.anthropic,
].find((e) => e === endpoint);
const regenerateEnabled =

View file

@ -0,0 +1,64 @@
import type { TMessage } from 'librechat-data-provider';
import { EModelEndpoint } from 'librechat-data-provider';
type TUseGenerations = {
endpoint?: string;
message: TMessage;
isSubmitting: boolean;
isEditing?: boolean;
latestMessage: TMessage | null;
};
export default function useGenerationsByLatest({
endpoint,
message,
isSubmitting,
isEditing = false,
latestMessage,
}: TUseGenerations) {
const { error, messageId, searchResult, finish_reason, isCreatedByUser } = message ?? {};
const isEditableEndpoint = !![
EModelEndpoint.azureOpenAI,
EModelEndpoint.openAI,
EModelEndpoint.assistant,
EModelEndpoint.gptPlugins,
EModelEndpoint.anthropic,
EModelEndpoint.anthropic,
].find((e) => e === endpoint);
const continueSupported =
latestMessage?.messageId === messageId &&
finish_reason &&
finish_reason !== 'stop' &&
!isEditing &&
!searchResult &&
isEditableEndpoint;
const branchingSupported =
// 5/21/23: Bing is allowing editing and Message regenerating
!![
EModelEndpoint.azureOpenAI,
EModelEndpoint.openAI,
EModelEndpoint.chatGPTBrowser,
EModelEndpoint.google,
EModelEndpoint.bingAI,
EModelEndpoint.gptPlugins,
EModelEndpoint.anthropic,
].find((e) => e === endpoint);
const regenerateEnabled =
!isCreatedByUser && !searchResult && !isEditing && !isSubmitting && branchingSupported;
const hideEditButton =
isSubmitting ||
error ||
searchResult ||
!branchingSupported ||
(!isEditableEndpoint && !isCreatedByUser);
return {
continueSupported,
regenerateEnabled,
hideEditButton,
};
}

View file

@ -75,9 +75,8 @@ const useMessageHandler = () => {
conversationId = null;
}
const currentMsg: TMessage = {
sender: 'User',
text,
current: true,
sender: 'User',
isCreatedByUser: true,
parentMessageId,
conversationId,

View file

@ -0,0 +1,34 @@
import { useSetRecoilState, useResetRecoilState } from 'recoil';
import type { TConversation } from 'librechat-data-provider';
import useOriginNavigate from './useOriginNavigate';
import useSetStorage from './useSetStorage';
import store from '~/store';
const useNavigateToConvo = (index = 0) => {
const setStorage = useSetStorage();
const navigate = useOriginNavigate();
const { setConversation } = store.useCreateConversationAtom(index);
const setSubmission = useSetRecoilState(store.submissionByIndex(index));
// const setConversation = useSetRecoilState(store.conversationByIndex(index));
const resetLatestMessage = useResetRecoilState(store.latestMessageFamily(index));
const navigateToConvo = (conversation: TConversation, _resetLatestMessage = true) => {
if (!conversation) {
console.log('Conversation not provided');
return;
}
setSubmission(null);
if (_resetLatestMessage) {
resetLatestMessage();
}
setStorage(conversation);
setConversation(conversation);
navigate(conversation?.conversationId);
};
return {
navigateToConvo,
};
};
export default useNavigateToConvo;

View file

@ -0,0 +1,92 @@
import { useCallback } from 'react';
import { useSetRecoilState, useResetRecoilState, useRecoilCallback } from 'recoil';
import { useGetEndpointsQuery } from 'librechat-data-provider';
import type { TConversation, TSubmission, TPreset, TModelsConfig } from 'librechat-data-provider';
import { buildDefaultConvo, getDefaultEndpoint } from '~/utils';
import useOriginNavigate from './useOriginNavigate';
import useSetStorage from './useSetStorage';
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 setSubmission = useSetRecoilState<TSubmission | null>(store.submissionByIndex(index));
const resetLatestMessage = useResetRecoilState(store.latestMessageFamily(index));
const { data: endpointsConfig = {} } = useGetEndpointsQuery();
const switchToConversation = useRecoilCallback(
({ snapshot }) =>
async (
conversation: TConversation,
preset: TPreset | null = null,
modelsData?: TModelsConfig,
buildDefault?: boolean,
) => {
const modelsConfig = modelsData ?? snapshot.getLoadable(store.modelsConfig).contents;
const { endpoint = null } = conversation;
if (endpoint === null || buildDefault) {
const defaultEndpoint = getDefaultEndpoint({
convoSetup: preset ?? conversation,
endpointsConfig,
});
const models = modelsConfig?.[defaultEndpoint] ?? [];
conversation = buildDefaultConvo({
conversation,
lastConversationSetup: preset as TConversation,
endpoint: defaultEndpoint,
models,
});
}
setStorage(conversation);
setConversation(conversation);
setSubmission({} as TSubmission);
resetLatestMessage();
if (conversation.conversationId === 'new' && !modelsData) {
navigate('new');
}
},
[endpointsConfig],
);
const newConversation = useCallback(
({
template = {},
preset,
modelsData,
buildDefault = true,
}: {
template?: Partial<TConversation>;
preset?: TPreset;
modelsData?: TModelsConfig;
buildDefault?: boolean;
} = {}) => {
switchToConversation(
{
conversationId: 'new',
title: 'New Chat',
endpoint: null,
...template,
createdAt: '',
updatedAt: '',
},
preset,
modelsData,
buildDefault,
);
},
[switchToConversation],
);
return {
switchToConversation,
newConversation,
};
};
export default useNewConvo;

View file

@ -5,14 +5,28 @@ export default function useOnClickOutside(
ref: RefObject<HTMLElement>,
handler: Handler,
excludeIds: string[],
customCondition?: (target: EventTarget | Element | null) => boolean,
): void {
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node | null;
if (target && 'id' in target && excludeIds.includes((target as HTMLElement).id)) {
return;
}
if (
target?.parentNode &&
'id' in target.parentNode &&
excludeIds.includes((target.parentNode as HTMLElement).id)
) {
return;
}
if (customCondition && customCondition(target)) {
return;
}
if (ref.current && !ref.current.contains(target)) {
handler();
}

View file

@ -0,0 +1,18 @@
import { useNavigate, useLocation } from 'react-router-dom';
const useOriginNavigate = () => {
const _navigate = useNavigate();
const location = useLocation();
const navigate = (url?: string | null, opts = {}) => {
if (!url) {
return;
}
const path = location.pathname.match(/^\/[^/]+\//);
_navigate(`${path ? path[0] : '/chat/'}${url}`, opts);
};
return navigate;
};
export default useOriginNavigate;

View file

@ -0,0 +1,119 @@
import { TPreset } from 'librechat-data-provider';
import type { TSetOptionsPayload, TSetExample, TSetOption } from '~/common';
import { useChatContext } from '~/Providers/ChatContext';
import { cleanupPreset } from '~/utils';
type TUsePresetOptions = (preset?: TPreset | boolean | null) => TSetOptionsPayload | boolean;
const usePresetOptions: TUsePresetOptions = (_preset) => {
const { preset, setPreset } = useChatContext();
if (!_preset) {
return false;
}
const getConversation: () => TPreset | null = () => preset;
const setOption: TSetOption = (param) => (newValue) => {
const update = {};
update[param] = newValue;
setPreset((prevState) =>
cleanupPreset({
preset: {
...prevState,
...update,
},
}),
);
};
const setExample: TSetExample = (i, type, newValue = null) => {
const update = {};
const current = preset?.examples?.slice() || [];
const currentExample = { ...current[i] } || {};
currentExample[type] = { content: newValue };
current[i] = currentExample;
update['examples'] = current;
setPreset((prevState) =>
cleanupPreset({
preset: {
...prevState,
...update,
},
}),
);
};
const addExample: () => void = () => {
const update = {};
const current = preset?.examples?.slice() || [];
current.push({ input: { content: '' }, output: { content: '' } });
update['examples'] = current;
setPreset((prevState) =>
cleanupPreset({
preset: {
...prevState,
...update,
},
}),
);
};
const removeExample: () => void = () => {
const update = {};
const current = preset?.examples?.slice() || [];
if (current.length <= 1) {
update['examples'] = [{ input: { content: '' }, output: { content: '' } }];
setPreset((prevState) =>
cleanupPreset({
preset: {
...prevState,
...update,
},
}),
);
return;
}
current.pop();
update['examples'] = current;
setPreset((prevState) =>
cleanupPreset({
preset: {
...prevState,
...update,
},
}),
);
};
const setAgentOption: TSetOption = (param) => (newValue) => {
const editablePreset = JSON.parse(JSON.stringify(_preset));
const { agentOptions } = editablePreset;
agentOptions[param] = newValue;
setPreset((prevState) =>
cleanupPreset({
preset: {
...prevState,
agentOptions,
},
}),
);
};
const checkPluginSelection: (value: string) => boolean = () => false;
const setTools: (newValue: string) => void = () => {
return;
};
return {
setOption,
setExample,
addExample,
removeExample,
getConversation,
checkPluginSelection,
setAgentOption,
setTools,
};
};
export default usePresetOptions;

325
client/src/hooks/useSSE.ts Normal file
View file

@ -0,0 +1,325 @@
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import {
/* @ts-ignore */
SSE,
createPayload,
useGetUserBalance,
tMessageSchema,
tConversationSchema,
useGetStartupConfig,
EModelEndpoint,
removeNullishValues,
} from 'librechat-data-provider';
import type { TResPlugin, TMessage, TConversation, TSubmission } from 'librechat-data-provider';
import { useAuthContext } from './AuthContext';
import useChatHelpers from './useChatHelpers';
import useSetStorage from './useSetStorage';
type TResData = {
plugin: TResPlugin;
final?: boolean;
initial?: boolean;
requestMessage: TMessage;
responseMessage: TMessage;
conversation: TConversation;
};
export default function useSSE(submission: TSubmission | null, index = 0) {
const setStorage = useSetStorage();
const { conversationId: paramId } = useParams();
const { token, isAuthenticated } = useAuthContext();
const {
addConvo,
setMessages,
setConversation,
setIsSubmitting,
resetLatestMessage,
invalidateConvos,
} = useChatHelpers(index, paramId);
const { data: startupConfig } = useGetStartupConfig();
const balanceQuery = useGetUserBalance({
enabled: !!isAuthenticated && startupConfig?.checkBalance,
});
const messageHandler = (data: string, submission: TSubmission) => {
const {
messages,
message,
plugin,
plugins,
initialResponse,
isRegenerate = false,
} = submission;
if (isRegenerate) {
setMessages([
...messages,
{
...initialResponse,
text: data,
parentMessageId: message?.overrideParentMessageId ?? null,
messageId: message?.overrideParentMessageId + '_',
plugin: plugin ?? null,
plugins: plugins ?? [],
submitting: true,
// unfinished: true
},
]);
} else {
setMessages([
...messages,
message,
{
...initialResponse,
text: data,
parentMessageId: message?.messageId,
messageId: message?.messageId + '_',
plugin: plugin ?? null,
plugins: plugins ?? [],
submitting: true,
// unfinished: true
},
]);
}
};
const cancelHandler = (data: TResData, submission: TSubmission) => {
const { requestMessage, responseMessage, conversation } = data;
const { messages, isRegenerate = false } = submission;
// update the messages
if (isRegenerate) {
setMessages([...messages, responseMessage]);
} else {
setMessages([...messages, requestMessage, responseMessage]);
}
setIsSubmitting(false);
// refresh title
if (requestMessage.parentMessageId == '00000000-0000-0000-0000-000000000000') {
setTimeout(() => {
invalidateConvos();
}, 2000);
// in case it takes too long.
setTimeout(() => {
invalidateConvos();
}, 5000);
}
setConversation((prevState) => {
const update = {
...prevState,
...conversation,
};
setStorage(update);
return update;
});
};
const createdHandler = (data: TResData, submission: TSubmission) => {
const { messages, message, initialResponse, isRegenerate = false } = submission;
if (isRegenerate) {
setMessages([
...messages,
{
...initialResponse,
parentMessageId: message?.overrideParentMessageId ?? null,
messageId: message?.overrideParentMessageId + '_',
submitting: true,
},
]);
} else {
setMessages([
...messages,
message,
{
...initialResponse,
parentMessageId: message?.messageId,
messageId: message?.messageId + '_',
submitting: true,
},
]);
}
const { conversationId } = message;
let update = {} as TConversation;
setConversation((prevState) => {
update = tConversationSchema.parse({
...prevState,
conversationId,
});
setStorage(update);
return update;
});
if (message.parentMessageId == '00000000-0000-0000-0000-000000000000') {
addConvo(update);
}
resetLatestMessage();
};
const finalHandler = (data: TResData, submission: TSubmission) => {
const { requestMessage, responseMessage, conversation } = data;
const { messages, isRegenerate = false } = submission;
// update the messages
if (isRegenerate) {
setMessages([...messages, responseMessage]);
} else {
setMessages([...messages, requestMessage, responseMessage]);
}
setIsSubmitting(false);
// refresh title
if (requestMessage.parentMessageId == '00000000-0000-0000-0000-000000000000') {
setTimeout(() => {
invalidateConvos();
}, 1500);
// in case it takes too long.
setTimeout(() => {
invalidateConvos();
}, 5000);
}
setConversation((prevState) => {
const update = {
...prevState,
...conversation,
};
setStorage(update);
return update;
});
};
const errorHandler = (data: TResData, submission: TSubmission) => {
const { messages, message } = submission;
console.log('Error:', data);
const errorResponse = tMessageSchema.parse({
...data,
error: true,
parentMessageId: message?.messageId,
});
setIsSubmitting(false);
setMessages([...messages, message, errorResponse]);
return;
};
const abortConversation = (conversationId = '', submission: TSubmission) => {
console.log(submission);
const { endpoint } = submission?.conversation || {};
fetch(`/api/ask/${endpoint}/abort`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
abortKey: conversationId,
}),
})
.then((response) => response.json())
.then((data) => {
console.log('aborted', data);
cancelHandler(data, submission);
})
.catch((error) => {
console.error('Error aborting request');
console.error(error);
// errorHandler({ text: 'Error aborting request' }, { ...submission, message });
});
return;
};
useEffect(() => {
if (submission === null) {
return;
}
if (Object.keys(submission).length === 0) {
return;
}
let { message } = submission;
const payloadData = createPayload(submission);
let { payload } = payloadData;
if (payload.endpoint === EModelEndpoint.assistant) {
payload = removeNullishValues(payload);
}
const events = new SSE(payloadData.server, {
payload: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
});
events.onmessage = (e: MessageEvent) => {
const data = JSON.parse(e.data);
if (data.final) {
const { plugins } = data;
finalHandler(data, { ...submission, plugins, message });
startupConfig?.checkBalance && balanceQuery.refetch();
console.log('final', data);
}
if (data.created) {
message = {
...data.message,
overrideParentMessageId: message?.overrideParentMessageId,
};
createdHandler(data, { ...submission, message });
} else {
const text = data.text || data.response;
const { plugin, plugins } = data;
if (data.message) {
messageHandler(text, { ...submission, plugin, plugins, message });
}
}
};
events.onopen = () => console.log('connection is opened');
events.oncancel = () =>
abortConversation(message?.conversationId ?? submission?.conversationId, submission);
events.onerror = function (e: MessageEvent) {
console.log('error in opening conn.');
startupConfig?.checkBalance && balanceQuery.refetch();
events.close();
let data = {} as TResData;
try {
data = JSON.parse(e.data);
} catch (error) {
console.error(error);
console.log(e);
}
errorHandler(data, { ...submission, message });
};
setIsSubmitting(true);
events.stream();
return () => {
const isCancelled = events.readyState <= 1;
events.close();
// setSource(null);
if (isCancelled) {
const e = new Event('cancel');
events.dispatchEvent(e);
}
setIsSubmitting(false);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [submission]);
}

View file

@ -0,0 +1,161 @@
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { TPreset, TPlugin, tConversationSchema, EModelEndpoint } from 'librechat-data-provider';
import type { TSetExample, TSetOption, TSetOptionsPayload } from '~/common';
import usePresetIndexOptions from './usePresetIndexOptions';
import { useChatContext } from '~/Providers/ChatContext';
import useLocalStorage from './useLocalStorage';
import store from '~/store';
type TUseSetOptions = (preset?: TPreset | boolean | null) => TSetOptionsPayload;
const useSetOptions: TUseSetOptions = (preset = false) => {
const setShowPluginStoreDialog = useSetRecoilState(store.showPluginStoreDialog);
const availableTools = useRecoilValue(store.availableTools);
const { conversation, setConversation } = useChatContext();
const [lastBingSettings, setLastBingSettings] = useLocalStorage('lastBingSettings', {});
const [lastModel, setLastModel] = useLocalStorage('lastSelectedModel', {
primaryModel: '',
secondaryModel: '',
});
const result = usePresetIndexOptions(preset);
if (result && typeof result !== 'boolean') {
return result;
}
const setOption: TSetOption = (param) => (newValue) => {
const { endpoint } = conversation ?? {};
const update = {};
update[param] = newValue;
if (param === 'model' && endpoint) {
const lastModelUpdate = { ...lastModel, [endpoint]: newValue };
setLastModel(lastModelUpdate);
} else if (param === 'jailbreak' && endpoint) {
setLastBingSettings({ ...lastBingSettings, jailbreak: newValue });
}
setConversation((prevState) =>
tConversationSchema.parse({
...prevState,
...update,
}),
);
};
const setExample: TSetExample = (i, type, newValue = null) => {
const update = {};
const current = conversation?.examples?.slice() || [];
const currentExample = { ...current[i] } || {};
currentExample[type] = { content: newValue };
current[i] = currentExample;
update['examples'] = current;
setConversation((prevState) =>
tConversationSchema.parse({
...prevState,
...update,
}),
);
};
const addExample: () => void = () => {
const update = {};
const current = conversation?.examples?.slice() || [];
current.push({ input: { content: '' }, output: { content: '' } });
update['examples'] = current;
setConversation((prevState) =>
tConversationSchema.parse({
...prevState,
...update,
}),
);
};
const removeExample: () => void = () => {
const update = {};
const current = conversation?.examples?.slice() || [];
if (current.length <= 1) {
update['examples'] = [{ input: { content: '' }, output: { content: '' } }];
setConversation((prevState) =>
tConversationSchema.parse({
...prevState,
...update,
}),
);
return;
}
current.pop();
update['examples'] = current;
setConversation((prevState) =>
tConversationSchema.parse({
...prevState,
...update,
}),
);
};
function checkPluginSelection(value: string) {
if (!conversation?.tools) {
return false;
}
return conversation.tools.find((el) => el.pluginKey === value) ? true : false;
}
const setAgentOption: TSetOption = (param) => (newValue) => {
const editableConvo = JSON.stringify(conversation);
const convo = JSON.parse(editableConvo);
const { agentOptions } = convo;
agentOptions[param] = newValue;
console.log('agentOptions', agentOptions, param, newValue);
if (param === 'model' && typeof newValue === 'string') {
const lastModelUpdate = { ...lastModel, [EModelEndpoint.gptPlugins]: newValue };
lastModelUpdate.secondaryModel = newValue;
setLastModel(lastModelUpdate);
}
setConversation((prevState) =>
tConversationSchema.parse({
...prevState,
agentOptions,
}),
);
};
const setTools: (newValue: string) => void = (newValue) => {
if (newValue === 'pluginStore') {
setShowPluginStoreDialog(true);
return;
}
const update = {};
const current = conversation?.tools || [];
const isSelected = checkPluginSelection(newValue);
const tool =
availableTools[availableTools.findIndex((el: TPlugin) => el.pluginKey === newValue)];
if (isSelected) {
update['tools'] = current.filter((el) => el.pluginKey !== newValue);
} else {
update['tools'] = [...current, tool];
}
localStorage.setItem('lastSelectedTools', JSON.stringify(update['tools']));
setConversation((prevState) =>
tConversationSchema.parse({
...prevState,
...update,
}),
);
};
return {
setOption,
setExample,
addExample,
removeExample,
setAgentOption,
checkPluginSelection,
setTools,
};
};
export default useSetOptions;

View file

@ -0,0 +1,31 @@
import type { TConversation } from 'librechat-data-provider';
import useLocalStorage from './useLocalStorage';
const useSetStorage = () => {
const [lastBingSettings, setLastBingSettings] = useLocalStorage('lastBingSettings', {});
const setLastConvo = useLocalStorage('lastConversationSetup', {})[1];
const [lastModel, setLastModel] = useLocalStorage('lastSelectedModel', {
primaryModel: '',
secondaryModel: '',
});
const setStorage = (conversation: TConversation) => {
const { endpoint } = conversation;
if (endpoint && endpoint !== 'bingAI') {
const lastModelUpdate = { ...lastModel, [endpoint]: conversation?.model };
if (endpoint === 'gptPlugins') {
lastModelUpdate.secondaryModel = conversation?.agentOptions?.model ?? '';
}
setLastModel(lastModelUpdate);
} else if (endpoint === 'bingAI') {
const { jailbreak, toneStyle } = conversation;
setLastBingSettings({ ...lastBingSettings, jailbreak, toneStyle });
}
setLastConvo(conversation);
};
return setStorage;
};
export default useSetStorage;

View file

@ -0,0 +1,113 @@
import { useEffect, useRef } from 'react';
import type { KeyboardEvent } from 'react';
import { useChatContext } from '~/Providers/ChatContext';
type KeyEvent = KeyboardEvent<HTMLTextAreaElement>;
export default function useTextarea({ setText, submitMessage }) {
const {
conversation,
isSubmitting,
latestMessage,
setShowBingToneSetting,
textareaHeight,
setTextareaHeight,
} = useChatContext();
const isComposing = useRef(false);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const isNotAppendable = (latestMessage?.unfinished && !isSubmitting) || latestMessage?.error;
const { conversationId, jailbreak } = conversation || {};
// auto focus to input, when enter a conversation.
useEffect(() => {
if (!conversationId) {
return;
}
// Prevents Settings from not showing on new conversation, also prevents showing toneStyle change without jailbreak
if (conversationId === 'new' || !jailbreak) {
setShowBingToneSetting(false);
}
if (conversationId !== 'search') {
inputRef.current?.focus();
}
// setShowBingToneSetting is a recoil setter, so it doesn't need to be in the dependency array
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversationId, jailbreak]);
useEffect(() => {
const timeoutId = setTimeout(() => {
inputRef.current?.focus();
}, 100);
return () => clearTimeout(timeoutId);
}, [isSubmitting]);
const handleKeyDown = (e: KeyEvent) => {
if (e.key === 'Enter' && isSubmitting) {
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
}
if (e.key === 'Enter' && !e.shiftKey && !isComposing?.current) {
submitMessage();
}
};
const handleKeyUp = (e: KeyEvent) => {
const target = e.target as HTMLTextAreaElement;
if (e.keyCode === 8 && target.value.trim() === '') {
setText(target.value);
}
if (e.key === 'Enter' && e.shiftKey) {
return console.log('Enter + Shift');
}
if (isSubmitting) {
return;
}
};
const handleCompositionStart = () => {
isComposing.current = true;
};
const handleCompositionEnd = () => {
isComposing.current = false;
};
const getPlaceholderText = () => {
if (isNotAppendable) {
return 'Edit your message or Regenerate.';
}
return 'Message ChatGPT…';
};
const onHeightChange = (height: number) => {
if (height > 208 && textareaHeight < 208) {
setTextareaHeight(Math.min(height, 208));
} else if (height > 208) {
return;
} else {
setTextareaHeight(height);
}
};
return {
inputRef,
handleKeyDown,
handleKeyUp,
handleCompositionStart,
handleCompositionEnd,
placeholder: getPlaceholderText(),
onHeightChange,
};
}