feat: support data retention for normal chats

Add retentionMode config variable supporting "all" and "temporary" values.
When "all" is set, data retention applies to all chats, not just temporary ones.
Adds isTemporary field to conversations for proper filtering.

Adapted to new TS method files in packages/data-schemas since upstream
moved models out of api/models/.

Based on danny-avila/LibreChat#10532

Co-Authored-By: WhammyLeaf <233105313+WhammyLeaf@users.noreply.github.com>
This commit is contained in:
WhammyLeaf 2025-11-15 21:26:05 +00:00 committed by Aron Gates
parent 6ecd1b510f
commit 30109e90b0
No known key found for this signature in database
GPG key ID: 4F5BDD01E0CFE2A0
11 changed files with 93 additions and 27 deletions

View file

@ -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);

View file

@ -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) {

View file

@ -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:

View file

@ -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<typeof mcpServersSchema>;
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

View file

@ -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 */

View file

@ -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,

View file

@ -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();
});
});

View file

@ -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<IConversation>);
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(

View file

@ -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) {

View file

@ -21,6 +21,10 @@ const convoSchema: Schema<IConversation> = new Schema(
meiliIndex: true,
},
messages: [{ type: Schema.Types.ObjectId, ref: 'Message' }],
isTemporary: {
type: Boolean,
default: false,
},
...conversationPreset,
agent_id: {
type: String,

View file

@ -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;