mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(core): Implement partial execution for all tool nodes (#15168)
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"node": "n8n-nodes-base.toolExecutor",
|
||||||
|
"nodeVersion": "1.0",
|
||||||
|
"codexVersion": "1.0",
|
||||||
|
"details": "Can execute tools by simulating an agent function call with a given query.",
|
||||||
|
"categories": ["Core Nodes"],
|
||||||
|
"resources": {
|
||||||
|
"primaryDocumentation": [
|
||||||
|
{
|
||||||
|
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.editimage/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"subcategories": {
|
||||||
|
"Core Nodes": ["Helpers"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Tool, StructuredTool } from '@langchain/core/tools';
|
||||||
|
import type { Toolkit } from 'langchain/agents';
|
||||||
|
import type {
|
||||||
|
IExecuteFunctions,
|
||||||
|
INodeExecutionData,
|
||||||
|
INodeType,
|
||||||
|
INodeTypeDescription,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { executeTool } from './utils/executeTool';
|
||||||
|
|
||||||
|
export class ToolExecutor implements INodeType {
|
||||||
|
description: INodeTypeDescription = {
|
||||||
|
displayName: 'Tool Executor',
|
||||||
|
name: 'toolExecutor',
|
||||||
|
version: 1,
|
||||||
|
defaults: {
|
||||||
|
name: 'Tool Executor',
|
||||||
|
},
|
||||||
|
hidden: true,
|
||||||
|
inputs: [NodeConnectionTypes.Main, NodeConnectionTypes.AiTool],
|
||||||
|
outputs: [NodeConnectionTypes.Main],
|
||||||
|
properties: [
|
||||||
|
{
|
||||||
|
displayName: 'Query',
|
||||||
|
name: 'query',
|
||||||
|
type: 'json',
|
||||||
|
default: '{}',
|
||||||
|
description: 'Parameters to pass to the tool as JSON or string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Tool Name',
|
||||||
|
name: 'toolName',
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
description: 'Name of the tool to execute if the connected tool is a toolkit',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
group: ['transform'],
|
||||||
|
description: 'Node to execute tools without an AI Agent',
|
||||||
|
};
|
||||||
|
|
||||||
|
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||||
|
const query = this.getNodeParameter('query', 0, {}) as string | object;
|
||||||
|
const toolName = this.getNodeParameter('toolName', 0, '') as string;
|
||||||
|
|
||||||
|
let parsedQuery: string | object;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsedQuery = typeof query === 'string' ? JSON.parse(query) : query;
|
||||||
|
} catch (error) {
|
||||||
|
parsedQuery = query;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultData: INodeExecutionData[] = [];
|
||||||
|
const toolInputs = await this.getInputConnectionData(NodeConnectionTypes.AiTool, 0);
|
||||||
|
|
||||||
|
if (!toolInputs || !Array.isArray(toolInputs)) {
|
||||||
|
throw new NodeOperationError(this.getNode(), 'No tool inputs found');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const tool of toolInputs) {
|
||||||
|
// Handle toolkits
|
||||||
|
if (tool && typeof (tool as Toolkit).getTools === 'function') {
|
||||||
|
const toolsInToolkit = (tool as Toolkit).getTools();
|
||||||
|
for (const toolkitTool of toolsInToolkit) {
|
||||||
|
if (toolkitTool instanceof Tool || toolkitTool instanceof StructuredTool) {
|
||||||
|
if (toolName === toolkitTool.name) {
|
||||||
|
const result = await executeTool(toolkitTool, parsedQuery);
|
||||||
|
resultData.push(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle single tool
|
||||||
|
if (!toolName || toolName === (tool as Tool).name) {
|
||||||
|
const result = await executeTool(tool as Tool, parsedQuery);
|
||||||
|
resultData.push(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new NodeOperationError(
|
||||||
|
this.getNode(),
|
||||||
|
`Error executing tool: ${(error as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [resultData];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { DynamicTool, DynamicStructuredTool } from '@langchain/core/tools';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||||
|
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { ToolExecutor } from '../ToolExecutor.node';
|
||||||
|
|
||||||
|
describe('ToolExecutor Node', () => {
|
||||||
|
let node: ToolExecutor;
|
||||||
|
let mockExecuteFunction: jest.Mocked<IExecuteFunctions>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
node = new ToolExecutor();
|
||||||
|
mockExecuteFunction = mock<IExecuteFunctions>();
|
||||||
|
|
||||||
|
mockExecuteFunction.logger = {
|
||||||
|
debug: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockExecuteFunction.getNode.mockReturnValue({
|
||||||
|
name: 'Tool Executor',
|
||||||
|
typeVersion: 1,
|
||||||
|
parameters: {},
|
||||||
|
} as INode);
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('description', () => {
|
||||||
|
it('should have the expected properties', () => {
|
||||||
|
expect(node.description).toBeDefined();
|
||||||
|
expect(node.description.name).toBe('toolExecutor');
|
||||||
|
expect(node.description.displayName).toBe('Tool Executor');
|
||||||
|
expect(node.description.version).toBe(1);
|
||||||
|
expect(node.description.properties).toBeDefined();
|
||||||
|
expect(node.description.inputs).toEqual([
|
||||||
|
NodeConnectionTypes.Main,
|
||||||
|
NodeConnectionTypes.AiTool,
|
||||||
|
]);
|
||||||
|
expect(node.description.outputs).toEqual([NodeConnectionTypes.Main]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ToolExecutor', () => {
|
||||||
|
it('should throw error if no tool inputs found', async () => {
|
||||||
|
mockExecuteFunction.getInputConnectionData.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(node.execute.call(mockExecuteFunction)).rejects.toThrow(
|
||||||
|
new NodeOperationError(mockExecuteFunction.getNode(), 'No tool inputs found'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('executes a basic tool with string input', async () => {
|
||||||
|
const mockInvoke = jest.fn().mockResolvedValue('test result');
|
||||||
|
|
||||||
|
const mockTool = new DynamicTool({
|
||||||
|
name: 'test_tool',
|
||||||
|
description: 'A test tool',
|
||||||
|
func: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockTool.invoke = mockInvoke;
|
||||||
|
|
||||||
|
mockExecuteFunction.getInputConnectionData.mockResolvedValue([mockTool]);
|
||||||
|
mockExecuteFunction.getNodeParameter.mockImplementation((param) => {
|
||||||
|
if (param === 'query') return 'test input';
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await node.execute.call(mockExecuteFunction);
|
||||||
|
|
||||||
|
expect(mockInvoke).toHaveBeenCalledWith('test input');
|
||||||
|
expect(result).toEqual([[{ json: 'test result' }]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('executes a structured tool with schema validation', async () => {
|
||||||
|
const mockTool = new DynamicStructuredTool({
|
||||||
|
name: 'test_structured_tool',
|
||||||
|
description: 'A test structured tool',
|
||||||
|
schema: z.object({
|
||||||
|
number: z.number(),
|
||||||
|
boolean: z.boolean(),
|
||||||
|
}),
|
||||||
|
func: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockInvoke = jest.fn().mockResolvedValue('test result');
|
||||||
|
mockTool.invoke = mockInvoke;
|
||||||
|
|
||||||
|
mockExecuteFunction.getInputConnectionData.mockResolvedValue([mockTool]);
|
||||||
|
mockExecuteFunction.getNodeParameter.mockImplementation((param) => {
|
||||||
|
if (param === 'query') return { number: '42', boolean: 'true' };
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await node.execute.call(mockExecuteFunction);
|
||||||
|
|
||||||
|
expect(mockTool.invoke).toHaveBeenCalledWith({ number: 42, boolean: true });
|
||||||
|
expect(result).toEqual([[{ json: 'test result' }]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('executes a specific tool from a toolkit with several tools', async () => {
|
||||||
|
const mockTool = new DynamicTool({
|
||||||
|
name: 'specific_tool',
|
||||||
|
description: 'A specific tool',
|
||||||
|
func: jest.fn().mockResolvedValue('specific result'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const irrelevantTool = new DynamicTool({
|
||||||
|
name: 'other_tool',
|
||||||
|
description: 'A specific irrelevant tool',
|
||||||
|
func: jest.fn().mockResolvedValue('specific result'),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockTool.invoke = jest.fn().mockResolvedValue('specific result');
|
||||||
|
|
||||||
|
const toolkit = {
|
||||||
|
getTools: () => [mockTool, irrelevantTool],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockExecuteFunction.getInputConnectionData.mockResolvedValue([toolkit]);
|
||||||
|
mockExecuteFunction.getNodeParameter.mockImplementation((param) => {
|
||||||
|
if (param === 'query') return 'test input';
|
||||||
|
if (param === 'toolName') return 'specific_tool';
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await node.execute.call(mockExecuteFunction);
|
||||||
|
|
||||||
|
expect(mockTool.invoke).toHaveBeenCalledWith('test input');
|
||||||
|
expect(result).toEqual([[{ json: 'specific result' }]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles JSON string query inputs', async () => {
|
||||||
|
const mockTool = new DynamicTool({
|
||||||
|
name: 'json_tool',
|
||||||
|
description: 'A tool that handles JSON',
|
||||||
|
func: jest.fn(),
|
||||||
|
});
|
||||||
|
mockTool.invoke = jest.fn().mockResolvedValue('json result');
|
||||||
|
|
||||||
|
mockExecuteFunction.getInputConnectionData.mockResolvedValue([mockTool]);
|
||||||
|
mockExecuteFunction.getNodeParameter.mockImplementation((param) => {
|
||||||
|
if (param === 'query') return '{"key": "value"}';
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await node.execute.call(mockExecuteFunction);
|
||||||
|
|
||||||
|
expect(mockTool.invoke).toHaveBeenCalledWith({ key: 'value' });
|
||||||
|
expect(result).toEqual([[{ json: 'json result' }]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { convertValueBySchema, convertObjectBySchema } from '../utils/convertToSchema';
|
||||||
|
|
||||||
|
describe('convertToSchema', () => {
|
||||||
|
describe('convertValueBySchema', () => {
|
||||||
|
it('should convert string to number when schema is ZodNumber', () => {
|
||||||
|
const result = convertValueBySchema('42', z.number());
|
||||||
|
expect(result).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert string to boolean when schema is ZodBoolean', () => {
|
||||||
|
expect(convertValueBySchema('true', z.boolean())).toBe(true);
|
||||||
|
expect(convertValueBySchema('false', z.boolean())).toBe(false);
|
||||||
|
expect(convertValueBySchema('TRUE', z.boolean())).toBe(true);
|
||||||
|
expect(convertValueBySchema('FALSE', z.boolean())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should parse JSON string when schema is ZodObject', () => {
|
||||||
|
const result = convertValueBySchema(
|
||||||
|
'{"key": "value", "other_key": 1, "booleanValue": false }',
|
||||||
|
z.object({}),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ key: 'value', other_key: 1, booleanValue: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original value if JSON parsing fails', () => {
|
||||||
|
const result = convertValueBySchema('invalid json', z.object({}));
|
||||||
|
expect(result).toEqual('invalid json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original value for non-string inputs', () => {
|
||||||
|
const input = { key: 'value' };
|
||||||
|
const result = convertValueBySchema(input, z.object({}));
|
||||||
|
expect(result).toEqual(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('convertObjectBySchema', () => {
|
||||||
|
it('should convert object values according to schema', () => {
|
||||||
|
const schema = z.object({
|
||||||
|
numberValue: z.number(),
|
||||||
|
booleanValue: z.boolean(),
|
||||||
|
object: z.object({}),
|
||||||
|
unchanged: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
numberValue: '42',
|
||||||
|
booleanValue: 'true',
|
||||||
|
object: '{"nested": "value"}',
|
||||||
|
unchanged: 'string value',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = convertObjectBySchema(input, schema);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
numberValue: 42,
|
||||||
|
booleanValue: true,
|
||||||
|
object: { nested: 'value' },
|
||||||
|
unchanged: 'string value',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original object if schema has no shape', () => {
|
||||||
|
const input = { key: 'value' };
|
||||||
|
const result = convertObjectBySchema(input, {});
|
||||||
|
expect(result).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return original object if input is null', () => {
|
||||||
|
const result = convertObjectBySchema(null, z.object({}));
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested objects', () => {
|
||||||
|
const schema = z.object({
|
||||||
|
nested: z.object({
|
||||||
|
numberValue: z.number(),
|
||||||
|
booleanValue: z.boolean(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
nested: {
|
||||||
|
numberValue: '42',
|
||||||
|
booleanValue: 'true',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = convertObjectBySchema(input, schema);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
nested: {
|
||||||
|
numberValue: 42,
|
||||||
|
booleanValue: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve fields not in schema', () => {
|
||||||
|
const schema = z.object({
|
||||||
|
number: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
number: '42',
|
||||||
|
extra: 'value',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = convertObjectBySchema(input, schema);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
number: 42,
|
||||||
|
extra: 'value',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const convertValueBySchema = (value: unknown, schema: any): unknown => {
|
||||||
|
if (!schema || !value) return value;
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (schema instanceof z.ZodNumber) {
|
||||||
|
return Number(value);
|
||||||
|
} else if (schema instanceof z.ZodBoolean) {
|
||||||
|
return value.toLowerCase() === 'true';
|
||||||
|
} else if (schema instanceof z.ZodObject) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return convertValueBySchema(parsed, schema);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema instanceof z.ZodObject && typeof value === 'object' && value !== null) {
|
||||||
|
const result: any = {};
|
||||||
|
for (const [key, val] of Object.entries(value)) {
|
||||||
|
const fieldSchema = schema.shape[key];
|
||||||
|
if (fieldSchema) {
|
||||||
|
result[key] = convertValueBySchema(val, fieldSchema);
|
||||||
|
} else {
|
||||||
|
result[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const convertObjectBySchema = (obj: any, schema: any): any => {
|
||||||
|
return convertValueBySchema(obj, schema);
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { StructuredTool } from 'langchain/tools';
|
||||||
|
import { type IDataObject, type INodeExecutionData } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { convertObjectBySchema } from './convertToSchema';
|
||||||
|
|
||||||
|
export async function executeTool(
|
||||||
|
tool: StructuredTool,
|
||||||
|
query: string | object,
|
||||||
|
): Promise<INodeExecutionData> {
|
||||||
|
let convertedQuery: string | object = query;
|
||||||
|
if ('schema' in tool && tool.schema) {
|
||||||
|
convertedQuery = convertObjectBySchema(query, tool.schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await tool.invoke(convertedQuery);
|
||||||
|
|
||||||
|
return {
|
||||||
|
json: result as IDataObject,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ describe('McpClientTool', () => {
|
|||||||
description: 'MyTool does something',
|
description: 'MyTool does something',
|
||||||
name: 'MyTool',
|
name: 'MyTool',
|
||||||
value: 'MyTool',
|
value: 'MyTool',
|
||||||
|
inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,5 +28,6 @@ export async function getTools(this: ILoadOptionsFunctions): Promise<INodeProper
|
|||||||
name: tool.name,
|
name: tool.name,
|
||||||
value: tool.name,
|
value: tool.name,
|
||||||
description: tool.description,
|
description: tool.description,
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,7 +133,9 @@
|
|||||||
"dist/nodes/vector_store/VectorStoreSupabaseLoad/VectorStoreSupabaseLoad.node.js",
|
"dist/nodes/vector_store/VectorStoreSupabaseLoad/VectorStoreSupabaseLoad.node.js",
|
||||||
"dist/nodes/vector_store/VectorStoreZep/VectorStoreZep.node.js",
|
"dist/nodes/vector_store/VectorStoreZep/VectorStoreZep.node.js",
|
||||||
"dist/nodes/vector_store/VectorStoreZepInsert/VectorStoreZepInsert.node.js",
|
"dist/nodes/vector_store/VectorStoreZepInsert/VectorStoreZepInsert.node.js",
|
||||||
"dist/nodes/vector_store/VectorStoreZepLoad/VectorStoreZepLoad.node.js"
|
"dist/nodes/vector_store/VectorStoreZepLoad/VectorStoreZepLoad.node.js",
|
||||||
|
"dist/nodes/ToolExecutor/ToolExecutor.node.js"
|
||||||
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -318,7 +318,6 @@ describe('LoadNodesAndCredentials', () => {
|
|||||||
group: ['input'],
|
group: ['input'],
|
||||||
inputs: [],
|
inputs: [],
|
||||||
outputs: ['ai_tool'],
|
outputs: ['ai_tool'],
|
||||||
usableAsTool: true,
|
|
||||||
properties: [
|
properties: [
|
||||||
{
|
{
|
||||||
default: 'A test node',
|
default: 'A test node',
|
||||||
@@ -371,7 +370,6 @@ describe('LoadNodesAndCredentials', () => {
|
|||||||
inputs: [],
|
inputs: [],
|
||||||
outputs: ['ai_tool'],
|
outputs: ['ai_tool'],
|
||||||
description: 'A test node',
|
description: 'A test node',
|
||||||
usableAsTool: true,
|
|
||||||
properties: [
|
properties: [
|
||||||
{
|
{
|
||||||
displayName: 'Description',
|
displayName: 'Description',
|
||||||
|
|||||||
@@ -319,8 +319,6 @@ export class LoadNodesAndCredentials {
|
|||||||
} as INodeTypeBaseDescription)
|
} as INodeTypeBaseDescription)
|
||||||
: deepCopy(usableNode);
|
: deepCopy(usableNode);
|
||||||
const wrapped = this.convertNodeToAiTool({ description }).description;
|
const wrapped = this.convertNodeToAiTool({ description }).description;
|
||||||
// TODO: Remove this when we support partial execution on all tool nodes
|
|
||||||
wrapped.usableAsTool = true;
|
|
||||||
|
|
||||||
this.types.nodes.push(wrapped);
|
this.types.nodes.push(wrapped);
|
||||||
this.known.nodes[wrapped.name] = { ...this.known.nodes[usableNode.name] };
|
this.known.nodes[wrapped.name] = { ...this.known.nodes[usableNode.name] };
|
||||||
|
|||||||
@@ -127,9 +127,16 @@ export class ManualExecutionService {
|
|||||||
|
|
||||||
// Rewire graph to be able to execute the destination tool node
|
// Rewire graph to be able to execute the destination tool node
|
||||||
if (isTool(destinationNode, workflow.nodeTypes)) {
|
if (isTool(destinationNode, workflow.nodeTypes)) {
|
||||||
workflow = rewireGraph(destinationNode, DirectedGraph.fromWorkflow(workflow)).toWorkflow({
|
const graph = rewireGraph(
|
||||||
|
destinationNode,
|
||||||
|
DirectedGraph.fromWorkflow(workflow),
|
||||||
|
data.agentRequest,
|
||||||
|
);
|
||||||
|
|
||||||
|
workflow = graph.toWorkflow({
|
||||||
...workflow,
|
...workflow,
|
||||||
});
|
});
|
||||||
|
data.destinationNode = graph.getDirectChildConnections(destinationNode).at(0)?.to?.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +157,7 @@ export class ManualExecutionService {
|
|||||||
data.pinData,
|
data.pinData,
|
||||||
data.dirtyNodeNames,
|
data.dirtyNodeNames,
|
||||||
data.destinationNode,
|
data.destinationNode,
|
||||||
|
data.agentRequest,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return workflowExecute.runPartialWorkflow(
|
return workflowExecute.runPartialWorkflow(
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ describe('WorkflowExecutionService', () => {
|
|||||||
const runPayload = mock<WorkflowRequest.ManualRunPayload>({
|
const runPayload = mock<WorkflowRequest.ManualRunPayload>({
|
||||||
startNodes: [],
|
startNodes: [],
|
||||||
destinationNode: undefined,
|
destinationNode: undefined,
|
||||||
|
agentRequest: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
workflowRunner.run.mockResolvedValue(executionId);
|
workflowRunner.run.mockResolvedValue(executionId);
|
||||||
@@ -123,6 +124,7 @@ describe('WorkflowExecutionService', () => {
|
|||||||
workflowData: { nodes: [node] },
|
workflowData: { nodes: [node] },
|
||||||
startNodes: [],
|
startNodes: [],
|
||||||
destinationNode: node.name,
|
destinationNode: node.name,
|
||||||
|
agentRequest: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
jest
|
jest
|
||||||
@@ -177,6 +179,7 @@ describe('WorkflowExecutionService', () => {
|
|||||||
nodes: [triggerNode],
|
nodes: [triggerNode],
|
||||||
},
|
},
|
||||||
triggerToStartFrom: undefined,
|
triggerToStartFrom: undefined,
|
||||||
|
agentRequest: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
workflowRunner.run.mockResolvedValue(executionId);
|
workflowRunner.run.mockResolvedValue(executionId);
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export class WorkflowExecutionService {
|
|||||||
destinationNode,
|
destinationNode,
|
||||||
dirtyNodeNames,
|
dirtyNodeNames,
|
||||||
triggerToStartFrom,
|
triggerToStartFrom,
|
||||||
|
agentRequest,
|
||||||
}: WorkflowRequest.ManualRunPayload,
|
}: WorkflowRequest.ManualRunPayload,
|
||||||
user: User,
|
user: User,
|
||||||
pushRef?: string,
|
pushRef?: string,
|
||||||
@@ -181,6 +182,7 @@ export class WorkflowExecutionService {
|
|||||||
partialExecutionVersion,
|
partialExecutionVersion,
|
||||||
dirtyNodeNames,
|
dirtyNodeNames,
|
||||||
triggerToStartFrom,
|
triggerToStartFrom,
|
||||||
|
agentRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name];
|
const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
StartNodeData,
|
StartNodeData,
|
||||||
ITaskData,
|
ITaskData,
|
||||||
IWorkflowBase,
|
IWorkflowBase,
|
||||||
|
AiAgentRequest,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import type { AuthenticatedRequest, ListQuery } from '@/requests';
|
import type { AuthenticatedRequest, ListQuery } from '@/requests';
|
||||||
@@ -35,6 +36,7 @@ export declare namespace WorkflowRequest {
|
|||||||
name: string;
|
name: string;
|
||||||
data?: ITaskData;
|
data?: ITaskData;
|
||||||
};
|
};
|
||||||
|
agentRequest?: AiAgentRequest;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload>;
|
type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload>;
|
||||||
|
|||||||
@@ -820,14 +820,32 @@ describe('WorkflowExecute', () => {
|
|||||||
.spyOn(workflowExecute, 'processRunExecutionData')
|
.spyOn(workflowExecute, 'processRunExecutionData')
|
||||||
.mockImplementationOnce(jest.fn());
|
.mockImplementationOnce(jest.fn());
|
||||||
|
|
||||||
|
const expectedToolExecutor: INode = {
|
||||||
|
name: 'PartialExecutionToolExecutor',
|
||||||
|
disabled: false,
|
||||||
|
type: '@n8n/n8n-nodes-langchain.toolExecutor',
|
||||||
|
parameters: {
|
||||||
|
query: {},
|
||||||
|
toolName: '',
|
||||||
|
},
|
||||||
|
id: agentNode.id,
|
||||||
|
typeVersion: 0,
|
||||||
|
position: [0, 0],
|
||||||
|
};
|
||||||
|
|
||||||
const expectedTool = {
|
const expectedTool = {
|
||||||
...tool,
|
...tool,
|
||||||
rewireOutputLogTo: NodeConnectionTypes.AiTool,
|
rewireOutputLogTo: NodeConnectionTypes.AiTool,
|
||||||
};
|
};
|
||||||
|
|
||||||
const expectedGraph = new DirectedGraph()
|
const expectedGraph = new DirectedGraph()
|
||||||
.addNodes(trigger, expectedTool)
|
.addNodes(trigger, expectedToolExecutor, expectedTool)
|
||||||
.addConnections({ from: trigger, to: expectedTool })
|
.addConnections({ from: trigger, to: expectedToolExecutor })
|
||||||
|
.addConnections({
|
||||||
|
from: expectedTool,
|
||||||
|
to: expectedToolExecutor,
|
||||||
|
type: NodeConnectionTypes.AiTool,
|
||||||
|
})
|
||||||
.toWorkflow({ ...workflow });
|
.toWorkflow({ ...workflow });
|
||||||
|
|
||||||
// ACT
|
// ACT
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ import { type INode, type INodeTypes, NodeConnectionTypes } from 'n8n-workflow';
|
|||||||
|
|
||||||
import { isTool } from '../is-tool';
|
import { isTool } from '../is-tool';
|
||||||
|
|
||||||
const mockNode = mock<INode>({ id: '1', type: 'n8n-nodes-base.openAi', typeVersion: 1 });
|
|
||||||
const mockNodeTypes = mock<INodeTypes>();
|
|
||||||
|
|
||||||
describe('isTool', () => {
|
describe('isTool', () => {
|
||||||
|
const mockNode = mock<INode>({
|
||||||
|
id: '1',
|
||||||
|
type: 'n8n-nodes-base.openAi',
|
||||||
|
typeVersion: 1,
|
||||||
|
parameters: {},
|
||||||
|
});
|
||||||
|
const mockNodeTypes = mock<INodeTypes>();
|
||||||
|
|
||||||
it('should return true for a node with AiTool output', () => {
|
it('should return true for a node with AiTool output', () => {
|
||||||
mockNodeTypes.getByNameAndVersion.mockReturnValue({
|
mockNodeTypes.getByNameAndVersion.mockReturnValue({
|
||||||
description: {
|
description: {
|
||||||
@@ -28,7 +33,53 @@ describe('isTool', () => {
|
|||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return true for a node with AiTool output in NodeOutputConfiguration', () => {
|
||||||
|
mockNodeTypes.getByNameAndVersion.mockReturnValue({
|
||||||
|
description: {
|
||||||
|
outputs: [{ type: NodeConnectionTypes.AiTool }, { type: NodeConnectionTypes.Main }],
|
||||||
|
version: 0,
|
||||||
|
defaults: {
|
||||||
|
name: '',
|
||||||
|
color: '',
|
||||||
|
},
|
||||||
|
inputs: [NodeConnectionTypes.Main],
|
||||||
|
properties: [],
|
||||||
|
displayName: '',
|
||||||
|
name: '',
|
||||||
|
group: [],
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = isTool(mockNode, mockNodeTypes);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for a vectore store node in retrieve-as-tool mode', () => {
|
||||||
|
mockNode.type = 'n8n-nodes-base.vectorStore';
|
||||||
|
mockNode.parameters = { mode: 'retrieve-as-tool' };
|
||||||
|
mockNodeTypes.getByNameAndVersion.mockReturnValue({
|
||||||
|
description: {
|
||||||
|
outputs: [NodeConnectionTypes.Main],
|
||||||
|
version: 0,
|
||||||
|
defaults: {
|
||||||
|
name: '',
|
||||||
|
color: '',
|
||||||
|
},
|
||||||
|
inputs: [NodeConnectionTypes.Main],
|
||||||
|
properties: [],
|
||||||
|
displayName: '',
|
||||||
|
name: '',
|
||||||
|
group: [],
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = isTool(mockNode, mockNodeTypes);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns false for node with no AiTool output', () => {
|
it('returns false for node with no AiTool output', () => {
|
||||||
|
mockNode.type = 'n8n-nodes-base.someTool';
|
||||||
|
mockNode.parameters = {};
|
||||||
mockNodeTypes.getByNameAndVersion.mockReturnValue({
|
mockNodeTypes.getByNameAndVersion.mockReturnValue({
|
||||||
description: {
|
description: {
|
||||||
outputs: [NodeConnectionTypes.Main],
|
outputs: [NodeConnectionTypes.Main],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
import { type INode, NodeConnectionTypes } from 'n8n-workflow';
|
||||||
|
|
||||||
import { createNodeData } from './helpers';
|
import { createNodeData } from './helpers';
|
||||||
import { DirectedGraph } from '../directed-graph';
|
import { DirectedGraph } from '../directed-graph';
|
||||||
@@ -19,15 +19,27 @@ describe('rewireGraph()', () => {
|
|||||||
|
|
||||||
const rewiredGraph = rewireGraph(tool, graph);
|
const rewiredGraph = rewireGraph(tool, graph);
|
||||||
|
|
||||||
const toolConnections = rewiredGraph.getDirectParentConnections(tool);
|
const executorNode = rewiredGraph
|
||||||
expect(toolConnections).toHaveLength(1);
|
.getNodesByNames(['PartialExecutionToolExecutor'])
|
||||||
expect(toolConnections[0].from.name).toBe('trigger');
|
.values()
|
||||||
expect(toolConnections[0].type).toBe(NodeConnectionTypes.Main);
|
.next().value as INode;
|
||||||
|
|
||||||
|
const toolConnections = rewiredGraph.getDirectParentConnections(executorNode);
|
||||||
|
|
||||||
|
expect(toolConnections).toHaveLength(2);
|
||||||
|
// Executor should be connected to tool
|
||||||
|
expect(toolConnections[0].from).toBe(tool);
|
||||||
|
expect(toolConnections[0].to).toBe(executorNode);
|
||||||
|
expect(toolConnections[0].type).toBe(NodeConnectionTypes.AiTool);
|
||||||
|
// Executor should be connected to trigger
|
||||||
|
expect(toolConnections[1].from).toBe(trigger);
|
||||||
|
expect(toolConnections[1].to).toBe(executorNode);
|
||||||
|
expect(toolConnections[1].type).toBe(NodeConnectionTypes.Main);
|
||||||
|
|
||||||
expect(rewiredGraph.hasNode(root.name)).toBe(false);
|
expect(rewiredGraph.hasNode(root.name)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rewires all incoming connections of the root node to the tool', () => {
|
it('rewires all incoming connections of the root node to the executorNode', () => {
|
||||||
const tool = createNodeData({ name: 'tool', type: 'n8n-nodes-base.ai-tool' });
|
const tool = createNodeData({ name: 'tool', type: 'n8n-nodes-base.ai-tool' });
|
||||||
const root = createNodeData({ name: 'root' });
|
const root = createNodeData({ name: 'root' });
|
||||||
const trigger = createNodeData({ name: 'trigger' });
|
const trigger = createNodeData({ name: 'trigger' });
|
||||||
@@ -46,10 +58,16 @@ describe('rewireGraph()', () => {
|
|||||||
|
|
||||||
const rewiredGraph = rewireGraph(tool, graph);
|
const rewiredGraph = rewireGraph(tool, graph);
|
||||||
|
|
||||||
const toolConnections = rewiredGraph.getDirectParentConnections(tool);
|
const executorNode = rewiredGraph
|
||||||
expect(toolConnections).toHaveLength(2);
|
.getNodesByNames(['PartialExecutionToolExecutor'])
|
||||||
expect(toolConnections.map((cn) => cn.from.name).sort()).toEqual(
|
.values()
|
||||||
['secondNode', 'thirdNode'].sort(),
|
.next().value as INode;
|
||||||
|
|
||||||
|
const executorConnections = rewiredGraph.getDirectParentConnections(executorNode);
|
||||||
|
|
||||||
|
expect(executorConnections).toHaveLength(3);
|
||||||
|
expect(executorConnections.map((cn) => cn.from.name).sort()).toEqual(
|
||||||
|
['tool', 'secondNode', 'thirdNode'].sort(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,9 +89,17 @@ describe('rewireGraph()', () => {
|
|||||||
|
|
||||||
const rewiredGraph = rewireGraph(tool, graph);
|
const rewiredGraph = rewireGraph(tool, graph);
|
||||||
|
|
||||||
const toolConnections = rewiredGraph.getDirectParentConnections(tool);
|
const executorNode = rewiredGraph
|
||||||
expect(toolConnections).toHaveLength(1);
|
.getNodesByNames(['PartialExecutionToolExecutor'])
|
||||||
expect(toolConnections[0].type).toBe(NodeConnectionTypes.Main);
|
.values()
|
||||||
|
.next().value as INode;
|
||||||
|
|
||||||
|
const executorConnections = rewiredGraph.getDirectParentConnections(executorNode);
|
||||||
|
|
||||||
|
expect(executorConnections).toHaveLength(2);
|
||||||
|
|
||||||
|
expect(executorConnections[0].type).toBe(NodeConnectionTypes.AiTool);
|
||||||
|
expect(executorConnections[1].type).toBe(NodeConnectionTypes.Main);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets rewireOutputLogTo to AiTool on the tool node', () => {
|
it('sets rewireOutputLogTo to AiTool on the tool node', () => {
|
||||||
@@ -116,4 +142,33 @@ describe('rewireGraph()', () => {
|
|||||||
|
|
||||||
expect(rewiredGraph.hasNode(root.name)).toBe(false);
|
expect(rewiredGraph.hasNode(root.name)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sets parameters.query and toolName on the executor node', () => {
|
||||||
|
const tool = createNodeData({ name: 'tool', type: 'n8n-nodes-base.ai-tool' });
|
||||||
|
const trigger = createNodeData({ name: 'trigger' });
|
||||||
|
const root = createNodeData({ name: 'root' });
|
||||||
|
const agentRequest = {
|
||||||
|
query: { some: 'query' },
|
||||||
|
tool: {
|
||||||
|
name: 'toolName',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const graph = new DirectedGraph();
|
||||||
|
graph.addNodes(trigger, root, tool);
|
||||||
|
graph.addConnections(
|
||||||
|
{ from: trigger, to: root, type: NodeConnectionTypes.Main },
|
||||||
|
{ from: tool, to: root, type: NodeConnectionTypes.AiTool },
|
||||||
|
);
|
||||||
|
|
||||||
|
const rewiredGraph = rewireGraph(tool, graph, agentRequest);
|
||||||
|
|
||||||
|
const executorNode = rewiredGraph
|
||||||
|
.getNodesByNames(['PartialExecutionToolExecutor'])
|
||||||
|
.values()
|
||||||
|
.next().value as INode;
|
||||||
|
|
||||||
|
expect(executorNode.parameters.query).toEqual(agentRequest.query);
|
||||||
|
expect(executorNode.parameters.toolName).toEqual(agentRequest.tool.name);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,5 +2,21 @@ import { type INode, type INodeTypes, NodeConnectionTypes } from 'n8n-workflow';
|
|||||||
|
|
||||||
export function isTool(node: INode, nodeTypes: INodeTypes) {
|
export function isTool(node: INode, nodeTypes: INodeTypes) {
|
||||||
const type = nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
const type = nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||||
return type.description.outputs.includes(NodeConnectionTypes.AiTool);
|
|
||||||
|
// Check if node is a vector store in retrieve-as-tool mode
|
||||||
|
if (node.type.includes('vectorStore')) {
|
||||||
|
const mode = node.parameters?.mode;
|
||||||
|
return mode === 'retrieve-as-tool';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for other tool nodes
|
||||||
|
for (const output of type.description.outputs) {
|
||||||
|
if (typeof output === 'string') {
|
||||||
|
return output === NodeConnectionTypes.AiTool;
|
||||||
|
} else if (output?.type && output.type === NodeConnectionTypes.AiTool) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import * as a from 'assert/strict';
|
import * as a from 'assert/strict';
|
||||||
import { type INode, NodeConnectionTypes } from 'n8n-workflow';
|
import { type AiAgentRequest, type INode, NodeConnectionTypes } from 'n8n-workflow';
|
||||||
|
|
||||||
import { type DirectedGraph } from './directed-graph';
|
import { type DirectedGraph } from './directed-graph';
|
||||||
|
|
||||||
export function rewireGraph(tool: INode, graph: DirectedGraph): DirectedGraph {
|
export const TOOL_EXECUTOR_NODE_NAME = 'PartialExecutionToolExecutor';
|
||||||
|
|
||||||
|
export function rewireGraph(
|
||||||
|
tool: INode,
|
||||||
|
graph: DirectedGraph,
|
||||||
|
agentRequest?: AiAgentRequest,
|
||||||
|
): DirectedGraph {
|
||||||
const modifiedGraph = graph.clone();
|
const modifiedGraph = graph.clone();
|
||||||
const children = modifiedGraph.getChildren(tool);
|
const children = modifiedGraph.getChildren(tool);
|
||||||
|
|
||||||
@@ -19,12 +25,33 @@ export function rewireGraph(tool: INode, graph: DirectedGraph): DirectedGraph {
|
|||||||
.getDirectParentConnections(rootNode)
|
.getDirectParentConnections(rootNode)
|
||||||
.filter((cn) => cn.type === NodeConnectionTypes.Main);
|
.filter((cn) => cn.type === NodeConnectionTypes.Main);
|
||||||
|
|
||||||
tool.rewireOutputLogTo = NodeConnectionTypes.AiTool;
|
// Create virtual agent node
|
||||||
|
const toolExecutor: INode = {
|
||||||
|
name: TOOL_EXECUTOR_NODE_NAME,
|
||||||
|
disabled: false,
|
||||||
|
type: '@n8n/n8n-nodes-langchain.toolExecutor',
|
||||||
|
parameters: {
|
||||||
|
query: agentRequest?.query ?? {},
|
||||||
|
toolName: agentRequest?.tool?.name ?? '',
|
||||||
|
},
|
||||||
|
id: rootNode.id,
|
||||||
|
typeVersion: 0,
|
||||||
|
position: [0, 0],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add virtual agent to graph
|
||||||
|
modifiedGraph.addNode(toolExecutor);
|
||||||
|
|
||||||
|
// Rewire tool output to virtual agent
|
||||||
|
tool.rewireOutputLogTo = NodeConnectionTypes.AiTool;
|
||||||
|
modifiedGraph.addConnection({ from: tool, to: toolExecutor, type: NodeConnectionTypes.AiTool });
|
||||||
|
|
||||||
|
// Rewire all incoming connections to virtual agent
|
||||||
for (const cn of allIncomingConnection) {
|
for (const cn of allIncomingConnection) {
|
||||||
modifiedGraph.addConnection({ from: cn.from, to: tool });
|
modifiedGraph.addConnection({ from: cn.from, to: toolExecutor, type: cn.type });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove original agent node
|
||||||
modifiedGraph.removeNode(rootNode);
|
modifiedGraph.removeNode(rootNode);
|
||||||
|
|
||||||
return modifiedGraph;
|
return modifiedGraph;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import type {
|
|||||||
INodeIssues,
|
INodeIssues,
|
||||||
INodeType,
|
INodeType,
|
||||||
ITaskStartedData,
|
ITaskStartedData,
|
||||||
|
AiAgentRequest,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import {
|
import {
|
||||||
LoggerProxy as Logger,
|
LoggerProxy as Logger,
|
||||||
@@ -51,6 +52,7 @@ import {
|
|||||||
Node,
|
Node,
|
||||||
UnexpectedError,
|
UnexpectedError,
|
||||||
UserError,
|
UserError,
|
||||||
|
OperationalError,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import PCancelable from 'p-cancelable';
|
import PCancelable from 'p-cancelable';
|
||||||
|
|
||||||
@@ -72,6 +74,7 @@ import {
|
|||||||
isTool,
|
isTool,
|
||||||
getNextExecutionIndex,
|
getNextExecutionIndex,
|
||||||
} from './partial-execution-utils';
|
} from './partial-execution-utils';
|
||||||
|
import { TOOL_EXECUTOR_NODE_NAME } from './partial-execution-utils/rewire-graph';
|
||||||
import { RoutingNode } from './routing-node';
|
import { RoutingNode } from './routing-node';
|
||||||
import { TriggersAndPollers } from './triggers-and-pollers';
|
import { TriggersAndPollers } from './triggers-and-pollers';
|
||||||
|
|
||||||
@@ -346,6 +349,7 @@ export class WorkflowExecute {
|
|||||||
pinData: IPinData = {},
|
pinData: IPinData = {},
|
||||||
dirtyNodeNames: string[] = [],
|
dirtyNodeNames: string[] = [],
|
||||||
destinationNodeName?: string,
|
destinationNodeName?: string,
|
||||||
|
agentRequest?: AiAgentRequest,
|
||||||
): PCancelable<IRun> {
|
): PCancelable<IRun> {
|
||||||
// TODO: Refactor the call-site to make `destinationNodeName` a required
|
// TODO: Refactor the call-site to make `destinationNodeName` a required
|
||||||
// after removing the old partial execution flow.
|
// after removing the old partial execution flow.
|
||||||
@@ -354,7 +358,7 @@ export class WorkflowExecute {
|
|||||||
'a destinationNodeName is required for the new partial execution flow',
|
'a destinationNodeName is required for the new partial execution flow',
|
||||||
);
|
);
|
||||||
|
|
||||||
const destination = workflow.getNode(destinationNodeName);
|
let destination = workflow.getNode(destinationNodeName);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
destination,
|
destination,
|
||||||
`Could not find a node with the name ${destinationNodeName} in the workflow.`,
|
`Could not find a node with the name ${destinationNodeName} in the workflow.`,
|
||||||
@@ -364,8 +368,15 @@ export class WorkflowExecute {
|
|||||||
|
|
||||||
// Partial execution of nodes as tools
|
// Partial execution of nodes as tools
|
||||||
if (isTool(destination, workflow.nodeTypes)) {
|
if (isTool(destination, workflow.nodeTypes)) {
|
||||||
graph = rewireGraph(destination, graph);
|
graph = rewireGraph(destination, graph, agentRequest);
|
||||||
workflow = graph.toWorkflow({ ...workflow });
|
workflow = graph.toWorkflow({ ...workflow });
|
||||||
|
// Rewire destination node to the virtual agent
|
||||||
|
const toolExecutorNode = workflow.getNode(TOOL_EXECUTOR_NODE_NAME);
|
||||||
|
if (!toolExecutorNode) {
|
||||||
|
throw new OperationalError('ToolExecutor can not be found');
|
||||||
|
}
|
||||||
|
destination = toolExecutorNode;
|
||||||
|
destinationNodeName = toolExecutorNode.name;
|
||||||
} else {
|
} else {
|
||||||
// Edge Case 1:
|
// Edge Case 1:
|
||||||
// Support executing a single node that is not connected to a trigger
|
// Support executing a single node that is not connected to a trigger
|
||||||
|
|||||||
@@ -43,6 +43,25 @@ export const predefinedNodesTypes: INodeTypeData = {
|
|||||||
type: new SplitInBatches(),
|
type: new SplitInBatches(),
|
||||||
sourcePath: '',
|
sourcePath: '',
|
||||||
},
|
},
|
||||||
|
'@n8n/n8n-nodes-langchain.toolExecutor': {
|
||||||
|
sourcePath: '',
|
||||||
|
type: {
|
||||||
|
description: {
|
||||||
|
displayName: 'Test tool executor',
|
||||||
|
name: 'toolTestExecutor',
|
||||||
|
group: ['transform'],
|
||||||
|
version: 1,
|
||||||
|
description: 'Test tool executor',
|
||||||
|
defaults: {
|
||||||
|
name: 'Test Tool Executor',
|
||||||
|
color: '#0000FF',
|
||||||
|
},
|
||||||
|
inputs: [NodeConnectionTypes.AiTool, NodeConnectionTypes.Main],
|
||||||
|
outputs: [NodeConnectionTypes.Main],
|
||||||
|
properties: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
'n8n-nodes-base.toolTest': {
|
'n8n-nodes-base.toolTest': {
|
||||||
sourcePath: '',
|
sourcePath: '',
|
||||||
type: {
|
type: {
|
||||||
|
|||||||
@@ -217,6 +217,12 @@ export interface IStartRunData {
|
|||||||
name: string;
|
name: string;
|
||||||
data?: ITaskData;
|
data?: ITaskData;
|
||||||
};
|
};
|
||||||
|
agentRequest?: {
|
||||||
|
query: NodeParameterValueType;
|
||||||
|
tool: {
|
||||||
|
name: NodeParameterValueType;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ITableData {
|
export interface ITableData {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import FromAiParametersModal from '@/components/FromAiParametersModal.vue';
|
import FromAiParametersModal from '@/components/FromAiParametersModal.vue';
|
||||||
import { FROM_AI_PARAMETERS_MODAL_KEY, STORES } from '@/constants';
|
import { FROM_AI_PARAMETERS_MODAL_KEY, STORES, AI_MCP_TOOL_NODE_TYPE } from '@/constants';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useParameterOverridesStore } from '@/stores/parameterOverrides.store';
|
import { useAgentRequestStore } from '@/stores/agentRequest.store';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||||
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
import { nextTick } from 'vue';
|
||||||
|
|
||||||
const ModalStub = {
|
const ModalStub = {
|
||||||
template: `
|
template: `
|
||||||
@@ -26,12 +28,20 @@ vi.mocked(useRouter);
|
|||||||
const mockNode = {
|
const mockNode = {
|
||||||
id: 'id1',
|
id: 'id1',
|
||||||
name: 'Test Node',
|
name: 'Test Node',
|
||||||
|
type: 'n8n-nodes-base.ai-tool',
|
||||||
parameters: {
|
parameters: {
|
||||||
testBoolean: "={{ $fromAI('testBoolean', ``, 'boolean') }}",
|
testBoolean: "={{ $fromAI('testBoolean', ``, 'boolean') }}",
|
||||||
testParam: "={{ $fromAi('testParam', ``, 'string') }}",
|
testParam: "={{ $fromAi('testParam', ``, 'string') }}",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockMcpNode = {
|
||||||
|
id: 'id1',
|
||||||
|
name: 'Test MCP Node',
|
||||||
|
type: AI_MCP_TOOL_NODE_TYPE,
|
||||||
|
parameters: {},
|
||||||
|
};
|
||||||
|
|
||||||
const mockParentNode = {
|
const mockParentNode = {
|
||||||
name: 'Parent Node',
|
name: 'Parent Node',
|
||||||
};
|
};
|
||||||
@@ -43,7 +53,7 @@ const mockRunData = {
|
|||||||
['Test Node']: [
|
['Test Node']: [
|
||||||
{
|
{
|
||||||
inputOverride: {
|
inputOverride: {
|
||||||
[NodeConnectionTypes.AiTool]: [[{ json: { testParam: 'override' } }]],
|
[NodeConnectionTypes.AiTool]: [[{ json: { query: { testParam: 'override' } } }]],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -57,10 +67,27 @@ const mockWorkflow = {
|
|||||||
getChildNodes: () => ['Parent Node'],
|
getChildNodes: () => ['Parent Node'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockTools = [
|
||||||
|
{
|
||||||
|
name: 'Test Tool',
|
||||||
|
value: 'test-tool',
|
||||||
|
inputSchema: {
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Test query',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const renderModal = createComponentRenderer(FromAiParametersModal);
|
const renderModal = createComponentRenderer(FromAiParametersModal);
|
||||||
let pinia: ReturnType<typeof createTestingPinia>;
|
let pinia: ReturnType<typeof createTestingPinia>;
|
||||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||||
let parameterOverridesStore: ReturnType<typeof useParameterOverridesStore>;
|
let agentRequestStore: ReturnType<typeof useAgentRequestStore>;
|
||||||
|
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
|
||||||
|
|
||||||
describe('FromAiParametersModal', () => {
|
describe('FromAiParametersModal', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
pinia = createTestingPinia({
|
pinia = createTestingPinia({
|
||||||
@@ -83,14 +110,23 @@ describe('FromAiParametersModal', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
workflowsStore = useWorkflowsStore();
|
workflowsStore = useWorkflowsStore();
|
||||||
workflowsStore.getNodeByName = vi
|
workflowsStore.getNodeByName = vi.fn().mockImplementation((name: string) => {
|
||||||
.fn()
|
switch (name) {
|
||||||
.mockImplementation((name: string) => (name === 'Test Node' ? mockNode : mockParentNode));
|
case 'Test Node':
|
||||||
|
return mockNode;
|
||||||
|
case 'Test MCP Node':
|
||||||
|
return mockMcpNode;
|
||||||
|
default:
|
||||||
|
return mockParentNode;
|
||||||
|
}
|
||||||
|
});
|
||||||
workflowsStore.getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
|
workflowsStore.getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
|
||||||
parameterOverridesStore = useParameterOverridesStore();
|
agentRequestStore = useAgentRequestStore();
|
||||||
parameterOverridesStore.clearParameterOverrides = vi.fn();
|
agentRequestStore.clearAgentRequests = vi.fn();
|
||||||
parameterOverridesStore.addParameterOverrides = vi.fn();
|
agentRequestStore.addAgentRequests = vi.fn();
|
||||||
parameterOverridesStore.substituteParameters = vi.fn();
|
agentRequestStore.generateAgentRequest = vi.fn();
|
||||||
|
nodeTypesStore = useNodeTypesStore();
|
||||||
|
nodeTypesStore.getNodeParameterOptions = vi.fn().mockResolvedValue(mockTools);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders correctly with node data', () => {
|
it('renders correctly with node data', () => {
|
||||||
@@ -112,6 +148,53 @@ describe('FromAiParametersModal', () => {
|
|||||||
expect(getByTitle('Test Test Node')).toBeTruthy();
|
expect(getByTitle('Test Test Node')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows tool selection for AI tool nodes', async () => {
|
||||||
|
const { findByRole } = renderModal({
|
||||||
|
props: {
|
||||||
|
modalName: FROM_AI_PARAMETERS_MODAL_KEY,
|
||||||
|
data: {
|
||||||
|
nodeName: 'Test MCP Node',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Modal: ModalStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pinia,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolSelect = await findByRole('combobox');
|
||||||
|
expect(toolSelect).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows tool parameters after tool selection', async () => {
|
||||||
|
const { getByTestId, findByRole, findByText } = renderModal({
|
||||||
|
props: {
|
||||||
|
modalName: FROM_AI_PARAMETERS_MODAL_KEY,
|
||||||
|
data: {
|
||||||
|
nodeName: 'Test MCP Node',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Modal: ModalStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pinia,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolSelect = await findByRole('combobox');
|
||||||
|
await userEvent.click(toolSelect);
|
||||||
|
|
||||||
|
const toolOption = await findByText('Test Tool');
|
||||||
|
await userEvent.click(toolOption);
|
||||||
|
await nextTick();
|
||||||
|
const inputs = getByTestId('from-ai-parameters-modal-inputs');
|
||||||
|
const inputByName = inputs.querySelector('input[name="query.query"]');
|
||||||
|
expect(inputByName).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('uses run data when available as initial values', async () => {
|
it('uses run data when available as initial values', async () => {
|
||||||
const { getByTestId } = renderModal({
|
const { getByTestId } = renderModal({
|
||||||
props: {
|
props: {
|
||||||
@@ -130,14 +213,10 @@ describe('FromAiParametersModal', () => {
|
|||||||
|
|
||||||
await userEvent.click(getByTestId('execute-workflow-button'));
|
await userEvent.click(getByTestId('execute-workflow-button'));
|
||||||
|
|
||||||
expect(parameterOverridesStore.addParameterOverrides).toHaveBeenCalledWith(
|
expect(agentRequestStore.addAgentRequests).toHaveBeenCalledWith('test-workflow', 'id1', {
|
||||||
'test-workflow',
|
'query.testBoolean': true,
|
||||||
'id1',
|
'query.testParam': 'override',
|
||||||
{
|
});
|
||||||
testBoolean: true,
|
|
||||||
testParam: 'override',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears parameter overrides when modal is executed', async () => {
|
it('clears parameter overrides when modal is executed', async () => {
|
||||||
@@ -158,13 +237,10 @@ describe('FromAiParametersModal', () => {
|
|||||||
|
|
||||||
await userEvent.click(getByTestId('execute-workflow-button'));
|
await userEvent.click(getByTestId('execute-workflow-button'));
|
||||||
|
|
||||||
expect(parameterOverridesStore.clearParameterOverrides).toHaveBeenCalledWith(
|
expect(agentRequestStore.clearAgentRequests).toHaveBeenCalledWith('test-workflow', 'id1');
|
||||||
'test-workflow',
|
|
||||||
'id1',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds substitutes for parameters when executed', async () => {
|
it('adds agent request with given parameters when executed', async () => {
|
||||||
const { getByTestId } = renderModal({
|
const { getByTestId } = renderModal({
|
||||||
props: {
|
props: {
|
||||||
modalName: FROM_AI_PARAMETERS_MODAL_KEY,
|
modalName: FROM_AI_PARAMETERS_MODAL_KEY,
|
||||||
@@ -182,17 +258,16 @@ describe('FromAiParametersModal', () => {
|
|||||||
|
|
||||||
const inputs = getByTestId('from-ai-parameters-modal-inputs');
|
const inputs = getByTestId('from-ai-parameters-modal-inputs');
|
||||||
await userEvent.click(inputs.querySelector('input[value="testBoolean"]') as Element);
|
await userEvent.click(inputs.querySelector('input[value="testBoolean"]') as Element);
|
||||||
await userEvent.clear(inputs.querySelector('input[name="testParam"]') as Element);
|
await userEvent.clear(inputs.querySelector('input[name="query.testParam"]') as Element);
|
||||||
await userEvent.type(inputs.querySelector('input[name="testParam"]') as Element, 'given value');
|
await userEvent.type(
|
||||||
|
inputs.querySelector('input[name="query.testParam"]') as Element,
|
||||||
|
'given value',
|
||||||
|
);
|
||||||
await userEvent.click(getByTestId('execute-workflow-button'));
|
await userEvent.click(getByTestId('execute-workflow-button'));
|
||||||
|
|
||||||
expect(parameterOverridesStore.addParameterOverrides).toHaveBeenCalledWith(
|
expect(agentRequestStore.addAgentRequests).toHaveBeenCalledWith('test-workflow', 'id1', {
|
||||||
'test-workflow',
|
'query.testBoolean': false,
|
||||||
'id1',
|
'query.testParam': 'given value',
|
||||||
{
|
});
|
||||||
testBoolean: false,
|
|
||||||
testParam: 'given value',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||||
import { FROM_AI_PARAMETERS_MODAL_KEY } from '@/constants';
|
import { FROM_AI_PARAMETERS_MODAL_KEY, AI_MCP_TOOL_NODE_TYPE } from '@/constants';
|
||||||
import { useParameterOverridesStore } from '@/stores/parameterOverrides.store';
|
import { useAgentRequestStore } from '@/stores/agentRequest.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { createEventBus } from '@n8n/utils/event-bus';
|
import { createEventBus } from '@n8n/utils/event-bus';
|
||||||
import {
|
import {
|
||||||
type FromAIArgument,
|
type FromAIArgument,
|
||||||
|
type IDataObject,
|
||||||
NodeConnectionTypes,
|
NodeConnectionTypes,
|
||||||
traverseNodeParametersWithParamNames,
|
traverseNodeParameters,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { computed, ref } from 'vue';
|
import type { IFormInput } from '@n8n/design-system';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { type IFormInput } from '@n8n/design-system';
|
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
import { type JSONSchema7 } from 'json-schema';
|
||||||
|
|
||||||
type Value = string | number | boolean | null | undefined;
|
type Value = string | number | boolean | null | undefined;
|
||||||
|
|
||||||
@@ -31,9 +34,10 @@ const telemetry = useTelemetry();
|
|||||||
const ndvStore = useNDVStore();
|
const ndvStore = useNDVStore();
|
||||||
const modalBus = createEventBus();
|
const modalBus = createEventBus();
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { runWorkflow } = useRunWorkflow({ router });
|
const { runWorkflow } = useRunWorkflow({ router });
|
||||||
const parameterOverridesStore = useParameterOverridesStore();
|
const agentRequestStore = useAgentRequestStore();
|
||||||
|
|
||||||
const node = computed(() =>
|
const node = computed(() =>
|
||||||
props.data.nodeName ? workflowsStore.getNodeByName(props.data.nodeName) : undefined,
|
props.data.nodeName ? workflowsStore.getNodeByName(props.data.nodeName) : undefined,
|
||||||
@@ -47,6 +51,9 @@ const parentNode = computed(() => {
|
|||||||
return workflowsStore.getNodeByName(parentNodes[0])?.name;
|
return workflowsStore.getNodeByName(parentNodes[0])?.name;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const parameters = ref<IFormInput[]>([]);
|
||||||
|
const selectedTool = ref<string>('');
|
||||||
|
|
||||||
const nodeRunData = computed(() => {
|
const nodeRunData = computed(() => {
|
||||||
if (!node.value) return undefined;
|
if (!node.value) return undefined;
|
||||||
|
|
||||||
@@ -80,38 +87,125 @@ const mapTypes: {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const parameters = computed(() => {
|
watch(
|
||||||
if (!node.value) return [];
|
[node, selectedTool],
|
||||||
|
async ([newNode, newSelectedTool]) => {
|
||||||
|
if (!newNode) {
|
||||||
|
parameters.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result: IFormInput[] = [];
|
const result: IFormInput[] = [];
|
||||||
const params = node.value.parameters;
|
|
||||||
const collectedArgs: Map<string, FromAIArgument> = new Map();
|
|
||||||
traverseNodeParametersWithParamNames(params, collectedArgs);
|
|
||||||
const inputOverrides =
|
|
||||||
nodeRunData.value?.inputOverride?.[NodeConnectionTypes.AiTool]?.[0]?.[0].json;
|
|
||||||
|
|
||||||
collectedArgs.forEach((value: FromAIArgument, paramName: string) => {
|
// Handle MCPClientTool nodes differently
|
||||||
const type = value.type ?? 'string';
|
if (newNode.type === AI_MCP_TOOL_NODE_TYPE) {
|
||||||
const initialValue = inputOverrides?.[value.key]
|
const tools = await nodeTypesStore.getNodeParameterOptions({
|
||||||
? inputOverrides[value.key]
|
nodeTypeAndVersion: {
|
||||||
: (parameterOverridesStore.getParameterOverride(
|
name: newNode.type,
|
||||||
workflowsStore.workflowId,
|
version: newNode.typeVersion,
|
||||||
node.value!.id,
|
},
|
||||||
paramName,
|
path: 'parmeters.includedTools',
|
||||||
) ?? mapTypes[type]?.defaultValue);
|
methodName: 'getTools',
|
||||||
|
currentNodeParameters: newNode.parameters,
|
||||||
|
});
|
||||||
|
|
||||||
result.push({
|
// Load available tools
|
||||||
name: paramName,
|
const toolOptions = tools?.map((tool) => ({
|
||||||
initialValue: initialValue as string | number | boolean | null | undefined,
|
label: tool.name,
|
||||||
properties: {
|
value: String(tool.value),
|
||||||
label: value.key,
|
disabled: false,
|
||||||
type: mapTypes[type].inputType,
|
}));
|
||||||
required: true,
|
|
||||||
},
|
result.push({
|
||||||
|
name: 'toolName',
|
||||||
|
initialValue: '',
|
||||||
|
properties: {
|
||||||
|
label: 'Tool name',
|
||||||
|
type: 'select',
|
||||||
|
options: toolOptions,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only show parameters for selected tool
|
||||||
|
if (newSelectedTool) {
|
||||||
|
const selectedToolData = tools?.find((tool) => String(tool.value) === newSelectedTool);
|
||||||
|
const schema = selectedToolData?.inputSchema as JSONSchema7;
|
||||||
|
if (schema.properties) {
|
||||||
|
for (const [propertyName, value] of Object.entries(schema.properties)) {
|
||||||
|
const typedValue = value as {
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
name: 'query.' + propertyName,
|
||||||
|
initialValue: '',
|
||||||
|
properties: {
|
||||||
|
label: propertyName,
|
||||||
|
type: mapTypes[typedValue.type ?? 'text'].inputType,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
parameters.value = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle regular tool nodes
|
||||||
|
const params = newNode.parameters;
|
||||||
|
const collectedArgs: FromAIArgument[] = [];
|
||||||
|
traverseNodeParameters(params, collectedArgs);
|
||||||
|
const inputOverrides =
|
||||||
|
nodeRunData.value?.inputOverride?.[NodeConnectionTypes.AiTool]?.[0]?.[0].json;
|
||||||
|
|
||||||
|
collectedArgs.forEach((value: FromAIArgument) => {
|
||||||
|
const type = value.type ?? 'string';
|
||||||
|
const inputQuery = inputOverrides?.query as IDataObject;
|
||||||
|
const initialValue = inputQuery?.[value.key]
|
||||||
|
? inputQuery[value.key]
|
||||||
|
: (agentRequestStore.getAgentRequest(
|
||||||
|
workflowsStore.workflowId,
|
||||||
|
newNode.id,
|
||||||
|
'query.' + value.key,
|
||||||
|
) ?? mapTypes[type]?.defaultValue);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
name: 'query.' + value.key,
|
||||||
|
initialValue: initialValue as string | number | boolean | null | undefined,
|
||||||
|
properties: {
|
||||||
|
label: value.key,
|
||||||
|
type: mapTypes[value.type ?? 'string'].inputType,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
if (result.length === 0) {
|
||||||
return result;
|
let inputQuery = inputOverrides?.query;
|
||||||
});
|
if (typeof inputQuery === 'object') {
|
||||||
|
inputQuery = JSON.stringify(inputQuery);
|
||||||
|
}
|
||||||
|
const queryValue =
|
||||||
|
inputQuery ??
|
||||||
|
agentRequestStore.getAgentRequest(workflowsStore.workflowId, newNode.id, 'query') ??
|
||||||
|
'';
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
name: 'query',
|
||||||
|
initialValue: (queryValue as string) ?? '',
|
||||||
|
properties: {
|
||||||
|
label: 'Query',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
parameters.value = result;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
modalBus.emit('close');
|
modalBus.emit('close');
|
||||||
@@ -119,14 +213,10 @@ const onClose = () => {
|
|||||||
|
|
||||||
const onExecute = async () => {
|
const onExecute = async () => {
|
||||||
if (!node.value) return;
|
if (!node.value) return;
|
||||||
const inputValues = inputs.value!.getValues();
|
const inputValues = inputs.value?.getValues() ?? {};
|
||||||
|
|
||||||
parameterOverridesStore.clearParameterOverrides(workflowsStore.workflowId, node.value.id);
|
agentRequestStore.clearAgentRequests(workflowsStore.workflowId, node.value.id);
|
||||||
parameterOverridesStore.addParameterOverrides(
|
agentRequestStore.addAgentRequests(workflowsStore.workflowId, node.value.id, inputValues);
|
||||||
workflowsStore.workflowId,
|
|
||||||
node.value.id,
|
|
||||||
inputValues,
|
|
||||||
);
|
|
||||||
|
|
||||||
const telemetryPayload = {
|
const telemetryPayload = {
|
||||||
node_type: node.value.type,
|
node_type: node.value.type,
|
||||||
@@ -139,11 +229,16 @@ const onExecute = async () => {
|
|||||||
|
|
||||||
await runWorkflow({
|
await runWorkflow({
|
||||||
destinationNode: node.value.name,
|
destinationNode: node.value.name,
|
||||||
source: 'RunData.TestExecuteModal',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add handler for tool selection change
|
||||||
|
const onUpdate = (change: { name: string; value: string }) => {
|
||||||
|
if (change.name !== 'toolName') return;
|
||||||
|
selectedTool.value = change.value;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -177,6 +272,7 @@ const onExecute = async () => {
|
|||||||
:column-view="true"
|
:column-view="true"
|
||||||
data-test-id="from-ai-parameters-modal-inputs"
|
data-test-id="from-ai-parameters-modal-inputs"
|
||||||
@submit="onExecute"
|
@submit="onExecute"
|
||||||
|
@update="onUpdate"
|
||||||
></N8nFormInputs>
|
></N8nFormInputs>
|
||||||
</el-row>
|
</el-row>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { useI18n } from '@/composables/useI18n';
|
|||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { type IUpdateInformation } from '@/Interface';
|
import { type IUpdateInformation } from '@/Interface';
|
||||||
import { generateCodeForAiTransform } from '@/components/ButtonParameter/utils';
|
import { generateCodeForAiTransform } from '@/components/ButtonParameter/utils';
|
||||||
import { hasFromAiExpressions } from '@/utils/nodes/nodeTransforms';
|
import { needsAgentInput } from '@/utils/nodes/nodeTransforms';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
|
||||||
const NODE_TEST_STEP_POPUP_COUNT_KEY = 'N8N_NODE_TEST_STEP_POPUP_COUNT';
|
const NODE_TEST_STEP_POPUP_COUNT_KEY = 'N8N_NODE_TEST_STEP_POPUP_COUNT';
|
||||||
@@ -345,7 +345,7 @@ async function onClick() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!pinnedData.hasData.value || shouldUnpinAndExecute) {
|
if (!pinnedData.hasData.value || shouldUnpinAndExecute) {
|
||||||
if (node.value && hasFromAiExpressions(node.value)) {
|
if (node.value && needsAgentInput(node.value)) {
|
||||||
uiStore.openModalWithData({
|
uiStore.openModalWithData({
|
||||||
name: FROM_AI_PARAMETERS_MODAL_KEY,
|
name: FROM_AI_PARAMETERS_MODAL_KEY,
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -130,9 +130,7 @@ const node = computed(() => ndvStore.activeNode);
|
|||||||
|
|
||||||
const isTriggerNode = computed(() => !!node.value && nodeTypesStore.isTriggerNode(node.value.type));
|
const isTriggerNode = computed(() => !!node.value && nodeTypesStore.isTriggerNode(node.value.type));
|
||||||
|
|
||||||
const isNodesAsToolNode = computed(
|
const isToolNode = computed(() => !!node.value && nodeTypesStore.isToolNode(node.value.type));
|
||||||
() => !!node.value && nodeTypesStore.isNodesAsToolNode(node.value.type),
|
|
||||||
);
|
|
||||||
|
|
||||||
const isExecutable = computed(() => {
|
const isExecutable = computed(() => {
|
||||||
if (props.nodeType && node.value) {
|
if (props.nodeType && node.value) {
|
||||||
@@ -146,7 +144,7 @@ const isExecutable = computed(() => {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
!inputNames.includes(NodeConnectionTypes.Main) &&
|
!inputNames.includes(NodeConnectionTypes.Main) &&
|
||||||
!isNodesAsToolNode.value &&
|
!isToolNode.value &&
|
||||||
!isTriggerNode.value
|
!isTriggerNode.value
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -29,9 +29,7 @@ const workflowsStore = useWorkflowsStore();
|
|||||||
const nodeTypesStore = useNodeTypesStore();
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
|
|
||||||
const node = computed(() => !!name.value && workflowsStore.getNodeByName(name.value));
|
const node = computed(() => !!name.value && workflowsStore.getNodeByName(name.value));
|
||||||
const isNodesAsToolNode = computed(
|
const isToolNode = computed(() => !!node.value && nodeTypesStore.isToolNode(node.value.type));
|
||||||
() => !!node.value && nodeTypesStore.isNodesAsToolNode(node.value.type),
|
|
||||||
);
|
|
||||||
|
|
||||||
const nodeDisabledTitle = computed(() => {
|
const nodeDisabledTitle = computed(() => {
|
||||||
return isDisabled.value ? i18n.baseText('node.enable') : i18n.baseText('node.disable');
|
return isDisabled.value ? i18n.baseText('node.enable') : i18n.baseText('node.disable');
|
||||||
@@ -51,7 +49,7 @@ const isExecuteNodeVisible = computed(() => {
|
|||||||
!props.readOnly &&
|
!props.readOnly &&
|
||||||
render.value.type === CanvasNodeRenderType.Default &&
|
render.value.type === CanvasNodeRenderType.Default &&
|
||||||
'configuration' in render.value.options &&
|
'configuration' in render.value.options &&
|
||||||
(!render.value.options.configuration || isNodesAsToolNode.value)
|
(!render.value.options.configuration || isToolNode.value)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { useSettingsStore } from '@/stores/settings.store';
|
|||||||
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||||
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
|
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
|
||||||
import { waitFor } from '@testing-library/vue';
|
import { waitFor } from '@testing-library/vue';
|
||||||
import { useParameterOverridesStore } from '@/stores/parameterOverrides.store';
|
import { useAgentRequestStore } from '@/stores/agentRequest.store';
|
||||||
|
|
||||||
vi.mock('@/stores/workflows.store', () => {
|
vi.mock('@/stores/workflows.store', () => {
|
||||||
const storeState: Partial<ReturnType<typeof useWorkflowsStore>> & {
|
const storeState: Partial<ReturnType<typeof useWorkflowsStore>> & {
|
||||||
@@ -66,12 +66,12 @@ vi.mock('@/stores/workflows.store', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('@/stores/parameterOverrides.store', () => {
|
vi.mock('@/stores/parameterOverrides.store', () => {
|
||||||
const storeState: Partial<ReturnType<typeof useParameterOverridesStore>> & {} = {
|
const storeState: Partial<ReturnType<typeof useAgentRequestStore>> & {} = {
|
||||||
parameterOverrides: {},
|
agentRequests: {},
|
||||||
substituteParameters: vi.fn(),
|
generateAgentRequest: vi.fn(),
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
useParameterOverridesStore: vi.fn().mockReturnValue(storeState),
|
useAgentRequestStore: vi.fn().mockReturnValue(storeState),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ describe('useRunWorkflow({ router })', () => {
|
|||||||
let router: ReturnType<typeof useRouter>;
|
let router: ReturnType<typeof useRouter>;
|
||||||
let workflowHelpers: ReturnType<typeof useWorkflowHelpers>;
|
let workflowHelpers: ReturnType<typeof useWorkflowHelpers>;
|
||||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||||
let parameterOverridesStore: ReturnType<typeof useParameterOverridesStore>;
|
let agentRequestStore: ReturnType<typeof useAgentRequestStore>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const pinia = createTestingPinia({ stubActions: false });
|
const pinia = createTestingPinia({ stubActions: false });
|
||||||
@@ -149,7 +149,7 @@ describe('useRunWorkflow({ router })', () => {
|
|||||||
uiStore = useUIStore();
|
uiStore = useUIStore();
|
||||||
workflowsStore = useWorkflowsStore();
|
workflowsStore = useWorkflowsStore();
|
||||||
settingsStore = useSettingsStore();
|
settingsStore = useSettingsStore();
|
||||||
parameterOverridesStore = useParameterOverridesStore();
|
agentRequestStore = useAgentRequestStore();
|
||||||
|
|
||||||
router = useRouter();
|
router = useRouter();
|
||||||
workflowHelpers = useWorkflowHelpers({ router });
|
workflowHelpers = useWorkflowHelpers({ router });
|
||||||
@@ -512,12 +512,16 @@ describe('useRunWorkflow({ router })', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does substituteParameters on partial execution if `partialExecutionVersion` is set to 2', async () => {
|
it('sends agentRequest on partial execution if `partialExecutionVersion` is set to 2', async () => {
|
||||||
// ARRANGE
|
// ARRANGE
|
||||||
const mockExecutionResponse = { executionId: '123' };
|
const mockExecutionResponse = { executionId: '123' };
|
||||||
const mockRunData = { nodeName: [] };
|
const mockRunData = { nodeName: [] };
|
||||||
const { runWorkflow } = useRunWorkflow({ router });
|
const { runWorkflow } = useRunWorkflow({ router });
|
||||||
const dataCaptor = captor();
|
const dataCaptor = captor();
|
||||||
|
const agentRequest = {
|
||||||
|
query: 'query',
|
||||||
|
toolName: 'tool',
|
||||||
|
};
|
||||||
|
|
||||||
const workflow = mock<Workflow>({
|
const workflow = mock<Workflow>({
|
||||||
name: 'Test Workflow',
|
name: 'Test Workflow',
|
||||||
@@ -559,16 +563,31 @@ describe('useRunWorkflow({ router })', () => {
|
|||||||
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(workflow);
|
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(workflow);
|
||||||
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue(workflowData);
|
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue(workflowData);
|
||||||
vi.mocked(workflowsStore).getWorkflowRunData = mockRunData;
|
vi.mocked(workflowsStore).getWorkflowRunData = mockRunData;
|
||||||
|
vi.mocked(agentRequestStore).generateAgentRequest.mockReturnValue(agentRequest);
|
||||||
// ACT
|
// ACT
|
||||||
const result = await runWorkflow({ destinationNode: 'Test node' });
|
const result = await runWorkflow({ destinationNode: 'Test node' });
|
||||||
|
|
||||||
// ASSERT
|
// ASSERT
|
||||||
expect(parameterOverridesStore.substituteParameters).toHaveBeenCalledWith(
|
expect(agentRequestStore.generateAgentRequest).toHaveBeenCalledWith('WorkflowId', 'Test id');
|
||||||
'WorkflowId',
|
expect(workflowsStore.runWorkflow).toHaveBeenCalledWith({
|
||||||
'Test id',
|
agentRequest: {
|
||||||
{ param: '0' },
|
query: 'query',
|
||||||
);
|
tool: {
|
||||||
|
name: 'tool',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
destinationNode: 'Test node',
|
||||||
|
dirtyNodeNames: undefined,
|
||||||
|
runData: mockRunData,
|
||||||
|
startNodes: [
|
||||||
|
{
|
||||||
|
name: 'Test node',
|
||||||
|
sourceData: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
triggerToStartFrom: undefined,
|
||||||
|
workflowData,
|
||||||
|
});
|
||||||
expect(result).toEqual(mockExecutionResponse);
|
expect(result).toEqual(mockExecutionResponse);
|
||||||
expect(workflowsStore.setWorkflowExecutionData).toHaveBeenCalledTimes(1);
|
expect(workflowsStore.setWorkflowExecutionData).toHaveBeenCalledTimes(1);
|
||||||
expect(workflowsStore.setWorkflowExecutionData).toHaveBeenCalledWith(dataCaptor);
|
expect(workflowsStore.setWorkflowExecutionData).toHaveBeenCalledWith(dataCaptor);
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import { useTelemetry } from './useTelemetry';
|
|||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||||
import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
|
import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
|
||||||
import { useParameterOverridesStore } from '@/stores/parameterOverrides.store';
|
import { useAgentRequestStore } from '@/stores/agentRequest.store';
|
||||||
|
|
||||||
export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof useRouter> }) {
|
export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof useRouter> }) {
|
||||||
const nodeHelpers = useNodeHelpers();
|
const nodeHelpers = useNodeHelpers();
|
||||||
@@ -52,7 +52,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
|||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
const externalHooks = useExternalHooks();
|
const externalHooks = useExternalHooks();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const parameterOverridesStore = useParameterOverridesStore();
|
const agentRequestStore = useAgentRequestStore();
|
||||||
|
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
const pushConnectionStore = usePushConnectionStore();
|
const pushConnectionStore = usePushConnectionStore();
|
||||||
@@ -295,14 +295,16 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
|||||||
if ('destinationNode' in options) {
|
if ('destinationNode' in options) {
|
||||||
startRunData.destinationNode = options.destinationNode;
|
startRunData.destinationNode = options.destinationNode;
|
||||||
const nodeId = workflowsStore.getNodeByName(options.destinationNode as string)?.id;
|
const nodeId = workflowsStore.getNodeByName(options.destinationNode as string)?.id;
|
||||||
if (nodeId && version === 2) {
|
if (workflow.id && nodeId && version === 2) {
|
||||||
const node = workflowData.nodes.find((nodeData) => nodeData.id === nodeId);
|
const agentRequest = agentRequestStore.generateAgentRequest(workflow.id, nodeId);
|
||||||
if (node?.parameters) {
|
|
||||||
node.parameters = parameterOverridesStore.substituteParameters(
|
if (agentRequest) {
|
||||||
workflow.id,
|
startRunData.agentRequest = {
|
||||||
nodeId,
|
query: agentRequest.query ?? {},
|
||||||
node?.parameters,
|
tool: {
|
||||||
);
|
name: agentRequest.toolName ?? '',
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ export const CALENDLY_TRIGGER_NODE_TYPE = 'n8n-nodes-base.calendlyTrigger';
|
|||||||
export const CODE_NODE_TYPE = 'n8n-nodes-base.code';
|
export const CODE_NODE_TYPE = 'n8n-nodes-base.code';
|
||||||
export const AI_CODE_NODE_TYPE = '@n8n/n8n-nodes-langchain.code';
|
export const AI_CODE_NODE_TYPE = '@n8n/n8n-nodes-langchain.code';
|
||||||
export const AI_MCP_TOOL_NODE_TYPE = '@n8n/n8n-nodes-langchain.mcpClientTool';
|
export const AI_MCP_TOOL_NODE_TYPE = '@n8n/n8n-nodes-langchain.mcpClientTool';
|
||||||
|
export const WIKIPEDIA_TOOL_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolWikipedia';
|
||||||
export const CRON_NODE_TYPE = 'n8n-nodes-base.cron';
|
export const CRON_NODE_TYPE = 'n8n-nodes-base.cron';
|
||||||
export const CLEARBIT_NODE_TYPE = 'n8n-nodes-base.clearbit';
|
export const CLEARBIT_NODE_TYPE = 'n8n-nodes-base.clearbit';
|
||||||
export const FILTER_NODE_TYPE = 'n8n-nodes-base.filter';
|
export const FILTER_NODE_TYPE = 'n8n-nodes-base.filter';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { setActivePinia, createPinia } from 'pinia';
|
import { setActivePinia, createPinia } from 'pinia';
|
||||||
import { useParameterOverridesStore } from './parameterOverrides.store';
|
import { useAgentRequestStore } from './agentRequest.store';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { nextTick } from 'vue';
|
import { nextTick } from 'vue';
|
||||||
|
|
||||||
@@ -26,8 +26,8 @@ describe('parameterOverrides.store', () => {
|
|||||||
describe('Initialization', () => {
|
describe('Initialization', () => {
|
||||||
it('initializes with empty state when localStorage is empty', () => {
|
it('initializes with empty state when localStorage is empty', () => {
|
||||||
localStorageMock.getItem.mockReturnValue(null);
|
localStorageMock.getItem.mockReturnValue(null);
|
||||||
const store = useParameterOverridesStore();
|
const store = useAgentRequestStore();
|
||||||
expect(store.parameterOverrides).toEqual({});
|
expect(store.agentRequests).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('initializes with data from localStorage', () => {
|
it('initializes with data from localStorage', () => {
|
||||||
@@ -37,16 +37,16 @@ describe('parameterOverrides.store', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
||||||
const store = useParameterOverridesStore();
|
const store = useAgentRequestStore();
|
||||||
expect(store.parameterOverrides).toEqual(mockData);
|
expect(store.agentRequests).toEqual(mockData);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles localStorage errors gracefully', () => {
|
it('handles localStorage errors gracefully', () => {
|
||||||
localStorageMock.getItem.mockImplementation(() => {
|
localStorageMock.getItem.mockImplementation(() => {
|
||||||
throw new Error('Storage error');
|
throw new Error('Storage error');
|
||||||
});
|
});
|
||||||
const store = useParameterOverridesStore();
|
const store = useAgentRequestStore();
|
||||||
expect(store.parameterOverrides).toEqual({});
|
expect(store.agentRequests).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,16 +58,16 @@ describe('parameterOverrides.store', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
||||||
const store = useParameterOverridesStore();
|
const store = useAgentRequestStore();
|
||||||
|
|
||||||
const overrides = store.getParameterOverrides('workflow-1', 'node-1');
|
const overrides = store.getAgentRequests('workflow-1', 'node-1');
|
||||||
expect(overrides).toEqual({ param1: 'value1', param2: 'value2' });
|
expect(overrides).toEqual({ param1: 'value1', param2: 'value2' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty object for non-existent workflow/node', () => {
|
it('returns empty object for non-existent workflow/node', () => {
|
||||||
const store = useParameterOverridesStore();
|
const store = useAgentRequestStore();
|
||||||
|
|
||||||
const overrides = store.getParameterOverrides('non-existent', 'node-1');
|
const overrides = store.getAgentRequests('non-existent', 'node-1');
|
||||||
expect(overrides).toEqual({});
|
expect(overrides).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,9 +78,9 @@ describe('parameterOverrides.store', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
||||||
const store = useParameterOverridesStore();
|
const store = useAgentRequestStore();
|
||||||
|
|
||||||
const override = store.getParameterOverride('workflow-1', 'node-1', 'param1');
|
const override = store.getAgentRequest('workflow-1', 'node-1', 'param1');
|
||||||
expect(override).toBe('value1');
|
expect(override).toBe('value1');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -91,31 +91,31 @@ describe('parameterOverrides.store', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
||||||
const store = useParameterOverridesStore();
|
const store = useAgentRequestStore();
|
||||||
|
|
||||||
const override = store.getParameterOverride('workflow-1', 'node-1', 'non-existent');
|
const override = store.getAgentRequest('workflow-1', 'node-1', 'non-existent');
|
||||||
expect(override).toBeUndefined();
|
expect(override).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Actions', () => {
|
describe('Actions', () => {
|
||||||
it('adds a parameter override', () => {
|
it('adds a parameter override', () => {
|
||||||
const store = useParameterOverridesStore();
|
const store = useAgentRequestStore();
|
||||||
|
|
||||||
store.addParameterOverride('workflow-1', 'node-1', 'param1', 'value1');
|
store.addAgentRequest('workflow-1', 'node-1', 'param1', 'value1');
|
||||||
|
|
||||||
expect(store.parameterOverrides['workflow-1']['node-1']['param1']).toBe('value1');
|
expect(store.agentRequests['workflow-1']['node-1']['param1']).toBe('value1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds multiple parameter overrides', () => {
|
it('adds multiple parameter overrides', () => {
|
||||||
const store = useParameterOverridesStore();
|
const store = useAgentRequestStore();
|
||||||
|
|
||||||
store.addParameterOverrides('workflow-1', 'node-1', {
|
store.addAgentRequests('workflow-1', 'node-1', {
|
||||||
param1: 'value1',
|
param1: 'value1',
|
||||||
param2: 'value2',
|
param2: 'value2',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(store.parameterOverrides['workflow-1']['node-1']).toEqual({
|
expect(store.agentRequests['workflow-1']['node-1']).toEqual({
|
||||||
param1: 'value1',
|
param1: 'value1',
|
||||||
param2: 'value2',
|
param2: 'value2',
|
||||||
});
|
});
|
||||||
@@ -129,12 +129,12 @@ describe('parameterOverrides.store', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
||||||
const store = useParameterOverridesStore();
|
const store = useAgentRequestStore();
|
||||||
|
|
||||||
store.clearParameterOverrides('workflow-1', 'node-1');
|
store.clearAgentRequests('workflow-1', 'node-1');
|
||||||
|
|
||||||
expect(store.parameterOverrides['workflow-1']['node-1']).toEqual({});
|
expect(store.agentRequests['workflow-1']['node-1']).toEqual({});
|
||||||
expect(store.parameterOverrides['workflow-1']['node-2']).toEqual({ param3: 'value3' });
|
expect(store.agentRequests['workflow-1']['node-2']).toEqual({ param3: 'value3' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears all parameter overrides for a workflow', () => {
|
it('clears all parameter overrides for a workflow', () => {
|
||||||
@@ -148,12 +148,12 @@ describe('parameterOverrides.store', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
||||||
const store = useParameterOverridesStore();
|
const store = useAgentRequestStore();
|
||||||
|
|
||||||
store.clearAllParameterOverrides('workflow-1');
|
store.clearAllAgentRequests('workflow-1');
|
||||||
|
|
||||||
expect(store.parameterOverrides['workflow-1']).toEqual({});
|
expect(store.agentRequests['workflow-1']).toEqual({});
|
||||||
expect(store.parameterOverrides['workflow-2']).toEqual({
|
expect(store.agentRequests['workflow-2']).toEqual({
|
||||||
'node-3': { param3: 'value3' },
|
'node-3': { param3: 'value3' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -168,43 +168,26 @@ describe('parameterOverrides.store', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
|
||||||
const store = useParameterOverridesStore();
|
const store = useAgentRequestStore();
|
||||||
|
|
||||||
store.clearAllParameterOverrides();
|
store.clearAllAgentRequests();
|
||||||
|
|
||||||
expect(store.parameterOverrides).toEqual({});
|
expect(store.agentRequests).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('substituteParameters', () => {
|
describe('generateAgentRequest', () => {
|
||||||
it('substitutes parameters in a node', () => {
|
it('generateAgentRequest', () => {
|
||||||
const store = useParameterOverridesStore();
|
const store = useAgentRequestStore();
|
||||||
|
|
||||||
store.addParameterOverrides('workflow-1', 'id1', {
|
store.addAgentRequests('workflow-1', 'id1', {
|
||||||
param1: 'override1',
|
param1: 'override1',
|
||||||
'parent.child': 'override2',
|
'parent.child': 'override2',
|
||||||
'parent.array[0].value': 'overrideArray1',
|
'parent.array[0].value': 'overrideArray1',
|
||||||
'parent.array[1].value': 'overrideArray2',
|
'parent.array[1].value': 'overrideArray2',
|
||||||
});
|
});
|
||||||
|
|
||||||
const nodeParameters = {
|
const result = store.generateAgentRequest('workflow-1', 'id1');
|
||||||
param1: 'original1',
|
|
||||||
parent: {
|
|
||||||
child: 'original2',
|
|
||||||
array: [
|
|
||||||
{
|
|
||||||
name: 'name',
|
|
||||||
value: 'original1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'name2',
|
|
||||||
value: 'original2',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = store.substituteParameters('workflow-1', 'id1', nodeParameters);
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
param1: 'override1',
|
param1: 'override1',
|
||||||
@@ -212,11 +195,9 @@ describe('parameterOverrides.store', () => {
|
|||||||
child: 'override2',
|
child: 'override2',
|
||||||
array: [
|
array: [
|
||||||
{
|
{
|
||||||
name: 'name',
|
|
||||||
value: 'overrideArray1',
|
value: 'overrideArray1',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'name2',
|
|
||||||
value: 'overrideArray2',
|
value: 'overrideArray2',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -227,17 +208,17 @@ describe('parameterOverrides.store', () => {
|
|||||||
|
|
||||||
describe('Persistence', () => {
|
describe('Persistence', () => {
|
||||||
it('saves to localStorage when state changes', async () => {
|
it('saves to localStorage when state changes', async () => {
|
||||||
const store = useParameterOverridesStore();
|
const store = useAgentRequestStore();
|
||||||
|
|
||||||
localStorageMock.setItem.mockReset();
|
localStorageMock.setItem.mockReset();
|
||||||
|
|
||||||
store.addParameterOverride('workflow-1', 'node-1', 'param1', 'value1');
|
store.addAgentRequest('workflow-1', 'node-1', 'param1', 'value1');
|
||||||
|
|
||||||
// Wait for the next tick to allow the watch to execute
|
// Wait for the next tick to allow the watch to execute
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||||
'n8n-parameter-overrides',
|
'n8n-agent-requests',
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
'workflow-1': {
|
'workflow-1': {
|
||||||
'node-1': { param1: 'value1' },
|
'node-1': { param1: 'value1' },
|
||||||
@@ -247,7 +228,7 @@ describe('parameterOverrides.store', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle localStorage errors when saving', async () => {
|
it('should handle localStorage errors when saving', async () => {
|
||||||
const store = useParameterOverridesStore();
|
const store = useAgentRequestStore();
|
||||||
|
|
||||||
localStorageMock.setItem.mockReset();
|
localStorageMock.setItem.mockReset();
|
||||||
|
|
||||||
@@ -255,11 +236,11 @@ describe('parameterOverrides.store', () => {
|
|||||||
throw new Error('Storage error');
|
throw new Error('Storage error');
|
||||||
});
|
});
|
||||||
|
|
||||||
store.addParameterOverride('workflow-1', 'node-1', 'param1', 'value1');
|
store.addAgentRequest('workflow-1', 'node-1', 'param1', 'value1');
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
expect(store.parameterOverrides['workflow-1']['node-1'].param1).toBe('value1');
|
expect(store.agentRequests['workflow-1']['node-1'].param1).toBe('value1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2,20 +2,20 @@ import { type INodeParameters, type NodeParameterValueType } from 'n8n-workflow'
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
interface IParameterOverridesStoreState {
|
interface IAgentRequestStoreState {
|
||||||
[workflowId: string]: {
|
[workflowId: string]: {
|
||||||
[nodeName: string]: INodeParameters;
|
[nodeName: string]: INodeParameters;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'n8n-parameter-overrides';
|
const STORAGE_KEY = 'n8n-agent-requests';
|
||||||
|
|
||||||
export const useParameterOverridesStore = defineStore('parameterOverrides', () => {
|
export const useAgentRequestStore = defineStore('agentRequest', () => {
|
||||||
// State
|
// State
|
||||||
const parameterOverrides = ref<IParameterOverridesStoreState>(loadFromLocalStorage());
|
const agentRequests = ref<IAgentRequestStoreState>(loadFromLocalStorage());
|
||||||
|
|
||||||
// Load initial state from localStorage
|
// Load initial state from localStorage
|
||||||
function loadFromLocalStorage(): IParameterOverridesStoreState {
|
function loadFromLocalStorage(): IAgentRequestStoreState {
|
||||||
try {
|
try {
|
||||||
const storedData = localStorage.getItem(STORAGE_KEY);
|
const storedData = localStorage.getItem(STORAGE_KEY);
|
||||||
return storedData ? JSON.parse(storedData) : {};
|
return storedData ? JSON.parse(storedData) : {};
|
||||||
@@ -26,12 +26,12 @@ export const useParameterOverridesStore = defineStore('parameterOverrides', () =
|
|||||||
|
|
||||||
// Save state to localStorage whenever it changes
|
// Save state to localStorage whenever it changes
|
||||||
watch(
|
watch(
|
||||||
parameterOverrides,
|
agentRequests,
|
||||||
(newValue) => {
|
(newValue) => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newValue));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(newValue));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save parameter overrides to localStorage:', error);
|
console.error('Failed to save agent requests to localStorage:', error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
@@ -39,30 +39,30 @@ export const useParameterOverridesStore = defineStore('parameterOverrides', () =
|
|||||||
|
|
||||||
// Helper function to ensure workflow and node entries exist
|
// Helper function to ensure workflow and node entries exist
|
||||||
const ensureWorkflowAndNodeExist = (workflowId: string, nodeId: string): void => {
|
const ensureWorkflowAndNodeExist = (workflowId: string, nodeId: string): void => {
|
||||||
if (!parameterOverrides.value[workflowId]) {
|
if (!agentRequests.value[workflowId]) {
|
||||||
parameterOverrides.value[workflowId] = {};
|
agentRequests.value[workflowId] = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parameterOverrides.value[workflowId][nodeId]) {
|
if (!agentRequests.value[workflowId][nodeId]) {
|
||||||
parameterOverrides.value[workflowId][nodeId] = {};
|
agentRequests.value[workflowId][nodeId] = {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
const getParameterOverrides = (workflowId: string, nodeId: string): INodeParameters => {
|
const getAgentRequests = (workflowId: string, nodeId: string): INodeParameters => {
|
||||||
return parameterOverrides.value[workflowId]?.[nodeId] || {};
|
return agentRequests.value[workflowId]?.[nodeId] || {};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getParameterOverride = (
|
const getAgentRequest = (
|
||||||
workflowId: string,
|
workflowId: string,
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
paramName: string,
|
paramName: string,
|
||||||
): NodeParameterValueType | undefined => {
|
): NodeParameterValueType | undefined => {
|
||||||
return parameterOverrides.value[workflowId]?.[nodeId]?.[paramName];
|
return agentRequests.value[workflowId]?.[nodeId]?.[paramName];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
const addParameterOverride = (
|
const addAgentRequest = (
|
||||||
workflowId: string,
|
workflowId: string,
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
paramName: string,
|
paramName: string,
|
||||||
@@ -70,40 +70,36 @@ export const useParameterOverridesStore = defineStore('parameterOverrides', () =
|
|||||||
): INodeParameters => {
|
): INodeParameters => {
|
||||||
ensureWorkflowAndNodeExist(workflowId, nodeId);
|
ensureWorkflowAndNodeExist(workflowId, nodeId);
|
||||||
|
|
||||||
parameterOverrides.value[workflowId][nodeId] = {
|
agentRequests.value[workflowId][nodeId] = {
|
||||||
...parameterOverrides.value[workflowId][nodeId],
|
...agentRequests.value[workflowId][nodeId],
|
||||||
[paramName]: paramValues,
|
[paramName]: paramValues,
|
||||||
};
|
};
|
||||||
|
|
||||||
return parameterOverrides.value[workflowId][nodeId];
|
return agentRequests.value[workflowId][nodeId];
|
||||||
};
|
};
|
||||||
|
|
||||||
const addParameterOverrides = (
|
const addAgentRequests = (workflowId: string, nodeId: string, params: INodeParameters): void => {
|
||||||
workflowId: string,
|
|
||||||
nodeId: string,
|
|
||||||
params: INodeParameters,
|
|
||||||
): void => {
|
|
||||||
ensureWorkflowAndNodeExist(workflowId, nodeId);
|
ensureWorkflowAndNodeExist(workflowId, nodeId);
|
||||||
|
|
||||||
parameterOverrides.value[workflowId][nodeId] = {
|
agentRequests.value[workflowId][nodeId] = {
|
||||||
...parameterOverrides.value[workflowId][nodeId],
|
...agentRequests.value[workflowId][nodeId],
|
||||||
...params,
|
...params,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearParameterOverrides = (workflowId: string, nodeId: string): void => {
|
const clearAgentRequests = (workflowId: string, nodeId: string): void => {
|
||||||
if (parameterOverrides.value[workflowId]) {
|
if (agentRequests.value[workflowId]) {
|
||||||
parameterOverrides.value[workflowId][nodeId] = {};
|
agentRequests.value[workflowId][nodeId] = {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearAllParameterOverrides = (workflowId?: string): void => {
|
const clearAllAgentRequests = (workflowId?: string): void => {
|
||||||
if (workflowId) {
|
if (workflowId) {
|
||||||
// Clear overrides for a specific workflow
|
// Clear requests for a specific workflow
|
||||||
parameterOverrides.value[workflowId] = {};
|
agentRequests.value[workflowId] = {};
|
||||||
} else {
|
} else {
|
||||||
// Clear all overrides
|
// Clear all requests
|
||||||
parameterOverrides.value = {};
|
agentRequests.value = {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,7 +116,7 @@ export const useParameterOverridesStore = defineStore('parameterOverrides', () =
|
|||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOverrideObject(path: string[], value: NodeParameterValueType): INodeParameters {
|
function buildRequestObject(path: string[], value: NodeParameterValueType): INodeParameters {
|
||||||
const result: INodeParameters = {};
|
const result: INodeParameters = {};
|
||||||
let current = result;
|
let current = result;
|
||||||
|
|
||||||
@@ -192,31 +188,23 @@ export const useParameterOverridesStore = defineStore('parameterOverrides', () =
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const substituteParameters = (
|
const generateAgentRequest = (workflowId: string, nodeId: string): INodeParameters => {
|
||||||
workflowId: string,
|
const nodeRequests = agentRequests.value[workflowId]?.[nodeId] || {};
|
||||||
nodeId: string,
|
|
||||||
nodeParameters: INodeParameters,
|
|
||||||
): INodeParameters => {
|
|
||||||
if (!nodeParameters) return {};
|
|
||||||
|
|
||||||
const nodeOverrides = parameterOverrides.value[workflowId]?.[nodeId] || {};
|
return Object.entries(nodeRequests).reduce(
|
||||||
|
(acc, [path, value]) => deepMerge(acc, buildRequestObject(parsePath(path), value)),
|
||||||
const overrideParams = Object.entries(nodeOverrides).reduce(
|
|
||||||
(acc, [path, value]) => deepMerge(acc, buildOverrideObject(parsePath(path), value)),
|
|
||||||
{} as INodeParameters,
|
{} as INodeParameters,
|
||||||
);
|
);
|
||||||
|
|
||||||
return deepMerge(nodeParameters, overrideParams);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
parameterOverrides,
|
agentRequests,
|
||||||
getParameterOverrides,
|
getAgentRequests,
|
||||||
getParameterOverride,
|
getAgentRequest,
|
||||||
addParameterOverride,
|
addAgentRequest,
|
||||||
addParameterOverrides,
|
addAgentRequests,
|
||||||
clearParameterOverrides,
|
clearAgentRequests,
|
||||||
clearAllParameterOverrides,
|
clearAllAgentRequests,
|
||||||
substituteParameters,
|
generateAgentRequest,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -135,14 +135,19 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const isNodesAsToolNode = computed(() => {
|
const isToolNode = computed(() => {
|
||||||
return (nodeTypeName: string) => {
|
return (nodeTypeName: string) => {
|
||||||
const nodeType = getNodeType.value(nodeTypeName);
|
const nodeType = getNodeType.value(nodeTypeName);
|
||||||
return !!(
|
if (nodeType?.outputs && Array.isArray(nodeType.outputs)) {
|
||||||
nodeType &&
|
const outputTypes = nodeType.outputs.map(
|
||||||
nodeType.outputs.includes(NodeConnectionTypes.AiTool) &&
|
(output: NodeConnectionType | INodeOutputConfiguration) =>
|
||||||
nodeType.usableAsTool
|
typeof output === 'string' ? output : output.type,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return outputTypes.includes(NodeConnectionTypes.AiTool);
|
||||||
|
} else {
|
||||||
|
return nodeType?.outputs.includes(NodeConnectionTypes.AiTool);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -388,7 +393,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
|
|||||||
getCredentialOnlyNodeType,
|
getCredentialOnlyNodeType,
|
||||||
isConfigNode,
|
isConfigNode,
|
||||||
isTriggerNode,
|
isTriggerNode,
|
||||||
isNodesAsToolNode,
|
isToolNode,
|
||||||
isCoreNodeType,
|
isCoreNodeType,
|
||||||
visibleNodeTypes,
|
visibleNodeTypes,
|
||||||
nativelyNumberSuffixedDefaults,
|
nativelyNumberSuffixedDefaults,
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
AI_CODE_TOOL_LANGCHAIN_NODE_TYPE,
|
||||||
|
AI_MCP_TOOL_NODE_TYPE,
|
||||||
|
WIKIPEDIA_TOOL_NODE_TYPE,
|
||||||
|
} from '@/constants';
|
||||||
import type { INodeUi } from '@/Interface';
|
import type { INodeUi } from '@/Interface';
|
||||||
import type { NodeTypeProvider } from '@/utils/nodeTypes/nodeTypeTransforms';
|
import type { NodeTypeProvider } from '@/utils/nodeTypes/nodeTypeTransforms';
|
||||||
import type { INodeCredentialDescription, FromAIArgument } from 'n8n-workflow';
|
import type { INodeCredentialDescription, FromAIArgument } from 'n8n-workflow';
|
||||||
@@ -79,10 +84,19 @@ export function doesNodeHaveAllCredentialsFilled(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the given node has any fromAi expressions in its parameters.
|
* Checks if the given node needs agentInput
|
||||||
*/
|
*/
|
||||||
export function hasFromAiExpressions(node: Pick<INodeUi, 'parameters'>) {
|
export function needsAgentInput(node: Pick<INodeUi, 'parameters' | 'type'>) {
|
||||||
|
const nodeTypesNeedModal = [
|
||||||
|
WIKIPEDIA_TOOL_NODE_TYPE,
|
||||||
|
AI_MCP_TOOL_NODE_TYPE,
|
||||||
|
AI_CODE_TOOL_LANGCHAIN_NODE_TYPE,
|
||||||
|
];
|
||||||
const collectedArgs: FromAIArgument[] = [];
|
const collectedArgs: FromAIArgument[] = [];
|
||||||
traverseNodeParameters(node.parameters, collectedArgs);
|
traverseNodeParameters(node.parameters, collectedArgs);
|
||||||
return collectedArgs.length > 0;
|
return (
|
||||||
|
collectedArgs.length > 0 ||
|
||||||
|
nodeTypesNeedModal.includes(node.type) ||
|
||||||
|
(node.type.includes('vectorStore') && node.parameters?.mode === 'retrieve-as-tool')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,8 +118,8 @@ import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
|
|||||||
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
|
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
|
||||||
import { useBuilderStore } from '@/stores/builder.store';
|
import { useBuilderStore } from '@/stores/builder.store';
|
||||||
import { useFoldersStore } from '@/stores/folders.store';
|
import { useFoldersStore } from '@/stores/folders.store';
|
||||||
import { useParameterOverridesStore } from '@/stores/parameterOverrides.store';
|
import { useAgentRequestStore } from '@/stores/agentRequest.store';
|
||||||
import { hasFromAiExpressions } from '@/utils/nodes/nodeTransforms';
|
import { needsAgentInput } from '@/utils/nodes/nodeTransforms';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'NodeView',
|
name: 'NodeView',
|
||||||
@@ -172,7 +172,7 @@ const ndvStore = useNDVStore();
|
|||||||
const templatesStore = useTemplatesStore();
|
const templatesStore = useTemplatesStore();
|
||||||
const builderStore = useBuilderStore();
|
const builderStore = useBuilderStore();
|
||||||
const foldersStore = useFoldersStore();
|
const foldersStore = useFoldersStore();
|
||||||
const parameterOverridesStore = useParameterOverridesStore();
|
const agentRequestStore = useAgentRequestStore();
|
||||||
|
|
||||||
const canvasEventBus = createEventBus<CanvasEventBusEvents>();
|
const canvasEventBus = createEventBus<CanvasEventBusEvents>();
|
||||||
|
|
||||||
@@ -1166,7 +1166,7 @@ async function onRunWorkflowToNode(id: string) {
|
|||||||
const node = workflowsStore.getNodeById(id);
|
const node = workflowsStore.getNodeById(id);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
|
|
||||||
if (hasFromAiExpressions(node) && nodeTypesStore.isNodesAsToolNode(node.type)) {
|
if (needsAgentInput(node) && nodeTypesStore.isToolNode(node.type)) {
|
||||||
uiStore.openModalWithData({
|
uiStore.openModalWithData({
|
||||||
name: FROM_AI_PARAMETERS_MODAL_KEY,
|
name: FROM_AI_PARAMETERS_MODAL_KEY,
|
||||||
data: {
|
data: {
|
||||||
@@ -1175,7 +1175,7 @@ async function onRunWorkflowToNode(id: string) {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
trackRunWorkflowToNode(node);
|
trackRunWorkflowToNode(node);
|
||||||
parameterOverridesStore.clearParameterOverrides(workflowsStore.workflowId, node.id);
|
agentRequestStore.clearAgentRequests(workflowsStore.workflowId, node.id);
|
||||||
|
|
||||||
void runWorkflow({ destinationNode: node.name, source: 'Node.executeNode' });
|
void runWorkflow({ destinationNode: node.name, source: 'Node.executeNode' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1492,6 +1492,7 @@ export interface INodePropertyOptions {
|
|||||||
description?: string;
|
description?: string;
|
||||||
routing?: INodePropertyRouting;
|
routing?: INodePropertyRouting;
|
||||||
outputConnectionType?: NodeConnectionType;
|
outputConnectionType?: NodeConnectionType;
|
||||||
|
inputSchema?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface INodeListSearchItems extends INodePropertyOptions {
|
export interface INodeListSearchItems extends INodePropertyOptions {
|
||||||
@@ -2309,6 +2310,7 @@ export interface IWorkflowExecutionDataProcess {
|
|||||||
name: string;
|
name: string;
|
||||||
data?: ITaskData;
|
data?: ITaskData;
|
||||||
};
|
};
|
||||||
|
agentRequest?: AiAgentRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExecuteWorkflowOptions {
|
export interface ExecuteWorkflowOptions {
|
||||||
@@ -2348,6 +2350,14 @@ type AiEventPayload = {
|
|||||||
nodeType?: string;
|
nodeType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Used to transport an agent request for partial execution
|
||||||
|
export interface AiAgentRequest {
|
||||||
|
query: string | INodeParameters;
|
||||||
|
tool: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface IWorkflowExecuteAdditionalData {
|
export interface IWorkflowExecuteAdditionalData {
|
||||||
credentialsHelper: ICredentialsHelper;
|
credentialsHelper: ICredentialsHelper;
|
||||||
executeWorkflow: (
|
executeWorkflow: (
|
||||||
|
|||||||
@@ -1558,7 +1558,7 @@ export function isExecutable(workflow: Workflow, node: INode, nodeTypeData: INod
|
|||||||
const outputNames = getConnectionTypes(outputs);
|
const outputNames = getConnectionTypes(outputs);
|
||||||
return (
|
return (
|
||||||
outputNames.includes(NodeConnectionTypes.Main) ||
|
outputNames.includes(NodeConnectionTypes.Main) ||
|
||||||
isTriggerNode(nodeTypeData) ||
|
outputNames.includes(NodeConnectionTypes.AiTool) ||
|
||||||
nodeTypeData.usableAsTool === true
|
isTriggerNode(nodeTypeData)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4323,7 +4323,7 @@ describe('NodeHelpers', () => {
|
|||||||
expected: true,
|
expected: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Should return false for node with only AiTool output and not a trigger',
|
description: 'Should return true for node with only AiTool output and not a trigger',
|
||||||
node: {
|
node: {
|
||||||
id: 'aiToolOutputNodeId',
|
id: 'aiToolOutputNodeId',
|
||||||
name: 'AiToolOutputNode',
|
name: 'AiToolOutputNode',
|
||||||
@@ -4343,10 +4343,10 @@ describe('NodeHelpers', () => {
|
|||||||
outputs: [NodeConnectionTypes.AiTool], // Only AiTool output, no Main
|
outputs: [NodeConnectionTypes.AiTool], // Only AiTool output, no Main
|
||||||
properties: [],
|
properties: [],
|
||||||
},
|
},
|
||||||
expected: false,
|
expected: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'Should return false for node with dynamic outputs set to AiTool only',
|
description: 'Should return true for node with dynamic outputs set to AiTool only',
|
||||||
node: {
|
node: {
|
||||||
id: 'dynamicAiToolNodeId',
|
id: 'dynamicAiToolNodeId',
|
||||||
name: 'DynamicAiToolNode',
|
name: 'DynamicAiToolNode',
|
||||||
@@ -4366,7 +4366,7 @@ describe('NodeHelpers', () => {
|
|||||||
outputs: '={{["ai_tool"]}}', // Dynamic expression that resolves to AiTool only
|
outputs: '={{["ai_tool"]}}', // Dynamic expression that resolves to AiTool only
|
||||||
properties: [],
|
properties: [],
|
||||||
},
|
},
|
||||||
expected: false,
|
expected: true,
|
||||||
mockReturnValue: [NodeConnectionTypes.AiTool],
|
mockReturnValue: [NodeConnectionTypes.AiTool],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user