| | |
| | 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> |
| | ); |
| | } |
| |
|