This commit is contained in:
Aron 2026-04-06 13:49:59 +01:00 committed by GitHub
commit eb0545b847
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 186 additions and 36 deletions

View file

@ -1,12 +1,13 @@
const { nanoid } = require('nanoid');
const { EnvVar } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
const { checkAccess, loadWebSearchAuth } = require('@librechat/api');
const { checkAccess, loadWebSearchAuth, createTempChatExpirationDate } = require('@librechat/api');
const {
Tools,
AuthType,
Permissions,
ToolCallTypes,
RetentionMode,
PermissionTypes,
} = require('librechat-data-provider');
const { getRoleByName, createToolCall, getToolCallsByConvo, getMessage } = require('~/models');
@ -181,6 +182,15 @@ const callTool = async (req, res) => {
user: req.user.id,
};
if (req?.body?.isTemporary || appConfig?.interfaceConfig?.retentionMode === RetentionMode.ALL) {
try {
toolCallData.expiredAt = createTempChatExpirationDate(appConfig?.interfaceConfig);
} catch (err) {
logger.error('Error creating tool call expiration date:', err);
toolCallData.expiredAt = null;
}
}
if (!artifact || !artifact.files || toolId !== Tools.execute_code) {
createToolCall(toolCallData).catch((error) => {
logger.error(`Error creating tool call: ${error.message}`);

View file

@ -1,6 +1,7 @@
const express = require('express');
const { isEnabled } = require('@librechat/api');
const { isEnabled, createTempChatExpirationDate } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { RetentionMode } = require('librechat-data-provider');
const {
getSharedMessages,
createSharedLink,
@ -98,7 +99,20 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => {
router.post('/:conversationId', requireJwtAuth, async (req, res) => {
try {
const { targetMessageId } = req.body;
const created = await createSharedLink(req.user.id, req.params.conversationId, targetMessageId);
let expiredAt;
if (req?.config?.interfaceConfig?.retentionMode === RetentionMode.ALL) {
try {
expiredAt = createTempChatExpirationDate(req.config?.interfaceConfig);
} catch (err) {
logger.error('Error creating shared link expiration date:', err);
}
}
const created = await createSharedLink(
req.user.id,
req.params.conversationId,
targetMessageId,
expiredAt,
);
if (created) {
res.status(200).json(created);
} else {
@ -112,7 +126,15 @@ router.post('/:conversationId', requireJwtAuth, async (req, res) => {
router.patch('/:shareId', requireJwtAuth, async (req, res) => {
try {
const updatedShare = await updateSharedLink(req.user.id, req.params.shareId);
let expiredAt;
if (req?.config?.interfaceConfig?.retentionMode === RetentionMode.ALL) {
try {
expiredAt = createTempChatExpirationDate(req.config?.interfaceConfig);
} catch (err) {
logger.error('Error creating shared link expiration date:', err);
}
}
const updatedShare = await updateSharedLink(req.user.id, req.params.shareId, expiredAt);
if (updatedShare) {
res.status(200).json(updatedShare);
} else {

View file

@ -27,6 +27,7 @@ const { filterFilesByAgentAccess } = require('~/server/services/Files/permission
const { createFile, getFiles, updateFile, claimCodeFile } = require('~/models');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { convertImage } = require('~/server/services/Files/images/convert');
const { getRetentionExpiry } = require('~/server/services/Files/process');
const { determineFileType } = require('~/server/utils');
const axios = createAxiosInstance();
@ -182,6 +183,7 @@ const processCodeOutput = async ({
source: appConfig.fileStrategy,
context: FileContext.execute_code,
metadata: { fileIdentifier },
...getRetentionExpiry(req),
};
await createFile(file, true);
return Object.assign(file, { messageId, toolCallId });
@ -241,6 +243,7 @@ const processCodeOutput = async ({
context: FileContext.execute_code,
usage: isUpdate ? (claimed.usage ?? 0) + 1 : 1,
createdAt: isUpdate ? claimed.createdAt : formattedDate,
...getRetentionExpiry(req),
};
await createFile(file, true);

View file

@ -8,6 +8,7 @@ const {
FileContext,
FileSources,
imageExtRegex,
RetentionMode,
EModelEndpoint,
EToolResources,
mergeFileConfig,
@ -20,7 +21,12 @@ const {
} = require('librechat-data-provider');
const { EnvVar } = require('@librechat/agents');
const { logger } = require('@librechat/data-schemas');
const { sanitizeFilename, parseText, processAudioFile } = require('@librechat/api');
const {
sanitizeFilename,
parseText,
processAudioFile,
createTempChatExpirationDate,
} = require('@librechat/api');
const {
convertImage,
resizeAndConvert,
@ -37,6 +43,23 @@ const { determineFileType } = require('~/server/utils');
const { STTService } = require('./Audio/STTService');
const db = require('~/models');
/**
* Returns `{ expiredAt }` when the request indicates data retention applies, otherwise `{}`.
* Spread into file data objects before calling createFile.
* @param {ServerRequest} req
* @returns {{ expiredAt?: Date }}
*/
function getRetentionExpiry(req) {
if (req?.body?.isTemporary || req?.config?.interfaceConfig?.retentionMode === RetentionMode.ALL) {
try {
return { expiredAt: createTempChatExpirationDate(req.config?.interfaceConfig) };
} catch (_err) {
return {};
}
}
return {};
}
/**
* Creates a modular file upload wrapper that ensures filename sanitization
* across all storage strategies. This prevents storage-specific implementations
@ -307,6 +330,7 @@ const processImageFile = async ({ req, res, metadata, returnFile = false }) => {
context: FileContext.message_attachment,
source,
type: `image/${appConfig.imageOutputType}`,
...getRetentionExpiry(req),
width,
height,
},
@ -359,6 +383,7 @@ const uploadImageBuffer = async ({ req, context, metadata = {}, resize = true })
source,
type,
width,
...getRetentionExpiry(req),
height,
},
true,
@ -445,6 +470,7 @@ const processFileUpload = async ({ req, res, metadata }) => {
context: isAssistantUpload ? FileContext.assistants : FileContext.message_attachment,
model: isAssistantUpload ? req.body.model : undefined,
type: file.mimetype,
...getRetentionExpiry(req),
embedded,
source,
height,
@ -541,6 +567,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
filename: file.originalname,
model: messageAttachment ? undefined : req.body.model,
context: messageAttachment ? FileContext.message_attachment : FileContext.agents,
...getRetentionExpiry(req),
});
if (!messageAttachment && tool_resource) {
@ -717,6 +744,7 @@ const processAgentFileUpload = async ({ req, res, metadata }) => {
source,
height,
width,
...getRetentionExpiry(req),
});
const result = await db.createFile(fileInfo, true);
@ -762,6 +790,7 @@ const processOpenAIFile = async ({
source,
model: openai.req.body.model,
filename: originalName ?? file_id,
...getRetentionExpiry(openai.req),
};
if (saveFile) {
@ -805,6 +834,7 @@ const processOpenAIImageOutput = async ({ req, buffer, file_id, filename, fileEx
context: FileContext.assistants_output,
file_id,
filename,
...getRetentionExpiry(req),
};
db.createFile(file, true);
return file;
@ -961,6 +991,7 @@ async function saveBase64Image(
user: req.user.id,
bytes: image.bytes,
width: image.width,
...getRetentionExpiry(req),
height: image.height,
},
true,
@ -1047,6 +1078,7 @@ function filterFile({ req, image, isAvatar }) {
module.exports = {
filterFile,
getRetentionExpiry,
processFileURL,
saveBase64Image,
processImageFile,

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);
@ -60,9 +61,9 @@ const BookmarkMenu: FC = () => {
const isActiveConvo = Boolean(
conversation &&
conversationId &&
conversationId !== Constants.NEW_CONVO &&
conversationId !== 'search',
conversationId &&
conversationId !== Constants.NEW_CONVO &&
conversationId !== 'search',
);
const handleSubmit = useCallback(

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

@ -131,6 +131,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',
@ -679,6 +680,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
@ -720,6 +726,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

@ -46,6 +46,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,12 @@ 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 +285,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 +406,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,7 @@ 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

@ -345,6 +345,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
user: string,
conversationId: string,
targetMessageId?: string,
expiredAt?: Date,
): Promise<t.CreateShareResult> {
if (!user || !conversationId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
@ -408,6 +409,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
title,
user,
...(targetMessageId && { targetMessageId }),
...(expiredAt && { expiredAt }),
});
return { shareId, conversationId };
@ -460,7 +462,11 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
/**
* Update a shared link with new messages
*/
async function updateSharedLink(user: string, shareId: string): Promise<t.UpdateShareResult> {
async function updateSharedLink(
user: string,
shareId: string,
expiredAt?: Date,
): Promise<t.UpdateShareResult> {
if (!user || !shareId) {
throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS');
}
@ -485,6 +491,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) {
messages: updatedMessages,
user,
shareId: newShareId,
...(expiredAt && { expiredAt }),
};
const updatedShare = (await SharedLink.findOneAndUpdate({ shareId, user }, update, {

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

@ -82,12 +82,16 @@ const file: Schema<IMongoFile> = new Schema(
type: String,
index: true,
},
expiredAt: {
type: Date,
},
},
{
timestamps: true,
},
);
file.index({ expiredAt: 1 }, { expireAfterSeconds: 0 });
file.index({ createdAt: 1, updatedAt: 1 });
file.index(
{ filename: 1, conversationId: 1, context: 1, tenantId: 1 },

View file

@ -8,6 +8,7 @@ export interface ISharedLink extends Document {
shareId?: string;
targetMessageId?: string;
isPublic: boolean;
expiredAt?: Date;
createdAt?: Date;
updatedAt?: Date;
tenantId?: string;
@ -45,10 +46,14 @@ const shareSchema: Schema<ISharedLink> = new Schema(
type: String,
index: true,
},
expiredAt: {
type: Date,
},
},
{ timestamps: true },
);
shareSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 });
shareSchema.index({ conversationId: 1, user: 1, targetMessageId: 1, tenantId: 1 });
export default shareSchema;

View file

@ -10,6 +10,7 @@ export interface IToolCallData extends Document {
attachments?: TAttachment[];
blockIndex?: number;
partIndex?: number;
expiredAt?: Date;
createdAt?: Date;
updatedAt?: Date;
tenantId?: string;
@ -50,10 +51,14 @@ const toolCallSchema: Schema<IToolCallData> = new Schema(
type: String,
index: true,
},
expiredAt: {
type: Date,
},
},
{ timestamps: true },
);
toolCallSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 });
toolCallSchema.index({ messageId: 1, user: 1, tenantId: 1 });
toolCallSchema.index({ conversationId: 1, user: 1, tenantId: 1 });

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;

View file

@ -23,6 +23,7 @@ export interface IMongoFile extends Omit<Document, 'model'> {
fileIdentifier?: string;
};
expiresAt?: Date;
expiredAt?: Date;
createdAt?: Date;
updatedAt?: Date;
tenantId?: string;

View file

@ -10,6 +10,7 @@ export interface ISharedLink {
shareId?: string;
targetMessageId?: string;
isPublic: boolean;
expiredAt?: Date;
createdAt?: Date;
updatedAt?: Date;
}