mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
feat(core): Implement Dynamic Parameters within regular nodes used as AI Tools (#10862)
This commit is contained in:
@@ -359,7 +359,7 @@ const declarativeNodeOptionParameters: INodeProperties = {
|
||||
export function convertNodeToAiTool<
|
||||
T extends object & { description: INodeTypeDescription | INodeTypeBaseDescription },
|
||||
>(item: T): T {
|
||||
// quick helper function for typeguard down below
|
||||
// quick helper function for type-guard down below
|
||||
function isFullDescription(obj: unknown): obj is INodeTypeDescription {
|
||||
return typeof obj === 'object' && obj !== null && 'properties' in obj;
|
||||
}
|
||||
@@ -368,9 +368,33 @@ export function convertNodeToAiTool<
|
||||
item.description.name += 'Tool';
|
||||
item.description.inputs = [];
|
||||
item.description.outputs = [NodeConnectionType.AiTool];
|
||||
item.description.displayName += ' Tool (wrapped)';
|
||||
item.description.displayName += ' Tool';
|
||||
delete item.description.usableAsTool;
|
||||
|
||||
const hasResource = item.description.properties.some((prop) => prop.name === 'resource');
|
||||
const hasOperation = item.description.properties.some((prop) => prop.name === 'operation');
|
||||
|
||||
if (!item.description.properties.map((prop) => prop.name).includes('toolDescription')) {
|
||||
const descriptionType: INodeProperties = {
|
||||
displayName: 'Tool Description',
|
||||
name: 'descriptionType',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Set Automatically',
|
||||
value: 'auto',
|
||||
description: 'Automatically set based on resource and operation',
|
||||
},
|
||||
{
|
||||
name: 'Set Manually',
|
||||
value: 'manual',
|
||||
description: 'Manually set the description',
|
||||
},
|
||||
],
|
||||
default: 'auto',
|
||||
};
|
||||
|
||||
const descProp: INodeProperties = {
|
||||
displayName: 'Description',
|
||||
name: 'toolDescription',
|
||||
@@ -382,7 +406,29 @@ export function convertNodeToAiTool<
|
||||
'Explain to the LLM what this tool does, a good, specific description would allow LLMs to produce expected results much more often',
|
||||
placeholder: `e.g. ${item.description.description}`,
|
||||
};
|
||||
|
||||
const noticeProp: INodeProperties = {
|
||||
displayName: 'Use the expression {{ $fromAI() }} for any data to be filled by the model',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
};
|
||||
|
||||
item.description.properties.unshift(descProp);
|
||||
|
||||
// If node has resource or operation we can determine pre-populate tool description based on it
|
||||
// so we add the descriptionType property as the first property
|
||||
if (hasResource || hasOperation) {
|
||||
item.description.properties.unshift(descriptionType);
|
||||
|
||||
descProp.displayOptions = {
|
||||
show: {
|
||||
descriptionType: ['manual'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
item.description.properties.unshift(noticeProp);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -961,6 +961,43 @@ export class WorkflowDataProxy {
|
||||
return taskData.data!.main[previousNodeOutput]![pairedItem.item];
|
||||
};
|
||||
|
||||
const handleFromAi = (
|
||||
name: string,
|
||||
_description?: string,
|
||||
_type: string = 'string',
|
||||
defaultValue?: unknown,
|
||||
) => {
|
||||
if (!name || name === '') {
|
||||
throw new ExpressionError('Please provide a key', {
|
||||
runIndex: that.runIndex,
|
||||
itemIndex: that.itemIndex,
|
||||
});
|
||||
}
|
||||
const nameValidationRegex = /^[a-zA-Z0-9_-]{0,64}$/;
|
||||
if (!nameValidationRegex.test(name)) {
|
||||
throw new ExpressionError(
|
||||
'Invalid parameter key, must be between 1 and 64 characters long and only contain lowercase letters, uppercase letters, numbers, underscores, and hyphens',
|
||||
{
|
||||
runIndex: that.runIndex,
|
||||
itemIndex: that.itemIndex,
|
||||
},
|
||||
);
|
||||
}
|
||||
const placeholdersDataInputData =
|
||||
that.runExecutionData?.resultData.runData[that.activeNodeName]?.[0].inputOverride?.[
|
||||
NodeConnectionType.AiTool
|
||||
]?.[0]?.[0].json;
|
||||
|
||||
if (Boolean(!placeholdersDataInputData)) {
|
||||
throw new ExpressionError('No execution data available', {
|
||||
runIndex: that.runIndex,
|
||||
itemIndex: that.itemIndex,
|
||||
type: 'no_execution_data',
|
||||
});
|
||||
}
|
||||
return placeholdersDataInputData?.[name] ?? defaultValue;
|
||||
};
|
||||
|
||||
const base = {
|
||||
$: (nodeName: string) => {
|
||||
if (!nodeName) {
|
||||
@@ -1303,6 +1340,10 @@ export class WorkflowDataProxy {
|
||||
);
|
||||
return dataProxy.getDataProxy();
|
||||
},
|
||||
$fromAI: handleFromAi,
|
||||
// Make sure mis-capitalized $fromAI is handled correctly even though we don't auto-complete it
|
||||
$fromai: handleFromAi,
|
||||
$fromAi: handleFromAi,
|
||||
$items: (nodeName?: string, outputIndex?: number, runIndex?: number) => {
|
||||
if (nodeName === undefined) {
|
||||
nodeName = (that.prevNodeGetter() as { name: string }).name;
|
||||
|
||||
@@ -3660,7 +3660,7 @@ describe('NodeHelpers', () => {
|
||||
it('should modify the name and displayName correctly', () => {
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.name).toBe('testNodeTool');
|
||||
expect(result.description.displayName).toBe('Test Node Tool (wrapped)');
|
||||
expect(result.description.displayName).toBe('Test Node Tool');
|
||||
});
|
||||
|
||||
it('should update inputs and outputs', () => {
|
||||
@@ -3685,19 +3685,6 @@ describe('NodeHelpers', () => {
|
||||
expect(toolDescriptionProp?.default).toBe(fullNodeWrapper.description.description);
|
||||
});
|
||||
|
||||
it('should not add toolDescription property if it already exists', () => {
|
||||
const toolDescriptionProp: INodeProperties = {
|
||||
displayName: 'Tool Description',
|
||||
name: 'toolDescription',
|
||||
type: 'string',
|
||||
default: 'Existing description',
|
||||
};
|
||||
fullNodeWrapper.description.properties = [toolDescriptionProp];
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.properties).toHaveLength(1);
|
||||
expect(result.description.properties[0]).toEqual(toolDescriptionProp);
|
||||
});
|
||||
|
||||
it('should set codex categories correctly', () => {
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.codex).toEqual({
|
||||
@@ -3718,8 +3705,102 @@ describe('NodeHelpers', () => {
|
||||
};
|
||||
fullNodeWrapper.description.properties = [existingProp];
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.properties).toHaveLength(2); // Existing prop + toolDescription
|
||||
expect(result.description.properties).toHaveLength(3); // Existing prop + toolDescription + notice
|
||||
expect(result.description.properties).toContainEqual(existingProp);
|
||||
});
|
||||
|
||||
it('should handle nodes with resource property', () => {
|
||||
const resourceProp: INodeProperties = {
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
options: [{ name: 'User', value: 'user' }],
|
||||
default: 'user',
|
||||
};
|
||||
fullNodeWrapper.description.properties = [resourceProp];
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.properties[1].name).toBe('descriptionType');
|
||||
expect(result.description.properties[2].name).toBe('toolDescription');
|
||||
expect(result.description.properties[3]).toEqual(resourceProp);
|
||||
});
|
||||
|
||||
it('should handle nodes with operation property', () => {
|
||||
const operationProp: INodeProperties = {
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
options: [{ name: 'Create', value: 'create' }],
|
||||
default: 'create',
|
||||
};
|
||||
fullNodeWrapper.description.properties = [operationProp];
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.properties[1].name).toBe('descriptionType');
|
||||
expect(result.description.properties[2].name).toBe('toolDescription');
|
||||
expect(result.description.properties[3]).toEqual(operationProp);
|
||||
});
|
||||
|
||||
it('should handle nodes with both resource and operation properties', () => {
|
||||
const resourceProp: INodeProperties = {
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
options: [{ name: 'User', value: 'user' }],
|
||||
default: 'user',
|
||||
};
|
||||
const operationProp: INodeProperties = {
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
options: [{ name: 'Create', value: 'create' }],
|
||||
default: 'create',
|
||||
};
|
||||
fullNodeWrapper.description.properties = [resourceProp, operationProp];
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.properties[1].name).toBe('descriptionType');
|
||||
expect(result.description.properties[2].name).toBe('toolDescription');
|
||||
expect(result.description.properties[3]).toEqual(resourceProp);
|
||||
expect(result.description.properties[4]).toEqual(operationProp);
|
||||
});
|
||||
|
||||
it('should handle nodes with empty properties', () => {
|
||||
fullNodeWrapper.description.properties = [];
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.properties).toHaveLength(2);
|
||||
expect(result.description.properties[1].name).toBe('toolDescription');
|
||||
});
|
||||
|
||||
it('should handle nodes with existing codex property', () => {
|
||||
fullNodeWrapper.description.codex = {
|
||||
categories: ['Existing'],
|
||||
subcategories: {
|
||||
Existing: ['Category'],
|
||||
},
|
||||
};
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.codex).toEqual({
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Tools'],
|
||||
Tools: ['Other Tools'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nodes with very long names', () => {
|
||||
fullNodeWrapper.description.name = 'veryLongNodeNameThatExceedsNormalLimits'.repeat(10);
|
||||
fullNodeWrapper.description.displayName =
|
||||
'Very Long Node Name That Exceeds Normal Limits'.repeat(10);
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.name.endsWith('Tool')).toBe(true);
|
||||
expect(result.description.displayName.endsWith('Tool')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle nodes with special characters in name and displayName', () => {
|
||||
fullNodeWrapper.description.name = 'special@#$%Node';
|
||||
fullNodeWrapper.description.displayName = 'Special @#$% Node';
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.name).toBe('special@#$%NodeTool');
|
||||
expect(result.description.displayName).toBe('Special @#$% Node Tool');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user