diff --git a/api/models/Conversation.js b/api/models/Conversation.js index 13c329aa4a..6428d3970a 100644 --- a/api/models/Conversation.js +++ b/api/models/Conversation.js @@ -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'); } }, /** diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index 766b2a21b0..ad82ede10a 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -31,7 +31,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) { @@ -45,7 +46,8 @@ router.get('/', async (req, res) => { isArchived, tags, search, - order, + sortBy, + sortDirection, }); res.status(200).json(result); } catch (error) { diff --git a/client/src/components/Chat/Input/Artifacts.tsx b/client/src/components/Chat/Input/Artifacts.tsx index 2840fc2653..6df404f451 100644 --- a/client/src/components/Chat/Input/Artifacts.tsx +++ b/client/src/components/Chat/Input/Artifacts.tsx @@ -1,4 +1,4 @@ -import React, { memo, useState, useCallback, useMemo } from 'react'; +import React, { memo, useState, useCallback, useMemo, useEffect } from 'react'; import * as Ariakit from '@ariakit/react'; import { CheckboxButton } from '@librechat/client'; import { ArtifactModes } from 'librechat-data-provider'; @@ -18,6 +18,7 @@ function Artifacts() { const { toggleState, debouncedChange, isPinned } = artifacts; const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isButtonExpanded, setIsButtonExpanded] = useState(false); const currentState = useMemo(() => { if (typeof toggleState === 'string' && toggleState) { @@ -33,11 +34,26 @@ function Artifacts() { const handleToggle = useCallback(() => { if (isEnabled) { debouncedChange({ value: '' }); + setIsButtonExpanded(false); } else { debouncedChange({ value: ArtifactModes.DEFAULT }); } }, [isEnabled, debouncedChange]); + const handleMenuButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + setIsButtonExpanded(!isButtonExpanded); + }, + [isButtonExpanded], + ); + + useEffect(() => { + if (!isPopoverOpen) { + setIsButtonExpanded(false); + } + }, [isPopoverOpen]); + const handleShadcnToggle = useCallback(() => { if (isShadcnEnabled) { debouncedChange({ value: ArtifactModes.DEFAULT }); @@ -77,21 +93,24 @@ function Artifacts() { 'border-amber-600/40 bg-amber-500/10 hover:bg-amber-700/10', 'transition-colors', )} - onClick={(e) => e.stopPropagation()} + onClick={handleMenuButtonClick} > -