🌡️ feat: Periodic Health Check to prevent UI Inactivity Connection Errors (#3589)

* 🌡️ feat: Periodic Health Check to prevent UI Inactivity Connection Errors

* feat: Add refetchOnWindowFocus option for health check

* feat: programmatically scroll to end when a chat request is initiated (and messages have rendered)
This commit is contained in:
Danny Avila 2024-08-08 14:52:12 -04:00 committed by GitHub
parent cf393b1308
commit 6ea2628b56
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 81 additions and 14 deletions

View file

@ -120,8 +120,6 @@ module.exports = {
], ],
rules: { rules: {
'@typescript-eslint/no-explicit-any': 'error', '@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unnecessary-condition': 'warn',
'@typescript-eslint/strict-boolean-expressions': 'warn',
}, },
}, },
{ {

View file

@ -33,7 +33,7 @@ export default function MessagesView({
return ( return (
<div className="flex-1 overflow-hidden overflow-y-auto"> <div className="flex-1 overflow-hidden overflow-y-auto">
<div className="dark:gpt-dark-gray relative h-full"> <div className="relative h-full">
<div <div
onScroll={debouncedHandleScroll} onScroll={debouncedHandleScroll}
ref={scrollableRef} ref={scrollableRef}
@ -68,7 +68,8 @@ export default function MessagesView({
</> </>
)} )}
<div <div
className="dark:gpt-dark-gray group h-0 w-full flex-shrink-0 dark:border-gray-800/50" id="messages-end"
className="group h-0 w-full flex-shrink-0"
ref={messagesEndRef} ref={messagesEndRef}
/> />
</div> </div>

View file

@ -0,0 +1,48 @@
import { useCallback, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { QueryKeys, Time, dataService } from 'librechat-data-provider';
import { logger } from '~/utils';
export const useHealthCheck = () => {
useQuery([QueryKeys.health], () => dataService.healthCheck(), {
refetchInterval: Time.TEN_MINUTES,
retry: false,
onError: (error) => {
console.error('Health check failed:', error);
},
cacheTime: 0,
staleTime: 0,
refetchOnWindowFocus: (query) => {
if (!query.state.dataUpdatedAt) {
return true;
}
const lastUpdated = new Date(query.state.dataUpdatedAt);
const tenMinutesAgo = new Date(Date.now() - Time.TEN_MINUTES);
logger.log(`Last health check: ${lastUpdated.toISOString()}`);
logger.log(`Ten minutes ago: ${tenMinutesAgo.toISOString()}`);
return lastUpdated < tenMinutesAgo;
},
});
};
export const useInteractionHealthCheck = () => {
const queryClient = useQueryClient();
const lastInteractionTimeRef = useRef(Date.now());
const checkHealthOnInteraction = useCallback(() => {
const currentTime = Date.now();
if (currentTime - lastInteractionTimeRef.current > Time.FIVE_MINUTES) {
logger.log(
'Checking health on interaction. Time elapsed:',
currentTime - lastInteractionTimeRef.current,
);
queryClient.invalidateQueries([QueryKeys.health]);
lastInteractionTimeRef.current = currentTime;
}
}, [queryClient]);
return checkHealthOnInteraction;
};

View file

@ -1,3 +1,4 @@
export * from './connection';
export * from './mutations'; export * from './mutations';
export * from './prompts'; export * from './prompts';
export * from './queries'; export * from './queries';

View file

@ -18,8 +18,8 @@ import type {
import type { SetterOrUpdater } from 'recoil'; import type { SetterOrUpdater } from 'recoil';
import type { TAskFunction, ExtendedFile } from '~/common'; import type { TAskFunction, ExtendedFile } from '~/common';
import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete'; import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete';
import { getEndpointField, logger, scrollToEnd } from '~/utils';
import useGetSender from '~/hooks/Conversations/useGetSender'; import useGetSender from '~/hooks/Conversations/useGetSender';
import { getEndpointField, logger } from '~/utils';
import useUserKey from '~/hooks/Input/useUserKey'; import useUserKey from '~/hooks/Input/useUserKey';
import store from '~/store'; import store from '~/store';
@ -249,6 +249,8 @@ export default function useChatFunctions({
if (index === 0 && setLatestMessage) { if (index === 0 && setLatestMessage) {
setLatestMessage(initialResponse); setLatestMessage(initialResponse);
} }
scrollToEnd();
setSubmission(submission); setSubmission(submission);
logger.log('Submission:'); logger.log('Submission:');
logger.dir(submission, { depth: null }); logger.dir(submission, { depth: null });

View file

@ -8,6 +8,7 @@ import { forceResize, insertTextAtCursor, getAssistantName } from '~/utils';
import { useAssistantsMapContext } from '~/Providers/AssistantsMapContext'; import { useAssistantsMapContext } from '~/Providers/AssistantsMapContext';
import useGetSender from '~/hooks/Conversations/useGetSender'; import useGetSender from '~/hooks/Conversations/useGetSender';
import useFileHandling from '~/hooks/Files/useFileHandling'; import useFileHandling from '~/hooks/Files/useFileHandling';
import { useInteractionHealthCheck } from '~/data-provider';
import { useChatContext } from '~/Providers/ChatContext'; import { useChatContext } from '~/Providers/ChatContext';
import useLocalize from '~/hooks/useLocalize'; import useLocalize from '~/hooks/useLocalize';
import { globalAudioId } from '~/common'; import { globalAudioId } from '~/common';
@ -29,6 +30,7 @@ export default function useTextarea({
const isComposing = useRef(false); const isComposing = useRef(false);
const { handleFiles } = useFileHandling(); const { handleFiles } = useFileHandling();
const assistantMap = useAssistantsMapContext(); const assistantMap = useAssistantsMapContext();
const checkHealth = useInteractionHealthCheck();
const enterToSend = useRecoilValue(store.enterToSend); const enterToSend = useRecoilValue(store.enterToSend);
const { const {
@ -152,6 +154,8 @@ export default function useTextarea({
return; return;
} }
checkHealth();
const isNonShiftEnter = e.key === 'Enter' && !e.shiftKey; const isNonShiftEnter = e.key === 'Enter' && !e.shiftKey;
const isCtrlEnter = e.key === 'Enter' && e.ctrlKey; const isCtrlEnter = e.key === 'Enter' && e.ctrlKey;
@ -185,7 +189,7 @@ export default function useTextarea({
submitButtonRef.current?.click(); submitButtonRef.current?.click();
} }
}, },
[isSubmitting, filesLoading, enterToSend, textAreaRef, submitButtonRef], [isSubmitting, checkHealth, filesLoading, enterToSend, textAreaRef, submitButtonRef],
); );
const handleCompositionStart = () => { const handleCompositionStart = () => {

View file

@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { EModelEndpoint } from 'librechat-data-provider'; import { Constants, EModelEndpoint } from 'librechat-data-provider';
import { import {
useGetModelsQuery, useGetModelsQuery,
useGetStartupConfig, useGetStartupConfig,
@ -8,14 +8,15 @@ import {
} from 'librechat-data-provider/react-query'; } from 'librechat-data-provider/react-query';
import type { TPreset } from 'librechat-data-provider'; import type { TPreset } from 'librechat-data-provider';
import { useNewConvo, useAppStartup, useAssistantListMap } from '~/hooks'; import { useNewConvo, useAppStartup, useAssistantListMap } from '~/hooks';
import { useGetConvoIdQuery, useHealthCheck } from '~/data-provider';
import { getDefaultModelSpec, getModelSpecIconURL } from '~/utils'; import { getDefaultModelSpec, getModelSpecIconURL } from '~/utils';
import { useGetConvoIdQuery } from '~/data-provider';
import ChatView from '~/components/Chat/ChatView'; import ChatView from '~/components/Chat/ChatView';
import useAuthRedirect from './useAuthRedirect'; import useAuthRedirect from './useAuthRedirect';
import { Spinner } from '~/components/svg'; import { Spinner } from '~/components/svg';
import store from '~/store'; import store from '~/store';
export default function ChatRoute() { export default function ChatRoute() {
useHealthCheck();
const { data: startupConfig } = useGetStartupConfig(); const { data: startupConfig } = useGetStartupConfig();
const { isAuthenticated, user } = useAuthRedirect(); const { isAuthenticated, user } = useAuthRedirect();
useAppStartup({ startupConfig, user }); useAppStartup({ startupConfig, user });
@ -32,7 +33,7 @@ export default function ChatRoute() {
refetchOnMount: 'always', refetchOnMount: 'always',
}); });
const initialConvoQuery = useGetConvoIdQuery(conversationId ?? '', { const initialConvoQuery = useGetConvoIdQuery(conversationId ?? '', {
enabled: isAuthenticated && conversationId !== 'new', enabled: isAuthenticated && conversationId !== Constants.NEW_CONVO,
}); });
const endpointsQuery = useGetEndpointsQuery({ enabled: isAuthenticated }); const endpointsQuery = useGetEndpointsQuery({ enabled: isAuthenticated });
const assistantListMap = useAssistantListMap(); const assistantListMap = useAssistantListMap();
@ -45,7 +46,7 @@ export default function ChatRoute() {
return; return;
} }
if (conversationId === 'new' && endpointsQuery.data && modelsQuery.data) { if (conversationId === Constants.NEW_CONVO && endpointsQuery.data && modelsQuery.data) {
const spec = getDefaultModelSpec(startupConfig.modelSpecs?.list); const spec = getDefaultModelSpec(startupConfig.modelSpecs?.list);
newConversation({ newConversation({
@ -73,7 +74,7 @@ export default function ChatRoute() {
}); });
hasSetConversation.current = true; hasSetConversation.current = true;
} else if ( } else if (
conversationId === 'new' && conversationId === Constants.NEW_CONVO &&
assistantListMap[EModelEndpoint.assistants] && assistantListMap[EModelEndpoint.assistants] &&
assistantListMap[EModelEndpoint.azureAssistants] assistantListMap[EModelEndpoint.azureAssistants]
) { ) {

View file

@ -43,3 +43,12 @@ export const getTextKey = (message?: TMessage | null, convoId?: string | null) =
Constants.COMMON_DIVIDER Constants.COMMON_DIVIDER
}${message.conversationId ?? convoId}`; }${message.conversationId ?? convoId}`;
}; };
export const scrollToEnd = () => {
setTimeout(() => {
const messagesEndElement = document.getElementById('messages-end');
if (messagesEndElement) {
messagesEndElement.scrollIntoView({ behavior: 'instant' });
}
}, 500);
};

View file

@ -6,9 +6,6 @@ module.exports = {
// darkMode: 'class', // darkMode: 'class',
darkMode: ['class'], darkMode: ['class'],
theme: { theme: {
// colors: {
// 'gpt-dark-gray': '#171717',
// },
fontFamily: { fontFamily: {
sans: ['Inter', 'sans-serif'], sans: ['Inter', 'sans-serif'],
mono: ['Roboto Mono', 'monospace'], mono: ['Roboto Mono', 'monospace'],

View file

@ -1,5 +1,6 @@
import type { AssistantsEndpoint } from './schemas'; import type { AssistantsEndpoint } from './schemas';
export const health = () => '/api/health';
export const user = () => '/api/user'; export const user = () => '/api/user';
export const balance = () => '/api/balance'; export const balance = () => '/api/balance';

View file

@ -573,3 +573,7 @@ export function addTagToConversation(
export function rebuildConversationTags(): Promise<t.TConversationTagsResponse> { export function rebuildConversationTags(): Promise<t.TConversationTagsResponse> {
return request.post(endpoints.conversationTags('rebuild')); return request.post(endpoints.conversationTags('rebuild'));
} }
export function healthCheck(): Promise<string> {
return request.get(endpoints.health());
}

View file

@ -37,6 +37,7 @@ export enum QueryKeys {
randomPrompts = 'randomPrompts', randomPrompts = 'randomPrompts',
roles = 'roles', roles = 'roles',
conversationTags = 'conversationTags', conversationTags = 'conversationTags',
health = 'health',
} }
export enum MutationKeys { export enum MutationKeys {