import { chatWithAssistant, replaceCode } from '@/api/ai'; import { VIEWS, EDITABLE_CANVAS_VIEWS, STORES, AI_ASSISTANT_EXPERIMENT, PLACEHOLDER_EMPTY_WORKFLOW_ID, CREDENTIAL_EDIT_MODAL_KEY, } from '@/constants'; import type { ChatRequest } from '@/types/assistant.types'; import type { ChatUI } from 'n8n-design-system/types/assistant'; import { defineStore } from 'pinia'; import type { PushPayload } from '@n8n/api-types'; import { computed, h, ref, watch } from 'vue'; import { useRootStore } from './root.store'; import { useUsersStore } from './users.store'; import { useRoute } from 'vue-router'; import { useSettingsStore } from './settings.store'; import { assert } from '@/utils/assert'; import { useWorkflowsStore } from './workflows.store'; import type { ICredentialType, INodeParameters, NodeError, INode } from 'n8n-workflow'; import { deepCopy } from 'n8n-workflow'; import { ndvEventBus, codeNodeEditorEventBus } from '@/event-bus'; import { useNDVStore } from './ndv.store'; import type { IUpdateInformation } from '@/Interface'; import { usePostHog } from './posthog.store'; import { useI18n } from '@/composables/useI18n'; import { useTelemetry } from '@/composables/useTelemetry'; import { useToast } from '@/composables/useToast'; import { useUIStore } from './ui.store'; import AiUpdatedCodeMessage from '@/components/AiUpdatedCodeMessage.vue'; import { useCredentialsStore } from './credentials.store'; import { useAIAssistantHelpers } from '@/composables/useAIAssistantHelpers'; export const MAX_CHAT_WIDTH = 425; export const MIN_CHAT_WIDTH = 250; export const DEFAULT_CHAT_WIDTH = 330; export const ENABLED_VIEWS = [ ...EDITABLE_CANVAS_VIEWS, VIEWS.EXECUTION_PREVIEW, VIEWS.WORKFLOWS, VIEWS.CREDENTIALS, VIEWS.PROJECTS_CREDENTIALS, VIEWS.PROJECTS_WORKFLOWS, VIEWS.PROJECT_SETTINGS, VIEWS.TEMPLATE_SETUP, ]; const READABLE_TYPES = ['code-diff', 'text', 'block']; export const useAssistantStore = defineStore(STORES.ASSISTANT, () => { const chatWidth = ref(DEFAULT_CHAT_WIDTH); const settings = useSettingsStore(); const rootStore = useRootStore(); const chatMessages = ref([]); const chatWindowOpen = ref(false); const usersStore = useUsersStore(); const uiStore = useUIStore(); const workflowsStore = useWorkflowsStore(); const route = useRoute(); const streaming = ref(); const ndvStore = useNDVStore(); const { getVariant } = usePostHog(); const locale = useI18n(); const telemetry = useTelemetry(); const assistantHelpers = useAIAssistantHelpers(); const suggestions = ref<{ [suggestionId: string]: { previous: INodeParameters; suggested: INodeParameters; }; }>({}); type NodeExecutionStatus = 'error' | 'not_executed' | 'success'; const chatSessionCredType = ref(); const chatSessionError = ref(); const currentSessionId = ref(); const currentSessionActiveExecutionId = ref(); const currentSessionWorkflowId = ref(); const lastUnread = ref(); const nodeExecutionStatus = ref('not_executed'); // This is used to show a message when the assistant is performing intermediate steps // We use streaming for assistants that support it, and this for agents const assistantThinkingMessage = ref(); const chatSessionTask = ref<'error' | 'support' | 'credentials' | undefined>(); // Indicate if last sent workflow and execution data is stale const workflowDataStale = ref(true); const workflowExecutionDataStale = ref(true); const isExperimentEnabled = computed( () => getVariant(AI_ASSISTANT_EXPERIMENT.name) === AI_ASSISTANT_EXPERIMENT.variant, ); const assistantMessages = computed(() => chatMessages.value.filter((msg) => msg.role === 'assistant'), ); const usersMessages = computed(() => chatMessages.value.filter((msg) => msg.role === 'user')); const isSessionEnded = computed(() => { const lastAssistantMessage = assistantMessages.value[assistantMessages.value.length - 1]; const sessionExplicitlyEnded = lastAssistantMessage?.type === 'event' && lastAssistantMessage?.eventName === 'end-session'; const sessionStarted = currentSessionId.value !== undefined; return !sessionStarted || sessionExplicitlyEnded; }); const isAssistantOpen = computed(() => canShowAssistant.value && chatWindowOpen.value); const isAssistantEnabled = computed( () => isExperimentEnabled.value && settings.isAiAssistantEnabled, ); const canShowAssistant = computed( () => isAssistantEnabled.value && ENABLED_VIEWS.includes(route.name as VIEWS), ); const canShowAssistantButtonsOnCanvas = computed( () => isAssistantEnabled.value && EDITABLE_CANVAS_VIEWS.includes(route.name as VIEWS), ); const unreadCount = computed( () => chatMessages.value.filter( (msg) => READABLE_TYPES.includes(msg.type) && msg.role === 'assistant' && !msg.read, ).length, ); function resetAssistantChat() { clearMessages(); currentSessionId.value = undefined; chatSessionError.value = undefined; lastUnread.value = undefined; currentSessionActiveExecutionId.value = undefined; suggestions.value = {}; nodeExecutionStatus.value = 'not_executed'; chatSessionCredType.value = undefined; chatSessionTask.value = undefined; currentSessionWorkflowId.value = workflowsStore.workflowId; } // As assistant sidebar opens and closes, use window width to calculate the container width // This will prevent animation race conditions from making ndv twitchy function openChat() { chatWindowOpen.value = true; chatMessages.value = chatMessages.value.map((msg) => ({ ...msg, read: true })); uiStore.appGridWidth = window.innerWidth - chatWidth.value; } function closeChat() { chatWindowOpen.value = false; // Looks smoother if we wait for slide animation to finish before updating the grid width setTimeout(() => { uiStore.appGridWidth = window.innerWidth; // If session has ended, reset the chat if (isSessionEnded.value) { resetAssistantChat(); } }, 200); } function addAssistantMessages(newMessages: ChatRequest.MessageResponse[], id: string) { const read = chatWindowOpen.value; const messages = [...chatMessages.value].filter( (msg) => !(msg.id === id && msg.role === 'assistant'), ); assistantThinkingMessage.value = undefined; newMessages.forEach((msg) => { if (msg.type === 'message') { messages.push({ id, type: 'text', role: 'assistant', content: msg.text, quickReplies: msg.quickReplies, codeSnippet: msg.codeSnippet, read, }); } else if (msg.type === 'code-diff') { messages.push({ id, role: 'assistant', type: 'code-diff', description: msg.description, codeDiff: msg.codeDiff, suggestionId: msg.suggestionId, quickReplies: msg.quickReplies, read, }); } else if (msg.type === 'summary') { messages.push({ id, type: 'block', role: 'assistant', title: msg.title, content: msg.content, quickReplies: msg.quickReplies, read, }); } else if (msg.type === 'event') { messages.push({ id, type: 'event', role: 'assistant', eventName: msg.eventName, read: true, }); } else if (msg.type === 'agent-suggestion') { messages.push({ id, type: 'block', role: 'assistant', title: msg.title, content: msg.text, quickReplies: msg.quickReplies, read, }); } else if (msg.type === 'intermediate-step') { assistantThinkingMessage.value = msg.text; } }); chatMessages.value = messages; } function updateWindowWidth(width: number) { chatWidth.value = Math.min(Math.max(width, MIN_CHAT_WIDTH), MAX_CHAT_WIDTH); } function isNodeErrorActive(context: ChatRequest.ErrorContext) { const targetNode = context.node.name; return ( chatSessionTask.value === 'error' && workflowsStore.activeExecutionId === currentSessionActiveExecutionId.value && targetNode === chatSessionError.value?.node.name ); } function isCredTypeActive(credType: ICredentialType) { return ( chatSessionTask.value === 'credentials' && credType.name === chatSessionCredType.value?.name ); } function clearMessages() { chatMessages.value = []; } function stopStreaming() { streaming.value = false; } function addAssistantError(content: string, id: string, retry?: () => Promise) { chatMessages.value.push({ id, role: 'assistant', type: 'error', content, read: true, retry, }); } function addLoadingAssistantMessage(message: string) { assistantThinkingMessage.value = message; } function addUserMessage(content: string, id: string) { chatMessages.value.push({ id, role: 'user', type: 'text', content, read: true, }); } function handleServiceError(e: unknown, id: string, retry?: () => Promise) { assert(e instanceof Error); stopStreaming(); assistantThinkingMessage.value = undefined; addAssistantError( locale.baseText('aiAssistant.serviceError.message', { interpolate: { message: e.message } }), id, retry, ); } function onEachStreamingMessage(response: ChatRequest.ResponsePayload, id: string) { if (response.sessionId && !currentSessionId.value) { currentSessionId.value = response.sessionId; telemetry.track( 'Assistant session started', { chat_session_id: currentSessionId.value, task: chatSessionTask.value, node_type: chatSessionError.value?.node.type, credential_type: chatSessionCredType.value?.name, }, { withPostHog: true }, ); // Track first user message in support chat now that we have a session id if (usersMessages.value.length === 1 && chatSessionTask.value === 'support') { const firstUserMessage = usersMessages.value[0] as ChatUI.TextMessage; trackUserMessage(firstUserMessage.content, false); } } else if (currentSessionId.value !== response.sessionId) { return; } addAssistantMessages(response.messages, id); } function getRandomId() { return `${Math.floor(Math.random() * 100000000)}`; } function onDoneStreaming(id: string) { stopStreaming(); workflowDataStale.value = false; workflowExecutionDataStale.value = false; lastUnread.value = chatMessages.value.find( (msg) => msg.id === id && !msg.read && msg.role === 'assistant' && READABLE_TYPES.includes(msg.type), ); setTimeout(() => { if (lastUnread.value?.id === id) { lastUnread.value = undefined; } }, 4000); } async function initCredHelp(credType: ICredentialType) { const hasExistingSession = !!currentSessionId.value; const credentialName = credType.displayName; const question = `How do I set up the credentials for ${credentialName}?`; await initSupportChat(question, credType); trackUserOpenedAssistant({ source: 'credential', task: 'credentials', has_existing_session: hasExistingSession, }); } /** * Gets information about the current view and active node to provide context to the assistant */ function getVisualContext( nodeInfo?: ChatRequest.NodeInfo, ): ChatRequest.AssistantContext | undefined { if (chatSessionTask.value === 'error') { return undefined; } const currentView = route.name as VIEWS; const activeNode = workflowsStore.activeNode(); const activeNodeForLLM = activeNode ? assistantHelpers.processNodeForAssistant(activeNode, ['position', 'parameters.notice']) : null; const activeModals = uiStore.activeModals; const isCredentialModalActive = activeModals.includes(CREDENTIAL_EDIT_MODAL_KEY); const activeCredential = isCredentialModalActive ? useCredentialsStore().getCredentialTypeByName(uiStore.activeCredentialType ?? '') : undefined; const executionResult = workflowsStore.workflowExecutionData?.data?.resultData; const isCurrentNodeExecuted = Boolean( executionResult?.runData?.hasOwnProperty(activeNode?.name ?? ''), ); const currentNodeHasError = executionResult?.error && 'node' in executionResult.error && executionResult.error.node?.name === activeNode?.name; const nodeError = currentNodeHasError ? (executionResult.error as NodeError) : undefined; const executionStatus = isCurrentNodeExecuted ? { status: nodeError ? 'error' : 'success', error: nodeError ? assistantHelpers.simplifyErrorForAssistant(nodeError) : undefined, } : undefined; return { currentView: { name: currentView, description: assistantHelpers.getCurrentViewDescription(currentView), }, activeNodeInfo: { node: activeNodeForLLM ?? undefined, nodeIssues: !isCurrentNodeExecuted ? activeNode?.issues : undefined, executionStatus, nodeInputData: nodeInfo?.nodeInputData, referencedNodes: nodeInfo?.schemas, }, activeCredentials: activeCredential ? { name: activeCredential?.name, displayName: activeCredential?.displayName, authType: nodeInfo?.authType?.name, } : undefined, currentWorkflow: workflowDataStale.value ? assistantHelpers.simplifyWorkflowForAssistant(workflowsStore.workflow) : undefined, executionData: workflowExecutionDataStale.value && executionResult ? assistantHelpers.simplifyResultData(executionResult) : undefined, }; } async function initSupportChat(userMessage: string, credentialType?: ICredentialType) { resetAssistantChat(); chatSessionTask.value = credentialType ? 'credentials' : 'support'; const activeNode = workflowsStore.activeNode() as INode; const nodeInfo = assistantHelpers.getNodeInfoForAssistant(activeNode); // For the initial message, only provide visual context if the task is support const visualContext = chatSessionTask.value === 'support' ? getVisualContext(nodeInfo) : undefined; if (nodeInfo.authType && chatSessionTask.value === 'credentials') { userMessage += ` I am using ${nodeInfo.authType.name}.`; } const id = getRandomId(); chatSessionCredType.value = credentialType; addUserMessage(userMessage, id); addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.thinking')); openChat(); streaming.value = true; let payload: ChatRequest.InitSupportChat | ChatRequest.InitCredHelp = { role: 'user', type: 'init-support-chat', user: { firstName: usersStore.currentUser?.firstName ?? '', }, context: visualContext, question: userMessage, }; if (credentialType) { payload = { ...payload, type: 'init-cred-help', credentialType: { name: credentialType.name, displayName: credentialType.displayName, }, }; } chatWithAssistant( rootStore.restApiContext, { payload, }, (msg) => onEachStreamingMessage(msg, id), () => onDoneStreaming(id), (e) => handleServiceError(e, id, async () => await initSupportChat(userMessage, credentialType)), ); } async function initErrorHelper(context: ChatRequest.ErrorContext) { const id = getRandomId(); if (chatSessionError.value) { if (isNodeErrorActive(context)) { // context has not changed return; } } resetAssistantChat(); chatSessionTask.value = 'error'; chatSessionError.value = context; currentSessionWorkflowId.value = workflowsStore.workflowId; if (workflowsStore.activeExecutionId) { currentSessionActiveExecutionId.value = workflowsStore.activeExecutionId; } const { authType, nodeInputData, schemas } = assistantHelpers.getNodeInfoForAssistant( context.node, ); addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.analyzingError')); openChat(); streaming.value = true; const payload: ChatRequest.RequestPayload['payload'] = { role: 'user', type: 'init-error-helper', user: { firstName: usersStore.currentUser?.firstName ?? '', }, error: context.error, node: assistantHelpers.processNodeForAssistant(context.node, [ 'position', 'parameters.notice', ]), nodeInputData, executionSchema: schemas, authType, }; chatWithAssistant( rootStore.restApiContext, { payload, }, (msg) => onEachStreamingMessage(msg, id), () => onDoneStreaming(id), (e) => handleServiceError(e, id, async () => await initErrorHelper(context)), ); } async function sendEvent( eventName: ChatRequest.InteractionEventName, error?: ChatRequest.ErrorContext['error'], ) { if (isSessionEnded.value || streaming.value) { return; } assert(currentSessionId.value); const id = getRandomId(); addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.thinking')); streaming.value = true; chatWithAssistant( rootStore.restApiContext, { payload: { role: 'user', type: 'event', eventName, error, }, sessionId: currentSessionId.value, }, (msg) => onEachStreamingMessage(msg, id), () => onDoneStreaming(id), (e) => handleServiceError(e, id, async () => await sendEvent(eventName, error)), ); } async function onNodeExecution(pushEvent: PushPayload<'nodeExecuteAfter'>) { if (!chatSessionError.value || pushEvent.nodeName !== chatSessionError.value.node.name) { return; } if (nodeExecutionStatus.value === 'success') { return; } if (pushEvent.data.error && nodeExecutionStatus.value !== 'error') { await sendEvent('node-execution-errored', pushEvent.data.error); nodeExecutionStatus.value = 'error'; telemetry.track('User executed node after assistant suggestion', { task: chatSessionTask.value, chat_session_id: currentSessionId.value, success: false, }); } else if ( pushEvent.data.executionStatus === 'success' && ['error', 'not_executed'].includes(nodeExecutionStatus.value) ) { await sendEvent('node-execution-succeeded'); nodeExecutionStatus.value = 'success'; telemetry.track('User executed node after assistant suggestion', { task: chatSessionTask.value, chat_session_id: currentSessionId.value, success: true, }); } } async function sendMessage( chatMessage: Pick, ) { if (isSessionEnded.value || streaming.value) { return; } const id = getRandomId(); const retry = async () => { chatMessages.value = chatMessages.value.filter((msg) => msg.id !== id); await sendMessage(chatMessage); }; try { addUserMessage(chatMessage.text, id); addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.thinking')); streaming.value = true; assert(currentSessionId.value); if ( chatMessage.quickReplyType === 'new-suggestion' && nodeExecutionStatus.value !== 'not_executed' ) { nodeExecutionStatus.value = 'not_executed'; } const activeNode = workflowsStore.activeNode() as INode; const nodeInfo = assistantHelpers.getNodeInfoForAssistant(activeNode); const userContext = getVisualContext(nodeInfo); chatWithAssistant( rootStore.restApiContext, { payload: { role: 'user', type: 'message', text: chatMessage.text, quickReplyType: chatMessage.quickReplyType, context: userContext, }, sessionId: currentSessionId.value, }, (msg) => onEachStreamingMessage(msg, id), () => onDoneStreaming(id), (e) => handleServiceError(e, id, retry), ); trackUserMessage(chatMessage.text, !!chatMessage.quickReplyType); } catch (e: unknown) { // in case of assert handleServiceError(e, id, retry); } } function trackUserMessage(message: string, isQuickReply: boolean) { if (!currentSessionId.value) { return; } telemetry.track('User sent message in Assistant', { message, is_quick_reply: isQuickReply, chat_session_id: currentSessionId.value, message_number: usersMessages.value.length, task: chatSessionTask.value, }); } function trackUserOpenedAssistant({ source, task, has_existing_session, }: { has_existing_session: boolean } & ( | { source: 'error'; task: 'error'; } | { source: 'canvas'; task: 'placeholder'; } | { source: 'credential'; task: 'credentials'; } )) { telemetry.track('User opened assistant', { source, task, has_existing_session, workflow_id: workflowsStore.workflowId, node_type: chatSessionError.value?.node?.type, error: chatSessionError.value?.error, chat_session_id: currentSessionId.value, }); } function updateParameters(nodeName: string, parameters: INodeParameters) { if (ndvStore.activeNodeName === nodeName) { Object.keys(parameters).forEach((key) => { const update: IUpdateInformation = { node: nodeName, name: `parameters.${key}`, value: parameters[key], }; ndvEventBus.emit('updateParameterValue', update); }); } else { workflowsStore.setNodeParameters( { name: nodeName, value: parameters, }, true, ); } } function getRelevantParameters( parameters: INodeParameters, keysToKeep: string[], ): INodeParameters { return keysToKeep.reduce((accu: INodeParameters, key: string) => { accu[key] = deepCopy(parameters[key]); return accu; }, {} as INodeParameters); } async function applyCodeDiff(index: number) { const codeDiffMessage = chatMessages.value[index]; if (!codeDiffMessage || codeDiffMessage.type !== 'code-diff') { throw new Error('No code diff to apply'); } try { assert(chatSessionError.value); assert(currentSessionId.value); codeDiffMessage.replacing = true; const suggestionId = codeDiffMessage.suggestionId; const currentWorkflow = workflowsStore.getCurrentWorkflow(); const activeNode = currentWorkflow.getNode(chatSessionError.value.node.name); assert(activeNode); const cached = suggestions.value[suggestionId]; if (cached) { updateParameters(activeNode.name, cached.suggested); } else { const { parameters: suggested } = await replaceCode(rootStore.restApiContext, { suggestionId: codeDiffMessage.suggestionId, sessionId: currentSessionId.value, }); suggestions.value[suggestionId] = { previous: getRelevantParameters(activeNode.parameters, Object.keys(suggested)), suggested, }; updateParameters(activeNode.name, suggested); } codeDiffMessage.replaced = true; codeNodeEditorEventBus.emit('codeDiffApplied'); showCodeUpdateToastIfNeeded(activeNode.name); } catch (e) { console.error(e); codeDiffMessage.error = true; } codeDiffMessage.replacing = false; } async function undoCodeDiff(index: number) { const codeDiffMessage = chatMessages.value[index]; if (!codeDiffMessage || codeDiffMessage.type !== 'code-diff') { throw new Error('No code diff to apply'); } try { assert(chatSessionError.value); assert(currentSessionId.value); codeDiffMessage.replacing = true; const suggestionId = codeDiffMessage.suggestionId; const suggestion = suggestions.value[suggestionId]; assert(suggestion); const currentWorkflow = workflowsStore.getCurrentWorkflow(); const activeNode = currentWorkflow.getNode(chatSessionError.value.node.name); assert(activeNode); const suggested = suggestion.previous; updateParameters(activeNode.name, suggested); codeDiffMessage.replaced = false; codeNodeEditorEventBus.emit('codeDiffApplied'); showCodeUpdateToastIfNeeded(activeNode.name); } catch (e) { console.error(e); codeDiffMessage.error = true; } codeDiffMessage.replacing = false; } function showCodeUpdateToastIfNeeded(errorNodeName: string) { if (errorNodeName !== ndvStore.activeNodeName) { useToast().showMessage({ type: 'success', title: locale.baseText('aiAssistant.codeUpdated.message.title'), message: h(AiUpdatedCodeMessage, { nodeName: errorNodeName, }), duration: 4000, }); } } watch(route, () => { const activeWorkflowId = workflowsStore.workflowId; if ( !currentSessionId.value || currentSessionWorkflowId.value === PLACEHOLDER_EMPTY_WORKFLOW_ID || currentSessionWorkflowId.value === activeWorkflowId ) { return; } resetAssistantChat(); }); watch( () => uiStore.stateIsDirty, () => { workflowDataStale.value = true; }, ); watch( () => workflowsStore.workflowExecutionData?.data?.resultData ?? {}, () => { workflowExecutionDataStale.value = true; }, { deep: true, immediate: true }, ); return { isAssistantEnabled, canShowAssistantButtonsOnCanvas, chatWidth, chatMessages, unreadCount, streaming, isAssistantOpen, canShowAssistant, currentSessionId, lastUnread, isSessionEnded, onNodeExecution, trackUserOpenedAssistant, closeChat, openChat, updateWindowWidth, isNodeErrorActive, initErrorHelper, initSupportChat, sendMessage, applyCodeDiff, undoCodeDiff, resetAssistantChat, chatWindowOpen, addAssistantMessages, assistantThinkingMessage, chatSessionError, chatSessionTask, initCredHelp, isCredTypeActive, handleServiceError, }; });