Skip to content

Commit 3d96feb

Browse files
author
Roman Snapko
committed
Fix chat name generation
1 parent d79539f commit 3d96feb

8 files changed

Lines changed: 185 additions & 39 deletions

File tree

ai-prompts/chat-name.txt

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
You are an AI assistant on the OpenOps platform, where users interact about FinOps, cloud providers (AWS, Azure, GCP), OpenOps features, and workflow automation.
22

3-
Your task:
4-
Given the following conversation, suggest a short, descriptive name (max five words) that best summarizes the main topic, question, or action discussed in this chat.
3+
Task:
4+
Analyze the provided conversation and attempt to produce a concise chat name describing the main topic, question, or action.
55

6-
Guidelines:
7-
- The name should be specific (not generic like "Chat" or "Conversation"), and reflect the user's intent (e.g., "AWS Cost Optimization", "Create Budget Workflow", "OpenOps Integration Help").
8-
- Limit the name to five words or less.
9-
- Respond with only the chat name.
6+
Rules:
7+
- If you can confidently produce a specific, helpful name (not generic like "Chat" or "Conversation"), set `isGenerated` to true and provide `name`.
8+
- The `name` must be five words or fewer
9+
- If there is insufficient information, the content is unclear, or you cannot determine a good name, set `isGenerated` to false.
10+
11+
Notes:
12+
- Keep the name short and specific.
13+
- Avoid quotes, punctuation-heavy outputs, or trailing spaces in the name.

packages/react-ui/src/app/features/ai/lib/ai-assistant-chat-history-api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { api } from '@/app/lib/api';
2-
import { ListChatsResponse } from '@openops/shared';
2+
import { GeneratedChatName, ListChatsResponse } from '@openops/shared';
33

