LibreChat/client/src/components/SidePanel/Memories/MemoryViewer.tsx
Dustin Healy 0446d0e190
fix: Address Accessibility Issues (#10260)
* chore: add i18n localization comment for AlwaysMakeProd component

* feat: enhance accessibility by adding aria-label and aria-labelledby to Switch component

* feat: add aria-labels for accessibility in Agent and Assistant avatar buttons

* fix: add switch aria-labels for accessibility in various components

* feat: add aria-labels and localization keys for accessibility in DataTable, DataTableColumnHeader, and OGDialogTemplate components

* chore: refactor out nested ternary

* feat: add aria-label to DataTable filter button for My Files modal

* feat: add aria-labels for Buttons and localization strings

* feat: add aria-labels to Checkboxes in Agent Builder

* feat: enhance accessibility by adding aria-label and aria-labelledby to Checkbox component

* feat: add aria-label to FileSearchCheckbox in Agent Builder

* feat: add aria-label to Prompts text input area

* feat: enhance accessibility by adding aria-label and aria-labelledby to TextAreaAutosize component

* feat: remove improper role: "list" prop from List in Conversations.tsx to enhance accessibility and stop aria rules conflicting within react-virtualized component

* feat: enhance accessibility by allowing tab navigation and adding ring highlights for conversation title editing accept/reject buttons

* feat: add aria-label to Copy Link button in the conversation share modal

* feat: add title to QR code svg in conversation share modal to  describe the image content

* feat: enhance accessibility by making Agent Avatar upload keyboard navigable and round out highlight border on focus

* feat: enhance accessibility by adding aria attributes around alerting users with screen readers to invalid email address inputs in the Agent Builder

* feat: add aria-labels to buttons in Advanced panel of Agent Builder

* feat: enhance accessibility by making FileUpload and Clear All buttons in PresetItems keyboard navigable

* feat: enchance accessiblity by indexing view and delete button aria-labels in shared links management modal to their specific chat titles

* feat: add border highlighting on focus for AnimatedSearchInput

* feat: add category description to aria-labels for prompts in ListCard

* feat: add proper scoping to rows and columns in table headers

* feat: add localized aria-labelling to EditTextPart's TextAreaAutosize component and base dynamic paramters panel components and their supporting translation keys

* feat: add localized aria-labels and aria-labelledBy to Checkbox components without them

* feat: add localized aria-labeledBy for endpoint settings Sliders

* feat: add localized aria-labels for TextareaAutosize components

* chore: remove unused i18n string

* feat: add localized aria-label for BookmarkForm Checkbox

* fix: add stopPropagation onKeyDown for Preview and Edit menu items in prompts that was causing the prompts to inadvertently be sent when triggered with keyboard navigation when Auto-send Prompts was toggled on

* fix: switch TableCell to TableHead for title cells according to harvard issue #789

* fix: add more descriptive localization key for file filter button in DataTable

* chore: remove self-explanatory code comment from RenameForm

* fix: remove stray bg-yellow highlight that was left in during debugging

* fix: add aria-label to model configurator panel back button

* fix: undo incorrect hoist of tool name split for aria-label and span in MCPInput

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2025-10-27 19:46:43 -04:00

421 lines
14 KiB
TypeScript

/* Memories */
import { useMemo, useState, useRef, useEffect } from 'react';
import { Plus } from 'lucide-react';
import { matchSorter } from 'match-sorter';
import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider';
import {
Table,
Input,
Label,
Button,
Switch,
Spinner,
TableRow,
OGDialog,
EditIcon,
TableHead,
TableBody,
TrashIcon,
TableCell,
TableHeader,
TooltipAnchor,
useToastContext,
OGDialogTrigger,
OGDialogTemplate,
} from '@librechat/client';
import type { TUserMemory } from 'librechat-data-provider';
import {
useUpdateMemoryPreferencesMutation,
useDeleteMemoryMutation,
useMemoriesQuery,
useGetUserQuery,
} from '~/data-provider';
import { useLocalize, useAuthContext, useHasAccess } from '~/hooks';
import MemoryCreateDialog from './MemoryCreateDialog';
import MemoryEditDialog from './MemoryEditDialog';
import AdminSettings from './AdminSettings';
import { cn } from '~/utils';
const EditMemoryButton = ({ memory }: { memory: TUserMemory }) => {
const localize = useLocalize();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
return (
<MemoryEditDialog
open={open}
memory={memory}
onOpenChange={setOpen}
triggerRef={triggerRef as React.MutableRefObject<HTMLButtonElement | null>}
>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_edit_memory')}
render={
<Button
variant="ghost"
aria-label={localize('com_ui_bookmarks_edit')}
onClick={() => setOpen(!open)}
className="h-8 w-8 p-0"
>
<EditIcon />
</Button>
}
/>
</OGDialogTrigger>
</MemoryEditDialog>
);
};
const DeleteMemoryButton = ({ memory }: { memory: TUserMemory }) => {
const localize = useLocalize();
const { showToast } = useToastContext();
const [open, setOpen] = useState(false);
const { mutate: deleteMemory } = useDeleteMemoryMutation();
const [deletingKey, setDeletingKey] = useState<string | null>(null);
const confirmDelete = async () => {
setDeletingKey(memory.key);
deleteMemory(memory.key, {
onSuccess: () => {
showToast({
message: localize('com_ui_deleted'),
status: 'success',
});
setOpen(false);
},
onError: () =>
showToast({
message: localize('com_ui_error'),
status: 'error',
}),
onSettled: () => setDeletingKey(null),
});
};
return (
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_delete_memory')}
render={
<Button
variant="ghost"
aria-label={localize('com_ui_delete')}
onClick={() => setOpen(!open)}
className="h-8 w-8 p-0"
>
{deletingKey === memory.key ? (
<Spinner className="size-4 animate-spin" />
) : (
<TrashIcon className="size-4" />
)}
</Button>
}
/>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_memory')}
className="w-11/12 max-w-lg"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_confirm')} &quot;{memory.key}&quot;?
</Label>
}
selection={{
selectHandler: confirmDelete,
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
);
};
const pageSize = 10;
export default function MemoryViewer() {
const localize = useLocalize();
const { user } = useAuthContext();
const { data: userData } = useGetUserQuery();
const { data: memData, isLoading } = useMemoriesQuery();
const { showToast } = useToastContext();
const [pageIndex, setPageIndex] = useState(0);
const [searchQuery, setSearchQuery] = useState('');
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [referenceSavedMemories, setReferenceSavedMemories] = useState(true);
const updateMemoryPreferencesMutation = useUpdateMemoryPreferencesMutation({
onSuccess: () => {
showToast({
message: localize('com_ui_preferences_updated'),
status: 'success',
});
},
onError: () => {
showToast({
message: localize('com_ui_error_updating_preferences'),
status: 'error',
});
setReferenceSavedMemories((prev) => !prev);
},
});
useEffect(() => {
if (userData?.personalization?.memories !== undefined) {
setReferenceSavedMemories(userData.personalization.memories);
}
}, [userData?.personalization?.memories]);
const handleMemoryToggle = (checked: boolean) => {
setReferenceSavedMemories(checked);
updateMemoryPreferencesMutation.mutate({ memories: checked });
};
const hasReadAccess = useHasAccess({
permissionType: PermissionTypes.MEMORIES,
permission: Permissions.READ,
});
const hasUpdateAccess = useHasAccess({
permissionType: PermissionTypes.MEMORIES,
permission: Permissions.UPDATE,
});
const hasCreateAccess = useHasAccess({
permissionType: PermissionTypes.MEMORIES,
permission: Permissions.CREATE,
});
const hasOptOutAccess = useHasAccess({
permissionType: PermissionTypes.MEMORIES,
permission: Permissions.OPT_OUT,
});
const memories: TUserMemory[] = useMemo(() => memData?.memories ?? [], [memData]);
const filteredMemories = useMemo(() => {
return matchSorter(memories, searchQuery, {
keys: ['key', 'value'],
});
}, [memories, searchQuery]);
const currentRows = useMemo(() => {
return filteredMemories.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
}, [filteredMemories, pageIndex]);
const getProgressBarColor = (percentage: number): string => {
if (percentage > 90) {
return 'stroke-red-500';
}
if (percentage > 75) {
return 'stroke-yellow-500';
}
return 'stroke-green-500';
};
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center p-4">
<Spinner />
</div>
);
}
if (!hasReadAccess) {
return (
<div className="flex h-full w-full items-center justify-center p-4">
<div className="text-center">
<p className="text-sm text-text-secondary">{localize('com_ui_no_read_access')}</p>
</div>
</div>
);
}
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<div role="region" aria-label={localize('com_ui_memories')} className="mt-2 space-y-2">
<div className="flex items-center gap-4">
<Input
placeholder={localize('com_ui_memories_filter')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
aria-label={localize('com_ui_memories_filter')}
/>
</div>
{/* Memory Usage and Toggle Display */}
{(memData?.tokenLimit || hasOptOutAccess) && (
<div
className={cn(
'flex items-center rounded-lg',
memData?.tokenLimit != null && hasOptOutAccess ? 'justify-between' : 'justify-end',
)}
>
{/* Usage Display */}
{memData?.tokenLimit && (
<div className="flex items-center gap-2">
<div className="relative size-10">
<svg className="size-10 -rotate-90 transform">
<circle
cx="20"
cy="20"
r="16"
stroke="currentColor"
strokeWidth="3"
fill="none"
className="text-gray-200 dark:text-gray-700"
/>
<circle
cx="20"
cy="20"
r="16"
strokeWidth="3"
fill="none"
strokeDasharray={`${2 * Math.PI * 16}`}
strokeDashoffset={`${2 * Math.PI * 16 * (1 - (memData.usagePercentage ?? 0) / 100)}`}
className={`transition-all ${getProgressBarColor(memData.usagePercentage ?? 0)}`}
strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-medium">{memData.usagePercentage}%</span>
</div>
</div>
<div className="text-sm text-text-secondary">{localize('com_ui_usage')}</div>
</div>
)}
{/* Memory Toggle */}
{hasOptOutAccess && (
<div className="flex items-center gap-2 text-xs">
<span>{localize('com_ui_use_memory')}</span>
<Switch
checked={referenceSavedMemories}
onCheckedChange={handleMemoryToggle}
aria-label={localize('com_ui_reference_saved_memories')}
disabled={updateMemoryPreferencesMutation.isLoading}
/>
</div>
)}
</div>
)}
{/* Create Memory Button */}
{hasCreateAccess && (
<div className="flex w-full justify-end">
<MemoryCreateDialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<OGDialogTrigger asChild>
<Button
variant="outline"
className="w-full bg-transparent"
aria-label={localize('com_ui_create_memory')}
>
<Plus className="size-4" aria-hidden />
{localize('com_ui_create_memory')}
</Button>
</OGDialogTrigger>
</MemoryCreateDialog>
</div>
)}
<div className="rounded-lg border border-border-light bg-transparent shadow-sm transition-colors">
<Table className="w-full table-fixed">
<TableHeader>
<TableRow className="border-b border-border-light hover:bg-surface-secondary">
<TableHead
className={`${
hasUpdateAccess ? 'w-[75%]' : 'w-[100%]'
} bg-surface-secondary py-3 text-left text-sm font-medium text-text-secondary`}
>
<div>{localize('com_ui_memory')}</div>
</TableHead>
{hasUpdateAccess && (
<TableHead className="w-[25%] bg-surface-secondary py-3 text-center text-sm font-medium text-text-secondary">
<div>{localize('com_assistants_actions')}</div>
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{currentRows.length ? (
currentRows.map((memory: TUserMemory, idx: number) => (
<TableRow
key={idx}
className="border-b border-border-light hover:bg-surface-secondary"
>
<TableCell className={`${hasUpdateAccess ? 'w-[75%]' : 'w-[100%]'} px-4 py-4`}>
<div
className="overflow-hidden text-ellipsis whitespace-nowrap text-sm text-text-primary"
title={memory.value}
>
{memory.value}
</div>
</TableCell>
{hasUpdateAccess && (
<TableCell className="w-[25%] px-4 py-4">
<div className="flex justify-center gap-2">
<EditMemoryButton memory={memory} />
<DeleteMemoryButton memory={memory} />
</div>
</TableCell>
)}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={hasUpdateAccess ? 2 : 1}
className="h-24 text-center text-sm text-text-secondary"
>
{localize('com_ui_no_memories')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination controls */}
{filteredMemories.length > pageSize && (
<div
className="flex items-center justify-end gap-2"
role="navigation"
aria-label="Pagination"
>
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex((prev) => Math.max(prev - 1, 0))}
disabled={pageIndex === 0}
aria-label={localize('com_ui_prev')}
>
{localize('com_ui_prev')}
</Button>
<div className="text-sm" aria-live="polite">
{`${pageIndex + 1} / ${Math.ceil(filteredMemories.length / pageSize)}`}
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
setPageIndex((prev) =>
(prev + 1) * pageSize < filteredMemories.length ? prev + 1 : prev,
)
}
disabled={(pageIndex + 1) * pageSize >= filteredMemories.length}
aria-label={localize('com_ui_next')}
>
{localize('com_ui_next')}
</Button>
</div>
)}
{/* Admin Settings */}
{user?.role === SystemRoles.ADMIN && (
<div className="mt-4">
<AdminSettings />
</div>
)}
</div>
</div>
);
}