import type { ChatUI } from '@n8n/design-system/types/assistant'; import type { ChatRequest } from '@/types/assistant.types'; import { useI18n } from '@n8n/i18n'; import { isTextMessage, isWorkflowUpdatedMessage, isToolMessage } from '@/types/assistant.types'; export interface MessageProcessingResult { messages: ChatUI.AssistantMessage[]; thinkingMessage?: string; shouldClearThinking: boolean; } export function useBuilderMessages() { const locale = useI18n(); /** * Clear rating from all messages */ function clearRatingLogic(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] { return messages.map((message) => { if (message.type === 'text' && 'showRating' in message) { // Pick all properties except showRating and ratingStyle // eslint-disable-next-line @typescript-eslint/no-unused-vars const { showRating, ratingStyle, ...cleanMessage } = message; return cleanMessage; } return message; }); } /** * Apply rating logic to messages - only show rating on the last AI text message after workflow-updated * when no tools are running */ function applyRatingLogic(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] { const { hasAnyRunningTools, isStillThinking } = getThinkingState(messages); // Don't apply rating if tools are still running if (hasAnyRunningTools || isStillThinking) { // Remove any existing ratings return clearRatingLogic(messages); } // Find the index of the last workflow-updated message let lastWorkflowUpdateIndex = -1; for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].type === 'workflow-updated') { lastWorkflowUpdateIndex = i; break; } } // If no workflow-updated, return messages as-is if (lastWorkflowUpdateIndex === -1) { return messages; } // Find the last assistant text message after workflow-updated let lastAssistantTextIndex = -1; for (let i = messages.length - 1; i >= 0; i--) { if ( messages[i].type === 'text' && messages[i].role === 'assistant' && i > lastWorkflowUpdateIndex ) { lastAssistantTextIndex = i; break; } } // Apply rating only to the last assistant text message after workflow-updated return messages.map((message, index) => { if ( message.type === 'text' && message.role === 'assistant' && index === lastAssistantTextIndex ) { return { ...message, showRating: true, ratingStyle: 'regular', }; } // Remove any existing rating from other messages if (message.type === 'text' && 'showRating' in message) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { showRating, ratingStyle, ...cleanMessage } = message; return cleanMessage; } return message; }); } /** * Process a single message and add it to the messages array */ function processSingleMessage( messages: ChatUI.AssistantMessage[], msg: ChatRequest.MessageResponse, messageId: string, ): boolean { let shouldClearThinking = false; if (isTextMessage(msg)) { messages.push({ id: messageId, role: 'assistant', type: 'text', content: msg.text, read: false, } satisfies ChatUI.AssistantMessage); shouldClearThinking = true; } else if (isWorkflowUpdatedMessage(msg)) { messages.push({ ...msg, id: messageId, read: false, } satisfies ChatUI.AssistantMessage); // Don't clear thinking for workflow updates - they're just state changes } else if (isToolMessage(msg)) { processToolMessage(messages, msg, messageId); } else if ('type' in msg && msg.type === 'error' && 'content' in msg) { // Handle error messages from the API // API sends error messages with type: 'error' and content field messages.push({ id: messageId, role: 'assistant', type: 'error', content: msg.content, read: false, }); shouldClearThinking = true; } return shouldClearThinking; } /** * Process a tool message - either update existing or add new */ function processToolMessage( messages: ChatUI.AssistantMessage[], msg: ChatRequest.ToolMessage, messageId: string, ): void { // Use toolCallId as the message ID for consistency across updates const toolMessageId = msg.toolCallId || messageId; // Check if we already have this tool message const existingIndex = msg.toolCallId ? messages.findIndex((m) => m.type === 'tool' && m.toolCallId === msg.toolCallId) : -1; if (existingIndex !== -1) { // Update existing tool message - merge updates array const existing = messages[existingIndex] as ChatUI.ToolMessage; const toolMessage: ChatUI.ToolMessage = { ...existing, status: msg.status, updates: [...(existing.updates || []), ...(msg.updates || [])], }; messages[existingIndex] = toolMessage as ChatUI.AssistantMessage; } else { // Add new tool message const toolMessage: ChatUI.AssistantMessage = { id: toolMessageId, role: 'assistant', type: 'tool', toolName: msg.toolName, toolCallId: msg.toolCallId, status: msg.status, updates: msg.updates || [], read: false, }; messages.push(toolMessage); } } /** * If any tools are running, then it's still running tools and not done thinking * If all tools are done and no text response yet, then it's still thinking * Otherwise, it's done * * @param messages * @returns */ function getThinkingState(messages: ChatUI.AssistantMessage[]): { hasAnyRunningTools: boolean; isStillThinking: boolean; } { const allToolMessages = messages.filter( (msg): msg is ChatUI.ToolMessage => msg.type === 'tool', ); const hasAnyRunningTools = allToolMessages.some((msg) => msg.status === 'running'); if (hasAnyRunningTools) { return { hasAnyRunningTools: true, isStillThinking: false, }; } const hasCompletedTools = allToolMessages.some((msg) => msg.status === 'completed'); // Find the last completed tool message let lastCompletedToolIndex = -1; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.type === 'tool' && msg.status === 'completed') { lastCompletedToolIndex = i; break; } } // Check if there's any text message after the last completed tool // Note: workflow-updated messages shouldn't count as they're just canvas state updates let hasTextAfterTools = false; if (lastCompletedToolIndex !== -1) { for (let i = lastCompletedToolIndex + 1; i < messages.length; i++) { const msg = messages[i]; if (msg.type === 'text') { hasTextAfterTools = true; break; } } } return { hasAnyRunningTools: false, isStillThinking: hasCompletedTools && !hasTextAfterTools, }; } /** * Determine the thinking message based on tool states */ function determineThinkingMessage(messages: ChatUI.AssistantMessage[]): string | undefined { const { hasAnyRunningTools, isStillThinking } = getThinkingState(messages); if (hasAnyRunningTools) { return locale.baseText('aiAssistant.thinkingSteps.runningTools'); } else if (isStillThinking) { return locale.baseText('aiAssistant.thinkingSteps.processingResults'); } return undefined; } function processAssistantMessages( currentMessages: ChatUI.AssistantMessage[], newMessages: ChatRequest.MessageResponse[], baseId: string, ): MessageProcessingResult { const mutableMessages = [...currentMessages]; let shouldClearThinking = false; newMessages.forEach((msg, index) => { // Generate unique ID for each message in the batch const messageId = `${baseId}-${index}`; const clearThinking = processSingleMessage(mutableMessages, msg, messageId); shouldClearThinking = shouldClearThinking || clearThinking; }); const thinkingMessage = determineThinkingMessage(mutableMessages); // Apply rating logic only to messages after workflow-updated const finalMessages = applyRatingLogic(mutableMessages); return { messages: finalMessages, thinkingMessage, shouldClearThinking: shouldClearThinking && mutableMessages.length > currentMessages.length, }; } function createUserMessage(content: string, id: string): ChatUI.AssistantMessage { return { id, role: 'user', type: 'text', content, read: true, }; } function createAssistantMessage(content: string, id: string): ChatUI.AssistantMessage { return { id, role: 'assistant', type: 'text', content, read: true, }; } function createErrorMessage( content: string, id: string, retry?: () => Promise, ): ChatUI.AssistantMessage { return { id, role: 'assistant', type: 'error', content, retry, read: false, }; } function clearMessages(): ChatUI.AssistantMessage[] { return []; } function addMessages( currentMessages: ChatUI.AssistantMessage[], newMessages: ChatUI.AssistantMessage[], ): ChatUI.AssistantMessage[] { return [...currentMessages, ...newMessages]; } function mapAssistantMessageToUI( message: ChatRequest.MessageResponse, id: string, ): ChatUI.AssistantMessage { // Handle specific message types using type guards if (isTextMessage(message)) { return { id, role: message.role ?? 'assistant', type: 'text', content: message.text, read: false, } satisfies ChatUI.AssistantMessage; } if (isWorkflowUpdatedMessage(message)) { return { ...message, id, read: false, } satisfies ChatUI.AssistantMessage; } if (isToolMessage(message)) { return { id, role: 'assistant', type: 'tool', toolName: message.toolName, toolCallId: message.toolCallId, status: message.status, updates: message.updates || [], read: false, } satisfies ChatUI.AssistantMessage; } // Handle event messages if ('type' in message && message.type === 'event') { return { ...message, id, read: false, } satisfies ChatUI.AssistantMessage; } // Default fallback return { id, role: 'assistant', type: 'text', content: locale.baseText('aiAssistant.thinkingSteps.thinking'), read: false, } satisfies ChatUI.AssistantMessage; } return { processAssistantMessages, createUserMessage, createAssistantMessage, createErrorMessage, clearMessages, addMessages, mapAssistantMessageToUI, applyRatingLogic, clearRatingLogic, }; }