Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:08:12 +08:00

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;