mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🧹 perf: Share Voices Store, Gate Timestamp Ticker, Stabilize Greeting Springs (#14335)
This commit is contained in:
parent
eeb4ea226c
commit
f4a0e0c194
5 changed files with 118 additions and 76 deletions
|
|
@ -19,6 +19,11 @@ import { useLocalize, useAuthContext } from '~/hooks';
|
|||
const containerClassName =
|
||||
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white dark:bg-presentation dark:text-white text-black dark:after:shadow-none ';
|
||||
|
||||
/** Stable references: fresh literals re-initialized SplitText's springs and
|
||||
* re-rendered every grapheme span on each Landing render. */
|
||||
const greetingAnimationFrom = { opacity: 0, transform: 'translate3d(0,50px,0)' };
|
||||
const greetingAnimationTo = { opacity: 1, transform: 'translate3d(0,0,0)' };
|
||||
|
||||
function getTextSizeClass(text: string | undefined | null) {
|
||||
if (!text) {
|
||||
return 'text-xl sm:text-2xl';
|
||||
|
|
@ -202,8 +207,8 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
|
|||
className={`${getTextSizeClass(name)} font-medium text-text-primary`}
|
||||
delay={50}
|
||||
textAlign="center"
|
||||
animationFrom={{ opacity: 0, transform: 'translate3d(0,50px,0)' }}
|
||||
animationTo={{ opacity: 1, transform: 'translate3d(0,0,0)' }}
|
||||
animationFrom={greetingAnimationFrom}
|
||||
animationTo={greetingAnimationTo}
|
||||
easing={easings.easeOutCubic}
|
||||
threshold={0}
|
||||
rootMargin="0px"
|
||||
|
|
@ -217,8 +222,8 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
|
|||
className={`${getTextSizeClass(greetingText)} font-medium text-text-primary`}
|
||||
delay={50}
|
||||
textAlign="center"
|
||||
animationFrom={{ opacity: 0, transform: 'translate3d(0,50px,0)' }}
|
||||
animationTo={{ opacity: 1, transform: 'translate3d(0,0,0)' }}
|
||||
animationFrom={greetingAnimationFrom}
|
||||
animationTo={greetingAnimationTo}
|
||||
easing={easings.easeOutCubic}
|
||||
threshold={0}
|
||||
rootMargin="0px"
|
||||
|
|
|
|||
|
|
@ -2,23 +2,9 @@ import { useTranslation } from 'react-i18next';
|
|||
import useTimeTick from '~/hooks/useTimeTick';
|
||||
import { getMessageTimestamp } from '~/utils';
|
||||
|
||||
/**
|
||||
* Inline message timestamp shown next to the author name in the message header.
|
||||
* On hover-capable pointers it reveals on row hover/focus; on touch and other
|
||||
* non-hover devices it stays visible. Recent messages show the relative form
|
||||
* ("10 minutes ago") with the absolute date on hover; older messages show the
|
||||
* absolute date directly.
|
||||
*/
|
||||
export default function MessageTimestamp({ value }: { value?: string | null }) {
|
||||
const { i18n } = useTranslation();
|
||||
// Re-render on a shared interval so relative labels stay current while idle.
|
||||
useTimeTick();
|
||||
const timestamp = getMessageTimestamp(value, i18n.language);
|
||||
|
||||
if (!timestamp) {
|
||||
return null;
|
||||
}
|
||||
type Timestamp = NonNullable<ReturnType<typeof getMessageTimestamp>>;
|
||||
|
||||
function TimestampText({ timestamp }: { timestamp: Timestamp }) {
|
||||
return (
|
||||
<time
|
||||
dateTime={timestamp.iso}
|
||||
|
|
@ -29,3 +15,38 @@ export default function MessageTimestamp({ value }: { value?: string | null }) {
|
|||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
/** Only recent timestamps subscribe to the shared minute ticker, so the
|
||||
* per-minute sweep re-renders a handful of rows instead of every message. */
|
||||
function RecentTimestamp({ value, language }: { value?: string | null; language: string }) {
|
||||
useTimeTick();
|
||||
const timestamp = getMessageTimestamp(value, language);
|
||||
|
||||
if (!timestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TimestampText timestamp={timestamp} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline message timestamp shown next to the author name in the message header.
|
||||
* On hover-capable pointers it reveals on row hover/focus; on touch and other
|
||||
* non-hover devices it stays visible. Recent messages show the relative form
|
||||
* ("10 minutes ago") with the absolute date on hover; older messages show the
|
||||
* absolute date directly.
|
||||
*/
|
||||
export default function MessageTimestamp({ value }: { value?: string | null }) {
|
||||
const { i18n } = useTranslation();
|
||||
const timestamp = getMessageTimestamp(value, i18n.language);
|
||||
|
||||
if (!timestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (timestamp.isRecent) {
|
||||
return <RecentTimestamp value={value} language={i18n.language} />;
|
||||
}
|
||||
|
||||
return <TimestampText timestamp={timestamp} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useMemo, useSyncExternalStore } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import type { VoiceOption } from '~/common';
|
||||
import { subscribeSpeechVoices, getSpeechVoicesSnapshot } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
function useTextToSpeechBrowser({
|
||||
|
|
@ -9,63 +10,19 @@ function useTextToSpeechBrowser({
|
|||
setIsSpeaking: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}) {
|
||||
const voiceName = useRecoilValue(store.voice);
|
||||
const [voices, setVoices] = useState<VoiceOption[]>([]);
|
||||
const cloudBrowserVoices = useRecoilValue(store.cloudBrowserVoices);
|
||||
const [isSpeechSynthesisSupported, setIsSpeechSynthesisSupported] = useState(true);
|
||||
const { voices: availableVoices, supported: isSpeechSynthesisSupported } = useSyncExternalStore(
|
||||
subscribeSpeechVoices,
|
||||
getSpeechVoicesSnapshot,
|
||||
getSpeechVoicesSnapshot,
|
||||
);
|
||||
|
||||
const updateVoices = useCallback(() => {
|
||||
const synth = window.speechSynthesis as SpeechSynthesis | undefined;
|
||||
if (!synth) {
|
||||
setIsSpeechSynthesisSupported(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const availableVoices = synth.getVoices();
|
||||
if (!Array.isArray(availableVoices)) {
|
||||
console.error('getVoices() did not return an array');
|
||||
return;
|
||||
}
|
||||
|
||||
const filteredVoices = availableVoices.filter(
|
||||
(v) => cloudBrowserVoices || v.localService === true,
|
||||
);
|
||||
const voiceOptions: VoiceOption[] = filteredVoices.map((v) => ({
|
||||
value: v.name,
|
||||
label: v.name,
|
||||
}));
|
||||
|
||||
setVoices(voiceOptions);
|
||||
} catch (error) {
|
||||
console.error('Error updating voices:', error);
|
||||
setIsSpeechSynthesisSupported(false);
|
||||
}
|
||||
}, [cloudBrowserVoices]);
|
||||
|
||||
useEffect(() => {
|
||||
const synth = window.speechSynthesis as SpeechSynthesis | undefined;
|
||||
if (!synth) {
|
||||
setIsSpeechSynthesisSupported(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (synth.getVoices().length) {
|
||||
updateVoices();
|
||||
} else {
|
||||
synth.onvoiceschanged = updateVoices;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in useEffect:', error);
|
||||
setIsSpeechSynthesisSupported(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (synth.onvoiceschanged) {
|
||||
synth.onvoiceschanged = null;
|
||||
}
|
||||
};
|
||||
}, [updateVoices]);
|
||||
const voices = useMemo(() => {
|
||||
const filteredVoices = availableVoices.filter(
|
||||
(v) => cloudBrowserVoices || v.localService === true,
|
||||
);
|
||||
return filteredVoices.map((v): VoiceOption => ({ value: v.name, label: v.name }));
|
||||
}, [availableVoices, cloudBrowserVoices]);
|
||||
|
||||
const generateSpeechLocal = (text: string) => {
|
||||
if (!isSpeechSynthesisSupported) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export * from './convos';
|
|||
export * from './routes';
|
||||
export * from './presets';
|
||||
export * from './prompts';
|
||||
export * from './voices';
|
||||
export * from './textarea';
|
||||
export * from './messages';
|
||||
export * from './focus';
|
||||
|
|
|
|||
58
client/src/utils/voices.ts
Normal file
58
client/src/utils/voices.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
export type SpeechVoicesSnapshot = {
|
||||
voices: SpeechSynthesisVoice[];
|
||||
supported: boolean;
|
||||
};
|
||||
|
||||
let snapshot: SpeechVoicesSnapshot = { voices: [], supported: true };
|
||||
const listeners = new Set<() => void>();
|
||||
let initialized = false;
|
||||
|
||||
const notify = () => {
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
const readVoices = (synth: SpeechSynthesis) => {
|
||||
try {
|
||||
const voices = synth.getVoices();
|
||||
if (!Array.isArray(voices)) {
|
||||
console.error('getVoices() did not return an array');
|
||||
return;
|
||||
}
|
||||
snapshot = { voices, supported: true };
|
||||
notify();
|
||||
} catch (error) {
|
||||
console.error('Error updating voices:', error);
|
||||
snapshot = { voices: [], supported: false };
|
||||
notify();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Module-level speech-synthesis voices store. Every message row mounts a TTS
|
||||
* button; per-instance `getVoices()` state guaranteed one post-mount re-render
|
||||
* per row and the instances clobbered each other's `onvoiceschanged` handler
|
||||
* (last mount won, any unmount nulled it for the rest). One shared listener
|
||||
* feeds all subscribers instead.
|
||||
*/
|
||||
export const subscribeSpeechVoices = (onStoreChange: () => void): (() => void) => {
|
||||
listeners.add(onStoreChange);
|
||||
if (!initialized) {
|
||||
initialized = true;
|
||||
const synth = window.speechSynthesis as SpeechSynthesis | undefined;
|
||||
if (!synth) {
|
||||
snapshot = { voices: [], supported: false };
|
||||
} else {
|
||||
readVoices(synth);
|
||||
try {
|
||||
synth.addEventListener('voiceschanged', () => readVoices(synth));
|
||||
} catch (error) {
|
||||
console.error('Error subscribing to voiceschanged:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(onStoreChange);
|
||||
};
|
||||
};
|
||||
|
||||
export const getSpeechVoicesSnapshot = (): SpeechVoicesSnapshot => snapshot;
|
||||
Loading…
Add table
Add a link
Reference in a new issue