mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🔊 refactor: Optimize Aria-Live Announcements for macOS VoiceOver (#3851)
This commit is contained in:
parent
757b6d3275
commit
dc40e577af
5 changed files with 59 additions and 170 deletions
|
|
@ -3,9 +3,7 @@ import React from 'react';
|
|||
|
||||
export interface AnnounceOptions {
|
||||
message: string;
|
||||
id?: string;
|
||||
isStream?: boolean;
|
||||
isComplete?: boolean;
|
||||
isStatus?: boolean;
|
||||
}
|
||||
|
||||
interface AnnouncerContextType {
|
||||
|
|
|
|||
|
|
@ -3,17 +3,17 @@ import React from 'react';
|
|||
|
||||
interface AnnouncerProps {
|
||||
statusMessage: string;
|
||||
responseMessage: string;
|
||||
logMessage: string;
|
||||
}
|
||||
|
||||
const Announcer: React.FC<AnnouncerProps> = ({ statusMessage, responseMessage }) => {
|
||||
const Announcer: React.FC<AnnouncerProps> = ({ statusMessage, logMessage }) => {
|
||||
return (
|
||||
<div className="sr-only">
|
||||
<div aria-live="assertive" aria-atomic="true">
|
||||
<div aria-live="polite" aria-atomic="true">
|
||||
{statusMessage}
|
||||
</div>
|
||||
<div aria-live="polite" aria-atomic="true">
|
||||
{responseMessage}
|
||||
{logMessage}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
// client/src/a11y/LiveAnnouncer.tsx
|
||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { findLastSeparatorIndex } from 'librechat-data-provider';
|
||||
import type { AnnounceOptions } from '~/Providers/AnnouncerContext';
|
||||
import AnnouncerContext from '~/Providers/AnnouncerContext';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
|
|
@ -10,161 +9,53 @@ interface LiveAnnouncerProps {
|
|||
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 [statusMessage, setStatusMessage] = useState('');
|
||||
const [responseMessage, setResponseMessage] = useState('');
|
||||
const [logMessage, setLogMessage] = useState('');
|
||||
|
||||
const counterRef = useRef(0);
|
||||
const isAnnouncingRef = useRef(false);
|
||||
const politeProcessedTextRef = useRef('');
|
||||
const queueRef = useRef<AnnouncementItem[]>([]);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastAnnouncementTimeRef = useRef(0);
|
||||
const statusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
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(
|
||||
() => ({ 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],
|
||||
);
|
||||
|
||||
const announceMessage = useCallback(
|
||||
(message: string, isAssertive: boolean) => {
|
||||
const setMessage = isAssertive ? setStatusMessage : setResponseMessage;
|
||||
setMessage(message);
|
||||
const announceStatus = useCallback((message: string) => {
|
||||
if (statusTimeoutRef.current) {
|
||||
clearTimeout(statusTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setStatusMessage(message);
|
||||
|
||||
lastAnnouncementTimeRef.current = Date.now();
|
||||
isAnnouncingRef.current = true;
|
||||
statusTimeoutRef.current = setTimeout(() => {
|
||||
setStatusMessage('');
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
timeoutRef.current = setTimeout(
|
||||
() => {
|
||||
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 announceLog = useCallback((message: string) => {
|
||||
setLogMessage(message);
|
||||
}, []);
|
||||
|
||||
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(
|
||||
({ message, id, isStream = false, isComplete = false }: AnnounceOptions) => {
|
||||
const announcementId = id ?? generateUniqueId('polite');
|
||||
if (isStream || isComplete) {
|
||||
const chunk = processChunks(message, politeProcessedTextRef);
|
||||
if (chunk) {
|
||||
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 = '';
|
||||
}
|
||||
({ message, isStatus = false }: AnnounceOptions) => {
|
||||
const finalMessage = (events[message] ?? message).replace(/[*`_]/g, '');
|
||||
|
||||
if (isStatus) {
|
||||
announceStatus(finalMessage);
|
||||
} else {
|
||||
addToQueue({ message, id: announcementId, isAssertive: false });
|
||||
politeProcessedTextRef.current = '';
|
||||
announceLog(finalMessage);
|
||||
}
|
||||
},
|
||||
[addToQueue],
|
||||
[events, announceStatus, announceLog],
|
||||
);
|
||||
|
||||
/** Announces an assertive message */
|
||||
const announceAssertive = useCallback(
|
||||
({ message, id }: AnnounceOptions) => {
|
||||
const announcementId = id ?? generateUniqueId('assertive');
|
||||
addToQueue({ message, id: announcementId, isAssertive: true });
|
||||
},
|
||||
[addToQueue],
|
||||
);
|
||||
const announceAssertive = announcePolite;
|
||||
|
||||
const contextValue = {
|
||||
announcePolite,
|
||||
|
|
@ -173,10 +64,8 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
|
|||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
queueRef.current = [];
|
||||
isAnnouncingRef.current = false;
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
if (statusTimeoutRef.current) {
|
||||
clearTimeout(statusTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
|
@ -184,7 +73,7 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
|
|||
return (
|
||||
<AnnouncerContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<Announcer statusMessage={statusMessage} responseMessage={responseMessage} />
|
||||
<Announcer statusMessage={statusMessage} logMessage={logMessage} />
|
||||
</AnnouncerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { v4 } from 'uuid';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
|
@ -54,6 +54,8 @@ export type EventHandlerParams = {
|
|||
resetLatestMessage?: Resetter;
|
||||
};
|
||||
|
||||
const MESSAGE_UPDATE_INTERVAL = 7000;
|
||||
|
||||
export default function useEventHandlers({
|
||||
genTitle,
|
||||
setMessages,
|
||||
|
|
@ -68,8 +70,9 @@ export default function useEventHandlers({
|
|||
}: EventHandlerParams) {
|
||||
const queryClient = useQueryClient();
|
||||
const setAbortScroll = useSetRecoilState(store.abortScroll);
|
||||
const { announcePolite, announceAssertive } = useLiveAnnouncer();
|
||||
const { announcePolite } = useLiveAnnouncer();
|
||||
|
||||
const lastAnnouncementTimeRef = useRef(Date.now());
|
||||
const { conversationId: paramId } = useParams();
|
||||
const { token } = useAuthContext();
|
||||
|
||||
|
|
@ -87,11 +90,11 @@ export default function useEventHandlers({
|
|||
} = submission;
|
||||
const text = data ?? '';
|
||||
setIsSubmitting(true);
|
||||
if (text.length > 0) {
|
||||
announcePolite({
|
||||
message: text,
|
||||
isStream: true,
|
||||
});
|
||||
|
||||
const currentTime = Date.now();
|
||||
if (currentTime - lastAnnouncementTimeRef.current > MESSAGE_UPDATE_INTERVAL) {
|
||||
announcePolite({ message: 'composing', isStatus: true });
|
||||
lastAnnouncementTimeRef.current = currentTime;
|
||||
}
|
||||
|
||||
if (isRegenerate) {
|
||||
|
|
@ -187,9 +190,9 @@ export default function useEventHandlers({
|
|||
},
|
||||
]);
|
||||
|
||||
announceAssertive({
|
||||
announcePolite({
|
||||
message: 'start',
|
||||
id: `start-${Date.now()}`,
|
||||
isStatus: true,
|
||||
});
|
||||
|
||||
let update = {} as TConversation;
|
||||
|
|
@ -245,8 +248,8 @@ export default function useEventHandlers({
|
|||
queryClient,
|
||||
setMessages,
|
||||
isAddedRequest,
|
||||
announcePolite,
|
||||
setConversation,
|
||||
announceAssertive,
|
||||
setShowStopButton,
|
||||
resetLatestMessage,
|
||||
],
|
||||
|
|
@ -267,9 +270,10 @@ export default function useEventHandlers({
|
|||
}
|
||||
|
||||
const { conversationId, parentMessageId } = userMessage;
|
||||
announceAssertive({
|
||||
lastAnnouncementTimeRef.current = Date.now();
|
||||
announcePolite({
|
||||
message: 'start',
|
||||
id: `start-${Date.now()}`,
|
||||
isStatus: true,
|
||||
});
|
||||
|
||||
let update = {} as TConversation;
|
||||
|
|
@ -323,8 +327,8 @@ export default function useEventHandlers({
|
|||
queryClient,
|
||||
setAbortScroll,
|
||||
isAddedRequest,
|
||||
announcePolite,
|
||||
setConversation,
|
||||
announceAssertive,
|
||||
resetLatestMessage,
|
||||
],
|
||||
);
|
||||
|
|
@ -345,16 +349,13 @@ export default function useEventHandlers({
|
|||
|
||||
/* a11y announcements */
|
||||
announcePolite({
|
||||
message: responseMessage?.text ?? '',
|
||||
isComplete: true,
|
||||
message: 'end',
|
||||
isStatus: true,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
announcePolite({
|
||||
message: 'end',
|
||||
id: `end-${Date.now()}`,
|
||||
});
|
||||
}, 100);
|
||||
announcePolite({
|
||||
message: responseMessage?.text ?? '',
|
||||
});
|
||||
|
||||
/* Update messages; if assistants endpoint, client doesn't receive responseMessage */
|
||||
if (runMessages) {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ export default {
|
|||
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.',
|
||||
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_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.',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue