Files
zooqueen 1280778d77 a chat is named by what was said in it, so "Untitled" cannot happen
Titling is a second model call, and it fails: it times out at 45s, it is refused
on a free route for want of balance, the gateway answers it with a 200 envelope
carrying no choices. The agents path treated every one of those as "no name" and
returned without saving, so the conversation reached the sidebar with no title
field at all — which the client renders as the word Untitled. Measured on the
live store: 353 of 485 conversations have no title, across every model and both
guest and account users.

The assistants path already knew the answer and fell back to the user's own
first line; the agents path, which is the one that actually runs, never got it.
So `opening` is that fallback as one function, and both paths call it. It needs
no call and cannot time out, because the line is already in hand — the only
empty case is a conversation opened with a file and no words, which has nothing
to read.

Naming this deterministically is also what makes the model call optional rather
than load-bearing: a chat now has a name from the moment it has a message, and a
successful title improves it rather than supplying it.
2026-08-20 16:17:53 -07:00

86 lines
2.4 KiB
JavaScript

const { isEnabled, opening } = require('@hanzochat/api');
const { logger } = require('@hanzochat/data-schemas');
const { CacheKeys } = require('@hanzochat/data-provider');
const getLogStores = require('~/cache/getLogStores');
const { saveConvo } = require('~/models');
/**
* Add title to conversation in a way that avoids memory retention
*/
const addTitle = async (req, { text, response, client }) => {
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 titleCache = getLogStores(CacheKeys.GEN_TITLE);
const key = `${req.user.id}-${response.conversationId}`;
/** @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();
if (client && typeof client.titleConvo === 'function') {
titlePromise = Promise.race([
client
.titleConvo({
text,
abortController,
})
.catch((error) => {
logger.error('Client title error:', error);
}),
timeoutPromise,
]);
} else {
return;
}
// A conversation is named by the model when the model answers, and by the
// line the user opened with when it does not. Titling is a second model
// call: it times out, it is refused on a free route, the gateway answers it
// with an envelope. None of that is a reason for a chat to have no name.
const title = (await titlePromise) || opening(text);
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);
await saveConvo(
req,
{
conversationId: response.conversationId,
title,
},
{ context: 'api/server/services/Endpoints/agents/title.js', noUpsert: true },
);
} catch (error) {
logger.error('Error generating title:', error);
}
};
module.exports = addTitle;