mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-31 08:56:48 +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
6ab4400ace
commit
9cbbeb6e85
6 changed files with 290 additions and 112 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,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 ?? '';
|
||||
|
|
@ -323,108 +334,131 @@ 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) {
|
||||
const tokenCount = await countTokens(text, model);
|
||||
const result = await db.updateMessage(req?.user?.id, { messageId, text, tokenCount });
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
if (index === undefined) {
|
||||
const tokenCount = await countTokens(text, 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' });
|
||||
}
|
||||
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 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 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 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 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 };
|
||||
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;
|
||||
}
|
||||
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', validateMessageReq, async (req, res) => {
|
||||
try {
|
||||
const { conversationId, messageId } = req.params;
|
||||
const { feedback } = req.body;
|
||||
|
||||
const updatedMessage = await db.updateMessage(
|
||||
req?.user?.id,
|
||||
{
|
||||
const result = await db.updateMessage(req?.user?.id, {
|
||||
messageId,
|
||||
feedback: feedback || null,
|
||||
},
|
||||
{ context: 'updateFeedback' },
|
||||
);
|
||||
|
||||
// Best-effort: Assistants messages do not have deterministic AgentRun traces.
|
||||
if (!isAssistantsEndpoint(updatedMessage.endpoint)) {
|
||||
sendFeedbackScore({
|
||||
traceId: traceIdForMessage(messageId),
|
||||
feedback: updatedMessage.feedback,
|
||||
metadata: {
|
||||
messageId: updatedMessage.messageId ?? messageId,
|
||||
parentMessageId: updatedMessage.parentMessageId,
|
||||
conversationId: updatedMessage.conversationId ?? conversationId,
|
||||
sessionId: updatedMessage.conversationId ?? conversationId,
|
||||
userId: req?.user?.id,
|
||||
endpoint: updatedMessage.endpoint,
|
||||
sender: updatedMessage.sender,
|
||||
isCreatedByUser: updatedMessage.isCreatedByUser,
|
||||
tokenCount: updatedMessage.tokenCount,
|
||||
},
|
||||
}).catch((err) => logger.error('[langfuse] feedback score failed:', err));
|
||||
content: updatedContent,
|
||||
tokenCount,
|
||||
});
|
||||
await enforceForcedRetention(
|
||||
req,
|
||||
conversationId,
|
||||
messageId,
|
||||
'PUT /api/messages - edit content',
|
||||
);
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error('Error updating message:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
res.json({
|
||||
messageId,
|
||||
conversationId,
|
||||
feedback: updatedMessage.feedback,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error updating message feedback:', error);
|
||||
res.status(500).json({ error: 'Failed to update feedback' });
|
||||
}
|
||||
});
|
||||
router.put(
|
||||
'/:conversationId/:messageId/feedback',
|
||||
validateMessageReq,
|
||||
configMiddleware,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { conversationId, messageId } = req.params;
|
||||
const { feedback } = req.body;
|
||||
|
||||
const updatedMessage = await db.updateMessage(
|
||||
req?.user?.id,
|
||||
{
|
||||
messageId,
|
||||
feedback: feedback || null,
|
||||
},
|
||||
{ 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)) {
|
||||
sendFeedbackScore({
|
||||
traceId: traceIdForMessage(messageId),
|
||||
feedback: updatedMessage.feedback,
|
||||
metadata: {
|
||||
messageId: updatedMessage.messageId ?? messageId,
|
||||
parentMessageId: updatedMessage.parentMessageId,
|
||||
conversationId: updatedMessage.conversationId ?? conversationId,
|
||||
sessionId: updatedMessage.conversationId ?? conversationId,
|
||||
userId: req?.user?.id,
|
||||
endpoint: updatedMessage.endpoint,
|
||||
sender: updatedMessage.sender,
|
||||
isCreatedByUser: updatedMessage.isCreatedByUser,
|
||||
tokenCount: updatedMessage.tokenCount,
|
||||
},
|
||||
}).catch((err) => logger.error('[langfuse] feedback score failed:', err));
|
||||
}
|
||||
|
||||
res.json({
|
||||
messageId,
|
||||
conversationId,
|
||||
feedback: updatedMessage.feedback,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error updating message feedback:', error);
|
||||
res.status(500).json({ error: 'Failed to update feedback' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.delete('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue