const { isEnabled } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { CacheKeys } = require('librechat-data-provider'); const getLogStores = require('~/cache/getLogStores'); const { saveConvo } = require('~/models'); /** * Add title to conversation in a way that avoids memory retention. * * @param {ServerRequest} req * @param {Object} params * @param {string} params.text - The user's first message. * @param {TMessage} [params.response] - The assistant response (legacy/`final` timing only). * @param {AgentClient} params.client * @param {string} [params.conversationId] - Required for `immediate` timing, where * `response` is not yet available; falls back to `response.conversationId`. * @param {boolean} [params.immediate] - When true, the title is generated in parallel * with the response (from the user's first message) and persisted to the conversation * only after `convoReady` resolves (the conversation row must exist for `noUpsert`). * @param {Promise} [params.convoReady] - Resolves once the conversation has been * persisted; awaited before saving the title in `immediate` mode. * @param {AbortSignal} [params.signal] - When aborted (e.g. the user stops an * immediate-mode generation), cancels the in-flight title model call so a * turn stopped before the title finished does not consume the title model. A * title that already finished generating is still persisted and surfaced. * @param {AbortSignal} [params.discardSignal] - When aborted, discards an * already-generated title instead of persisting it. Used only when this stream * is superseded by a newer run (or the turn failed), so a stale title does not * clobber the conversation now owned by the newer run. A plain user Stop does * NOT abort this — its generated title is kept. * @param {(params: { conversationId: string, title: string }) => Promise|void} [params.onTitleGenerated] * Called after the title is cached and before persistence waits for the * conversation row. Used by live streams to push the title immediately. */ const addTitle = async ( req, { text, response, client, conversationId, immediate = false, convoReady, signal, discardSignal, onTitleGenerated, }, ) => { const { TITLE_CONVO = true } = process.env ?? {}; if (!isEnabled(TITLE_CONVO)) { return; } if (client.options.titleConvo === false) { return; } // Skip title generation for temporary conversations if (req?.body?.isTemporary) { return; } const convoId = conversationId ?? response?.conversationId; if (!convoId) { logger.warn('[addTitle] Missing conversationId; skipping title generation'); return; } const titleCache = getLogStores(CacheKeys.GEN_TITLE); const key = `${req.user.id}-${convoId}`; /** @type {NodeJS.Timeout} */ let timeoutId; try { const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error('Title generation timeout')), 45000); }).catch((error) => { logger.error('Title error:', error); }); let titlePromise; let abortController = new AbortController(); /** Propagate a request abort (Stop) to the title generation so a cancelled * turn does not consume the title model or surface a title. */ if (signal) { if (signal.aborted) { abortController.abort(); } else { signal.addEventListener('abort', () => abortController.abort(), { once: true }); } } if (client && typeof client.titleConvo === 'function') { titlePromise = Promise.race([ client .titleConvo({ text, abortController, immediate, }) .catch((error) => { logger.error('Client title error:', error); }), timeoutPromise, ]); } else { return; } const title = await titlePromise; if (!abortController.signal.aborted) { abortController.abort(); } if (timeoutId) { clearTimeout(timeoutId); } if (!title) { logger.debug(`[${key}] No title generated`); return; } await titleCache.set(key, title, 120000); if (!signal?.aborted && typeof onTitleGenerated === 'function') { try { await onTitleGenerated({ conversationId: convoId, title }); } catch (error) { logger.error('Error emitting generated title:', error); } } /** In immediate mode the title is generated in parallel with the response, * so the conversation row may not exist yet. `saveConvo` with `noUpsert` * is a silent no-op when the row is missing, which would drop the title * from the database (the cache above still serves the live UI). Wait for * the controller to signal the conversation has been persisted. */ if (convoReady) { await convoReady; } if (discardSignal?.aborted) { // This stream was superseded by a newer run (or the turn failed) after the // title had already been generated — discard it so a stale title does not // clobber the conversation now owned by the newer run. A plain user Stop is // not a discard: its generated title falls through and is persisted below. // Only clear the cache if it still holds THIS task's title: a replacement // stream shares the `userId-conversationId` key and may have already cached // its own (valid) title that we must not remove. const cached = await titleCache.get(key); if (cached === title) { await titleCache.delete(key); } return; } await saveConvo( { userId: req?.user?.id, isTemporary: req?.body?.isTemporary, interfaceConfig: req?.config?.interfaceConfig, }, { conversationId: convoId, title, }, { context: 'api/server/services/Endpoints/agents/title.js', noUpsert: true }, ); } catch (error) { logger.error('Error generating title:', error); } }; module.exports = addTitle;