💬 feat: Temporary Chats (#5493)

* feat: add expiredAt property to Conversation and Message models

Added `expiredAt` property to both Conversation and Message schemas.
Configured `expireAfterSeconds` index in MongoDB to automatically delete documents after a specified period.

* feat(data-provider): add isTemporary and expiredAt properties to support temporary chats

Added `isTemporary` property to TPayload and TSubmission for API calls for temporary chat.
Additionally, added `expiredAt` property to `tConversationSchema` to determine if a chat is temporary.

* feat: implement isTemporary state management

Add Recoil state for tracking temporary conversations, update event handlers to respect temporary chat status

* feat: add configuration to interfaceconfig to hide the temporary chat switch

* feat: add Temporary Chat UI with switch and modify related behaviors

- Added a Temporary Chat switch button at the end of dropdown lists in each model.
- Updated the form background color to black when Temporary Chat is enabled.
- Modified Navigation to exclude Temporary Chats from the chat list.

* fix: exclude Temporary Chats from search results

Updated the getConvosQueried query to ensure that Temporary Chats are not included in the search results.

* fix: hide bookmark button for Temporary Chats

Updated the UI to ensure that the bookmark button is not displayed when a chat is as Temporary Chat.

* chore: update isTemporary state management in ChatRoute

* chore: fix to pass the tests
This commit is contained in:
Yuichi Oneda 2025-02-06 08:11:47 -08:00 committed by GitHub
parent 5f9543f6fc
commit 8c404ae056
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 185 additions and 13 deletions

View file

@ -96,6 +96,14 @@ module.exports = {
update.conversationId = newConversationId;
}
if (req.body.isTemporary) {
const expiredAt = new Date();
expiredAt.setDate(expiredAt.getDate() + 30);
update.expiredAt = expiredAt;
} else {
update.expiredAt = null;
}
/** Note: the resulting Model object is necessary for Meilisearch operations */
const conversation = await Conversation.findOneAndUpdate(
{ conversationId, user: req.user.id },
@ -143,6 +151,9 @@ module.exports = {
if (Array.isArray(tags) && tags.length > 0) {
query.tags = { $in: tags };
}
query.$and = [{ $or: [{ expiredAt: null }, { expiredAt: { $exists: false } }] }];
try {
const totalConvos = (await Conversation.countDocuments(query)) || 1;
const totalPages = Math.ceil(totalConvos / pageSize);
@ -172,6 +183,7 @@ module.exports = {
Conversation.findOne({
user,
conversationId: convo.conversationId,
$or: [{ expiredAt: { $exists: false } }, { expiredAt: null }],
}).lean(),
),
);

View file

@ -52,6 +52,15 @@ async function saveMessage(req, params, metadata) {
user: req.user.id,
messageId: params.newMessageId || params.messageId,
};
if (req?.body?.isTemporary) {
const expiredAt = new Date();
expiredAt.setDate(expiredAt.getDate() + 30);
update.expiredAt = expiredAt;
} else {
update.expiredAt = null;
}
const message = await Message.findOneAndUpdate(
{ messageId: params.messageId, user: req.user.id },
update,

View file

@ -37,6 +37,9 @@ const convoSchema = mongoose.Schema(
files: {
type: [String],
},
expiredAt: {
type: Date,
},
},
{ timestamps: true },
);
@ -50,6 +53,8 @@ if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) {
});
}
// Create TTL index
convoSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 });
convoSchema.index({ createdAt: 1, updatedAt: 1 });
convoSchema.index({ conversationId: 1, user: 1 }, { unique: true });

View file

@ -134,6 +134,9 @@ const messageSchema = mongoose.Schema(
default: undefined,
},
*/
expiredAt: {
type: Date,
},
},
{ timestamps: true },
);
@ -146,7 +149,7 @@ if (process.env.MEILI_HOST && process.env.MEILI_MASTER_KEY) {
primaryKey: 'messageId',
});
}
messageSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 });
messageSchema.index({ createdAt: 1 });
messageSchema.index({ messageId: 1, user: 1 }, { unique: true });

