diff --git a/client/src/components/Chat/Menus/BookmarkMenu.tsx b/client/src/components/Chat/Menus/BookmarkMenu.tsx index d66fccd24b..7fc106e57b 100644 --- a/client/src/components/Chat/Menus/BookmarkMenu.tsx +++ b/client/src/components/Chat/Menus/BookmarkMenu.tsx @@ -26,8 +26,9 @@ const BookmarkMenu: FC = () => { const conversationId = conversation?.conversationId ?? ''; const updateConvoTags = useBookmarkSuccess(conversationId); const tags = conversation?.tags; - const isTemporary = conversation?.expiredAt != null; - + const isTemporary = + conversation.isTemporary || + (conversation.isTemporary === undefined && conversation.expiredAt != null); const menuId = useId(); const [isMenuOpen, setIsMenuOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); diff --git a/client/src/routes/ChatRoute.tsx b/client/src/routes/ChatRoute.tsx index a17d349037..10a05ee56c 100644 --- a/client/src/routes/ChatRoute.tsx +++ b/client/src/routes/ChatRoute.tsx @@ -62,7 +62,10 @@ export default function ChatRoute() { const endpointsQuery = useGetEndpointsQuery({ enabled: isAuthenticated }); const assistantListMap = useAssistantListMap(); - const isTemporaryChat = conversation && conversation.expiredAt ? true : false; + const isTemporaryChat = + conversation && + (conversation.isTemporary || + (conversation.isTemporary === undefined && conversation.expiredAt != null)); useEffect(() => { if (conversationId === Constants.NEW_CONVO) { diff --git a/librechat.example.yaml b/librechat.example.yaml index 03bb5f5bc2..b841f812ad 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -133,6 +133,7 @@ interface: # Temporary chat retention period in hours (default: 720, min: 1, max: 8760) # temporaryChatRetention: 1 + retentionMode: "temporary" # Example Cloudflare turnstile (optional) #turnstile: diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index ae3f5b9560..98ac38499e 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -44,6 +44,7 @@ export const excludedKeys = new Set([ 'createdAt', 'updatedAt', 'expiredAt', + 'isTemporary', 'messages', 'isArchived', 'tags', @@ -640,6 +641,11 @@ const mcpServersSchema = z export type TMcpServersConfig = z.infer; +export enum RetentionMode { + ALL = 'all', + TEMPORARY = 'temporary', +} + export const interfaceSchema = z .object({ privacyPolicy: z @@ -683,6 +689,7 @@ export const interfaceSchema = z .optional(), temporaryChat: z.boolean().optional(), temporaryChatRetention: z.number().min(1).max(8760).optional(), + retentionMode: z.nativeEnum(RetentionMode).default(RetentionMode.TEMPORARY), runCode: z.boolean().optional(), webSearch: z.boolean().optional(), peoplePicker: z diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 084f74af86..b677222c11 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -797,6 +797,7 @@ export const tConversationSchema = z.object({ iconURL: z.string().nullable().optional(), /* temporary chat */ expiredAt: z.string().nullable().optional(), + isTemporary: z.boolean().optional(), /* file token limits */ fileTokenLimit: coerceNumber.optional(), /** @deprecated */ diff --git a/packages/data-schemas/src/app/interface.ts b/packages/data-schemas/src/app/interface.ts index 1701a22fad..f07b3b8d4d 100644 --- a/packages/data-schemas/src/app/interface.ts +++ b/packages/data-schemas/src/app/interface.ts @@ -49,6 +49,8 @@ export async function loadDefaultInterface({ multiConvo: interfaceConfig?.multiConvo, agents: interfaceConfig?.agents, temporaryChat: interfaceConfig?.temporaryChat, + temporaryChatRetention: interfaceConfig?.temporaryChatRetention, + retentionMode: interfaceConfig?.retentionMode, runCode: interfaceConfig?.runCode, webSearch: interfaceConfig?.webSearch, fileSearch: interfaceConfig?.fileSearch, diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index 9e4c2d2f5d..b045486291 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -398,13 +398,24 @@ describe('Conversation Operations', () => { expect(secondSave?.expiredAt).toBeNull(); }); - it('should filter out expired conversations in getConvosByCursor', async () => { + it('should filter out temporary conversations in getConvosByCursor', async () => { // Create some test conversations - const nonExpiredConvo = await Conversation.create({ + const newNonTemporaryConvo = await Conversation.create({ conversationId: uuidv4(), user: 'user123', - title: 'Non-expired', + title: 'New Non-temporary Conversation', endpoint: EModelEndpoint.openAI, + isTemporary: false, + expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + updatedAt: new Date(), + }); + + const oldNonTemporaryConvo = await Conversation.create({ + conversationId: uuidv4(), + user: 'user123', + title: 'Old Non-Temporary Conversation', + endpoint: EModelEndpoint.openAI, + isTemporary: undefined, expiredAt: null, updatedAt: new Date(), }); @@ -412,9 +423,10 @@ describe('Conversation Operations', () => { await Conversation.create({ conversationId: uuidv4(), user: 'user123', - title: 'Future expired', + title: 'Temporary conversation', endpoint: EModelEndpoint.openAI, - expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours from now + isTemporary: true, + expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), updatedAt: new Date(), }); @@ -423,41 +435,61 @@ describe('Conversation Operations', () => { const result = await getConvosByCursor('user123'); - // Should only return conversations with null or non-existent expiredAt - expect(result?.conversations).toHaveLength(1); - expect(result?.conversations[0]?.conversationId).toBe(nonExpiredConvo.conversationId); + // Should return both non-temporary conversations, not the temporary one + expect(result?.conversations).toHaveLength(2); + const convoIds = result?.conversations.map((c) => c.conversationId); + expect(convoIds).toContain(newNonTemporaryConvo.conversationId); + expect(convoIds).toContain(oldNonTemporaryConvo.conversationId); }); it('should filter out expired conversations in getConvosQueried', async () => { - // Create test conversations - const nonExpiredConvo = await Conversation.create({ + const newNonTemporaryConvo = await Conversation.create({ conversationId: uuidv4(), user: 'user123', - title: 'Non-expired', + title: 'New Non-temporary Conversation', endpoint: EModelEndpoint.openAI, - expiredAt: null, + isTemporary: false, + expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + updatedAt: new Date(), }); - const expiredConvo = await Conversation.create({ + const oldNonTemporaryConvo = await Conversation.create({ conversationId: uuidv4(), user: 'user123', - title: 'Expired', + title: 'Old Non-Temporary Conversation', endpoint: EModelEndpoint.openAI, + isTemporary: undefined, + expiredAt: null, + updatedAt: new Date(), + }); + + const tempConvo = await Conversation.create({ + conversationId: uuidv4(), + user: 'user123', + title: 'Temporary conversation', + endpoint: EModelEndpoint.openAI, + isTemporary: true, expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + updatedAt: new Date(), }); const convoIds = [ - { conversationId: nonExpiredConvo.conversationId }, - { conversationId: expiredConvo.conversationId }, + { conversationId: newNonTemporaryConvo.conversationId }, + { conversationId: oldNonTemporaryConvo.conversationId }, + { conversationId: tempConvo.conversationId }, ]; const result = await getConvosQueried('user123', convoIds); - // Should only return the non-expired conversation - expect(result?.conversations).toHaveLength(1); - expect(result?.conversations[0].conversationId).toBe(nonExpiredConvo.conversationId); - expect(result?.convoMap[nonExpiredConvo.conversationId]).toBeDefined(); - expect(result?.convoMap[expiredConvo.conversationId]).toBeUndefined(); + // Should only return the non-temporary conversations + expect(result?.conversations).toHaveLength(2); + + const resultIds = result?.conversations.map((c) => c.conversationId); + expect(resultIds).toContain(newNonTemporaryConvo.conversationId); + expect(resultIds).toContain(oldNonTemporaryConvo.conversationId); + expect(result?.convoMap[newNonTemporaryConvo.conversationId]).toBeDefined(); + expect(result?.convoMap[oldNonTemporaryConvo.conversationId]).toBeDefined(); + expect(result?.convoMap[tempConvo.conversationId]).toBeUndefined(); }); }); diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index 00b5cfee7a..eb2136affc 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -1,4 +1,5 @@ import type { FilterQuery, Model, SortOrder } from 'mongoose'; +import { RetentionMode } from 'librechat-data-provider'; import { createTempChatExpirationDate } from '~/utils/tempChatRetention'; import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite'; import logger from '~/config/winston'; @@ -174,6 +175,15 @@ export function createConversationMethods( } if (isTemporary) { + update.isTemporary = true; + } else { + update.isTemporary = false; + } + + if ( + isTemporary || + interfaceConfig?.retentionMode === RetentionMode.ALL + ) { try { update.expiredAt = createTempChatExpirationDate(interfaceConfig); } catch (err) { @@ -278,7 +288,7 @@ export function createConversationMethods( } filters.push({ - $or: [{ expiredAt: null }, { expiredAt: { $exists: false } }], + $or: [{ isTemporary: false }, { isTemporary: { $exists: false } }], } as FilterQuery); if (search) { @@ -399,7 +409,7 @@ export function createConversationMethods( const results = await Conversation.find({ user, conversationId: { $in: conversationIds }, - $or: [{ expiredAt: { $exists: false } }, { expiredAt: null }], + $or: [{ isTemporary: false }, { isTemporary: { $exists: false } }], }).lean(); results.sort( diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index 2e638b6bfb..4eb73a7054 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -1,4 +1,5 @@ import type { DeleteResult, FilterQuery, Model } from 'mongoose'; +import { RetentionMode } from 'librechat-data-provider'; import logger from '~/config/winston'; import { createTempChatExpirationDate } from '~/utils/tempChatRetention'; import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite'; @@ -91,7 +92,10 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa messageId: params.newMessageId || params.messageId, }; - if (isTemporary) { + if ( + isTemporary || + interfaceConfig?.retentionMode === RetentionMode.ALL + ) { try { update.expiredAt = createTempChatExpirationDate(interfaceConfig); } catch (err) { diff --git a/packages/data-schemas/src/schema/convo.ts b/packages/data-schemas/src/schema/convo.ts index c8f394935a..6b845d6ce9 100644 --- a/packages/data-schemas/src/schema/convo.ts +++ b/packages/data-schemas/src/schema/convo.ts @@ -21,6 +21,10 @@ const convoSchema: Schema = new Schema( meiliIndex: true, }, messages: [{ type: Schema.Types.ObjectId, ref: 'Message' }], + isTemporary: { + type: Boolean, + default: false, + }, ...conversationPreset, agent_id: { type: String, diff --git a/packages/data-schemas/src/types/convo.ts b/packages/data-schemas/src/types/convo.ts index c7888efba2..5cd828e9d6 100644 --- a/packages/data-schemas/src/types/convo.ts +++ b/packages/data-schemas/src/types/convo.ts @@ -6,6 +6,7 @@ export interface IConversation extends Document { title?: string; user?: string; messages?: Types.ObjectId[]; + isTemporary?: boolean; // Fields provided by conversationPreset (adjust types as needed) endpoint?: string; endpointType?: string;