mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-07 00:15:23 +02:00
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:
parent
6ecd1b510f
commit
30109e90b0
11 changed files with 93 additions and 27 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue