🔊 refactor: Optimize Aria-Live Announcements for macOS VoiceOver (#3851)

This commit is contained in:
Danny Avila 2024-08-30 00:14:37 -04:00 committed by GitHub
parent 757b6d3275
commit dc40e577af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 59 additions and 170 deletions

View file

@ -3,9 +3,7 @@ import React from 'react';
export interface AnnounceOptions { export interface AnnounceOptions {
message: string; message: string;
id?: string; isStatus?: boolean;
isStream?: boolean;
isComplete?: boolean;
} }
interface AnnouncerContextType { interface AnnouncerContextType {

View file

@ -3,17 +3,17 @@ import React from 'react';
interface AnnouncerProps { interface AnnouncerProps {
statusMessage: string; statusMessage: string;
responseMessage: string; logMessage: string;
} }
const Announcer: React.FC<AnnouncerProps> = ({ statusMessage, responseMessage }) => { const Announcer: React.FC<AnnouncerProps> = ({ statusMessage, logMessage }) => {
return ( return (
<div className="sr-only"> <div className="sr-only">
<div aria-live="assertive" aria-atomic="true"> <div aria-live="polite" aria-atomic="true">
{statusMessage} {statusMessage}
</div> </div>
<div aria-live="polite" aria-atomic="true"> <div aria-live="polite" aria-atomic="true">
{responseMessage} {logMessage}
</div> </div>
</div> </div>
); );

View file

@ -1,6 +1,5 @@
// client/src/a11y/LiveAnnouncer.tsx // client/src/a11y/LiveAnnouncer.tsx
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { findLastSeparatorIndex } from 'librechat-data-provider';
import type { AnnounceOptions } from '~/Providers/AnnouncerContext'; import type { AnnounceOptions } from '~/Providers/AnnouncerContext';
import AnnouncerContext from '~/Providers/AnnouncerContext'; import AnnouncerContext from '~/Providers/AnnouncerContext';
import useLocalize from '~/hooks/useLocalize'; import useLocalize from '~/hooks/useLocalize';
@ -10,161 +9,53 @@ interface LiveAnnouncerProps {
children: React.ReactNode; children: React.ReactNode;
} }
interface AnnouncementItem {
message: string;
id: string;
isAssertive: boolean;
}
/** Chunk size for processing text */
const CHUNK_SIZE = 200;
/** Minimum delay between announcements */
const MIN_ANNOUNCEMENT_DELAY = 1000;
/** Delay before clearing the live region */
const CLEAR_DELAY = 5000;
/** Regex to remove *, `, and _ from message text */
const replacementRegex = /[*`_]/g;
const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => { const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
const [statusMessage, setStatusMessage] = useState(''); const [statusMessage, setStatusMessage] = useState('');
const [responseMessage, setResponseMessage] = useState(''); const [logMessage, setLogMessage] = useState('');
const counterRef = useRef(0); const statusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isAnnouncingRef = useRef(false);
const politeProcessedTextRef = useRef('');
const queueRef = useRef<AnnouncementItem[]>([]);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastAnnouncementTimeRef = useRef(0);
const localize = useLocalize(); const localize = useLocalize();
/** Generates a unique ID for announcement messages */
const generateUniqueId = (prefix: string) => {
counterRef.current += 1;
return `${prefix}-${counterRef.current}`;
};
/** Processes the text in chunks and returns a chunk of text */
const processChunks = (text: string, processedTextRef: React.MutableRefObject<string>) => {
const remainingText = text.slice(processedTextRef.current.length);
if (remainingText.length < CHUNK_SIZE) {
return ''; /* Not enough characters to process */
}
let separatorIndex = -1;
let startIndex = CHUNK_SIZE;
while (separatorIndex === -1 && startIndex <= remainingText.length) {
separatorIndex = findLastSeparatorIndex(remainingText.slice(startIndex));
if (separatorIndex !== -1) {
separatorIndex += startIndex; /* Adjust the index to account for the starting position */
} else {
startIndex += CHUNK_SIZE; /* Move the starting position by another CHUNK_SIZE characters */
}
}
if (separatorIndex === -1) {
return ''; /* No separator found, wait for more text */
}
const chunkText = remainingText.slice(0, separatorIndex + 1);
processedTextRef.current += chunkText;
return chunkText.trim();
};
/** Localized event announcements, i.e., "the AI is replying, finished, etc." */
const events: Record<string, string | undefined> = useMemo( const events: Record<string, string | undefined> = useMemo(
() => ({ start: localize('com_a11y_start'), end: localize('com_a11y_end') }), () => ({
start: localize('com_a11y_start'),
end: localize('com_a11y_end'),
composing: localize('com_a11y_ai_composing'),
}),
[localize], [localize],
); );
const announceMessage = useCallback( const announceStatus = useCallback((message: string) => {
(message: string, isAssertive: boolean) => { if (statusTimeoutRef.current) {
const setMessage = isAssertive ? setStatusMessage : setResponseMessage; clearTimeout(statusTimeoutRef.current);
setMessage(message); }
if (timeoutRef.current) { setStatusMessage(message);
clearTimeout(timeoutRef.current);
}
lastAnnouncementTimeRef.current = Date.now(); statusTimeoutRef.current = setTimeout(() => {
isAnnouncingRef.current = true; setStatusMessage('');
}, 1000);
}, []);
timeoutRef.current = setTimeout( const announceLog = useCallback((message: string) => {
() => { setLogMessage(message);
isAnnouncingRef.current = false; }, []);
setMessage(''); // Clear the message after a delay
if (queueRef.current.length > 0) {
const nextAnnouncement = queueRef.current.shift();
if (nextAnnouncement) {
const { message: _msg, isAssertive } = nextAnnouncement;
const nextMessage = (events[_msg] ?? _msg).replace(replacementRegex, '');
announceMessage(nextMessage, isAssertive);
}
}
},
isAssertive ? MIN_ANNOUNCEMENT_DELAY : CLEAR_DELAY,
);
},
[events],
);
const addToQueue = useCallback(
(item: AnnouncementItem) => {
if (item.isAssertive) {
/* For assertive messages, clear the queue and announce immediately */
queueRef.current = [];
const { message: _msg, isAssertive } = item;
const message = (events[_msg] ?? _msg).replace(replacementRegex, '');
announceMessage(message, isAssertive);
} else {
queueRef.current.push(item);
if (!isAnnouncingRef.current) {
const nextAnnouncement = queueRef.current.shift();
if (nextAnnouncement) {
const { message: _msg, isAssertive } = nextAnnouncement;
const message = (events[_msg] ?? _msg).replace(replacementRegex, '');
announceMessage(message, isAssertive);
}
}
}
},
[events, announceMessage],
);
/** Announces a polite message */
const announcePolite = useCallback( const announcePolite = useCallback(
({ message, id, isStream = false, isComplete = false }: AnnounceOptions) => { ({ message, isStatus = false }: AnnounceOptions) => {
const announcementId = id ?? generateUniqueId('polite'); const finalMessage = (events[message] ?? message).replace(/[*`_]/g, '');
if (isStream || isComplete) {
const chunk = processChunks(message, politeProcessedTextRef); if (isStatus) {
if (chunk) { announceStatus(finalMessage);
addToQueue({ message: chunk, id: announcementId, isAssertive: false });
}
if (isComplete) {
const remainingText = message.slice(politeProcessedTextRef.current.length);
if (remainingText.trim()) {
addToQueue({ message: remainingText.trim(), id: announcementId, isAssertive: false });
}
politeProcessedTextRef.current = '';
}
} else { } else {
addToQueue({ message, id: announcementId, isAssertive: false }); announceLog(finalMessage);
politeProcessedTextRef.current = '';
} }
}, },
[addToQueue], [events, announceStatus, announceLog],
); );
/** Announces an assertive message */ const announceAssertive = announcePolite;
const announceAssertive = useCallback(
({ message, id }: AnnounceOptions) => {
const announcementId = id ?? generateUniqueId('assertive');
addToQueue({ message, id: announcementId, isAssertive: true });
},
[addToQueue],
);
const contextValue = { const contextValue = {
announcePolite, announcePolite,
@ -173,10 +64,8 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
useEffect(() => { useEffect(() => {
return () => { return () => {
queueRef.current = []; if (statusTimeoutRef.current) {
isAnnouncingRef.current = false; clearTimeout(statusTimeoutRef.current);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
} }
}; };
}, []); }, []);
@ -184,7 +73,7 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
return ( return (
<AnnouncerContext.Provider value={contextValue}> <AnnouncerContext.Provider value={contextValue}>
{children} {children}
<Announcer statusMessage={statusMessage} responseMessage={responseMessage} /> <Announcer statusMessage={statusMessage} logMessage={logMessage} />
</AnnouncerContext.Provider> </AnnouncerContext.Provider>
); );
}; };

