Skip to content

Commit 2a47a28

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(ai): auto-title a conversation from its first user message (#1850)
The console sidebar lists conversations straight off the `ai_conversations` rows, so an untitled conversation renders a generic "New conversation" label — and once a user has a handful, the list is a wall of identical rows with no way to tell them apart or find one. Set `title` from the first USER message the moment it lands, in `addMessage`: collapse whitespace, cap at 60 chars. Deterministic, no extra model call. Only seeds when the conversation has no title yet and the turn is a user turn, so the optional LLM auto-titler still overwrites it with a nicer title when enabled, and an explicit title is never clobbered. Tests: untitled→titled, existing-title kept, assistant-first-turn ignored, whitespace-collapse + truncation. Suite green (23). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 110a333 commit 2a47a28

2 files changed

Lines changed: 53 additions & 4 deletions

File tree

packages/services/service-ai/src/__tests__/objectql-conversation-service.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,33 @@ describe('ObjectQLConversationService', () => {
251251
expect(updated.updatedAt >= conv.updatedAt).toBe(true);
252252
});
253253

254+
it('auto-titles an untitled conversation from its first user message', async () => {
255+
const conv = await service.create(); // no title
256+
expect(conv.title).toBeUndefined();
257+
const updated = await service.addMessage(conv.id, { role: 'user', content: 'Build me a ticketing system' });
258+
expect(updated.title).toBe('Build me a ticketing system');
259+
});
260+
261+
it('does not overwrite an existing title, and only a USER first turn seeds it', async () => {
262+
const titled = await service.create({ title: 'My title' });
263+
const afterUser = await service.addMessage(titled.id, { role: 'user', content: 'hello' });
264+
expect(afterUser.title).toBe('My title'); // kept
265+
266+
const fresh = await service.create();
267+
const afterAssistant = await service.addMessage(fresh.id, { role: 'assistant', content: 'hi there' });
268+
expect(afterAssistant.title).toBeUndefined(); // assistant turn does not seed a title
269+
});
270+
271+
it('collapses whitespace and truncates a long first message into the title', async () => {
272+
const conv = await service.create();
273+
const long = 'design an\n employee onboarding system with departments tasks members and a dashboard and reports';
274+
const updated = await service.addMessage(conv.id, { role: 'user', content: long });
275+
expect(updated.title!.length).toBeLessThanOrEqual(60);
276+
expect(updated.title).not.toContain('\n');
277+
expect(updated.title).toMatch(/^design an employee onboarding/);
278+
expect(updated.title!.endsWith('…')).toBe(true);
279+
});
280+
254281
it('should add a tool message with toolCallId', async () => {
255282
const conv = await service.create();
256283
const msg: ModelMessage = {

packages/services/service-ai/src/conversation/objectql-conversation-service.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ const MESSAGE_ORDER = [
5959
* Production environments should use this implementation to ensure
6060
* conversation history survives service restarts.
6161
*/
62+
/**
63+
* A short, single-line title derived from a user message: collapse whitespace
64+
* and cap the length. Pure + deterministic (no model call).
65+
*/
66+
function firstMessageTitle(text: string, maxLength = 60): string {
67+
const flat = text.replace(/\s+/g, ' ').trim();
68+
if (flat.length <= maxLength) return flat;
69+
return `${flat.slice(0, maxLength - 1).trimEnd()}…`;
70+
}
71+
6272
export class ObjectQLConversationService implements IAIConversationService {
6373
private readonly engine: IDataEngine;
6474

@@ -237,10 +247,22 @@ export class ObjectQLConversationService implements IAIConversationService {
237247
created_at: now,
238248
});
239249

240-
// Update conversation timestamp
241-
await this.engine.update(CONVERSATIONS_OBJECT, { id: conversationId, updated_at: now }, {
242-
where: { id: conversationId },
243-
});
250+
// Auto-title from the first user message. The sidebar lists conversations
251+
// straight off the `ai_conversations` rows, so an untitled conversation
252+
// shows a generic label — a wall of identical rows once a user has a few.
253+
// The LLM auto-titler may be disabled or run a beat later; this gives every
254+
// conversation a readable label the instant its first user turn lands,
255+
// deterministically and with no extra model call. A nicer LLM title (when
256+
// enabled) simply overwrites it.
257+
const titleUpdate =
258+
message.role === 'user' && !row.title && contentStr
259+
? { title: firstMessageTitle(contentStr) }
260+
: {};
261+
await this.engine.update(
262+
CONVERSATIONS_OBJECT,
263+
{ id: conversationId, updated_at: now, ...titleUpdate },
264+
{ where: { id: conversationId } },
265+
);
244266

245267
// Return the full updated conversation
246268
return (await this.get(conversationId))!;

0 commit comments

Comments
 (0)