refactor: DataTable and ArchivedChats; fix: sorting ArchivedChats API

This commit is contained in:
Marco Beretta 2025-09-15 22:51:34 +02:00
parent 8e22e70439
commit a253aa5d22
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
5 changed files with 2296 additions and 549 deletions

View file

@ -28,7 +28,7 @@ const getConvo = async (user, conversationId) => {
return await Conversation.findOne({ user, conversationId }).lean();
} catch (error) {
logger.error('[getConvo] Error getting single conversation', error);
return { message: 'Error getting single conversation' };
throw new Error('Error getting single conversation');
}
};
@ -151,13 +151,21 @@ module.exports = {
const result = await Conversation.bulkWrite(bulkOps);
return result;
} catch (error) {
logger.error('[saveBulkConversations] Error saving conversations in bulk', error);
logger.error('[bulkSaveConvos] Error saving conversations in bulk', error);
throw new Error('Failed to save conversations in bulk.');
}
},
getConvosByCursor: async (
user,
{ cursor, limit = 25, isArchived = false, tags, search, order = 'desc' } = {},
{
cursor,
limit = 25,
isArchived = false,
tags,
search,
sortBy = 'createdAt',
sortDirection = 'desc',
} = {},
) => {
const filters = [{ user }];
if (isArchived) {
@ -184,35 +192,77 @@ module.exports = {
filters.push({ conversationId: { $in: matchingIds } });
} catch (error) {
logger.error('[getConvosByCursor] Error during meiliSearch', error);
return { message: 'Error during meiliSearch' };
throw new Error('Error during meiliSearch');
}
}
const validSortFields = ['title', 'createdAt', 'updatedAt'];
if (!validSortFields.includes(sortBy)) {
throw new Error(
`Invalid sortBy field: ${sortBy}. Must be one of ${validSortFields.join(', ')}`,
);
}
const finalSortBy = sortBy;
const finalSortDirection = sortDirection === 'asc' ? 'asc' : 'desc';
let cursorFilter = null;
if (cursor) {
filters.push({ updatedAt: { $lt: new Date(cursor) } });
try {
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString());
const { primary, secondary } = decoded;
const primaryValue = finalSortBy === 'title' ? primary : new Date(primary);
const secondaryValue = new Date(secondary);
const op = finalSortDirection === 'asc' ? '$gt' : '$lt';
cursorFilter = {
$or: [
{ [finalSortBy]: { [op]: primaryValue } },
{
[finalSortBy]: primaryValue,
updatedAt: { [op]: secondaryValue },
},
],
};
} catch (err) {
logger.warn('[getConvosByCursor] Invalid cursor format, starting from beginning');
}
if (cursorFilter) {
filters.push(cursorFilter);
}
}
const query = filters.length === 1 ? filters[0] : { $and: filters };
try {
const sortOrder = finalSortDirection === 'asc' ? 1 : -1;
const sortObj = { [finalSortBy]: sortOrder };
if (finalSortBy !== 'updatedAt') {
sortObj.updatedAt = sortOrder;
}
const convos = await Conversation.find(query)
.select(
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL',
)
.sort({ updatedAt: order === 'asc' ? 1 : -1 })
.sort(sortObj)
.limit(limit + 1)
.lean();
let nextCursor = null;
if (convos.length > limit) {
const lastConvo = convos.pop();
nextCursor = lastConvo.updatedAt.toISOString();
const primaryValue = lastConvo[finalSortBy];
const primaryStr = finalSortBy === 'title' ? primaryValue : primaryValue.toISOString();
const secondaryStr = lastConvo.updatedAt.toISOString();
const composite = { primary: primaryStr, secondary: secondaryStr };
nextCursor = Buffer.from(JSON.stringify(composite)).toString('base64');
}
return { conversations: convos, nextCursor };
} catch (error) {
logger.error('[getConvosByCursor] Error getting conversations', error);
return { message: 'Error getting conversations' };
throw new Error('Error getting conversations');
}
},
getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => {
@ -252,7 +302,7 @@ module.exports = {
return { conversations: limited, nextCursor, convoMap };
} catch (error) {
logger.error('[getConvosQueried] Error getting conversations', error);
return { message: 'Error fetching conversations' };
throw new Error('Error fetching conversations');
}
},
getConvo,
@ -269,7 +319,7 @@ module.exports = {
}
} catch (error) {
logger.error('[getConvoTitle] Error getting conversation title', error);
return { message: 'Error getting conversation title' };
throw new Error('Error getting conversation title');
}
},
/**

View file

@ -30,7 +30,8 @@ router.get('/', async (req, res) => {
const cursor = req.query.cursor;
const isArchived = isEnabled(req.query.isArchived);
const search = req.query.search ? decodeURIComponent(req.query.search) : undefined;
const order = req.query.order || 'desc';
const sortBy = req.query.sortBy || 'createdAt';
const sortDirection = req.query.sortDirection || 'desc';
let tags;
if (req.query.tags) {
@ -44,7 +45,8 @@ router.get('/', async (req, res) => {
isArchived,
tags,
search,
order,
sortBy,
sortDirection,
});
res.status(200).json(result);
} catch (error) {

View file

@ -12,7 +12,6 @@ import {
Label,
TooltipAnchor,
Spinner,
DataTable,
useToastContext,
useMediaQuery,
} from '@librechat/client';
@ -26,6 +25,7 @@ import { MinimalIcon } from '~/components/Endpoints';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks';
import { formatDate } from '~/utils';
import DataTable from './DataTable';
const DEFAULT_PARAMS = {
isArchived: true,
@ -44,13 +44,12 @@ const defaultSort: SortingState = [
},
];
// Define the table column type for better type safety
// (kept from your original code)
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
meta?: {
size?: string | number;
mobileSize?: string | number;
minWidth?: string | number;
priority?: number;
};
};
@ -86,17 +85,16 @@ export default function ArchivedChatsTable() {
}));
}, []);
// Robust against stale state; keeps UI sort in sync with backend defaults
const handleSortingChange = useCallback(
(updater: SortingState | ((old: SortingState) => SortingState)) => {
setSorting((prev) => {
const next = typeof updater === 'function' ? updater(prev) : updater;
// If user clears sorting, fall back to default both in UI and query
const coerced = next.length === 0 ? defaultSort : next;
const coerced = next;
const primary = coerced[0];
setQueryParams((p) => {
const newParams = (() => {
if (primary && isSortKey(primary.id)) {
return {
...p,
@ -104,18 +102,24 @@ export default function ArchivedChatsTable() {
sortDirection: primary.desc ? 'desc' : 'asc',
};
}
// Fallback if id isn't one of the permitted keys
return {
...p,
sortBy: 'createdAt',
sortDirection: 'desc',
};
})();
setTimeout(() => {
refetch();
}, 0);
return newParams;
});
return coerced;
});
},
[setQueryParams, setSorting],
[setQueryParams, setSorting, refetch],
);
const handleError = useCallback(
@ -189,13 +193,7 @@ export default function ArchivedChatsTable() {
cell: ({ row }) => {
const { conversationId, title } = row.original;
return (
<a
href={`/c/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 truncate underline"
aria-label={localize('com_ui_open_conversation', { 0: title })}
>
<div className="flex items-center gap-2">
<MinimalIcon
endpoint={row.original.endpoint}
size={28}
@ -203,13 +201,21 @@ export default function ArchivedChatsTable() {
iconClassName="size-4"
aria-hidden="true"
/>
<span>{title}</span>
<a
href={`/c/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center truncate underline"
aria-label={localize('com_ui_open_conversation', { 0: title })}
>
{title}
</a>
</div>
);
},
meta: {
size: isSmallScreen ? '70%' : '50%',
mobileSize: '70%',
priority: 3,
minWidth: 'min-content',
},
enableSorting: true,
},
@ -220,8 +226,8 @@ export default function ArchivedChatsTable() {
),
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
meta: {
size: isSmallScreen ? '30%' : '35%',
mobileSize: '30%',
priority: 2,
minWidth: '80px',
},
enableSorting: true,
},
@ -265,7 +271,7 @@ export default function ArchivedChatsTable() {
description={localize('com_ui_delete')}
render={
<Button
variant="ghost"
variant="destructive"
className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => {
setDeleteRow(row.original);
@ -281,8 +287,8 @@ export default function ArchivedChatsTable() {
);
},
meta: {
size: '15%',
mobileSize: '25%',
priority: 1,
minWidth: '120px',
},
enableSorting: false,
},

File diff suppressed because it is too large Load diff

View file

@ -31,6 +31,7 @@ export * from './InputOTP';
export * from './MultiSearch';
export * from './Resizable';
export * from './Select';
export { default as DataTableErrorBoundary } from './DataTable/DataTableErrorBoundary';
export { default as Radio } from './Radio';
export { default as Badge } from './Badge';
export { default as Avatar } from './Avatar';