mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-21 21:50:49 +02:00
refactor: DataTable and ArchivedChats; fix: sorting ArchivedChats API
This commit is contained in:
parent
8e22e70439
commit
a253aa5d22
5 changed files with 2296 additions and 549 deletions
|
@ -28,7 +28,7 @@ const getConvo = async (user, conversationId) => {
|
||||||
return await Conversation.findOne({ user, conversationId }).lean();
|
return await Conversation.findOne({ user, conversationId }).lean();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getConvo] Error getting single conversation', 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);
|
const result = await Conversation.bulkWrite(bulkOps);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} 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.');
|
throw new Error('Failed to save conversations in bulk.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getConvosByCursor: async (
|
getConvosByCursor: async (
|
||||||
user,
|
user,
|
||||||
{ cursor, limit = 25, isArchived = false, tags, search, order = 'desc' } = {},
|
{
|
||||||
|
cursor,
|
||||||
|
limit = 25,
|
||||||
|
isArchived = false,
|
||||||
|
tags,
|
||||||
|
search,
|
||||||
|
sortBy = 'createdAt',
|
||||||
|
sortDirection = 'desc',
|
||||||
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
const filters = [{ user }];
|
const filters = [{ user }];
|
||||||
if (isArchived) {
|
if (isArchived) {
|
||||||
|
@ -184,35 +192,77 @@ module.exports = {
|
||||||
filters.push({ conversationId: { $in: matchingIds } });
|
filters.push({ conversationId: { $in: matchingIds } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getConvosByCursor] Error during meiliSearch', 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) {
|
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 };
|
const query = filters.length === 1 ? filters[0] : { $and: filters };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const sortOrder = finalSortDirection === 'asc' ? 1 : -1;
|
||||||
|
const sortObj = { [finalSortBy]: sortOrder };
|
||||||
|
|
||||||
|
if (finalSortBy !== 'updatedAt') {
|
||||||
|
sortObj.updatedAt = sortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
const convos = await Conversation.find(query)
|
const convos = await Conversation.find(query)
|
||||||
.select(
|
.select(
|
||||||
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL',
|
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL',
|
||||||
)
|
)
|
||||||
.sort({ updatedAt: order === 'asc' ? 1 : -1 })
|
.sort(sortObj)
|
||||||
.limit(limit + 1)
|
.limit(limit + 1)
|
||||||
.lean();
|
.lean();
|
||||||
|
|
||||||
let nextCursor = null;
|
let nextCursor = null;
|
||||||
if (convos.length > limit) {
|
if (convos.length > limit) {
|
||||||
const lastConvo = convos.pop();
|
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 };
|
return { conversations: convos, nextCursor };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getConvosByCursor] Error getting conversations', 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) => {
|
getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => {
|
||||||
|
@ -252,7 +302,7 @@ module.exports = {
|
||||||
return { conversations: limited, nextCursor, convoMap };
|
return { conversations: limited, nextCursor, convoMap };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getConvosQueried] Error getting conversations', error);
|
logger.error('[getConvosQueried] Error getting conversations', error);
|
||||||
return { message: 'Error fetching conversations' };
|
throw new Error('Error fetching conversations');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getConvo,
|
getConvo,
|
||||||
|
@ -269,7 +319,7 @@ module.exports = {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getConvoTitle] Error getting conversation title', error);
|
logger.error('[getConvoTitle] Error getting conversation title', error);
|
||||||
return { message: 'Error getting conversation title' };
|
throw new Error('Error getting conversation title');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -30,7 +30,8 @@ router.get('/', async (req, res) => {
|
||||||
const cursor = req.query.cursor;
|
const cursor = req.query.cursor;
|
||||||
const isArchived = isEnabled(req.query.isArchived);
|
const isArchived = isEnabled(req.query.isArchived);
|
||||||
const search = req.query.search ? decodeURIComponent(req.query.search) : undefined;
|
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;
|
let tags;
|
||||||
if (req.query.tags) {
|
if (req.query.tags) {
|
||||||
|
@ -44,7 +45,8 @@ router.get('/', async (req, res) => {
|
||||||
isArchived,
|
isArchived,
|
||||||
tags,
|
tags,
|
||||||
search,
|
search,
|
||||||
order,
|
sortBy,
|
||||||
|
sortDirection,
|
||||||
});
|
});
|
||||||
res.status(200).json(result);
|
res.status(200).json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -12,7 +12,6 @@ import {
|
||||||
Label,
|
Label,
|
||||||
TooltipAnchor,
|
TooltipAnchor,
|
||||||
Spinner,
|
Spinner,
|
||||||
DataTable,
|
|
||||||
useToastContext,
|
useToastContext,
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
} from '@librechat/client';
|
} from '@librechat/client';
|
||||||
|
@ -26,6 +25,7 @@ import { MinimalIcon } from '~/components/Endpoints';
|
||||||
import { NotificationSeverity } from '~/common';
|
import { NotificationSeverity } from '~/common';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import { formatDate } from '~/utils';
|
import { formatDate } from '~/utils';
|
||||||
|
import DataTable from './DataTable';
|
||||||
|
|
||||||
const DEFAULT_PARAMS = {
|
const DEFAULT_PARAMS = {
|
||||||
isArchived: true,
|
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> & {
|
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
||||||
meta?: {
|
meta?: {
|
||||||
size?: string | number;
|
size?: string | number;
|
||||||
mobileSize?: string | number;
|
mobileSize?: string | number;
|
||||||
minWidth?: 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(
|
const handleSortingChange = useCallback(
|
||||||
(updater: SortingState | ((old: SortingState) => SortingState)) => {
|
(updater: SortingState | ((old: SortingState) => SortingState)) => {
|
||||||
setSorting((prev) => {
|
setSorting((prev) => {
|
||||||
const next = typeof updater === 'function' ? updater(prev) : updater;
|
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||||
|
|
||||||
// If user clears sorting, fall back to default both in UI and query
|
const coerced = next;
|
||||||
const coerced = next.length === 0 ? defaultSort : next;
|
|
||||||
const primary = coerced[0];
|
const primary = coerced[0];
|
||||||
|
|
||||||
setQueryParams((p) => {
|
setQueryParams((p) => {
|
||||||
|
const newParams = (() => {
|
||||||
if (primary && isSortKey(primary.id)) {
|
if (primary && isSortKey(primary.id)) {
|
||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
|
@ -104,18 +102,24 @@ export default function ArchivedChatsTable() {
|
||||||
sortDirection: primary.desc ? 'desc' : 'asc',
|
sortDirection: primary.desc ? 'desc' : 'asc',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Fallback if id isn't one of the permitted keys
|
|
||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
sortBy: 'createdAt',
|
sortBy: 'createdAt',
|
||||||
sortDirection: 'desc',
|
sortDirection: 'desc',
|
||||||
};
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
refetch();
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return newParams;
|
||||||
});
|
});
|
||||||
|
|
||||||
return coerced;
|
return coerced;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setQueryParams, setSorting],
|
[setQueryParams, setSorting, refetch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleError = useCallback(
|
const handleError = useCallback(
|
||||||
|
@ -189,13 +193,7 @@ export default function ArchivedChatsTable() {
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const { conversationId, title } = row.original;
|
const { conversationId, title } = row.original;
|
||||||
return (
|
return (
|
||||||
<a
|
<div className="flex items-center gap-2">
|
||||||
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 })}
|
|
||||||
>
|
|
||||||
<MinimalIcon
|
<MinimalIcon
|
||||||
endpoint={row.original.endpoint}
|
endpoint={row.original.endpoint}
|
||||||
size={28}
|
size={28}
|
||||||
|
@ -203,13 +201,21 @@ export default function ArchivedChatsTable() {
|
||||||
iconClassName="size-4"
|
iconClassName="size-4"
|
||||||
aria-hidden="true"
|
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>
|
</a>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
size: isSmallScreen ? '70%' : '50%',
|
priority: 3,
|
||||||
mobileSize: '70%',
|
minWidth: 'min-content',
|
||||||
},
|
},
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
},
|
},
|
||||||
|
@ -220,8 +226,8 @@ export default function ArchivedChatsTable() {
|
||||||
),
|
),
|
||||||
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
|
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
|
||||||
meta: {
|
meta: {
|
||||||
size: isSmallScreen ? '30%' : '35%',
|
priority: 2,
|
||||||
mobileSize: '30%',
|
minWidth: '80px',
|
||||||
},
|
},
|
||||||
enableSorting: true,
|
enableSorting: true,
|
||||||
},
|
},
|
||||||
|
@ -265,7 +271,7 @@ export default function ArchivedChatsTable() {
|
||||||
description={localize('com_ui_delete')}
|
description={localize('com_ui_delete')}
|
||||||
render={
|
render={
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="destructive"
|
||||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDeleteRow(row.original);
|
setDeleteRow(row.original);
|
||||||
|
@ -281,8 +287,8 @@ export default function ArchivedChatsTable() {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
size: '15%',
|
priority: 1,
|
||||||
mobileSize: '25%',
|
minWidth: '120px',
|
||||||
},
|
},
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
},
|
},
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -31,6 +31,7 @@ export * from './InputOTP';
|
||||||
export * from './MultiSearch';
|
export * from './MultiSearch';
|
||||||
export * from './Resizable';
|
export * from './Resizable';
|
||||||
export * from './Select';
|
export * from './Select';
|
||||||
|
export { default as DataTableErrorBoundary } from './DataTable/DataTableErrorBoundary';
|
||||||
export { default as Radio } from './Radio';
|
export { default as Radio } from './Radio';
|
||||||
export { default as Badge } from './Badge';
|
export { default as Badge } from './Badge';
|
||||||
export { default as Avatar } from './Avatar';
|
export { default as Avatar } from './Avatar';
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue