mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
☕ feat: Prevent Screen Sleep During Response Generation (#10597)
* feat: prevent screen sleep during response generation * refactor: screen wake lock functionality during response generation * chore: import order * chore: reorder import statements in WakeLockManager component --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
5ac9ac57cc
commit
040d083088
7 changed files with 262 additions and 0 deletions
|
|
@ -8,6 +8,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
|
import { Toast, ThemeProvider, ToastProvider } from '@librechat/client';
|
||||||
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
|
||||||
import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
|
import { ScreenshotProvider, useApiErrorBoundary } from './hooks';
|
||||||
|
import WakeLockManager from '~/components/System/WakeLockManager';
|
||||||
import { getThemeFromEnv } from './utils/getThemeFromEnv';
|
import { getThemeFromEnv } from './utils/getThemeFromEnv';
|
||||||
import { initializeFontSize } from '~/store/fontSize';
|
import { initializeFontSize } from '~/store/fontSize';
|
||||||
import { LiveAnnouncer } from '~/a11y';
|
import { LiveAnnouncer } from '~/a11y';
|
||||||
|
|
@ -51,6 +52,7 @@ const App = () => {
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<DndProvider backend={HTML5Backend}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
<WakeLockManager />
|
||||||
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||||
<Toast />
|
<Toast />
|
||||||
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[1000] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />
|
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[1000] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,13 @@ const toggleSwitchConfigs = [
|
||||||
hoverCardText: undefined,
|
hoverCardText: undefined,
|
||||||
key: 'hideSidePanel',
|
key: 'hideSidePanel',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
stateAtom: store.keepScreenAwake,
|
||||||
|
localizationKey: 'com_nav_keep_screen_awake',
|
||||||
|
switchId: 'keepScreenAwake',
|
||||||
|
hoverCardText: undefined,
|
||||||
|
key: 'keepScreenAwake',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ThemeSelector = ({
|
export const ThemeSelector = ({
|
||||||
|
|
|
||||||
31
client/src/components/System/WakeLockManager.tsx
Normal file
31
client/src/components/System/WakeLockManager.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
|
import useWakeLock from '~/hooks/useWakeLock';
|
||||||
|
import store from '~/store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WakeLockManager Component
|
||||||
|
*
|
||||||
|
* Manages the Screen Wake Lock during AI response generation to prevent
|
||||||
|
* device screens from sleeping or dimming during long-running operations.
|
||||||
|
*
|
||||||
|
* The wake lock is only active when:
|
||||||
|
* 1. Any conversation is currently generating a response (anySubmittingSelector)
|
||||||
|
* 2. User has not disabled the feature in settings (keepScreenAwake preference)
|
||||||
|
*
|
||||||
|
* This component is rendered at the root level of the application
|
||||||
|
* to ensure wake lock state persists across all conversations and routes.
|
||||||
|
*
|
||||||
|
* @see useWakeLock - The hook that manages the actual wake lock implementation
|
||||||
|
* @see anySubmittingSelector - Recoil selector tracking if any conversation is generating
|
||||||
|
*/
|
||||||
|
const WakeLockManager = () => {
|
||||||
|
const isSubmitting = useRecoilValue(store.anySubmittingSelector);
|
||||||
|
const keepScreenAwake = useRecoilValue(store.keepScreenAwake);
|
||||||
|
|
||||||
|
const shouldPreventSleep = isSubmitting && keepScreenAwake;
|
||||||
|
useWakeLock(shouldPreventSleep);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WakeLockManager;
|
||||||
211
client/src/hooks/useWakeLock.ts
Normal file
211
client/src/hooks/useWakeLock.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended Navigator type that includes the Screen Wake Lock API
|
||||||
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API
|
||||||
|
*/
|
||||||
|
type WakeLockCapableNavigator = Navigator & {
|
||||||
|
wakeLock?: {
|
||||||
|
request: (type: WakeLockType) => Promise<WakeLockSentinel>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if we're in a client-side environment (browser)
|
||||||
|
* Prevents SSR issues by verifying window, navigator, and document exist
|
||||||
|
*/
|
||||||
|
const isClientEnvironment =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
typeof navigator !== 'undefined' &&
|
||||||
|
typeof document !== 'undefined';
|
||||||
|
|
||||||
|
const getNavigator = () => navigator as WakeLockCapableNavigator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if the browser supports the Screen Wake Lock API
|
||||||
|
* Checking outside component scope for better performance
|
||||||
|
*/
|
||||||
|
const supportsWakeLock = isClientEnvironment && 'wakeLock' in navigator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable/disable debug logging for wake lock operations
|
||||||
|
* Set to true during development to see wake lock lifecycle events
|
||||||
|
*/
|
||||||
|
const DEBUG_WAKE_LOCK = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to prevent screen from sleeping during critical operations
|
||||||
|
* Uses the Screen Wake Lock API to keep the device screen active
|
||||||
|
*
|
||||||
|
* @param shouldHold - Boolean flag indicating whether to acquire/hold the wake lock
|
||||||
|
* @returns void - This hook manages wake lock state internally
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const isGeneratingResponse = useRecoilValue(anySubmittingSelector);
|
||||||
|
* useWakeLock(isGeneratingResponse);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* - Automatically handles page visibility changes (reacquires lock when tab becomes visible)
|
||||||
|
* - Properly cleans up lock on unmount or when shouldHold becomes false
|
||||||
|
* - Gracefully degrades on browsers without Wake Lock API support
|
||||||
|
* - Wake locks are automatically released when user switches tabs
|
||||||
|
*/
|
||||||
|
export const useWakeLock = (shouldHold: boolean) => {
|
||||||
|
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!supportsWakeLock) {
|
||||||
|
if (DEBUG_WAKE_LOCK) {
|
||||||
|
console.log('[WakeLock] API not supported in this browser');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flag to prevent operations after effect cleanup
|
||||||
|
* Essential for avoiding race conditions when:
|
||||||
|
* - Component unmounts while lock is being acquired
|
||||||
|
* - shouldHold changes while async operations are in flight
|
||||||
|
* - Multiple visibility change events fire in quick succession
|
||||||
|
*/
|
||||||
|
let cancelled = false;
|
||||||
|
const { wakeLock } = getNavigator();
|
||||||
|
|
||||||
|
if (!wakeLock) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases the currently held wake lock
|
||||||
|
* Called when: shouldHold becomes false, component unmounts, or before acquiring new lock
|
||||||
|
*/
|
||||||
|
const releaseLock = async () => {
|
||||||
|
if (!wakeLockRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await wakeLockRef.current.release();
|
||||||
|
if (DEBUG_WAKE_LOCK) {
|
||||||
|
console.log('[WakeLock] Lock released successfully');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[WakeLock] release failed', error);
|
||||||
|
} finally {
|
||||||
|
wakeLockRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests a new wake lock from the browser
|
||||||
|
* Checks multiple conditions before requesting to avoid unnecessary API calls:
|
||||||
|
* - shouldHold must be true (user wants lock)
|
||||||
|
* - cancelled must be false (effect still active)
|
||||||
|
* - document must be visible (API requirement - locks only work in visible tabs)
|
||||||
|
* - no existing lock (prevent duplicate locks)
|
||||||
|
*/
|
||||||
|
const requestLock = async () => {
|
||||||
|
if (
|
||||||
|
!shouldHold ||
|
||||||
|
cancelled ||
|
||||||
|
document.visibilityState !== 'visible' ||
|
||||||
|
wakeLockRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sentinel = await wakeLock.request('screen');
|
||||||
|
wakeLockRef.current = sentinel;
|
||||||
|
|
||||||
|
if (DEBUG_WAKE_LOCK) {
|
||||||
|
console.log('[WakeLock] Lock acquired successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRITICAL: Recursive re-acquire logic for automatic lock restoration
|
||||||
|
*
|
||||||
|
* The browser automatically releases wake locks when:
|
||||||
|
* - User switches to a different tab
|
||||||
|
* - User minimizes the browser window
|
||||||
|
* - Device goes to sleep
|
||||||
|
* - User navigates to a different page
|
||||||
|
*
|
||||||
|
* This handler automatically re-acquires the lock when:
|
||||||
|
* 1. The lock is released by the browser
|
||||||
|
* 2. The effect is still active (not cancelled)
|
||||||
|
* 3. The component still wants to hold the lock (shouldHold is true)
|
||||||
|
* 4. The tab is visible again (document.visibilityState === 'visible')
|
||||||
|
*
|
||||||
|
* This ensures users don't need to manually restart their work after
|
||||||
|
* switching tabs during long-running operations like AI response generation.
|
||||||
|
*/
|
||||||
|
const handleRelease = () => {
|
||||||
|
wakeLockRef.current = null;
|
||||||
|
sentinel.removeEventListener('release', handleRelease);
|
||||||
|
|
||||||
|
if (DEBUG_WAKE_LOCK) {
|
||||||
|
console.log('[WakeLock] Lock released, checking if re-acquire needed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled && shouldHold && document.visibilityState === 'visible') {
|
||||||
|
if (DEBUG_WAKE_LOCK) {
|
||||||
|
console.log('[WakeLock] Re-acquiring lock');
|
||||||
|
}
|
||||||
|
void requestLock();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sentinel.addEventListener('release', handleRelease);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[WakeLock] request failed', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles browser tab visibility changes
|
||||||
|
* When user returns to the tab, re-acquire the lock if it's still needed
|
||||||
|
* This is necessary because wake locks are automatically released when tab becomes hidden
|
||||||
|
*/
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEBUG_WAKE_LOCK) {
|
||||||
|
console.log('[WakeLock] Visibility changed:', document.visibilityState);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.visibilityState === 'visible' && shouldHold) {
|
||||||
|
void requestLock();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (shouldHold) {
|
||||||
|
void requestLock();
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
} else {
|
||||||
|
void releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup function runs when:
|
||||||
|
* - Component unmounts
|
||||||
|
* - shouldHold changes
|
||||||
|
* - Effect dependencies change
|
||||||
|
*
|
||||||
|
* Sets cancelled flag first to prevent any in-flight operations from completing
|
||||||
|
* Removes event listeners to prevent memory leaks
|
||||||
|
* Releases any held wake lock
|
||||||
|
*/
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
void releaseLock();
|
||||||
|
};
|
||||||
|
}, [shouldHold]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useWakeLock;
|
||||||
|
|
@ -480,6 +480,7 @@
|
||||||
"com_nav_font_size_xs": "Extra Small",
|
"com_nav_font_size_xs": "Extra Small",
|
||||||
"com_nav_help_faq": "Help & FAQ",
|
"com_nav_help_faq": "Help & FAQ",
|
||||||
"com_nav_hide_panel": "Hide right-most side panel",
|
"com_nav_hide_panel": "Hide right-most side panel",
|
||||||
|
"com_nav_keep_screen_awake": "Keep screen awake during response generation",
|
||||||
"com_nav_info_balance": "Balance shows how many token credits you have left to use. Token credits translate to monetary value (e.g., 1000 credits = $0.001 USD)",
|
"com_nav_info_balance": "Balance shows how many token credits you have left to use. Token credits translate to monetary value (e.g., 1000 credits = $0.001 USD)",
|
||||||
"com_nav_info_code_artifacts": "Enables the display of experimental code artifacts next to the chat",
|
"com_nav_info_code_artifacts": "Enables the display of experimental code artifacts next to the chat",
|
||||||
"com_nav_info_code_artifacts_agent": "Enables the use of code artifacts for this agent. By default, additional instructions specific to the use of artifacts are added, unless \"Custom Prompt Mode\" is enabled.",
|
"com_nav_info_code_artifacts_agent": "Enables the use of code artifacts for this agent. By default, additional instructions specific to the use of artifacts are added, unless \"Custom Prompt Mode\" is enabled.",
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,14 @@ const isSubmittingFamily = atomFamily({
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const anySubmittingSelector = selector<boolean>({
|
||||||
|
key: 'anySubmittingSelector',
|
||||||
|
get: ({ get }) => {
|
||||||
|
const keys = get(conversationKeysAtom);
|
||||||
|
return keys.some((key) => get(isSubmittingFamily(key)) === true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const optionSettingsFamily = atomFamily<TOptionSettings, string | number>({
|
const optionSettingsFamily = atomFamily<TOptionSettings, string | number>({
|
||||||
key: 'optionSettingsByIndex',
|
key: 'optionSettingsByIndex',
|
||||||
default: {},
|
default: {},
|
||||||
|
|
@ -399,6 +407,7 @@ export default {
|
||||||
showPopoverFamily,
|
showPopoverFamily,
|
||||||
latestMessageFamily,
|
latestMessageFamily,
|
||||||
messagesSiblingIdxFamily,
|
messagesSiblingIdxFamily,
|
||||||
|
anySubmittingSelector,
|
||||||
allConversationsSelector,
|
allConversationsSelector,
|
||||||
conversationByKeySelector,
|
conversationByKeySelector,
|
||||||
useClearConvoState,
|
useClearConvoState,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ const localStorageAtoms = {
|
||||||
LocalStorageKeys.ENABLE_USER_MSG_MARKDOWN,
|
LocalStorageKeys.ENABLE_USER_MSG_MARKDOWN,
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
|
keepScreenAwake: atomWithLocalStorage('keepScreenAwake', true),
|
||||||
|
|
||||||
// Chat settings
|
// Chat settings
|
||||||
enterToSend: atomWithLocalStorage('enterToSend', true),
|
enterToSend: atomWithLocalStorage('enterToSend', true),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue