Skip to content

Commit 086bfd0

Browse files
author
Roman Snapko
committed
Merge branch 'main' into feat/update-connections-page
2 parents 6fe67ca + 220ad69 commit 086bfd0

16 files changed

Lines changed: 389 additions & 93 deletions

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: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,35 @@ import {
22
AiAuth,
33
getAiModelFromConnection,
44
getAiProviderLanguageModel,
5+
isLLMTelemetryEnabled,
56
} from '@openops/common';
67
import {
78
AppSystemProp,
89
cacheWrapper,
910
hashUtils,
11+
logger,
1012
system,
1113
} from '@openops/server-shared';
1214
import {
1315
AiConfigParsed,
1416
ApplicationError,
1517
CustomAuthConnectionValue,
1618
ErrorCode,
19+
GeneratedChatName,
20+
isEmpty,
1721
removeConnectionBrackets,
1822
} from '@openops/shared';
19-
import { LanguageModel, ModelMessage, UIMessage, generateText } from 'ai';
23+
import { generateObject, LanguageModel, ModelMessage, UIMessage } from 'ai';
24+
import { z } from 'zod';
2025
import { appConnectionService } from '../../app-connection/app-connection-service/app-connection-service';
2126
import { aiConfigService } from '../config/ai-config.service';
27+
import { findFirstKeyInObject } from '../mcp/llm-query-router';
2228
import { loadPrompt } from './prompts.service';
2329
import { Conversation } from './types';
24-
import { mergeToolResultsIntoMessages } from './utils';
30+
import {
31+
mergeToolResultsIntoMessages,
32+
sanitizeMessagesForChatName,
33+
} from './utils';
2534

2635
const chatContextKey = (
2736
chatId: string,
@@ -84,28 +93,74 @@ export const generateChatIdForMCP = (params: {
8493
});
8594
};
8695

96+
const generatedChatNameSchema = z.object({
97+
name: z
98+
.string()
99+
.max(100)
100+
.nullable()
101+
.describe('Conversation name or null if it was not generated'),
102+
isGenerated: z.boolean().describe('Whether the name was generated or not'),
103+
});
104+
105+
/**
106+
* Attempts to repair a malformed JSON string produced by the model for chat name generation.
107+
* It extracts only the expected fields according to generatedChatNameSchema.
108+
* Returns null if the input cannot be parsed or repaired (so the AI SDK can retry/throw).
109+
*/
110+
const repairText = (text: string): string | null => {
111+
try {
112+
const parsed = JSON.parse(text);
113+
114+
const nameRaw = findFirstKeyInObject(parsed, 'name');
115+
let name: string | null = null;
116+
if (typeof nameRaw === 'string') {
117+
const trimmed = nameRaw.trim();
118+
name = trimmed.length > 0 ? trimmed.slice(0, 100) : null;
119+
}
120+
121+
const isGeneratedRaw = findFirstKeyInObject(parsed, 'isGenerated');
122+
const isGenerated =
123+
typeof isGeneratedRaw === 'boolean' ? isGeneratedRaw : Boolean(name);
124+
125+
return JSON.stringify({ name, isGenerated });
126+
} catch {
127+
return null;
128+
}
129+
};
130+
87131
export async function generateChatName(
88132
messages: ModelMessage[],
89133
projectId: string,
90-
): Promise<string> {
91-
const { languageModel } = await getLLMConfig(projectId);
134+
): Promise<GeneratedChatName> {
135+
const { languageModel, aiConfig } = await getLLMConfig(projectId);
92136
const systemPrompt = await loadPrompt('chat-name.txt');
93137
if (!systemPrompt.trim()) {
94138
throw new Error('Failed to load prompt to generate the chat name.');
95139
}
96-
const prompt: ModelMessage[] = [
97-
{
98-
role: 'system',
99-
content: systemPrompt,
100-
} as const,
101-
...messages,
102-
];
103-
const response = await generateText({
104-
model: languageModel,
105-
messages: prompt,
106-
maxRetries: 2,
107-
});
108-
return response.text.trim();
140+
141+
const sanitizedMessages: ModelMessage[] =
142+
sanitizeMessagesForChatName(messages);
143+
144+
if (isEmpty(sanitizedMessages)) {
145+
return { name: null, isGenerated: false };
146+
}
147+
148+
try {
149+
const result = await generateObject({
150+
model: languageModel,
151+
system: systemPrompt,
152+
messages: sanitizedMessages,
153+
schema: generatedChatNameSchema,
154+
...aiConfig.modelSettings,
155+
experimental_telemetry: { isEnabled: isLLMTelemetryEnabled() },
156+
experimental_repairText: async ({ text }) => repairText(text),
157+
maxRetries: 2,
158+
});
159+
return result.object;
160+
} catch (error) {
161+
logger.error('Failed to generate chat name', { error });
162+
return { name: null, isGenerated: false };
163+
}
109164
}
110165

