mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-01 19:41:32 +00:00
fix: enforce forced retention on message edits, feedback, and error saves
Two more message-write paths bypassed ephemeral enforcement: - The edit and feedback endpoints call updateMessage directly, without loading retention config, so editing an older permanent message after a switch to ephemeral left the message and its conversation non-temporary and visible. Load config on those routes and run a new applyForcedRetention helper after the update, which stamps the message and cascades the conversation/messages. - The sendError and denyRequest middleware save messages with retention config but never call saveConvo, so a validation/model error or denied-request message could outlive its conversation. Pass capExpiryToConversation like the other message-only paths. Extract the conversation cascade into a shared cascadeForcedConversationRetention helper used by both saveMessage and applyForcedRetention.
This commit is contained in:
parent
571de8c3db
commit
84ab681adf
6 changed files with 264 additions and 90 deletions
|
|
@ -49,7 +49,10 @@ const denyRequest = async (req, res, errorMessage) => {
|
|||
interfaceConfig: req?.config?.interfaceConfig,
|
||||
},
|
||||
{ ...userMessage, user: req.user.id },
|
||||
{ context: `api/server/middleware/denyRequest.js - ${responseText}` },
|
||||
{
|
||||
context: `api/server/middleware/denyRequest.js - ${responseText}`,
|
||||
capExpiryToConversation: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ const sendError = async (req, res, options, callback) => {
|
|||
{ ...errorMessage, user },
|
||||
{
|
||||
context: 'api/server/utils/streamResponse.js - sendError',
|
||||
capExpiryToConversation: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,17 @@ const db = require('~/models');
|
|||
const router = express.Router();
|
||||
router.use(requireJwtAuth);
|
||||
|
||||
/**
|
||||
* Enforces forced (ephemeral) retention after a message-only update (edit/feedback),
|
||||
* which bypasses the saveMessage/saveConvo enforcement. No-op outside forced retention.
|
||||
*/
|
||||
const enforceForcedRetention = (req, conversationId, messageId, context) =>
|
||||
db.applyForcedRetention(
|
||||
{ userId: req?.user?.id, interfaceConfig: req?.config?.interfaceConfig },
|
||||
{ conversationId, messageId },
|
||||
{ context, capExpiryToConversation: true },
|
||||
);
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const user = req.user.id ?? '';
|
||||
|
|
@ -324,79 +335,96 @@ router.get('/:conversationId/:messageId', validateMessageReq, async (req, res) =
|
|||
}
|
||||
});
|
||||
|
||||
router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
|
||||
try {
|
||||
const { conversationId, messageId } = req.params;
|
||||
const { text, index, model } = req.body;
|
||||
router.put(
|
||||
'/:conversationId/:messageId',
|
||||
validateMessageReq,
|
||||
configMiddleware,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { conversationId, messageId } = req.params;
|
||||
const { text, index, model } = req.body;
|
||||
|
||||
if (index === undefined) {
|
||||
/** A user turn's persisted `quotes` are re-prepended into the prompt on
|
||||
* every send, but this edit only changes `text`. Count the merged
|
||||
* text+quotes so the stored `tokenCount` stays authoritative (matching the
|
||||
* send path); a plain text-only count under-reports by the quote block. */
|
||||
const existing = (
|
||||
await db.getMessages(
|
||||
{ conversationId, messageId, user: req.user.id },
|
||||
'quotes isCreatedByUser',
|
||||
)
|
||||
if (index === undefined) {
|
||||
/** A user turn's persisted `quotes` are re-prepended into the prompt on
|
||||
* every send, but this edit only changes `text`. Count the merged
|
||||
* text+quotes so the stored `tokenCount` stays authoritative (matching the
|
||||
* send path); a plain text-only count under-reports by the quote block. */
|
||||
const existing = (
|
||||
await db.getMessages(
|
||||
{ conversationId, messageId, user: req.user.id },
|
||||
'quotes isCreatedByUser',
|
||||
)
|
||||
)?.[0];
|
||||
const textToCount = mergeQuotedTextForCount(
|
||||
text,
|
||||
existing?.quotes,
|
||||
existing?.isCreatedByUser === true,
|
||||
);
|
||||
const tokenCount = await countTokens(textToCount, model);
|
||||
const result = await db.updateMessage(req?.user?.id, { messageId, text, tokenCount });
|
||||
await enforceForcedRetention(
|
||||
req,
|
||||
conversationId,
|
||||
messageId,
|
||||
'PUT /api/messages - edit text',
|
||||
);
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
if (typeof index !== 'number' || index < 0) {
|
||||
return res.status(400).json({ error: 'Invalid index' });
|
||||
}
|
||||
|
||||
const message = (
|
||||
await db.getMessages({ conversationId, messageId, user: req.user.id }, 'content tokenCount')
|
||||
)?.[0];
|
||||
const textToCount = mergeQuotedTextForCount(
|
||||
text,
|
||||
existing?.quotes,
|
||||
existing?.isCreatedByUser === true,
|
||||
if (!message) {
|
||||
return res.status(404).json({ error: 'Message not found' });
|
||||
}
|
||||
|
||||
const existingContent = message.content;
|
||||
if (!Array.isArray(existingContent) || index >= existingContent.length) {
|
||||
return res.status(400).json({ error: 'Invalid index' });
|
||||
}
|
||||
|
||||
const updatedContent = [...existingContent];
|
||||
if (!updatedContent[index]) {
|
||||
return res.status(400).json({ error: 'Content part not found' });
|
||||
}
|
||||
|
||||
const currentPartType = updatedContent[index].type;
|
||||
if (currentPartType !== ContentTypes.TEXT && currentPartType !== ContentTypes.THINK) {
|
||||
return res.status(400).json({ error: 'Cannot update non-text content' });
|
||||
}
|
||||
|
||||
const oldText = updatedContent[index][currentPartType];
|
||||
updatedContent[index] = { type: currentPartType, [currentPartType]: text };
|
||||
|
||||
let tokenCount = message.tokenCount;
|
||||
if (tokenCount !== undefined) {
|
||||
const oldTokenCount = await countTokens(oldText, model);
|
||||
const newTokenCount = await countTokens(text, model);
|
||||
tokenCount = Math.max(0, tokenCount - oldTokenCount) + newTokenCount;
|
||||
}
|
||||
|
||||
const result = await db.updateMessage(req?.user?.id, {
|
||||
messageId,
|
||||
content: updatedContent,
|
||||
tokenCount,
|
||||
});
|
||||
await enforceForcedRetention(
|
||||
req,
|
||||
conversationId,
|
||||
messageId,
|
||||
'PUT /api/messages - edit content',
|
||||
);
|
||||
const tokenCount = await countTokens(textToCount, model);
|
||||
const result = await db.updateMessage(req?.user?.id, { messageId, text, tokenCount });
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error('Error updating message:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
if (typeof index !== 'number' || index < 0) {
|
||||
return res.status(400).json({ error: 'Invalid index' });
|
||||
}
|
||||
|
||||
const message = (
|
||||
await db.getMessages({ conversationId, messageId, user: req.user.id }, 'content tokenCount')
|
||||
)?.[0];
|
||||
if (!message) {
|
||||
return res.status(404).json({ error: 'Message not found' });
|
||||
}
|
||||
|
||||
const existingContent = message.content;
|
||||
if (!Array.isArray(existingContent) || index >= existingContent.length) {
|
||||
return res.status(400).json({ error: 'Invalid index' });
|
||||
}
|
||||
|
||||
const updatedContent = [...existingContent];
|
||||
if (!updatedContent[index]) {
|
||||
return res.status(400).json({ error: 'Content part not found' });
|
||||
}
|
||||
|
||||
const currentPartType = updatedContent[index].type;
|
||||
if (currentPartType !== ContentTypes.TEXT && currentPartType !== ContentTypes.THINK) {
|
||||
return res.status(400).json({ error: 'Cannot update non-text content' });
|
||||
}
|
||||
|
||||
const oldText = updatedContent[index][currentPartType];
|
||||
updatedContent[index] = { type: currentPartType, [currentPartType]: text };
|
||||
|
||||
let tokenCount = message.tokenCount;
|
||||
if (tokenCount !== undefined) {
|
||||
const oldTokenCount = await countTokens(oldText, model);
|
||||
const newTokenCount = await countTokens(text, model);
|
||||
tokenCount = Math.max(0, tokenCount - oldTokenCount) + newTokenCount;
|
||||
}
|
||||
|
||||
const result = await db.updateMessage(req?.user?.id, {
|
||||
messageId,
|
||||
content: updatedContent,
|
||||
tokenCount,
|
||||
});
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error('Error updating message:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:conversationId/:messageId/feedback',
|
||||
|
|
@ -415,6 +443,8 @@ router.put(
|
|||
},
|
||||
{ context: 'updateFeedback' },
|
||||
);
|
||||
await enforceForcedRetention(req, conversationId, messageId, 'PUT /api/messages - feedback');
|
||||
|
||||
|
||||
// Best-effort: Assistants messages do not have deterministic AgentRun traces.
|
||||
if (!isAssistantsEndpoint(updatedMessage.endpoint)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue