e115934061
Publish `@librechat/data-schemas` to NPM / pack (push) Failing after 8s
Publish `@librechat/client` to NPM / pack (push) Failing after 0s
GitNexus Index / index (push) Failing after 1s
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been skipped
Sync Helm Chart Tags / Sync chart tags (push) Failing after 2s
Publish `librechat-data-provider` to NPM / pack (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Failing after 0s
Sync Helm Chart Tags / Ignore non-main push (push) Has been skipped
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Failing after 1s
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
173 lines
6.0 KiB
JavaScript
173 lines
6.0 KiB
JavaScript
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<void>} [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>|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;
|