44
export const aiAssistantChatHistoryApi = {
55
list() {
@@ -9,7 +9,7 @@ export const aiAssistantChatHistoryApi = {
99
return api.delete<void>(`/v1/ai/conversation/${chatId}`);
1010
},
1111
generateName(chatId: string) {
12-
return api.post<{ chatName: string }>('/v1/ai/conversation/chat-name', {
12+
return api.post<GeneratedChatName>('/v1/ai/conversation/chat-name', {
1313
chatId,
1414
});
1515
},

packages/react-ui/src/app/features/ai/lib/assistant-ui-chat-hook.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,10 @@ export const useAssistantChat = ({
243243
if (messagesRef.current.length >= MIN_MESSAGES_BEFORE_NAME_GENERATION) {
244244
try {
245245
hasAttemptedNameGenerationRef.current[chatId] = true;
246-
await aiAssistantChatHistoryApi.generateName(chatId);
246+
const result = await aiAssistantChatHistoryApi.generateName(chatId);
247+
if (!result.isGenerated) {
248+
hasAttemptedNameGenerationRef.current[chatId] = false;
249+
}
247250
qc.invalidateQueries({ queryKey: [QueryKeys.assistantHistory] });
248251
} catch (error) {
249252
console.error('Failed to generate chat name', error);

packages/server/api/src/app/ai/chat/ai-chat.service.ts

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
AiAuth,
33
getAiModelFromConnection,
44
getAiProviderLanguageModel,
5+
isLLMTelemetryEnabled,
56
} from '@openops/common';
67
import {
78
AppSystemProp,
@@ -14,14 +15,19 @@ import {
1415
ApplicationError,
1516
CustomAuthConnectionValue,
1617
ErrorCode,
18+
GeneratedChatName,
1719
removeConnectionBrackets,
1820
} from '@openops/shared';
19-
import { LanguageModel, ModelMessage, UIMessage, generateText } from 'ai';
21+
import { generateObject, LanguageModel, ModelMessage, UIMessage } from 'ai';
22+
import { z } from 'zod';
2023
import { appConnectionService } from '../../app-connection/app-connection-service/app-connection-service';
2124
import { aiConfigService } from '../config/ai-config.service';
2225
import { loadPrompt } from './prompts.service';
2326
import { Conversation } from './types';
24-
import { mergeToolResultsIntoMessages } from './utils';
27+
import {
28+
mergeToolResultsIntoMessages,
29+
sanitizeMessagesForChatName,
30+
} from './utils';
2531

2632
const chatContextKey = (
2733
chatId: string,
@@ -84,28 +90,38 @@ export const generateChatIdForMCP = (params: {
8490
});
8591
};
8692

93+
const generatedChatNameSchema = z.object({
94+
name: z
95+
.string()
96+
.max(100)
97+
.nullable()
98+
.describe('Conversation name or null if it was not generated'),
99+
isGenerated: z.boolean().describe('Whether the name was generated or not'),
100+
});
101+
87102
export async function generateChatName(
88103
messages: ModelMessage[],
89104
projectId: string,
90-
): Promise<string> {
105+
): Promise<GeneratedChatName> {
91106
const { languageModel } = await getLLMConfig(projectId);
92107
const systemPrompt = await loadPrompt('chat-name.txt');
93108
if (!systemPrompt.trim()) {
94109
throw new Error('Failed to load prompt to generate the chat name.');
95110
}
96-
const prompt: ModelMessage[] = [
97-
{
98-
role: 'system',
99-
content: systemPrompt,
100-
} as const,
101-
...messages,
102-
];
103-
const response = await generateText({
111+
112+
const sanitizedMessages: ModelMessage[] =
113+
sanitizeMessagesForChatName(messages);
114+
115+
const result = await generateObject({
104116
model: languageModel,
105-
messages: prompt,
117+
system: systemPrompt,
118+
messages: sanitizedMessages,
119+
schema: generatedChatNameSchema,
120+
experimental_telemetry: { isEnabled: isLLMTelemetryEnabled() },
106121
maxRetries: 2,
107122
});
108-
return response.text.trim();
123+
124+
return result.object;
109125
}
110126

111127
export const updateChatName = async (

packages/server/api/src/app/ai/chat/ai-mcp-chat.controller.ts

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
UpdateChatModelRequest,
2323
UpdateChatModelResponse,
2424
} from '@openops/shared';
25-
import { ModelMessage, UserModelMessage } from 'ai';
25+
import { ModelMessage } from 'ai';
2626
import { FastifyReply } from 'fastify';
2727
import { StatusCodes } from 'http-status-codes';
2828
import {
@@ -247,27 +247,24 @@ export const aiMCPChatController: FastifyPluginAsyncTypebox = async (app) => {
247247
const { chatHistory } = await getConversation(chatId, userId, projectId);
248248

249249
if (chatHistory.length === 0) {
250-
return await reply.code(200).send({ chatName: DEFAULT_CHAT_NAME });
250+
return await reply
251+
.code(200)
252+
.send({ name: DEFAULT_CHAT_NAME, isGenerated: false });
251253
}
252254

253-
const userMessages = chatHistory.filter(
254-
(msg): msg is UserModelMessage =>
255-
msg &&
256-
typeof msg === 'object' &&
257-
'role' in msg &&
258-
msg.role === 'user',
259-
);
255+
const generated = await generateChatName(chatHistory, projectId);
260256

261-
if (userMessages.length === 0) {
262-
return await reply.code(200).send({ chatName: DEFAULT_CHAT_NAME });
257+
if (!generated.isGenerated) {
258+
return await reply
259+
.code(200)
260+
.send({ name: DEFAULT_CHAT_NAME, isGenerated: false });
263261
}
264262

265-
const rawChatName = await generateChatName(userMessages, projectId);
266-
const chatName = rawChatName.trim() || DEFAULT_CHAT_NAME;
267-
268-
await updateChatName(chatId, userId, projectId, chatName);
263+
if (generated.isGenerated && generated.name) {
264+
await updateChatName(chatId, userId, projectId, generated.name);
265+
}
269266

270-
return await reply.code(200).send({ chatName });
267+
return await reply.code(200).send(generated);
271268
} catch (error) {
272269
return handleError(error, reply, 'generate chat name');
273270
}

packages/server/api/src/app/ai/chat/utils.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,46 @@ export function mergeToolResultsIntoMessages(
5252
return uiMessages;
5353
}
5454

55+
/**
56+
* Sanitize chat history for secondary tasks like naming/summarization.
57+
* - keeps only 'user' and 'assistant' roles
58+
* - strips tool calls and non-text parts
59+
* - merges multiple text parts into a single string with newlines
60+
*/
61+
export function sanitizeMessagesForChatName(
62+
messages: ModelMessage[],
63+
): ModelMessage[] {
64+
return messages
65+
.filter((m) => m.role === 'user' || m.role === 'assistant')
66+
.map((m) => {
67+
if (typeof m.content === 'string') {
68+
const text = m.content.trim();
69+
return text ? { role: m.role, content: text } : null;
70+
}
71+
if (Array.isArray(m.content)) {
72+
const textParts: string[] = [];
73+
for (const part of m.content as Array<unknown>) {
74+
if (
75+
part &&
76+
typeof part === 'object' &&
77+
'type' in (part as Record<string, unknown>)
78+
) {
79+
const p = part as { type?: string; text?: string };
80+
if (p.type === 'text' && typeof p.text === 'string') {
81+
textParts.push(p.text);
82+
}
83+
}
84+
}
85+
const merged = textParts.join('\n').trim();
86+
return merged
87+
? ({ role: m.role, content: merged } as ModelMessage)
88+
: null;
89+
}
90+
return null;
91+
})
92+
.filter((m) => m !== null);
93+
}
94+
5595
function isToolMessage(msg: ModelMessage): boolean {
5696
return (
5797
msg.role === 'tool' && Array.isArray(msg.content) && msg.content.length > 0

packages/server/api/test/unit/ai/utils.test.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
import { ModelMessage } from 'ai';
3-
import { mergeToolResultsIntoMessages } from '../../../src/app/ai/chat/utils';
3+
import {
4+
mergeToolResultsIntoMessages,
5+
sanitizeMessagesForChatName,
6+
} from '../../../src/app/ai/chat/utils';
47

58
describe('mergeToolResultsIntoMessages', () => {
69
describe('basic message handling', () => {
@@ -801,3 +804,80 @@ describe('mergeToolResultsIntoMessages', () => {
801804
});
802805
});
803806
});
807+
808+
describe('sanitizeMessagesForChatName', () => {
809+
it('keeps only user and assistant roles, dropping tool messages', () => {
810+
const messages: ModelMessage[] = [
811+
{ role: 'system', content: 'sys' },
812+
{ role: 'user', content: 'hello ' },
813+
{
814+
role: 'assistant',
815+
content: [{ type: 'text', text: 'hi' } as any],
816+
},
817+
{
818+
role: 'tool',
819+
content: [{ toolCallId: 'x', type: 'tool_result', content: 'ok' } as any],
820+
},
821+
];
822+
823+
const result = sanitizeMessagesForChatName(messages);
824+
expect(result).toEqual([
825+
{ role: 'user', content: 'hello' },
826+
{ role: 'assistant', content: 'hi' },
827+
]);
828+
});
829+
830+
it('trims string content and removes empty messages after trim', () => {
831+
const messages: ModelMessage[] = [
832+
{ role: 'user', content: ' ' },
833+
{ role: 'assistant', content: ' answer ' },
834+
];
835+
836+
const result = sanitizeMessagesForChatName(messages);
837+
expect(result).toEqual([{ role: 'assistant', content: 'answer' }]);
838+
});
839+
840+
it('merges multiple text parts with newlines and trims overall', () => {
841+
const messages: ModelMessage[] = [
842+
{
843+
role: 'user',
844+
content: [
845+
{ type: 'text', text: 'Line 1' } as any,
846+
{ type: 'text', text: 'Line 2' } as any,
847+
],
848+
},
849+
];
850+
851+
const result = sanitizeMessagesForChatName(messages);
852+
expect(result).toEqual([{ role: 'user', content: 'Line 1\nLine 2' }]);
853+
});
854+
855+
it('ignores non-text parts (e.g., tool_use, image) and keeps only text', () => {
856+
const messages: ModelMessage[] = [
857+
{
858+
role: 'assistant',
859+
content: [
860+
{ type: 'tool_use', id: 't1', name: 'X', input: {} } as any,
861+
{ type: 'text', text: 'Only this is kept' } as any,
862+
{ type: 'image', image: 'raw' } as any,
863+
],
864+
},
865+
];
866+
867+
const result = sanitizeMessagesForChatName(messages);
868+
expect(result).toEqual([
869+
{ role: 'assistant', content: 'Only this is kept' },
870+
]);
871+
});
872+
873+
it('returns empty array when nothing useful remains', () => {
874+
const messages: ModelMessage[] = [
875+
{ role: 'system', content: 'not included' },
876+
{ role: 'tool', content: [] as any },
877+
{ role: 'user', content: [] as any },
878+
];
879+
880+
const result = sanitizeMessagesForChatName(messages);
881+
expect(result).toEqual([]);
882+
});
883+
});

packages/shared/src/lib/ai/chat/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,9 @@ export const UpdateChatModelResponse = Type.Object({
129129
export type UpdateChatModelResponse = Static<typeof UpdateChatModelResponse>;
130130

131131
export * from './code-output-structure';
132+
133+
export const GeneratedChatName = Type.Object({
134+
name: Type.Union([Type.String(), Type.Null()]),
135+
isGenerated: Type.Boolean(),
136+
});
137+
export type GeneratedChatName = Static<typeof GeneratedChatName>;

0 commit comments

Comments
 (0)