import type { IDataObject, IRunExecutionData, NodeApiError, NodeError, NodeOperationError, } from 'n8n-workflow'; import { deepCopy, type INode } from 'n8n-workflow'; import { useWorkflowHelpers } from './useWorkflowHelpers'; import { useRouter } from 'vue-router'; import { useNDVStore } from '@/stores/ndv.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { executionDataToJson, getMainAuthField, getNodeAuthOptions } from '@/utils/nodeTypesUtils'; import type { ChatRequest } from '@/types/assistant.types'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useDataSchema } from './useDataSchema'; import { AI_ASSISTANT_MAX_CONTENT_LENGTH, VIEWS } from '@/constants'; import { useI18n } from './useI18n'; import type { IWorkflowDb } from '@/Interface'; import { getObjectSizeInKB } from '@/utils/objectUtils'; const CANVAS_VIEWS = [VIEWS.NEW_WORKFLOW, VIEWS.WORKFLOW, VIEWS.EXECUTION_DEBUG]; const EXECUTION_VIEWS = [VIEWS.EXECUTION_PREVIEW]; const WORKFLOW_LIST_VIEWS = [VIEWS.WORKFLOWS, VIEWS.PROJECTS_WORKFLOWS]; const CREDENTIALS_LIST_VIEWS = [VIEWS.CREDENTIALS, VIEWS.PROJECTS_CREDENTIALS]; export const useAIAssistantHelpers = () => { const ndvStore = useNDVStore(); const nodeTypesStore = useNodeTypesStore(); const workflowsStore = useWorkflowsStore(); const workflowHelpers = useWorkflowHelpers({ router: useRouter() }); const locale = useI18n(); /** Regular expression to extract the node names from the expressions in the template. Supports single quotes, double quotes, and backticks. */ const entityRegex = /\$\(\s*(\\?["'`])((?:\\.|(?!\1)[^\\])*)\1\s*\)/g; /** * Extract the node names from the expressions in the template. */ function extractNodeNames(template: string): string[] { let matches; const nodeNames: string[] = []; while ((matches = entityRegex.exec(template)) !== null) { nodeNames.push(matches[2]); } return nodeNames; } /** * Unescape quotes in the string. Supports single quotes, double quotes, and backticks. */ function unescapeQuotes(str: string): string { return str.replace(/\\(['"`])/g, '$1'); } /** * Extract the node names from the expressions in the node parameters. */ function getReferencedNodes(node: INode): string[] { const referencedNodes: Set = new Set(); if (!node) { return []; } // Go through all parameters and check if they contain expressions on any level for (const key in node.parameters) { let names: string[] = []; if ( node.parameters[key] && typeof node.parameters[key] === 'object' && Object.keys(node.parameters[key]).length ) { names = extractNodeNames(JSON.stringify(node.parameters[key])); } else if (typeof node.parameters[key] === 'string' && node.parameters[key]) { names = extractNodeNames(node.parameters[key]); } if (names.length) { names .map((name) => unescapeQuotes(name)) .forEach((name) => { referencedNodes.add(name); }); } } return referencedNodes.size ? Array.from(referencedNodes) : []; } /** * Processes node object before sending it to AI assistant * - Removes unnecessary properties * - Extracts expressions from the parameters and resolves them * @param node original node object * @param propsToRemove properties to remove from the node object * @returns processed node */ function processNodeForAssistant(node: INode, propsToRemove: string[]): INode { // Make a copy of the node object so we don't modify the original const nodeForLLM = deepCopy(node); propsToRemove.forEach((key) => { delete nodeForLLM[key as keyof INode]; }); const resolvedParameters = workflowHelpers.getNodeParametersWithResolvedExpressions( nodeForLLM.parameters, ); nodeForLLM.parameters = resolvedParameters; return nodeForLLM; } function getNodeInfoForAssistant(node: INode): ChatRequest.NodeInfo { if (!node) { return {}; } // Get all referenced nodes and their schemas const referencedNodeNames = getReferencedNodes(node); const schemas = getNodesSchemas(referencedNodeNames); const nodeType = nodeTypesStore.getNodeType(node.type); // Get node credentials details for the ai assistant let authType = undefined; if (nodeType) { const authField = getMainAuthField(nodeType); const credentialInUse = node.parameters[authField?.name ?? '']; const availableAuthOptions = getNodeAuthOptions(nodeType); authType = availableAuthOptions.find((option) => option.value === credentialInUse); } let nodeInputData: { inputNodeName?: string; inputData?: IDataObject } | undefined = undefined; const ndvInput = ndvStore.ndvInputData; if (isNodeReferencingInputData(node) && ndvInput?.length) { const inputData = ndvStore.ndvInputData[0].json; const inputNodeName = ndvStore.input.nodeName; nodeInputData = { inputNodeName, inputData, }; } return { authType, schemas, nodeInputData, }; } /** * Simplify node error object for AI assistant */ function simplifyErrorForAssistant( error: NodeError | NodeApiError | NodeOperationError, ): ChatRequest.ErrorContext['error'] { const simple: ChatRequest.ErrorContext['error'] = { name: error.name, message: error.message, }; if ('type' in error) { simple.type = error.type; } if ('description' in error && error.description) { simple.description = error.description; } if (error.stack) { simple.stack = error.stack; } if ('lineNumber' in error) { simple.lineNumber = error.lineNumber; } return simple; } function isNodeReferencingInputData(node: INode): boolean { const parametersString = JSON.stringify(node.parameters); const references = ['$json', '$input', '$binary']; return references.some((ref) => parametersString.includes(ref)); } /** * Get the schema for the referenced nodes as expected by the AI assistant * @param nodeNames The names of the nodes to get the schema for * @returns An array of NodeExecutionSchema objects */ function getNodesSchemas(nodeNames: string[]) { const schemas: ChatRequest.NodeExecutionSchema[] = []; for (const name of nodeNames) { const node = workflowsStore.getNodeByName(name); if (!node) { continue; } const { getSchemaForExecutionData, getInputDataWithPinned } = useDataSchema(); const schema = getSchemaForExecutionData(executionDataToJson(getInputDataWithPinned(node))); schemas.push({ nodeName: node.name, schema, }); } return schemas; } function getCurrentViewDescription(view: VIEWS) { switch (true) { case WORKFLOW_LIST_VIEWS.includes(view): return locale.baseText('aiAssistant.prompts.currentView.workflowList'); case CREDENTIALS_LIST_VIEWS.includes(view): return locale.baseText('aiAssistant.prompts.currentView.credentialsList'); case EXECUTION_VIEWS.includes(view): return locale.baseText('aiAssistant.prompts.currentView.executionsView'); case CANVAS_VIEWS.includes(view): return locale.baseText('aiAssistant.prompts.currentView.workflowEditor'); default: return undefined; } } /** * Prepare workflow execution result data for the AI assistant * by removing data from nodes **/ function simplifyResultData( data: IRunExecutionData['resultData'], ): ChatRequest.ExecutionResultData { const simplifiedResultData: ChatRequest.ExecutionResultData = { runData: {}, }; // Handle optional error if (data.error) { simplifiedResultData.error = data.error; } // Map runData, excluding the `data` field from ITaskData for (const key of Object.keys(data.runData)) { const taskDataArray = data.runData[key]; simplifiedResultData.runData[key] = taskDataArray.map((taskData) => { const { data: taskDataContent, ...taskDataWithoutData } = taskData; return taskDataWithoutData; }); } // Handle lastNodeExecuted if it exists if (data.lastNodeExecuted) { simplifiedResultData.lastNodeExecuted = data.lastNodeExecuted; } // Handle metadata if it exists if (data.metadata) { simplifiedResultData.metadata = data.metadata; } return simplifiedResultData; } const simplifyWorkflowForAssistant = (workflow: IWorkflowDb): Partial => ({ name: workflow.name, active: workflow.active, connections: workflow.connections, nodes: workflow.nodes, }); /** * Reduces AI Assistant request payload size to make it fit the specified content length. * If, after two passes, the payload is still too big, throws an error' * @param payload The request payload to trim * @param size The maximum size of the payload in KB */ const trimPayloadToSize = ( payload: ChatRequest.RequestPayload, size = AI_ASSISTANT_MAX_CONTENT_LENGTH, ): void => { const requestPayload = payload.payload; // For support chat, remove parameters from the active node object and all nodes in the workflow if (requestPayload.type === 'init-support-chat') { if (requestPayload.context?.activeNodeInfo?.node) { requestPayload.context.activeNodeInfo.node.parameters = {}; } if (requestPayload.context?.currentWorkflow) { requestPayload.context.currentWorkflow?.nodes?.forEach((node) => { node.parameters = {}; }); } if (requestPayload.context?.executionData?.runData) { requestPayload.context.executionData.runData = {}; } if ( requestPayload.context?.executionData?.error && 'node' in requestPayload.context?.executionData?.error ) { if (requestPayload.context?.executionData?.error?.node) { requestPayload.context.executionData.error.node.parameters = {}; } } // If the payload is still too big, remove the whole context object if (getRequestPayloadSize(payload) > size) { requestPayload.context = undefined; } // For error helper, remove parameters from the active node object // This will leave just the error, user info and basic node structure in the payload } else if (requestPayload.type === 'init-error-helper') { requestPayload.node.parameters = {}; } // If the payload is still too big, throw an error that will be shown to the user if (getRequestPayloadSize(payload) > size) { throw new Error(locale.baseText('aiAssistant.payloadTooBig.message')); } }; /** * Get the size of the request payload in KB, returns 0 if the payload is not a valid object */ const getRequestPayloadSize = (payload: ChatRequest.RequestPayload): number => { try { return getObjectSizeInKB(payload.payload); } catch (error) { return 0; } }; return { processNodeForAssistant, getNodeInfoForAssistant, simplifyErrorForAssistant, isNodeReferencingInputData, getNodesSchemas, getCurrentViewDescription, getReferencedNodes, simplifyResultData, simplifyWorkflowForAssistant, trimPayloadSize: trimPayloadToSize, }; };