View file

@ -1,5 +1,5 @@
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { useCallback } from 'react'; import { useCallback, useRef } from 'react';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
@ -54,6 +54,8 @@ export type EventHandlerParams = {
resetLatestMessage?: Resetter; resetLatestMessage?: Resetter;
}; };
const MESSAGE_UPDATE_INTERVAL = 7000;
export default function useEventHandlers({ export default function useEventHandlers({
genTitle, genTitle,
setMessages, setMessages,
@ -68,8 +70,9 @@ export default function useEventHandlers({
}: EventHandlerParams) { }: EventHandlerParams) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const setAbortScroll = useSetRecoilState(store.abortScroll); const setAbortScroll = useSetRecoilState(store.abortScroll);
const { announcePolite, announceAssertive } = useLiveAnnouncer(); const { announcePolite } = useLiveAnnouncer();
const lastAnnouncementTimeRef = useRef(Date.now());
const { conversationId: paramId } = useParams(); const { conversationId: paramId } = useParams();
const { token } = useAuthContext(); const { token } = useAuthContext();
@ -87,11 +90,11 @@ export default function useEventHandlers({
} = submission; } = submission;
const text = data ?? ''; const text = data ?? '';
setIsSubmitting(true); setIsSubmitting(true);
if (text.length > 0) {
announcePolite({ const currentTime = Date.now();
message: text, if (currentTime - lastAnnouncementTimeRef.current > MESSAGE_UPDATE_INTERVAL) {
isStream: true, announcePolite({ message: 'composing', isStatus: true });
}); lastAnnouncementTimeRef.current = currentTime;
} }
if (isRegenerate) { if (isRegenerate) {
@ -187,9 +190,9 @@ export default function useEventHandlers({
}, },
]); ]);
announceAssertive({ announcePolite({
message: 'start', message: 'start',
id: `start-${Date.now()}`, isStatus: true,
}); });
let update = {} as TConversation; let update = {} as TConversation;
@ -245,8 +248,8 @@ export default function useEventHandlers({
queryClient, queryClient,
setMessages, setMessages,
isAddedRequest, isAddedRequest,
announcePolite,
setConversation, setConversation,
announceAssertive,
setShowStopButton, setShowStopButton,
resetLatestMessage, resetLatestMessage,
], ],
@ -267,9 +270,10 @@ export default function useEventHandlers({
} }
const { conversationId, parentMessageId } = userMessage; const { conversationId, parentMessageId } = userMessage;
announceAssertive({ lastAnnouncementTimeRef.current = Date.now();
announcePolite({
message: 'start', message: 'start',
id: `start-${Date.now()}`, isStatus: true,
}); });
let update = {} as TConversation; let update = {} as TConversation;
@ -323,8 +327,8 @@ export default function useEventHandlers({
queryClient, queryClient,
setAbortScroll, setAbortScroll,
isAddedRequest, isAddedRequest,
announcePolite,
setConversation, setConversation,
announceAssertive,
resetLatestMessage, resetLatestMessage,
], ],
); );
@ -345,16 +349,13 @@ export default function useEventHandlers({
/* a11y announcements */ /* a11y announcements */
announcePolite({ announcePolite({
message: responseMessage?.text ?? '', message: 'end',
isComplete: true, isStatus: true,
}); });
setTimeout(() => { announcePolite({
announcePolite({ message: responseMessage?.text ?? '',
message: 'end', });
id: `end-${Date.now()}`,
});
}, 100);
/* Update messages; if assistants endpoint, client doesn't receive responseMessage */ /* Update messages; if assistants endpoint, client doesn't receive responseMessage */
if (runMessages) { if (runMessages) {

View file

@ -14,7 +14,8 @@ export default {
com_nav_info_custom_prompt_mode: com_nav_info_custom_prompt_mode:
'When enabled, the default artifacts system prompt will not be included. All artifact-generating instructions must be provided manually in this mode.', 'When enabled, the default artifacts system prompt will not be included. All artifact-generating instructions must be provided manually in this mode.',
com_ui_artifact_click: 'Click to open', com_ui_artifact_click: 'Click to open',
com_a11y_start: 'The AI is replying.', com_a11y_start: 'The AI has started their reply.',
com_a11y_ai_composing: 'The AI is still composing.',
com_a11y_end: 'The AI has finished their reply.', com_a11y_end: 'The AI has finished their reply.',
com_error_moderation: com_error_moderation:
'It appears that the content submitted has been flagged by our moderation system for not aligning with our community guidelines. We\'re unable to proceed with this specific topic. If you have any other questions or topics you\'d like to explore, please edit your message, or create a new conversation.', 'It appears that the content submitted has been flagged by our moderation system for not aligning with our community guidelines. We\'re unable to proceed with this specific topic. If you have any other questions or topics you\'d like to explore, please edit your message, or create a new conversation.',