111166
export const updateChatName = async (

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

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ 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';
28+
import removeMarkdown from 'markdown-to-text';
2829
import {
2930
createChatContext,
3031
deleteChatHistory,
@@ -247,27 +248,30 @@ export const aiMCPChatController: FastifyPluginAsyncTypebox = async (app) => {
247248
const { chatHistory } = await getConversation(chatId, userId, projectId);
248249

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

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-
);
256+
const generated = await generateChatName(chatHistory, projectId);
260257

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

265-
const rawChatName = await generateChatName(userMessages, projectId);
266-
const chatName = rawChatName.trim() || DEFAULT_CHAT_NAME;
264+
const chatName = generated?.name
265+
? removeMarkdown(generated.name).trim()
266+
: DEFAULT_CHAT_NAME;
267267

268-
await updateChatName(chatId, userId, projectId, chatName);
268+
if (generated.isGenerated && generated.name) {
269+
await updateChatName(chatId, userId, projectId, chatName);
270+
}
269271

270-
return await reply.code(200).send({ chatName });
272+
return await reply
273+
.code(200)
274+
.send({ name: chatName, isGenerated: generated.isGenerated });
271275
} catch (error) {
272276
return handleError(error, reply, 'generate chat name');
273277
}

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@ import { ChatFlowContext, CODE_BLOCK_NAME, isNil } from '@openops/shared';
33
import { ToolSet } from 'ai';
44
import { readFile } from 'fs/promises';
55
import { join } from 'path';
6+
import { getAdditionalToolDescriptions } from '../mcp/external-tool-descriptions';
67
import { hasToolProvider } from '../mcp/tool-utils';
78
import { QueryClassification } from '../mcp/types';
89
import { MCPChatContext } from './ai-chat.service';
910

11+
function buildAdditionalToolNotes(selectedTools: ToolSet | undefined): string {
12+
const descriptions = getAdditionalToolDescriptions(selectedTools);
13+
return descriptions.length > 0 ? descriptions.join('\n\n') : '';
14+
}
15+
1016
export const getMcpSystemPrompt = async ({
1117
queryClassification,
1218
selectedTools,
@@ -58,7 +64,12 @@ export const getMcpSystemPrompt = async ({
5864

5965
const allPrompts = await Promise.all(promptPromises);
6066

61-
return allPrompts.join('\n\n');
67+
const additionalToolNotes = buildAdditionalToolNotes(selectedTools);
68+
const finalPrompts = additionalToolNotes
69+
? [...allPrompts, additionalToolNotes]
70+
: allPrompts;
71+
72+
return finalPrompts.join('\n\n');
6273
};
6374

6475
export const getBlockSystemPrompt = async (

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,57 @@ 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+
const isSupportedRole = (m: ModelMessage) =>
65+
m.role === 'user' || m.role === 'assistant';
66+
67+
const extractText = (content: ModelMessage['content']): string | null => {
68+
if (typeof content === 'string') {
69+
const text = content.trim();
70+
return text ?? null;
71+
}
72+
73+
if (Array.isArray(content)) {
74+
const merged = (content as Array<unknown>)
75+
.reduce<string[]>((acc, part) => {
76+
if (
77+
part &&
78+
typeof part === 'object' &&
79+
'type' in (part as Record<string, unknown>)
80+
) {
81+
const p = part as { type?: string; text?: string };
82+
if (p.type === 'text' && typeof p.text === 'string') {
83+
acc.push(p.text);
84+
}
85+
}
86+
return acc;
87+
}, [])
88+
.join('\n')
89+
.trim();
90+
91+
return merged ?? null;
92+
}
93+
94+
return null;
95+
};
96+
97+
return messages
98+
.filter(isSupportedRole)
99+
.map((m) => {
100+
const text = extractText(m.content);
101+
return text ? ({ role: m.role, content: text } as ModelMessage) : null;
102+
})
103+
.filter((m): m is ModelMessage => m !== null);
104+
}
105+
55106
function isToolMessage(msg: ModelMessage): boolean {
56107
return (
57108
msg.role === 'tool' && Array.isArray(msg.content) && msg.content.length > 0
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export type ToolDescription = {
2+
note: string;
3+
mcpServer?: string;
4+
};
5+
6+
export const MCP_TOOL_ADDITIONAL_DESCRIPTIONS: Record<string, ToolDescription> =
7+
{
8+
'session-sql': {
9+
note: `### Tool Usage Note for 'session-sql'
10+
- The 'session-sql' tool from AWS billing and cost management MCP server does NOT work with OpenOps tables.
11+
- When the user asks about OpenOps tables, table schema, or table operations, DO NOT use 'session-sql'.
12+
- This tool is only for AWS billing and cost management related SQL queries.`,
13+
mcpServer: 'aws-billing-cost-management',
14+
},
15+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { ToolSet } from 'ai';
2+
import { MCP_TOOL_ADDITIONAL_DESCRIPTIONS } from './external-tool-descriptions-data';
3+
4+
export function getAdditionalToolDescriptions(
5+
tools: ToolSet | string[] | undefined,
6+
): string[] {
7+
if (!tools) {
8+
return [];
9+
}
10+
11+
const toolNames = Array.isArray(tools) ? tools : Object.keys(tools);
12+
const descriptions: string[] = [];
13+
14+
for (const toolName of toolNames) {
15+
const description = MCP_TOOL_ADDITIONAL_DESCRIPTIONS[toolName];
16+
if (description?.note) {
17+
descriptions.push(description.note);
18+
}
19+
}
20+
21+
return descriptions;
22+
}

0 commit comments

Comments
 (0)