mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-27 13:48:51 +01:00
* 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>
421 lines
14 KiB
TypeScript
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')} "{memory.key}"?
|
|
</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>
|
|
);
|
|
}
|