View file

@ -33,6 +33,7 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
prompts: interfaceConfig?.prompts ?? defaults.prompts,
multiConvo: interfaceConfig?.multiConvo ?? defaults.multiConvo,
agents: interfaceConfig?.agents ?? defaults.agents,
temporaryChat: interfaceConfig?.temporaryChat ?? defaults.temporaryChat,
});
await updateAccessPermissions(roleName, {

View file

@ -13,6 +13,7 @@ export default function AudioRecorder({
methods,
textAreaRef,
isSubmitting,
isTemporary = false,
}: {
isRTL: boolean;
disabled: boolean;
@ -20,6 +21,7 @@ export default function AudioRecorder({
methods: ReturnType<typeof useChatFormContext>;
textAreaRef: React.RefObject<HTMLTextAreaElement>;
isSubmitting: boolean;
isTemporary?: boolean;
}) {
const { setValue, reset } = methods;
const localize = useLocalize();
@ -76,7 +78,11 @@ export default function AudioRecorder({
if (isLoading === true) {
return <Spinner className="stroke-gray-700 dark:stroke-gray-300" />;
}
return <ListeningIcon className="stroke-gray-700 dark:stroke-gray-300" />;
return (
<ListeningIcon
className={cn(isTemporary ? 'stroke-white' : 'stroke-gray-700 dark:stroke-gray-300')}
/>
);
};
return (

View file

@ -47,6 +47,7 @@ const ChatForm = ({ index = 0 }) => {
const TextToSpeech = useRecoilValue(store.textToSpeech);
const automaticPlayback = useRecoilValue(store.automaticPlayback);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const isTemporary = useRecoilValue(store.isTemporary);
const isSearching = useRecoilValue(store.isSearching);
const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index));
@ -146,6 +147,9 @@ const ChatForm = ({ index = 0 }) => {
const baseClasses = cn(
'md:py-3.5 m-0 w-full resize-none bg-surface-tertiary py-[13px] placeholder-black/50 dark:placeholder-white/50 [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]',
isCollapsed ? 'max-h-[52px]' : 'max-h-[65vh] md:max-h-[75vh]',
isTemporary
? 'bg-gray-600 text-white placeholder-white/20'
: 'bg-surface-tertiary placeholder-black/50 dark:placeholder-white/50',
);
const uploadActive = endpointSupportsFiles && !isUploadDisabled;
@ -181,7 +185,12 @@ const ChatForm = ({ index = 0 }) => {
/>
)}
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
<div className="transitional-all relative flex w-full flex-grow flex-col overflow-hidden rounded-3xl bg-surface-tertiary text-text-primary duration-200">
<div
className={cn(
'transitional-all relative flex w-full flex-grow flex-col overflow-hidden rounded-3xl text-text-primary ',
isTemporary ? 'text-white' : 'duration-200',
)}
>
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<FileFormWrapper disableInputs={disableInputs}>
{endpoint && (
@ -234,6 +243,7 @@ const ChatForm = ({ index = 0 }) => {
textAreaRef={textAreaRef}
disabled={!!disableInputs}
isSubmitting={isSubmitting}
isTemporary={isTemporary}
/>
)}
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />}

View file

@ -28,6 +28,7 @@ const BookmarkMenu: FC = () => {
const conversationId = conversation?.conversationId ?? '';
const updateConvoTags = useBookmarkSuccess(conversationId);
const tags = conversation?.tags;
const isTemporary = conversation?.expiredAt != null;
const menuId = useId();
const [isMenuOpen, setIsMenuOpen] = useState(false);
@ -139,6 +140,10 @@ const BookmarkMenu: FC = () => {
return null;
}
if (isTemporary) {
return null;
}
const renderButtonContent = () => {
if (mutation.isLoading) {
return <Spinner aria-label="Spinner" />;

View file

@ -1,6 +1,7 @@
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
import type { TModelSelectProps } from '~/common';
import { cn, cardStyle } from '~/utils/';
import { TemporaryChat } from './TemporaryChat';
export default function Anthropic({
conversation,
@ -19,8 +20,9 @@ export default function Anthropic({
showLabel={false}
className={cn(
cardStyle,
'min-w-48 z-50 flex h-[40px] w-48 flex-none items-center justify-center px-4 ring-0 hover:cursor-pointer',
'z-50 flex h-[40px] w-48 min-w-48 flex-none items-center justify-center px-4 ring-0 hover:cursor-pointer',
)}
footer={<TemporaryChat />}
/>
);
}

View file

@ -1,5 +1,6 @@
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
import type { TModelSelectProps } from '~/common';
import { TemporaryChat } from './TemporaryChat';
import { cn, cardStyle } from '~/utils/';
export default function ChatGPT({
@ -26,8 +27,9 @@ export default function ChatGPT({
showLabel={false}
className={cn(
cardStyle,
'min-w-48 z-50 flex h-[40px] w-60 flex-none items-center justify-center px-4 ring-0 hover:cursor-pointer',
'z-50 flex h-[40px] w-60 min-w-48 flex-none items-center justify-center px-4 ring-0 hover:cursor-pointer',
)}
footer={<TemporaryChat />}
/>
);
}

View file

@ -1,5 +1,6 @@
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
import type { TModelSelectProps } from '~/common';
import { TemporaryChat } from './TemporaryChat';
import { cn, cardStyle } from '~/utils/';
export default function Google({
@ -19,8 +20,9 @@ export default function Google({
showLabel={false}
className={cn(
cardStyle,
'min-w-48 z-50 flex h-[40px] w-48 flex-none items-center justify-center px-4 ring-0 hover:cursor-pointer',
'z-50 flex h-[40px] w-48 min-w-48 flex-none items-center justify-center px-4 ring-0 hover:cursor-pointer',
)}
footer={<TemporaryChat />}
/>
);
}

View file

@ -1,5 +1,6 @@
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
import type { TModelSelectProps } from '~/common';
import { TemporaryChat } from './TemporaryChat';
import { cn, cardStyle } from '~/utils/';
export default function OpenAI({
@ -19,8 +20,9 @@ export default function OpenAI({
showLabel={false}
className={cn(
cardStyle,
'min-w-48 z-50 flex h-[40px] w-48 flex-none items-center justify-center px-4 hover:cursor-pointer',
'z-50 flex h-[40px] w-48 min-w-48 flex-none items-center justify-center px-4 hover:cursor-pointer',
)}
footer={<TemporaryChat />}
/>
);
}

View file

@ -0,0 +1,61 @@
import { useMemo } from 'react';
import { MessageCircleDashed } from 'lucide-react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
import { Constants, getConfigDefaults } from 'librechat-data-provider';
import temporaryStore from '~/store/temporary';
import { Switch } from '~/components/ui';
import { cn } from '~/utils';
import store from '~/store';
export const TemporaryChat = () => {
const { data: startupConfig } = useGetStartupConfig();
const defaultInterface = getConfigDefaults().interface;
const [isTemporary, setIsTemporary] = useRecoilState(temporaryStore.isTemporary);
const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined;
const conversationId = conversation?.conversationId ?? '';
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],
);
if (!interfaceConfig.temporaryChat) {
return null;
}
const isActiveConvo = Boolean(
conversation &&
conversationId &&
conversationId !== Constants.NEW_CONVO &&
conversationId !== 'search',
);
const onClick = () => {
if (isActiveConvo) {
return;
}
setIsTemporary(!isTemporary);
};
return (
<div className="sticky bottom-0 border-t border-gray-200 bg-white px-6 py-4 dark:border-gray-700 dark:bg-gray-700">
<div className="flex items-center">
<div className={cn('flex flex-1 items-center gap-2', isActiveConvo && 'opacity-40')}>
<MessageCircleDashed className="icon-sm" />
<span className="text-sm text-gray-700 dark:text-gray-300">Temporary Chat</span>
</div>
<div className="ml-auto flex items-center">
<Switch
id="enableUserMsgMarkdown"
checked={isTemporary}
onCheckedChange={onClick}
disabled={isActiveConvo}
className="ml-4"
data-testid="enableUserMsgMarkdown"
/>
</div>
</div>
</div>
);
};

View file

@ -18,6 +18,7 @@ type SelectDropDownProps = {
showLabel?: boolean;
iconSide?: 'left' | 'right';
renderOption?: () => React.ReactNode;
footer?: React.ReactNode;
};
function SelectDropDownPop({
@ -28,6 +29,7 @@ function SelectDropDownPop({
showAbove = false,
showLabel = true,
emptyTitle = false,
footer,
}: SelectDropDownProps) {
const localize = useLocalize();
const transitionProps = { className: 'top-full mt-3' };
@ -78,9 +80,6 @@ function SelectDropDownPop({
'min-w-[75px] font-normal',
)}
>
{/* {!showLabel && !emptyTitle && (
<span className="text-xs text-gray-700 dark:text-gray-500">{title}:</span>
)} */}
{typeof value !== 'string' && value ? value.label ?? '' : value ?? ''}
</span>
</span>
@ -124,6 +123,7 @@ function SelectDropDownPop({
/>
);
})}
{footer}
</Content>
</Portal>
</div>

View file

@ -71,6 +71,7 @@ export default function useChatFunctions({
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index));
const setFilesToDelete = useSetFilesToDelete();
const getSender = useGetSender();
const isTemporary = useRecoilValue(store.isTemporary);
const queryClient = useQueryClient();
const { getExpiry } = useUserKey(conversation?.endpoint ?? '');
@ -293,6 +294,7 @@ export default function useChatFunctions({
isContinued,
isRegenerate,
initialResponse,
isTemporary,
};
if (isRegenerate) {

View file

@ -275,7 +275,7 @@ export default function useEventHandlers({
const createdHandler = useCallback(
(data: TResData, submission: EventSubmission) => {
const { messages, userMessage, isRegenerate = false } = submission;
const { messages, userMessage, isRegenerate = false, isTemporary = false } = submission;
const initialResponse = {
...submission.initialResponse,
parentMessageId: userMessage.messageId,
@ -317,6 +317,9 @@ export default function useEventHandlers({
return update;
});
if (isTemporary) {
return;
}
queryClient.setQueryData<ConversationData>([QueryKeys.allConversations], (convoData) => {
if (!convoData) {
return convoData;
@ -357,7 +360,12 @@ export default function useEventHandlers({
const finalHandler = useCallback(
(data: TFinalResData, submission: EventSubmission) => {
const { requestMessage, responseMessage, conversation, runMessages } = data;
const { messages, conversation: submissionConvo, isRegenerate = false } = submission;
const {
messages,
conversation: submissionConvo,
isRegenerate = false,
isTemporary = false,
} = submission;
setShowStopButton(false);
setCompleted((prev) => new Set(prev.add(submission.initialResponse.messageId)));
@ -401,6 +409,7 @@ export default function useEventHandlers({
if (
genTitle &&
isNewConvo &&
!isTemporary &&
requestMessage &&
requestMessage.parentMessageId === Constants.NO_PARENT
) {

View file

@ -38,6 +38,7 @@ const useNewConvo = (index = 0) => {
const clearAllConversations = store.useClearConvoState();
const defaultPreset = useRecoilValue(store.defaultPreset);
const { setConversation } = store.useCreateConversationAtom(index);
const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary);
const [files, setFiles] = useRecoilState(store.filesByIndex(index));
const clearAllLatestMessages = store.useClearLatestMessages(`useNewConvo ${index}`);
const setSubmission = useSetRecoilState<TSubmission | null>(store.submissionByIndex(index));
@ -195,6 +196,9 @@ const useNewConvo = (index = 0) => {
keepAddedConvos?: boolean;
} = {}) {
pauseGlobalAudio();
if (isTemporary) {
setIsTemporary(false);
}
const templateConvoId = _template.conversationId ?? '';
const paramEndpoint =

View file

@ -14,13 +14,22 @@ import { getDefaultModelSpec, getModelSpecIconURL } from '~/utils';
import { ToolCallsMapProvider } from '~/Providers';
import ChatView from '~/components/Chat/ChatView';
import useAuthRedirect from './useAuthRedirect';
import temporaryStore from '~/store/temporary';
import { Spinner } from '~/components/svg';
import { useRecoilCallback } from 'recoil';
import store from '~/store';
export default function ChatRoute() {
useHealthCheck();
const { data: startupConfig } = useGetStartupConfig();
const { isAuthenticated, user } = useAuthRedirect();
const setIsTemporary = useRecoilCallback(
({ set }) =>
(value: boolean) => {
set(temporaryStore.isTemporary, value);
},
[],
);
useAppStartup({ startupConfig, user });
const index = 0;
@ -141,6 +150,14 @@ export default function ChatRoute() {
return null;
}
const isTemporaryChat = conversation && conversation.expiredAt ? true : false;
if (conversationId !== Constants.NEW_CONVO && !isTemporaryChat) {
setIsTemporary(false);
} else if (isTemporaryChat) {
setIsTemporary(isTemporaryChat);
}
return (
<ToolCallsMapProvider conversationId={conversation.conversationId ?? ''}>
<ChatView index={index} />

View file

@ -11,6 +11,7 @@ import prompts from './prompts';
import lang from './language';
import settings from './settings';
import misc from './misc';
import isTemporary from './temporary';
export default {
...artifacts,
...families,
@ -25,4 +26,5 @@ export default {
...lang,
...settings,
...misc,
...isTemporary,
};

View file

@ -0,0 +1,10 @@
import { atom } from 'recoil';
const isTemporary = atom<boolean>({
key: 'isTemporary',
default: false,
});
export default {
isTemporary,
};

View file

@ -455,6 +455,7 @@ export const intefaceSchema = z
presets: z.boolean().optional(),
prompts: z.boolean().optional(),
agents: z.boolean().optional(),
temporaryChat: z.boolean().optional(),
})
.default({
endpointsMenu: true,
@ -466,6 +467,7 @@ export const intefaceSchema = z
bookmarks: true,
prompts: true,
agents: true,
temporaryChat: true,
});
export type TInterfaceConfig = z.infer<typeof intefaceSchema>;

View file

@ -3,7 +3,8 @@ import { EndpointURLs } from './config';
import * as s from './schemas';
export default function createPayload(submission: t.TSubmission) {
const { conversation, userMessage, endpointOption, isEdited, isContinued } = submission;
const { conversation, userMessage, endpointOption, isEdited, isContinued, isTemporary } =
submission;
const { conversationId } = s.tConvoUpdateSchema.parse(conversation);
const { endpoint, endpointType } = endpointOption as {
endpoint: s.EModelEndpoint;
@ -23,6 +24,7 @@ export default function createPayload(submission: t.TSubmission) {
...endpointOption,
isContinued: !!(isEdited && isContinued),
conversationId,
isTemporary,
};
return { server, payload };

View file

@ -590,6 +590,8 @@ export const tConversationSchema = z.object({
greeting: z.string().optional(),
spec: z.string().nullable().optional(),
iconURL: z.string().nullable().optional(),
/* temporary chat */
expiredAt: z.string().nullable().optional(),
/** @deprecated */
resendImages: z.boolean().optional(),
/** @deprecated */

View file

@ -44,6 +44,7 @@ export type TPayload = Partial<TMessage> &
isContinued: boolean;
conversationId: string | null;
messages?: TMessages;
isTemporary: boolean;
};
export type TSubmission = {
@ -53,6 +54,7 @@ export type TSubmission = {
userMessage: TMessage;
isEdited?: boolean;
isContinued?: boolean;
isTemporary: boolean;
messages: TMessage[];
isRegenerate?: boolean;
conversationId?: string;