From fff46fa75e859d3ed254bd1c37703874d0f1bcc8 Mon Sep 17 00:00:00 2001 From: oleg Date: Thu, 6 Feb 2025 17:19:27 +0100 Subject: [PATCH] refactor(Call n8n Sub-Workflow Tool Node): Remove duplicate FromAIParser service (no-changelog) (#13105) --- .../ToolWorkflow/v2/utils/FromAIParser.ts | 284 ------------------ .../v2/utils/WorkflowToolService.ts | 27 +- 2 files changed, 14 insertions(+), 297 deletions(-) delete mode 100644 packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts deleted file mode 100644 index 4b9b6ed58e..0000000000 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/FromAIParser.ts +++ /dev/null @@ -1,284 +0,0 @@ -import type { ISupplyDataFunctions } from 'n8n-workflow'; -import { jsonParse, NodeOperationError } from 'n8n-workflow'; -import { z } from 'zod'; - -type AllowedTypes = 'string' | 'number' | 'boolean' | 'json'; -export interface FromAIArgument { - key: string; - description?: string; - type?: AllowedTypes; - defaultValue?: string | number | boolean | Record; -} - -// TODO: We copied this class from the core package, once the new nodes context work is merged, this should be available in root node context and this file can be removed. -// Please apply any changes to both files - -/** - * AIParametersParser - * - * This class encapsulates the logic for parsing node parameters, extracting $fromAI calls, - * generating Zod schemas, and creating LangChain tools. - */ -export class AIParametersParser { - private ctx: ISupplyDataFunctions; - - /** - * Constructs an instance of AIParametersParser. - * @param ctx The execution context. - */ - constructor(ctx: ISupplyDataFunctions) { - this.ctx = ctx; - } - - /** - * Generates a Zod schema based on the provided FromAIArgument placeholder. - * @param placeholder The FromAIArgument object containing key, type, description, and defaultValue. - * @returns A Zod schema corresponding to the placeholder's type and constraints. - */ - generateZodSchema(placeholder: FromAIArgument): z.ZodTypeAny { - let schema: z.ZodTypeAny; - - switch (placeholder.type?.toLowerCase()) { - case 'string': - schema = z.string(); - break; - case 'number': - schema = z.number(); - break; - case 'boolean': - schema = z.boolean(); - break; - case 'json': - schema = z.record(z.any()); - break; - default: - schema = z.string(); - } - - if (placeholder.description) { - schema = schema.describe(`${schema.description ?? ''} ${placeholder.description}`.trim()); - } - - if (placeholder.defaultValue !== undefined) { - schema = schema.default(placeholder.defaultValue); - } - - return schema; - } - - /** - * Recursively traverses the nodeParameters object to find all $fromAI calls. - * @param payload The current object or value being traversed. - * @param collectedArgs The array collecting FromAIArgument objects. - */ - traverseNodeParameters(payload: unknown, collectedArgs: FromAIArgument[]) { - if (typeof payload === 'string') { - const fromAICalls = this.extractFromAICalls(payload); - fromAICalls.forEach((call) => collectedArgs.push(call)); - } else if (Array.isArray(payload)) { - payload.forEach((item: unknown) => this.traverseNodeParameters(item, collectedArgs)); - } else if (typeof payload === 'object' && payload !== null) { - Object.values(payload).forEach((value) => this.traverseNodeParameters(value, collectedArgs)); - } - } - - /** - * Extracts all $fromAI calls from a given string - * @param str The string to search for $fromAI calls. - * @returns An array of FromAIArgument objects. - * - * This method uses a regular expression to find the start of each $fromAI function call - * in the input string. It then employs a character-by-character parsing approach to - * accurately extract the arguments of each call, handling nested parentheses and quoted strings. - * - * The parsing process: - * 1. Finds the starting position of a $fromAI call using regex. - * 2. Iterates through characters, keeping track of parentheses depth and quote status. - * 3. Handles escaped characters within quotes to avoid premature quote closing. - * 4. Builds the argument string until the matching closing parenthesis is found. - * 5. Parses the extracted argument string into a FromAIArgument object. - * 6. Repeats the process for all $fromAI calls in the input string. - * - */ - extractFromAICalls(str: string): FromAIArgument[] { - const args: FromAIArgument[] = []; - // Regular expression to match the start of a $fromAI function call - const pattern = /\$fromAI\s*\(\s*/gi; - let match: RegExpExecArray | null; - - while ((match = pattern.exec(str)) !== null) { - const startIndex = match.index + match[0].length; - let current = startIndex; - let inQuotes = false; - let quoteChar = ''; - let parenthesesCount = 1; - let argsString = ''; - - // Parse the arguments string, handling nested parentheses and quotes - while (current < str.length && parenthesesCount > 0) { - const char = str[current]; - - if (inQuotes) { - // Handle characters inside quotes, including escaped characters - if (char === '\\' && current + 1 < str.length) { - argsString += char + str[current + 1]; - current += 2; - continue; - } - - if (char === quoteChar) { - inQuotes = false; - quoteChar = ''; - } - argsString += char; - } else { - // Handle characters outside quotes - if (['"', "'", '`'].includes(char)) { - inQuotes = true; - quoteChar = char; - } else if (char === '(') { - parenthesesCount++; - } else if (char === ')') { - parenthesesCount--; - } - - // Only add characters if we're still inside the main parentheses - if (parenthesesCount > 0 || char !== ')') { - argsString += char; - } - } - - current++; - } - - // If parentheses are balanced, parse the arguments - if (parenthesesCount === 0) { - try { - const parsedArgs = this.parseArguments(argsString); - args.push(parsedArgs); - } catch (error) { - // If parsing fails, throw an ApplicationError with details - throw new NodeOperationError( - this.ctx.getNode(), - `Failed to parse $fromAI arguments: ${argsString}: ${error}`, - ); - } - } else { - // Log an error if parentheses are unbalanced - throw new NodeOperationError( - this.ctx.getNode(), - `Unbalanced parentheses while parsing $fromAI call: ${str.slice(startIndex)}`, - ); - } - } - - return args; - } - - /** - * Parses the arguments of a single $fromAI function call. - * @param argsString The string containing the function arguments. - * @returns A FromAIArgument object. - */ - parseArguments(argsString: string): FromAIArgument { - // Split arguments by commas not inside quotes - const args: string[] = []; - let currentArg = ''; - let inQuotes = false; - let quoteChar = ''; - let escapeNext = false; - - for (let i = 0; i < argsString.length; i++) { - const char = argsString[i]; - - if (escapeNext) { - currentArg += char; - escapeNext = false; - continue; - } - - if (char === '\\') { - escapeNext = true; - continue; - } - - if (['"', "'", '`'].includes(char)) { - if (!inQuotes) { - inQuotes = true; - quoteChar = char; - currentArg += char; - } else if (char === quoteChar) { - inQuotes = false; - quoteChar = ''; - currentArg += char; - } else { - currentArg += char; - } - continue; - } - - if (char === ',' && !inQuotes) { - args.push(currentArg.trim()); - currentArg = ''; - continue; - } - - currentArg += char; - } - - if (currentArg) { - args.push(currentArg.trim()); - } - - // Remove surrounding quotes if present - const cleanArgs = args.map((arg) => { - const trimmed = arg.trim(); - if ( - (trimmed.startsWith("'") && trimmed.endsWith("'")) || - (trimmed.startsWith('`') && trimmed.endsWith('`')) || - (trimmed.startsWith('"') && trimmed.endsWith('"')) - ) { - return trimmed - .slice(1, -1) - .replace(/\\'/g, "'") - .replace(/\\`/g, '`') - .replace(/\\"/g, '"') - .replace(/\\\\/g, '\\'); - } - return trimmed; - }); - - const type = cleanArgs?.[2] || 'string'; - - if (!['string', 'number', 'boolean', 'json'].includes(type.toLowerCase())) { - throw new NodeOperationError(this.ctx.getNode(), `Invalid type: ${type}`); - } - - return { - key: cleanArgs[0] || '', - description: cleanArgs[1], - type: (cleanArgs?.[2] ?? 'string') as AllowedTypes, - defaultValue: this.parseDefaultValue(cleanArgs[3]), - }; - } - - /** - * Parses the default value, preserving its original type. - * @param value The default value as a string. - * @returns The parsed default value in its appropriate type. - */ - parseDefaultValue( - value: string | undefined, - ): string | number | boolean | Record | undefined { - if (value === undefined || value === '') return undefined; - const lowerValue = value.toLowerCase(); - if (lowerValue === 'true') return true; - if (lowerValue === 'false') return false; - if (!isNaN(Number(value))) return Number(value); - try { - return jsonParse(value); - } catch { - return value; - } - } -} diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts index f54bd5e8cf..3930938370 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts @@ -8,6 +8,7 @@ import { getCurrentWorkflowInputData } from 'n8n-nodes-base/dist/utils/workflowI import type { ExecuteWorkflowData, ExecutionError, + FromAIArgument, IDataObject, IExecuteWorkflowInfo, INodeExecutionData, @@ -18,12 +19,15 @@ import type { IWorkflowDataProxyData, ResourceMapperValue, } from 'n8n-workflow'; -import { jsonParse, NodeConnectionType, NodeOperationError } from 'n8n-workflow'; +import { + generateZodSchema, + jsonParse, + NodeConnectionType, + NodeOperationError, + traverseNodeParameters, +} from 'n8n-workflow'; import { z } from 'zod'; -import type { FromAIArgument } from './FromAIParser'; -import { AIParametersParser } from './FromAIParser'; - /** Main class for creating the Workflow tool Processes the node parameters and creates AI Agent tool capable of executing n8n workflows @@ -278,8 +282,7 @@ export class WorkflowToolService { description: string, func: (query: string | IDataObject, runManager?: CallbackManagerForToolRun) => Promise, ): Promise { - const fromAIParser = new AIParametersParser(this.context); - const collectedArguments = await this.extractFromAIParameters(fromAIParser); + const collectedArguments = await this.extractFromAIParameters(); // If there are no `fromAI` arguments, fallback to creating a simple tool if (collectedArguments.length === 0) { @@ -287,15 +290,13 @@ export class WorkflowToolService { } // Otherwise, prepare Zod schema and create a structured tool - const schema = this.createZodSchema(collectedArguments, fromAIParser); + const schema = this.createZodSchema(collectedArguments); return new DynamicStructuredTool({ schema, name, description, func }); } - private async extractFromAIParameters( - fromAIParser: AIParametersParser, - ): Promise { + private async extractFromAIParameters(): Promise { const collectedArguments: FromAIArgument[] = []; - fromAIParser.traverseNodeParameters(this.context.getNode().parameters, collectedArguments); + traverseNodeParameters(this.context.getNode().parameters, collectedArguments); const uniqueArgsMap = new Map(); for (const arg of collectedArguments) { @@ -305,9 +306,9 @@ export class WorkflowToolService { return Array.from(uniqueArgsMap.values()); } - private createZodSchema(args: FromAIArgument[], parser: AIParametersParser): z.ZodObject { + private createZodSchema(args: FromAIArgument[]): z.ZodObject { const schemaObj = args.reduce((acc: Record, placeholder) => { - acc[placeholder.key] = parser.generateZodSchema(placeholder); + acc[placeholder.key] = generateZodSchema(placeholder); return acc; }, {});