mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 18:00:15 +01:00
🌡️ 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:
parent
cf393b1308
commit
6ea2628b56
12 changed files with 81 additions and 14 deletions
|
|
@ -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',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
48
client/src/data-provider/connection.ts
Normal file
48
client/src/data-provider/connection.ts
Normal 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;
|
||||||
|
};
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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 = () => {
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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'],
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue