From a48576b6fa8681c48208edf0275cd28e0b8c67ba Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:11:26 +0200 Subject: [PATCH] feat: add ephemeral retention mode for forced temporary chats Extends the `retentionMode` interface option with a new `ephemeral` value that forces every conversation to be temporary and applies expiration deadlines to all data. Unlike `all` (which keeps chats visible until they expire), `ephemeral` marks all chats temporary so they are hidden from history and search, with the per-chat toggle locked on. - Add RetentionMode.EPHEMERAL plus isAllDataRetention and isForcedTemporaryRetention helpers - Force isTemporary and expiry on conversations, messages, imports, and files - Force the TEMPORARY_CHAT permission on so the locked indicator is shown - Lock the temporary-chat toggle in the UI and force new/existing chats temporary - Document the new mode in librechat.example.yaml --- api/server/routes/__tests__/share.spec.js | 44 ++++++++++--------- api/server/utils/import/importBatchBuilder.js | 10 +++-- api/server/utils/import/importers.spec.js | 17 +++++++ client/src/components/Chat/TemporaryChat.tsx | 25 ++++++++--- client/src/locales/en/translation.json | 1 + client/src/routes/ChatRoute.tsx | 11 ++--- librechat.example.yaml | 6 ++- packages/api/src/app/permissions.spec.ts | 40 ++++++++++++++++- packages/api/src/app/permissions.ts | 18 +++++--- packages/api/src/files/retention.spec.ts | 10 +++++ packages/api/src/files/retention.ts | 8 ++-- .../specs/config-schemas.spec.ts | 20 +++++++++ packages/data-provider/src/config.ts | 9 ++++ .../src/methods/conversation.spec.ts | 24 ++++++++++ .../data-schemas/src/methods/conversation.ts | 11 ++++- .../data-schemas/src/methods/message.spec.ts | 24 ++++++++++ packages/data-schemas/src/methods/message.ts | 11 ++++- 17 files changed, 238 insertions(+), 51 deletions(-) diff --git a/api/server/routes/__tests__/share.spec.js b/api/server/routes/__tests__/share.spec.js index 253fa93fd8..e579639105 100644 --- a/api/server/routes/__tests__/share.spec.js +++ b/api/server/routes/__tests__/share.spec.js @@ -38,27 +38,29 @@ jest.mock('@librechat/data-schemas', () => ({ SYSTEM_TENANT_ID: '__SYSTEM__', })); -jest.mock('librechat-data-provider', () => ({ - PermissionTypes: { - SHARED_LINKS: 'SHARED_LINKS', - }, - Permissions: { - CREATE: 'CREATE', - SHARE_PUBLIC: 'SHARE_PUBLIC', - }, - RetentionMode: { - ALL: 'all', - TEMPORARY: 'temporary', - }, - FileSources: { - local: 'local', - s3: 's3', - cloudfront: 'cloudfront', - azure_blob: 'azure_blob', - firebase: 'firebase', - text: 'text', - }, -})); +jest.mock('librechat-data-provider', () => { + const RetentionMode = { ALL: 'all', TEMPORARY: 'temporary', EPHEMERAL: 'ephemeral' }; + return { + PermissionTypes: { + SHARED_LINKS: 'SHARED_LINKS', + }, + Permissions: { + CREATE: 'CREATE', + SHARE_PUBLIC: 'SHARE_PUBLIC', + }, + RetentionMode, + isAllDataRetention: (mode) => mode === RetentionMode.ALL || mode === RetentionMode.EPHEMERAL, + isForcedTemporaryRetention: (mode) => mode === RetentionMode.EPHEMERAL, + FileSources: { + local: 'local', + s3: 's3', + cloudfront: 'cloudfront', + azure_blob: 'azure_blob', + firebase: 'firebase', + text: 'text', + }, + }; +}); jest.mock('mongoose', () => ({ models: { diff --git a/api/server/utils/import/importBatchBuilder.js b/api/server/utils/import/importBatchBuilder.js index b1856737cd..c382888b82 100644 --- a/api/server/utils/import/importBatchBuilder.js +++ b/api/server/utils/import/importBatchBuilder.js @@ -7,8 +7,9 @@ const { const { EModelEndpoint, Constants, - RetentionMode, openAISettings, + isAllDataRetention, + isForcedTemporaryRetention, } = require('librechat-data-provider'); const { bulkIncrementTagCounts, bulkSaveConvos, bulkSaveMessages } = require('~/models'); const { FALLBACK_MODEL_BY_ENDPOINT } = require('./defaults'); @@ -45,19 +46,20 @@ class ImportBatchBuilder { return this.retentionFields; } - if (this.interfaceConfig?.retentionMode !== RetentionMode.ALL) { + if (!isAllDataRetention(this.interfaceConfig?.retentionMode)) { this.retentionFields = {}; return this.retentionFields; } + const isTemporary = isForcedTemporaryRetention(this.interfaceConfig?.retentionMode); try { this.retentionFields = { - isTemporary: false, + isTemporary, expiredAt: createTempChatExpirationDate(this.interfaceConfig), }; } catch (error) { logger.error('[ImportBatchBuilder] Error creating import expiration date:', error); - this.retentionFields = { isTemporary: false, expiredAt: createFallbackRetentionDate() }; + this.retentionFields = { isTemporary, expiredAt: createFallbackRetentionDate() }; } return this.retentionFields; } diff --git a/api/server/utils/import/importers.spec.js b/api/server/utils/import/importers.spec.js index a9bd679f55..46e87889e0 100644 --- a/api/server/utils/import/importers.spec.js +++ b/api/server/utils/import/importers.spec.js @@ -1145,6 +1145,23 @@ describe('importLibreChatConvo', () => { expect(result.conversation.expiredAt).toBeInstanceOf(Date); expect(result.conversation.expiredAt).toBe(message.expiredAt); }); + + it('marks imported conversations and messages temporary under ephemeral retention', () => { + const requestUserId = 'user-123'; + const builder = new ImportBatchBuilder(requestUserId, { + retentionMode: RetentionMode.EPHEMERAL, + temporaryChatRetention: 24, + }); + builder.startConversation(EModelEndpoint.openAI); + const message = builder.addUserMessage('Ephemeral import'); + const result = builder.finishConversation('Imported ephemeral chat'); + + expect(message.isTemporary).toBe(true); + expect(message.expiredAt).toBeInstanceOf(Date); + expect(result.conversation.isTemporary).toBe(true); + expect(result.conversation.expiredAt).toBeInstanceOf(Date); + expect(result.conversation.expiredAt).toBe(message.expiredAt); + }); }); }); diff --git a/client/src/components/Chat/TemporaryChat.tsx b/client/src/components/Chat/TemporaryChat.tsx index fe4b87262a..6181f17b56 100644 --- a/client/src/components/Chat/TemporaryChat.tsx +++ b/client/src/components/Chat/TemporaryChat.tsx @@ -4,24 +4,32 @@ import { TooltipAnchor } from '@librechat/client'; import { MessageCircleDashed } from 'lucide-react'; import { Constants } from 'librechat-data-provider'; import { useRecoilState, useRecoilCallback } from 'recoil'; +import { isForcedTemporaryRetention } from 'librechat-data-provider'; import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts'; +import { useGetStartupConfig } from '~/data-provider'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; import store from '~/store'; export function TemporaryChat() { const localize = useLocalize(); + const { data: startupConfig } = useGetStartupConfig(); const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary); const conversation = useRecoilValue(store.conversationByIndex(0)); const isSubmitting = useRecoilValue(store.isSubmittingFamily(0)); const tooltipDescription = useShortcutHint('toggleTemporaryChat', localize('com_ui_temporary')); const ariaKey = useShortcutAriaKey('toggleTemporaryChat'); + const isEnforced = isForcedTemporaryRetention(startupConfig?.interface?.retentionMode); + const handleBadgeToggle = useRecoilCallback( () => () => { + if (isEnforced) { + return; + } setIsTemporary(!isTemporary); }, - [isTemporary], + [isTemporary, isEnforced], ); const conversationId = conversation?.conversationId; @@ -35,21 +43,26 @@ export function TemporaryChat() { return null; } + const isActive = isEnforced || isTemporary; + const label = isEnforced ? localize('com_ui_temporary_enforced') : localize('com_ui_temporary'); + return (