mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-11 16:53:45 +00:00
fix: refresh project stats when a message-only save hides a project chat
saveMessage's forced-retention cascade converts an old project-assigned chat to isTemporary: true, but message-only writes (branch/artifact/abort saves) have no saveConvo afterward to recompute the project's cached stats, so the count and lastConversationId kept pointing at a chat the project view no longer shows. Make cascadeForcedConversationRetention report whether it converted the parent row and refresh the owning project's stats from saveMessage when it did. The applyForcedRetention refresh is gated on the same signal, so conforming parents no longer trigger a redundant stats recompute on every edit/feedback write.
This commit is contained in:
parent
e9835fdb2f
commit
79d75ad8fc
4 changed files with 63 additions and 13 deletions
|
|
@ -300,19 +300,19 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C
|
|||
);
|
||||
}
|
||||
|
||||
function forceConversationRetention(
|
||||
async function forceConversationRetention(
|
||||
user: string,
|
||||
conversationId: string,
|
||||
interfaceConfig?: AppConfig['interfaceConfig'],
|
||||
): Promise<void> {
|
||||
if (!isForcedTemporaryRetention(interfaceConfig?.retentionMode)) {
|
||||
return Promise.resolve();
|
||||
return;
|
||||
}
|
||||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const Message = mongoose.models.Message as Model<IMessage>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
const File = mongoose.models.File as Model<IMongoFile>;
|
||||
return cascadeForcedConversationRetention(
|
||||
await cascadeForcedConversationRetention(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
|
|
|
|||
|
|
@ -1200,6 +1200,41 @@ describe('Message Operations', () => {
|
|||
expect(refreshed?.lastConversationId ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('refreshes owning project stats when a message-only save converts a project chat', async () => {
|
||||
const ChatProject = mongoose.models.ChatProject as mongoose.Model<IChatProject>;
|
||||
await ChatProject.deleteMany({});
|
||||
const conversationId = uuidv4();
|
||||
const project = await ChatProject.create({
|
||||
name: 'Ephemeral Project',
|
||||
user: 'user123',
|
||||
conversationCount: 1,
|
||||
lastConversationId: conversationId,
|
||||
lastConversationAt: new Date(),
|
||||
});
|
||||
const projectId = project._id!.toString();
|
||||
await Conversation().create({
|
||||
conversationId,
|
||||
user: 'user123',
|
||||
endpoint: 'openAI',
|
||||
title: 'Project chat',
|
||||
isTemporary: false,
|
||||
chatProjectId: projectId,
|
||||
});
|
||||
|
||||
await saveMessage(
|
||||
{
|
||||
userId: 'user123',
|
||||
interfaceConfig: { temporaryChatRetention: 24, retentionMode: RetentionMode.EPHEMERAL },
|
||||
},
|
||||
{ messageId: uuidv4(), conversationId, text: 'branch', user: 'user123' },
|
||||
{ context: 'branch', capExpiryToConversation: true },
|
||||
);
|
||||
|
||||
const refreshed = await ChatProject.findById(projectId).lean<IChatProject>();
|
||||
expect(refreshed?.conversationCount).toBe(0);
|
||||
expect(refreshed?.lastConversationId ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('converts a permanent conversation and its messages without a messageId (tag write)', async () => {
|
||||
const conversationId = uuidv4();
|
||||
await Conversation().create({
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
const Conversation = mongoose.models.Conversation as Model<IConversation>;
|
||||
const SharedLink = mongoose.models.SharedLink as Model<ISharedLink>;
|
||||
const File = mongoose.models.File as Model<IMongoFile>;
|
||||
await cascadeForcedConversationRetention(
|
||||
const convertedParent = await cascadeForcedConversationRetention(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
|
|
@ -224,6 +224,16 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
conversationId,
|
||||
cascadeExpiredAt,
|
||||
);
|
||||
/**
|
||||
* Message-only writes (branch/artifact/abort saves) have no `saveConvo` afterward to
|
||||
* recompute project stats, so when the cascade just hid a project chat the cached
|
||||
* count/lastConversationId must be refreshed here.
|
||||
*/
|
||||
if (convertedParent) {
|
||||
await refreshForcedRetentionProjectStats(userId, {
|
||||
conversationId,
|
||||
} as FilterQuery<IConversation>);
|
||||
}
|
||||
}
|
||||
|
||||
return message.toObject();
|
||||
|
|
@ -467,7 +477,7 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
},
|
||||
]);
|
||||
}
|
||||
await cascadeForcedConversationRetention(
|
||||
const convertedParent = await cascadeForcedConversationRetention(
|
||||
Conversation,
|
||||
Message,
|
||||
SharedLink,
|
||||
|
|
@ -476,9 +486,11 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
|
|||
conversationId,
|
||||
forcedExpiredAt,
|
||||
);
|
||||
await refreshForcedRetentionProjectStats(userId, {
|
||||
conversationId,
|
||||
} as FilterQuery<IConversation>);
|
||||
if (convertedParent) {
|
||||
await refreshForcedRetentionProjectStats(userId, {
|
||||
conversationId,
|
||||
} as FilterQuery<IConversation>);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -224,7 +224,9 @@ export const capForcedRetentionToParent = async (
|
|||
* Converts or re-caps a parent conversation to the forced deadline and, when that first
|
||||
* brings the conversation into the forced window, backfills its lagging messages, shares, and
|
||||
* files. Shared by every forced-retention message-write path so a single conversation/message
|
||||
* rule is enforced regardless of which save touched the chat.
|
||||
* rule is enforced regardless of which save touched the chat. Returns whether the parent row
|
||||
* was converted, so callers can refresh caches (e.g. project stats) that a visibility flip
|
||||
* invalidates.
|
||||
*/
|
||||
export const cascadeForcedConversationRetention = async (
|
||||
Conversation: Model<IConversation>,
|
||||
|
|
@ -234,13 +236,13 @@ export const cascadeForcedConversationRetention = async (
|
|||
userId: string,
|
||||
conversationId: string,
|
||||
forcedExpiredAt: Date,
|
||||
): Promise<void> => {
|
||||
): Promise<boolean> => {
|
||||
const parent = await Conversation.findOne(
|
||||
{ conversationId, user: userId },
|
||||
'isTemporary expiredAt',
|
||||
).lean<RetentionFilterDocument | null>();
|
||||
if (parent == null) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const expiredAt = capForcedRetentionExpiry(parent.expiredAt, forcedExpiredAt);
|
||||
/**
|
||||
|
|
@ -255,12 +257,13 @@ export const cascadeForcedConversationRetention = async (
|
|||
await capConversationSharedLinks(SharedLink, userId, conversationId, expiredAt);
|
||||
await capConversationFiles(File, conversationId, expiredAt);
|
||||
if (!conversationNeedsForcedRetention(parent, expiredAt)) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
await Conversation.updateOne(
|
||||
const convoResult = await Conversation.updateOne(
|
||||
{ conversationId, user: userId, ...forcedRetentionGapFilter<IConversation>(expiredAt) },
|
||||
{ $set: { isTemporary: true, expiredAt } },
|
||||
);
|
||||
return (convoResult.modifiedCount ?? 0) > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue