LibreChat/api/server/routes/agents/chat.js
Danny Avila 0e850a5d5f
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.
2025-12-15 18:48:52 -05:00

154 lines
4.5 KiB
JavaScript

const express = require('express');
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,
canAccessAgentFromBody,
} = require('~/server/middleware');
const { initializeClient } = require('~/server/services/Endpoints/agents');
const AgentController = require('~/server/controllers/agents/request');
const addTitle = require('~/server/services/Endpoints/agents/title');
const { getRoleByName } = require('~/models/Role');
const router = express.Router();
router.use(moderateText);
const checkAgentAccess = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
skipCheck: skipAgentCheck,
getRoleByName,
});
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);
router.use(buildEndpointOption);
router.use(setHeaders);
const controller = async (req, res, next) => {
await AgentController(req, res, next, initializeClient, addTitle);
};
/**
* @route POST / (regular endpoint)
* @desc Chat with an assistant
* @access Public
* @param {express.Request} req - The request object, containing the request data.
* @param {express.Response} res - The response object, used to send back a response.
* @returns {void}
*/
router.post('/', controller);
/**
* @route POST /:endpoint (ephemeral agents)
* @desc Chat with an assistant
* @access Public
* @param {express.Request} req - The request object, containing the request data.
* @param {express.Response} res - The response object, used to send back a response.
* @returns {void}
*/
router.post('/:endpoint', controller);
module.exports = router;