| import { useState, useCallback, useMemo } from 'react'; |
| import { ArrowUpLeft } from 'lucide-react'; |
| import { useSetRecoilState } from 'recoil'; |
| import { |
| Button, |
| Input, |
| Table, |
| TableBody, |
| TableCell, |
| TableHead, |
| TableHeader, |
| TableRow, |
| useToastContext, |
| } from '@librechat/client'; |
| import { |
| flexRender, |
| getCoreRowModel, |
| getFilteredRowModel, |
| getPaginationRowModel, |
| getSortedRowModel, |
| useReactTable, |
| type ColumnDef, |
| type SortingState, |
| type VisibilityState, |
| type ColumnFiltersState, |
| } from '@tanstack/react-table'; |
| import { |
| fileConfig as defaultFileConfig, |
| checkOpenAIStorage, |
| mergeFileConfig, |
| megabyte, |
| isAssistantsEndpoint, |
| getEndpointFileConfig, |
| type TFile, |
| } from 'librechat-data-provider'; |
| import { useFileMapContext, useChatContext } from '~/Providers'; |
| import { useLocalize, useUpdateFiles } from '~/hooks'; |
| import { useGetFileConfig } from '~/data-provider'; |
| import store from '~/store'; |
|
|
| interface DataTableProps<TData, TValue> { |
| columns: ColumnDef<TData, TValue>[]; |
| data: TData[]; |
| } |
|
|
| export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) { |
| const localize = useLocalize(); |
| const [sorting, setSorting] = useState<SortingState>([]); |
| const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); |
| const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}); |
| const [{ pageIndex, pageSize }, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); |
| const setShowFiles = useSetRecoilState(store.showFiles); |
|
|
| const pagination = useMemo( |
| () => ({ |
| pageIndex, |
| pageSize, |
| }), |
| [pageIndex, pageSize], |
| ); |
|
|
| const table = useReactTable({ |
| data, |
| columns, |
| state: { |
| sorting, |
| columnFilters, |
| columnVisibility, |
| pagination, |
| }, |
| onSortingChange: setSorting, |
| onPaginationChange: setPagination, |
| getCoreRowModel: getCoreRowModel(), |
| getSortedRowModel: getSortedRowModel(), |
| onColumnFiltersChange: setColumnFilters, |
| getFilteredRowModel: getFilteredRowModel(), |
| onColumnVisibilityChange: setColumnVisibility, |
| getPaginationRowModel: getPaginationRowModel(), |
| defaultColumn: { |
| minSize: 0, |
| size: 10, |
| maxSize: 10, |
| enableResizing: true, |
| }, |
| }); |
|
|
| const fileMap = useFileMapContext(); |
| const { showToast } = useToastContext(); |
| const { setFiles, conversation } = useChatContext(); |
| const { data: fileConfig = null } = useGetFileConfig({ |
| select: (data) => mergeFileConfig(data), |
| }); |
| const { addFile } = useUpdateFiles(setFiles); |
|
|
| const handleFileClick = useCallback( |
| (file: TFile) => { |
| if (!fileMap?.[file.file_id] || !conversation?.endpoint) { |
| showToast({ |
| message: localize('com_ui_attach_error'), |
| status: 'error', |
| }); |
| return; |
| } |
|
|
| const fileData = fileMap[file.file_id]; |
| const endpoint = conversation.endpoint; |
| const endpointType = conversation.endpointType; |
|
|
| if (!fileData.source) { |
| return; |
| } |
|
|
| const isOpenAIStorage = checkOpenAIStorage(fileData.source); |
| const isAssistants = isAssistantsEndpoint(endpoint); |
|
|
| if (isOpenAIStorage && !isAssistants) { |
| showToast({ |
| message: localize('com_ui_attach_error_openai'), |
| status: 'error', |
| }); |
| return; |
| } |
|
|
| if (!isOpenAIStorage && isAssistants) { |
| showToast({ |
| message: localize('com_ui_attach_warn_endpoint'), |
| status: 'warning', |
| }); |
| } |
|
|
| const endpointFileConfig = getEndpointFileConfig({ |
| fileConfig, |
| endpoint, |
| endpointType, |
| }); |
|
|
| if (endpointFileConfig.disabled === true) { |
| showToast({ |
| message: localize('com_ui_attach_error_disabled'), |
| status: 'error', |
| }); |
| return; |
| } |
|
|
| if (fileData.bytes > (endpointFileConfig.fileSizeLimit ?? Number.MAX_SAFE_INTEGER)) { |
| showToast({ |
| message: `${localize('com_ui_attach_error_size')} ${ |
| (endpointFileConfig.fileSizeLimit ?? 0) / megabyte |
| } MB (${endpoint})`, |
| status: 'error', |
| }); |
| return; |
| } |
|
|
| if (!defaultFileConfig.checkType(file.type, endpointFileConfig.supportedMimeTypes ?? [])) { |
| showToast({ |
| message: `${localize('com_ui_attach_error_type')} ${file.type} (${endpoint})`, |
| status: 'error', |
| }); |
| return; |
| } |
|
|
| addFile({ |
| progress: 1, |
| attached: true, |
| file_id: fileData.file_id, |
| filepath: fileData.filepath, |
| preview: fileData.filepath, |
| type: fileData.type, |
| height: fileData.height, |
| width: fileData.width, |
| filename: fileData.filename, |
| source: fileData.source, |
| size: fileData.bytes, |
| metadata: fileData.metadata, |
| }); |
| }, |
| [addFile, fileMap, conversation, localize, showToast, fileConfig], |
| ); |
|
|
| const filenameFilter = table.getColumn('filename')?.getFilterValue() as string; |
|
|
| return ( |
| <div role="region" aria-label={localize('com_files_table')} className="mt-2 space-y-2"> |
| <div className="flex items-center gap-4"> |
| <Input |
| placeholder={localize('com_files_filter')} |
| value={filenameFilter ?? ''} |
| onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)} |
| aria-label={localize('com_files_filter')} |
| /> |
| </div> |
| |
| <div className="rounded-lg border border-border-light bg-transparent shadow-sm transition-colors"> |
| <div className="overflow-x-auto"> |
| <Table> |
| <TableHeader> |
| {table.getHeaderGroups().map((headerGroup) => ( |
| <TableRow key={headerGroup.id} className="border-b border-border-light"> |
| {headerGroup.headers.map((header, index) => ( |
| <TableHead |
| key={header.id} |
| style={{ width: index === 0 ? '75%' : '25%' }} |
| className="bg-surface-secondary py-3 text-left text-sm font-medium text-text-secondary" |
| > |
| <div className="px-4"> |
| {header.isPlaceholder |
| ? null |
| : flexRender(header.column.columnDef.header, header.getContext())} |
| </div> |
| </TableHead> |
| ))} |
| </TableRow> |
| ))} |
| </TableHeader> |
| <TableBody> |
| {table.getRowModel().rows.length ? ( |
| table.getRowModel().rows.map((row) => ( |
| <TableRow |
| key={row.id} |
| data-state={row.getIsSelected() && 'selected'} |
| className="border-b border-border-light transition-colors hover:bg-surface-secondary [&:last-child]:border-0" |
| > |
| {row.getVisibleCells().map((cell) => { |
| const isFilenameCell = cell.column.id === 'filename'; |
| |
| return ( |
| <TableCell |
| style={{ |
| width: '150px', |
| maxWidth: '150px', |
| overflow: 'hidden', |
| textOverflow: 'ellipsis', |
| whiteSpace: 'nowrap', |
| }} |
| data-skip-refocus="true" |
| key={cell.id} |
| role={isFilenameCell ? 'button' : undefined} |
| tabIndex={isFilenameCell ? 0 : undefined} |
| onClick={(e) => { |
| if (isFilenameCell) { |
| const clickedElement = e.target as HTMLElement; |
| // Check if clicked element is within cell and not a button/link |
| if ( |
| clickedElement.closest('td') && |
| !clickedElement.closest('button, a') |
| ) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| handleFileClick(row.original as TFile); |
| } |
| } |
| }} |
| onKeyDown={(e) => { |
| if (isFilenameCell && (e.key === 'Enter' || e.key === ' ')) { |
| const clickedElement = e.target as HTMLElement; |
| if ( |
| clickedElement.closest('td') && |
| !clickedElement.closest('button, a') |
| ) { |
| e.preventDefault(); |
| e.stopPropagation(); |
| handleFileClick(row.original as TFile); |
| } |
| } |
| }} |
| > |
| {flexRender(cell.column.columnDef.cell, cell.getContext())} |
| </TableCell> |
| ); |
| })} |
| </TableRow> |
| )) |
| ) : ( |
| <TableRow> |
| <TableCell |
| colSpan={columns.length} |
| className="h-24 text-center text-sm text-text-secondary" |
| > |
| {localize('com_files_no_results')} |
| </TableCell> |
| </TableRow> |
| )} |
| </TableBody> |
| </Table> |
| </div> |
| </div> |
| |
| <div className="flex items-center justify-between"> |
| <Button |
| variant="outline" |
| size="sm" |
| onClick={() => setShowFiles(true)} |
| aria-label={localize('com_sidepanel_manage_files')} |
| > |
| <ArrowUpLeft className="h-4 w-4" aria-hidden="true" /> |
| <span className="ml-2">{localize('com_sidepanel_manage_files')}</span> |
| </Button> |
| |
| <div className="flex items-center gap-2" role="navigation" aria-label="Pagination"> |
| <Button |
| variant="outline" |
| size="sm" |
| onClick={() => table.previousPage()} |
| disabled={!table.getCanPreviousPage()} |
| aria-label={localize('com_ui_prev')} |
| > |
| {localize('com_ui_prev')} |
| </Button> |
| <div aria-live="polite" className="text-sm"> |
| {`${pageIndex + 1} / ${table.getPageCount()}`} |
| </div> |
| <Button |
| variant="outline" |
| size="sm" |
| onClick={() => table.nextPage()} |
| disabled={!table.getCanNextPage()} |
| aria-label={localize('com_ui_next')} |
| > |
| {localize('com_ui_next')} |
| </Button> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|