feat: Implement Resumable Generation Jobs with SSE Support

- Introduced GenerationJobManager to handle resumable LLM generation jobs independently of HTTP connections.
- Added support for subscribing to ongoing generation jobs via SSE, allowing clients to reconnect and receive updates without losing progress.
- Enhanced existing agent controllers and routes to integrate resumable functionality, including job creation, completion, and error handling.
- Updated client-side hooks to manage adaptive SSE streams, switching between standard and resumable modes based on user settings.
- Added UI components and settings for enabling/disabling resumable streams, improving user experience during unstable connections.
This commit is contained in:
Danny Avila 2025-12-03 21:48:04 -05:00
parent 5bfebc7c9d
commit 0e850a5d5f
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
17 changed files with 1212 additions and 37 deletions

View file

@ -1,9 +1,11 @@
const express = require('express');
const { generateCheckAccess, skipAgentCheck } = require('@librechat/api');
const { generateCheckAccess, skipAgentCheck, GenerationJobManager } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { PermissionTypes, Permissions, PermissionBits } = require('librechat-data-provider');
const {
setHeaders,
moderateText,
requireJwtAuth,
// validateModel,
validateConvoAccess,
buildEndpointOption,
@ -28,6 +30,97 @@ const checkAgentResourceAccess = canAccessAgentFromBody({
requiredPermission: PermissionBits.VIEW,
});
/**
* @route GET /stream/:streamId
* @desc Subscribe to an ongoing generation job's SSE stream
* @access Private
*/
router.get('/stream/:streamId', requireJwtAuth, (req, res) => {
const { streamId } = req.params;
const job = GenerationJobManager.getJob(streamId);
if (!job) {
return res.status(404).json({
error: 'Stream not found',
message: 'The generation job does not exist or has expired.',
});
}
// Disable compression for SSE
res.setHeader('Content-Encoding', 'identity');
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
logger.debug(`[AgentStream] Client subscribed to ${streamId}`);
const unsubscribe = GenerationJobManager.subscribe(
streamId,
(event) => {
if (!res.writableEnded) {
res.write(`event: message\ndata: ${JSON.stringify(event)}\n\n`);
if (typeof res.flush === 'function') {
res.flush();
}
}
},
(event) => {
if (!res.writableEnded) {
res.write(`event: message\ndata: ${JSON.stringify(event)}\n\n`);
if (typeof res.flush === 'function') {
res.flush();
}
res.end();
}
},
(error) => {
if (!res.writableEnded) {
res.write(`event: error\ndata: ${JSON.stringify({ error })}\n\n`);
if (typeof res.flush === 'function') {
res.flush();
}
res.end();
}
},
);
if (!unsubscribe) {
return res.status(404).json({ error: 'Failed to subscribe to stream' });
}
if (job.status === 'complete' || job.status === 'error' || job.status === 'aborted') {
res.write(`event: message\ndata: ${JSON.stringify({ final: true, status: job.status })}\n\n`);
res.end();
return;
}
req.on('close', () => {
logger.debug(`[AgentStream] Client disconnected from ${streamId}`);
unsubscribe();
});
});
/**
* @route POST /abort
* @desc Abort an ongoing generation job
* @access Private
*/
router.post('/abort', (req, res) => {
const { streamId, abortKey } = req.body;
const jobStreamId = streamId || abortKey?.split(':')?.[0];
if (jobStreamId && GenerationJobManager.hasJob(jobStreamId)) {
GenerationJobManager.abortJob(jobStreamId);
logger.debug(`[AgentStream] Job aborted: ${jobStreamId}`);
return res.json({ success: true, aborted: jobStreamId });
}
res.status(404).json({ error: 'Job not found' });
});
router.use(checkAgentAccess);
router.use(checkAgentResourceAccess);
router.use(validateConvoAccess);