diff --git a/cypress/composables/ndv.ts b/cypress/composables/ndv.ts
index 9ec6f3ba19..3ad823d4d5 100644
--- a/cypress/composables/ndv.ts
+++ b/cypress/composables/ndv.ts
@@ -225,7 +225,7 @@ export function populateMapperFields(fields: ReadonlyArray<[string, string]>) {
getParameterInputByName(name).type(value);
// Click on a parent to dismiss the pop up which hides the field below.
- getParameterInputByName(name).parent().parent().parent().click('topLeft');
+ getParameterInputByName(name).parent().parent().parent().parent().click('topLeft');
}
}
diff --git a/cypress/e2e/16-form-trigger-node.cy.ts b/cypress/e2e/16-form-trigger-node.cy.ts
index ed901107ea..033753bc5c 100644
--- a/cypress/e2e/16-form-trigger-node.cy.ts
+++ b/cypress/e2e/16-form-trigger-node.cy.ts
@@ -62,12 +62,14 @@ describe('n8n Form Trigger', () => {
getVisibleSelect().contains('Dropdown').click();
cy.contains('button', 'Add Field Option').click();
cy.contains('label', 'Field Options')
+ .parent()
.parent()
.nextAll()
.find('[data-test-id="parameter-input-field"]')
.eq(0)
.type('Option 1');
cy.contains('label', 'Field Options')
+ .parent()
.parent()
.nextAll()
.find('[data-test-id="parameter-input-field"]')
diff --git a/cypress/e2e/48-subworkflow-inputs.cy.ts b/cypress/e2e/48-subworkflow-inputs.cy.ts
index d529ec2c25..716d253b8c 100644
--- a/cypress/e2e/48-subworkflow-inputs.cy.ts
+++ b/cypress/e2e/48-subworkflow-inputs.cy.ts
@@ -90,8 +90,8 @@ describe('Sub-workflow creation and typed usage', () => {
clickExecuteNode();
const expected = [
- ['-1', 'A String', '0:11:true2:3', 'aKey:-1', '[empty object]', 'false'],
- ['-1', 'Another String', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'false'],
+ ['-1', 'A String', '0:11:true2:3', 'aKey:-1', '[empty object]', 'true'],
+ ['-1', 'Another String', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'true'],
];
assertOutputTableContent(expected);
@@ -110,8 +110,8 @@ describe('Sub-workflow creation and typed usage', () => {
clickExecuteNode();
const expected2 = [
- ['-1', '5', '0:11:true2:3', 'aKey:-1', '[empty object]', 'false'],
- ['-1', '5', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'false'],
+ ['-1', '5', '0:11:true2:3', 'aKey:-1', '[empty object]', 'true'],
+ ['-1', '5', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'true'],
];
assertOutputTableContent(expected2);
@@ -167,8 +167,8 @@ describe('Sub-workflow creation and typed usage', () => {
);
assertOutputTableContent([
- ['[null]', '[null]', '[null]', '[null]', '[null]', 'false'],
- ['[null]', '[null]', '[null]', '[null]', '[null]', 'false'],
+ ['[null]', '[null]', '[null]', '[null]', '[null]', 'true'],
+ ['[null]', '[null]', '[null]', '[null]', '[null]', 'true'],
]);
clickExecuteNode();
diff --git a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts
index c5d88b1b25..ddc55ae25e 100644
--- a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts
+++ b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts
@@ -107,7 +107,7 @@ describe('LoadNodesAndCredentials', () => {
};
fullNodeWrapper.description.properties = [existingProp];
const result = instance.convertNodeToAiTool(fullNodeWrapper);
- expect(result.description.properties).toHaveLength(3); // Existing prop + toolDescription + notice
+ expect(result.description.properties).toHaveLength(2); // Existing prop + toolDescription
expect(result.description.properties).toContainEqual(existingProp);
});
@@ -121,9 +121,9 @@ describe('LoadNodesAndCredentials', () => {
};
fullNodeWrapper.description.properties = [resourceProp];
const result = instance.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[0].name).toBe('descriptionType');
+ expect(result.description.properties[1].name).toBe('toolDescription');
+ expect(result.description.properties[2]).toEqual(resourceProp);
});
it('should handle nodes with operation property', () => {
@@ -136,9 +136,9 @@ describe('LoadNodesAndCredentials', () => {
};
fullNodeWrapper.description.properties = [operationProp];
const result = instance.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);
+ expect(result.description.properties[0].name).toBe('descriptionType');
+ expect(result.description.properties[1].name).toBe('toolDescription');
+ expect(result.description.properties[2]).toEqual(operationProp);
});
it('should handle nodes with both resource and operation properties', () => {
@@ -158,17 +158,17 @@ describe('LoadNodesAndCredentials', () => {
};
fullNodeWrapper.description.properties = [resourceProp, operationProp];
const result = instance.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);
+ expect(result.description.properties[0].name).toBe('descriptionType');
+ expect(result.description.properties[1].name).toBe('toolDescription');
+ expect(result.description.properties[2]).toEqual(resourceProp);
+ expect(result.description.properties[3]).toEqual(operationProp);
});
it('should handle nodes with empty properties', () => {
fullNodeWrapper.description.properties = [];
const result = instance.convertNodeToAiTool(fullNodeWrapper);
- expect(result.description.properties).toHaveLength(2);
- expect(result.description.properties[1].name).toBe('toolDescription');
+ expect(result.description.properties).toHaveLength(1);
+ expect(result.description.properties[0].name).toBe('toolDescription');
});
it('should handle nodes with existing codex property', () => {
diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts
index 8d0f050dee..e33d736c1a 100644
--- a/packages/cli/src/events/relays/telemetry.event-relay.ts
+++ b/packages/cli/src/events/relays/telemetry.event-relay.ts
@@ -701,6 +701,7 @@ export class TelemetryEventRelay extends EventRelay {
sharing_role: userRole,
credential_type: null,
is_managed: false,
+ ...TelemetryHelpers.resolveAIMetrics(workflow.nodes, this.nodeTypes),
};
if (!manualExecEventProperties.node_graph_string) {
diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts
index 4d6493d7ee..dadf44c7ab 100644
--- a/packages/cli/src/load-nodes-and-credentials.ts
+++ b/packages/cli/src/load-nodes-and-credentials.ts
@@ -484,14 +484,6 @@ export class LoadNodesAndCredentials {
placeholder: `e.g. ${item.description.description}`,
};
- const noticeProp: INodeProperties = {
- displayName:
- "Use the expression {{ $fromAI('placeholder_name') }} 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
@@ -505,8 +497,6 @@ export class LoadNodesAndCredentials {
},
};
}
-
- item.description.properties.unshift(noticeProp);
}
}
diff --git a/packages/design-system/src/components/N8nCheckbox/__snapshots__/Checkbox.test.ts.snap b/packages/design-system/src/components/N8nCheckbox/__snapshots__/Checkbox.test.ts.snap
index fa1e46c889..323ba7ae4f 100644
--- a/packages/design-system/src/components/N8nCheckbox/__snapshots__/Checkbox.test.ts.snap
+++ b/packages/design-system/src/components/N8nCheckbox/__snapshots__/Checkbox.test.ts.snap
@@ -31,34 +31,39 @@ exports[`components > N8nCheckbox > should render with both child and label 1`]
class="container"
data-test-id="input-label"
>
-
-
-
-
- Checkbox
-
-
-
+
+
+ Checkbox
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -131,34 +136,39 @@ exports[`components > N8nCheckbox > should render with label 1`] = `
class="container"
data-test-id="input-label"
>
-
-
-
-
- Checkbox
-
-
-
+
+
+ Checkbox
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/packages/design-system/src/components/N8nFormBox/__snapshots__/FormBox.test.ts.snap b/packages/design-system/src/components/N8nFormBox/__snapshots__/FormBox.test.ts.snap
index 7123cc6c5a..949105aef3 100644
--- a/packages/design-system/src/components/N8nFormBox/__snapshots__/FormBox.test.ts.snap
+++ b/packages/design-system/src/components/N8nFormBox/__snapshots__/FormBox.test.ts.snap
@@ -33,41 +33,46 @@ exports[`FormBox > should render the component 1`] = `
class="container"
data-test-id="name"
>
-
-
-
-
- Name
- *
+ Name
+
+
+ *
+
+
-
-
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
should render the component 1`] = `
class="container"
data-test-id="email"
>
-
-
-
-
- Email
- *
+ Email
+
+
+ *
+
+
-
-
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
should render the component 1`] = `
class="container"
data-test-id="password"
>
-
-
-
-
- Password
- *
+ Password
+
+
+ *
+
+
-
-
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
v-bind="$attrs"
data-test-id="input-label"
>
-
-
-
-
+
+
+
+
+ {{ label }}
+ *
+
+
+
- {{ label }}
- *
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
-
-
+
@@ -116,6 +125,11 @@ const addTargetBlank = (html: string) =>
}
}
+.labelRow {
+ flex-direction: row;
+ display: flex;
+}
+
.main-content {
display: flex;
&:hover {
@@ -140,6 +154,7 @@ const addTargetBlank = (html: string) =>
.inputLabel {
display: block;
+ flex-grow: 1;
}
.container:hover,
.inputLabel:hover {
@@ -234,10 +249,10 @@ const addTargetBlank = (html: string) =>
display: flex;
&.small {
- margin-bottom: var(--spacing-5xs);
+ padding-bottom: var(--spacing-5xs);
}
&.medium {
- margin-bottom: var(--spacing-2xs);
+ padding-bottom: var(--spacing-2xs);
}
}
diff --git a/packages/design-system/src/components/N8nInputLabel/__snapshots__/InputLabel.test.ts.snap b/packages/design-system/src/components/N8nInputLabel/__snapshots__/InputLabel.test.ts.snap
index 5335044bf5..1c928d5e35 100644
--- a/packages/design-system/src/components/N8nInputLabel/__snapshots__/InputLabel.test.ts.snap
+++ b/packages/design-system/src/components/N8nInputLabel/__snapshots__/InputLabel.test.ts.snap
@@ -6,34 +6,39 @@ exports[`component > Text overflow behavior > displays ellipsis with options 1`]
class="container"
data-test-id="input-label"
>
-
-
-
-
- a label
-
-
-
+
+
+ a label
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -46,34 +51,39 @@ exports[`component > Text overflow behavior > displays full text without options
class="container"
data-test-id="input-label"
>
-
-
-
-
- a label
-
-
-
+
+
+ a label
+
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/packages/design-system/src/components/N8nSelectableList/SelectableList.test.ts b/packages/design-system/src/components/N8nSelectableList/SelectableList.test.ts
index e7ba9d4206..21f8040699 100644
--- a/packages/design-system/src/components/N8nSelectableList/SelectableList.test.ts
+++ b/packages/design-system/src/components/N8nSelectableList/SelectableList.test.ts
@@ -72,7 +72,7 @@ describe('N8nSelectableList', () => {
expect(wrapper.html()).toMatchSnapshot();
});
- it('renders disabled collection and clicks do not modify', async () => {
+ it('renders disabled collection without selectables', async () => {
const wrapper = render(N8nSelectableList, {
props: {
modelValue: {
@@ -87,20 +87,10 @@ describe('N8nSelectableList', () => {
},
});
- expect(wrapper.getByTestId('selectable-list-selectable-propA')).toBeInTheDocument();
- expect(wrapper.getByTestId('selectable-list-slot-propB')).toBeInTheDocument();
- expect(wrapper.queryByTestId('selectable-list-selectable-propB')).not.toBeInTheDocument();
- expect(wrapper.getByTestId('selectable-list-selectable-propC')).toBeInTheDocument();
-
- await fireEvent.click(wrapper.getByTestId('selectable-list-selectable-propA'));
-
- expect(wrapper.getByTestId('selectable-list-selectable-propA')).toBeInTheDocument();
- expect(wrapper.queryByTestId('selectable-list-slot-propA')).not.toBeInTheDocument();
-
- await fireEvent.click(wrapper.getByTestId('selectable-list-remove-slot-propB'));
-
+ expect(wrapper.queryByTestId('selectable-list-selectable-propA')).not.toBeInTheDocument();
expect(wrapper.getByTestId('selectable-list-slot-propB')).toBeInTheDocument();
expect(wrapper.queryByTestId('selectable-list-selectable-propB')).not.toBeInTheDocument();
+ expect(wrapper.queryByTestId('selectable-list-selectable-propC')).not.toBeInTheDocument();
expect(wrapper.html()).toMatchSnapshot();
});
diff --git a/packages/design-system/src/components/N8nSelectableList/SelectableList.vue b/packages/design-system/src/components/N8nSelectableList/SelectableList.vue
index e5ba985edf..de0133d2d0 100644
--- a/packages/design-system/src/components/N8nSelectableList/SelectableList.vue
+++ b/packages/design-system/src/components/N8nSelectableList/SelectableList.vue
@@ -59,7 +59,7 @@ function itemComparator(a: Item, b: Item) {
-
+
- {{ t('selectableList.addDefault') }} {{ item.name }}
+
+
+ {{ t('selectableList.addDefault') }} {{ item.name }}
+
+
renders disabled collection and clicks do not modify 1`] = `
+exports[`N8nSelectableList > renders disabled collection without selectables 1`] = `
"
-
+ Add a propA + Add a propC
+
@@ -11,7 +11,7 @@ exports[`N8nSelectableList > renders disabled collection and clicks do not modif
exports[`N8nSelectableList > renders multiple elements with some pre-selected 1`] = `
"
-
+ Add a propB + Add a propD
+
+ Add a propB
+ Add a propD
diff --git a/packages/editor-ui/src/__tests__/mocks.ts b/packages/editor-ui/src/__tests__/mocks.ts
index 15688de9ff..f514d809d9 100644
--- a/packages/editor-ui/src/__tests__/mocks.ts
+++ b/packages/editor-ui/src/__tests__/mocks.ts
@@ -54,7 +54,7 @@ export const mockNodeTypeDescription = ({
credentials = [],
inputs = [NodeConnectionType.Main],
outputs = [NodeConnectionType.Main],
- codex = {},
+ codex = undefined,
properties = [],
}: {
name?: INodeTypeDescription['name'];
diff --git a/packages/editor-ui/src/components/AiStarsIcon.vue b/packages/editor-ui/src/components/AiStarsIcon.vue
new file mode 100644
index 0000000000..44958d7feb
--- /dev/null
+++ b/packages/editor-ui/src/components/AiStarsIcon.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
diff --git a/packages/editor-ui/src/components/ExpressionParameterInput.vue b/packages/editor-ui/src/components/ExpressionParameterInput.vue
index 8706e73991..4217bc12ee 100644
--- a/packages/editor-ui/src/components/ExpressionParameterInput.vue
+++ b/packages/editor-ui/src/components/ExpressionParameterInput.vue
@@ -55,6 +55,12 @@ const workflowsStore = useWorkflowsStore();
const isDragging = computed(() => ndvStore.isDraggableDragging);
+function select() {
+ if (inlineInput.value) {
+ inlineInput.value.selectAll();
+ }
+}
+
function focus() {
if (inlineInput.value) {
inlineInput.value.focus();
@@ -162,7 +168,7 @@ watch(isDragging, (newIsDragging) => {
onClickOutside(container, (event) => onBlur(event));
-defineExpose({ focus });
+defineExpose({ focus, select });
@@ -220,6 +226,7 @@ defineExpose({ focus });
diff --git a/packages/editor-ui/src/components/ParameterInputFull.test.ts b/packages/editor-ui/src/components/ParameterInputFull.test.ts
new file mode 100644
index 0000000000..9dfe37fb4f
--- /dev/null
+++ b/packages/editor-ui/src/components/ParameterInputFull.test.ts
@@ -0,0 +1,158 @@
+import { renderComponent } from '@/__tests__/render';
+import type { useNDVStore } from '@/stores/ndv.store';
+import { createTestingPinia } from '@pinia/testing';
+import type { useNodeTypesStore } from '@/stores/nodeTypes.store';
+import type { useSettingsStore } from '@/stores/settings.store';
+import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
+import ParameterInputFull from './ParameterInputFull.vue';
+import { FROM_AI_AUTO_GENERATED_MARKER } from 'n8n-workflow';
+
+type Writeable = { -readonly [P in keyof T]: T[P] };
+
+let mockNdvState: Partial>;
+let mockNodeTypesState: Writeable>>;
+let mockSettingsState: Writeable>>;
+
+beforeEach(() => {
+ mockNdvState = {
+ hasInputData: true,
+ activeNode: {
+ id: '123',
+ name: 'myParam',
+ parameters: {},
+ position: [0, 0],
+ type: 'test',
+ typeVersion: 1,
+ },
+ isInputPanelEmpty: false,
+ isOutputPanelEmpty: false,
+ };
+ mockNodeTypesState = {
+ allNodeTypes: [],
+ };
+ mockSettingsState = {
+ settings: {
+ releaseChannel: 'stable',
+ } as never,
+ isEnterpriseFeatureEnabled: { externalSecrets: false } as never,
+ };
+ createAppModals();
+});
+
+vi.mock('@/stores/ndv.store', () => {
+ return {
+ useNDVStore: vi.fn(() => mockNdvState),
+ };
+});
+
+vi.mock('@/stores/nodeTypes.store', () => {
+ return {
+ useNodeTypesStore: vi.fn(() => mockNodeTypesState),
+ };
+});
+
+vi.mock('@/stores/settings.store', () => {
+ return {
+ useSettingsStore: vi.fn(() => mockSettingsState),
+ };
+});
+
+describe('ParameterInputFull.vue', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ createAppModals();
+ });
+
+ afterEach(() => {
+ cleanupAppModals();
+ });
+
+ it('should render basic parameter', async () => {
+ mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({});
+ const { getByTestId } = renderComponent(ParameterInputFull, {
+ pinia: createTestingPinia(),
+ props: {
+ path: 'myParam',
+ parameter: {
+ displayName: 'My Param',
+ name: 'myParam',
+ type: 'string',
+ },
+ },
+ });
+ expect(getByTestId('parameter-input')).toBeInTheDocument();
+ });
+
+ it('should render parameter with override button inline', async () => {
+ mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({
+ codex: {
+ categories: ['AI'],
+ subcategories: { AI: ['Tools'] },
+ },
+ });
+ const { getByTestId } = renderComponent(ParameterInputFull, {
+ pinia: createTestingPinia(),
+ props: {
+ path: 'myParam',
+ parameter: {
+ displayName: 'My Param',
+ name: 'myParam',
+ type: 'string',
+ },
+ value: '',
+ },
+ });
+ expect(getByTestId('parameter-input')).toBeInTheDocument();
+ expect(getByTestId('from-ai-override-button')).toBeInTheDocument();
+ });
+
+ it('should render parameter with override button in options', async () => {
+ mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({
+ codex: {
+ categories: ['AI'],
+ subcategories: { AI: ['Tools'] },
+ },
+ });
+ const { getByTestId } = renderComponent(ParameterInputFull, {
+ pinia: createTestingPinia(),
+ props: {
+ path: 'myParam',
+ parameter: {
+ displayName: 'My Param',
+ name: 'myParam',
+ type: 'string',
+ },
+ value: `={{
+ 'and the air is free'
+
+
+ }}`,
+ },
+ });
+ expect(getByTestId('parameter-input')).toBeInTheDocument();
+ expect(getByTestId('from-ai-override-button')).toBeInTheDocument();
+ });
+
+ it('should render parameter with active override', async () => {
+ mockNodeTypesState.getNodeType = vi.fn().mockReturnValue({
+ codex: {
+ categories: ['AI'],
+ subcategories: { AI: ['Tools'] },
+ },
+ });
+ const { queryByTestId, getByTestId } = renderComponent(ParameterInputFull, {
+ pinia: createTestingPinia(),
+ props: {
+ path: 'myParam',
+ value: `={{ ${FROM_AI_AUTO_GENERATED_MARKER} $fromAI('myParam') }}`,
+ parameter: {
+ displayName: 'My Param',
+ name: 'myParam',
+ type: 'string',
+ },
+ },
+ });
+ expect(getByTestId('fromAI-override-field')).toBeInTheDocument();
+ expect(queryByTestId('override-button')).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/editor-ui/src/components/ParameterInputFull.vue b/packages/editor-ui/src/components/ParameterInputFull.vue
index 332011b9ea..92a2dbfe1f 100644
--- a/packages/editor-ui/src/components/ParameterInputFull.vue
+++ b/packages/editor-ui/src/components/ParameterInputFull.vue
@@ -1,10 +1,12 @@
+
+
+
+
+
+
-
+
+
@@ -287,6 +421,17 @@ watch(
}
}
+.overrideButtonInOptions {
+ position: relative;
+ // To connect to input panel below the button
+ margin-bottom: -2px;
+}
+
+.noCornersBottom > button {
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
.options {
position: absolute;
bottom: -22px;
diff --git a/packages/editor-ui/src/components/ParameterInputOverrides/FromAiOverrideButton.vue b/packages/editor-ui/src/components/ParameterInputOverrides/FromAiOverrideButton.vue
new file mode 100644
index 0000000000..388941569e
--- /dev/null
+++ b/packages/editor-ui/src/components/ParameterInputOverrides/FromAiOverrideButton.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+ {{ i18n.baseText('parameterOverride.applyOverrideButtonTooltip') }}
+
+
+
+
+
+
+
+
+
diff --git a/packages/editor-ui/src/components/ParameterInputOverrides/FromAiOverrideField.vue b/packages/editor-ui/src/components/ParameterInputOverrides/FromAiOverrideField.vue
new file mode 100644
index 0000000000..4382bd18e5
--- /dev/null
+++ b/packages/editor-ui/src/components/ParameterInputOverrides/FromAiOverrideField.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+ {{
+ i18n.baseText('parameterOverride.overridePanelText')
+ }}
+ {{
+ i18n.baseText('parameterOverride.overridePanelTextModel')
+ }}
+
+
+
+
+
+
diff --git a/packages/editor-ui/src/components/ParameterInputOverrides/ParameterOverrideSelectableList.vue b/packages/editor-ui/src/components/ParameterInputOverrides/ParameterOverrideSelectableList.vue
new file mode 100644
index 0000000000..ff246ce571
--- /dev/null
+++ b/packages/editor-ui/src/components/ParameterInputOverrides/ParameterOverrideSelectableList.vue
@@ -0,0 +1,75 @@
+
+
+
+
+
+ {
+ parameterOverride.extraPropValues[name] = x.value;
+ valueChanged({
+ name: props.path,
+ value: buildValueFromOverride(parameterOverride, props, true),
+ });
+ }
+ "
+ />
+
+
+
diff --git a/packages/editor-ui/src/components/ParameterInputWrapper.vue b/packages/editor-ui/src/components/ParameterInputWrapper.vue
index 56ff5ad717..0770e562fa 100644
--- a/packages/editor-ui/src/components/ParameterInputWrapper.vue
+++ b/packages/editor-ui/src/components/ParameterInputWrapper.vue
@@ -18,7 +18,7 @@ import { useNDVStore } from '@/stores/ndv.store';
import { isValueExpression, parseResourceMapperFieldName } from '@/utils/nodeTypesUtils';
import type { EventBus } from 'n8n-design-system/utils';
import { createEventBus } from 'n8n-design-system/utils';
-import { computed } from 'vue';
+import { computed, useTemplateRef } from 'vue';
type Props = {
parameter: INodeProperties;
@@ -41,6 +41,7 @@ type Props = {
eventSource?: string;
label?: IParameterLabel;
eventBus?: EventBus;
+ canBeOverridden?: boolean;
};
const props = withDefaults(defineProps
(), {
@@ -151,6 +152,14 @@ function onValueChanged(parameterData: IUpdateInformation) {
function onTextInput(parameterData: IUpdateInformation) {
emit('textInput', parameterData);
}
+
+const param = useTemplateRef('param');
+const isSingleLineInput = computed(() => param.value?.isSingleLineInput);
+defineExpose({
+ isSingleLineInput,
+ focusInput: () => param.value?.focusInput(),
+ selectInput: () => param.value?.selectInput(),
+});
@@ -177,12 +186,17 @@ function onTextInput(parameterData: IUpdateInformation) {
:rows="rows"
:data-test-id="`parameter-input-${parsedParameterName}`"
:event-bus="eventBus"
+ :can-be-overridden="canBeOverridden"
@focus="onFocus"
@blur="onBlur"
@drop="onDrop"
@text-input="onTextInput"
@update="onValueChanged"
- />
+ >
+
+
+
+
!!getPropertyArgument(currentMode.value, 'searchFilterRequired'),
);
+const fromAIOverride = ref(
+ makeOverrideValue(
+ {
+ value: props.modelValue?.value ?? '',
+ ...props,
+ },
+ props.node && nodeTypesStore.getNodeType(props.node.type, props.node.typeVersion),
+ ),
+);
+
+const canBeContentOverride = computed(() => {
+ if (!props.node) return false;
+
+ return fromAIOverride.value !== null;
+});
+
+const isContentOverride = computed(
+ () =>
+ canBeContentOverride.value &&
+ !!isFromAIOverrideValue(props.modelValue?.value?.toString() ?? ''),
+);
+
+const showOverrideButton = computed(
+ () => canBeContentOverride.value && !isContentOverride.value && !props.isReadOnly,
+);
+
watch(currentQueryError, (curr, prev) => {
if (resourceDropdownVisible.value && curr && !prev) {
if (inputRef.value) {
@@ -678,6 +711,46 @@ function onInputBlur(event: FocusEvent) {
}
emit('blur');
}
+
+function applyOverride() {
+ if (!props.node || !fromAIOverride.value) return;
+
+ telemetry.track(
+ 'User turned on fromAI override',
+ {
+ nodeType: props.node.type,
+ parameter: props.path,
+ },
+ { withPostHog: true },
+ );
+ updateFromAIOverrideValues(fromAIOverride.value, props.modelValue.value?.toString() ?? '');
+
+ emit('update:modelValue', {
+ ...props.modelValue,
+ value: buildValueFromOverride(fromAIOverride.value, props, true),
+ });
+}
+
+function removeOverride() {
+ if (!props.node || !fromAIOverride.value) return;
+
+ telemetry.track(
+ 'User turned off fromAI override',
+ {
+ nodeType: props.node.type,
+ parameter: props.path,
+ },
+ { withPostHog: true },
+ );
+ emit('update:modelValue', {
+ ...props.modelValue,
+ value: buildValueFromOverride(fromAIOverride.value, props, false),
+ });
+ void setTimeout(() => {
+ inputRef.value?.focus();
+ inputRef.value?.select();
+ }, 0);
+}
@@ -723,9 +796,18 @@ function onInputBlur(event: FocusEvent) {
:class="{
[$style.resourceLocator]: true,
[$style.multipleModes]: hasMultipleModes,
+ [$style.inputContainerInputCorners]:
+ hasMultipleModes && canBeContentOverride && !isContentOverride,
}"
>
-
+
@@ -821,6 +922,14 @@ function onInputBlur(event: FocusEvent) {
+
onInputChange(x.value?.toString())"
+ />
diff --git a/packages/editor-ui/src/components/ResourceLocator/resourceLocator.scss b/packages/editor-ui/src/components/ResourceLocator/resourceLocator.scss
index d2ec38a981..7413f59eb5 100644
--- a/packages/editor-ui/src/components/ResourceLocator/resourceLocator.scss
+++ b/packages/editor-ui/src/components/ResourceLocator/resourceLocator.scss
@@ -21,12 +21,39 @@ $--mode-selector-width: 92px;
}
}
+.inputField {
+ flex-grow: 1;
+ position: relative;
+}
+
+.fromAiOverrideField {
+ position: relative;
+
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.overrideButtonInline {
+ > button {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ }
+ position: relative;
+}
+
+.rightNoCorner > * {
+ --input-border-bottom-right-radius: 0;
+ --input-border-top-right-radius: 0;
+}
+
.resourceLocator {
- display: flex;
+ display: inline-flex;
flex-wrap: wrap;
+ width: 100%;
position: relative;
--input-issues-width: 28px;
+ --input-override-width: 30px;
.inputContainer {
display: flex;
@@ -38,6 +65,10 @@ $--mode-selector-width: 92px;
> div {
width: 100%;
+
+ > div {
+ display: flex;
+ }
}
}
@@ -52,13 +83,19 @@ $--mode-selector-width: 92px;
border-radius: var(--border-radius-base);
}
+ .backgroundOverride {
+ right: var(--input-override-width);
+ }
+
&.multipleModes {
.inputContainer {
display: flex;
align-items: center;
flex-basis: calc(100% - $--mode-selector-width);
flex-grow: 1;
+ }
+ .inputContainerInputCorners {
input {
border-radius: 0 var(--border-radius-base) var(--border-radius-base) 0;
}
diff --git a/packages/editor-ui/src/components/__snapshots__/MultipleParameter.test.ts.snap b/packages/editor-ui/src/components/__snapshots__/MultipleParameter.test.ts.snap
index c31a9afabc..be28af8e45 100644
--- a/packages/editor-ui/src/components/__snapshots__/MultipleParameter.test.ts.snap
+++ b/packages/editor-ui/src/components/__snapshots__/MultipleParameter.test.ts.snap
@@ -2,17 +2,21 @@
exports[`MultipleParameter > should render correctly 1`] = `
"
-
+
Currently no items exist
Add item
diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json
index fe445c8f6e..6ae2334fcd 100644
--- a/packages/editor-ui/src/plugins/i18n/locales/en.json
+++ b/packages/editor-ui/src/plugins/i18n/locales/en.json
@@ -1450,6 +1450,10 @@
"parameterInputList.parameterOptions": "Parameter Options",
"parameterInputList.loadingFields": "Loading fields...",
"parameterInputList.loadingError": "Error loading fields. Refresh you page and try again.",
+ "parameterOverride.overridePanelText": "Defined automatically by the ",
+ "parameterOverride.overridePanelTextModel": "model",
+ "parameterOverride.applyOverrideButtonTooltip": "Let the model define this parameter",
+ "parameterOverride.descriptionTooltip": "Explain to the LLM how it should generate this value, a good, specific description would allow LLMs to produce expected results much more often",
"personalizationModal.businessOwner": "Business Owner",
"personalizationModal.continue": "Continue",
"personalizationModal.cicd": "CI/CD",
diff --git a/packages/editor-ui/src/utils/fromAIOverrideUtils.test.ts b/packages/editor-ui/src/utils/fromAIOverrideUtils.test.ts
new file mode 100644
index 0000000000..8144f5c60c
--- /dev/null
+++ b/packages/editor-ui/src/utils/fromAIOverrideUtils.test.ts
@@ -0,0 +1,167 @@
+import type { FromAIOverride, OverrideContext } from './fromAIOverrideUtils';
+import {
+ buildValueFromOverride,
+ fromAIExtraProps,
+ isFromAIOverrideValue,
+ makeOverrideValue,
+ parseOverrides,
+} from './fromAIOverrideUtils';
+import type { INodeTypeDescription } from 'n8n-workflow';
+
+const DISPLAY_NAME = 'aDisplayName';
+const PARAMETER_NAME = 'aName';
+
+const makeContext = (value: string, path?: string): OverrideContext => ({
+ parameter: {
+ name: PARAMETER_NAME,
+ displayName: DISPLAY_NAME,
+ type: 'string',
+ },
+ value,
+ path: path ?? `parameters.${PARAMETER_NAME}`,
+});
+
+const MOCK_NODE_TYPE_MIXIN = {
+ version: 0,
+ defaults: {},
+ inputs: [],
+ outputs: [],
+ properties: [],
+ displayName: '',
+ group: [],
+ description: '',
+};
+
+const AI_NODE_TYPE: INodeTypeDescription = {
+ name: 'AN_AI_NODE_TYPE',
+ codex: {
+ categories: ['AI'],
+ subcategories: {
+ AI: ['Tools'],
+ },
+ },
+ ...MOCK_NODE_TYPE_MIXIN,
+};
+
+const AI_DENYLIST_NODE_TYPE: INodeTypeDescription = {
+ name: 'toolCode',
+ codex: {
+ categories: ['AI'],
+ subcategories: {
+ AI: ['Tools'],
+ },
+ },
+ ...MOCK_NODE_TYPE_MIXIN,
+};
+
+const NON_AI_NODE_TYPE: INodeTypeDescription = {
+ name: 'AN_NOT_AI_NODE_TYPE',
+ ...MOCK_NODE_TYPE_MIXIN,
+};
+
+describe('makeOverrideValue', () => {
+ test.each<[string, ...Parameters]>([
+ ['null nodeType', makeContext(''), null],
+ ['non-ai node type', makeContext(''), NON_AI_NODE_TYPE],
+ ['ai node type on denylist', makeContext(''), AI_DENYLIST_NODE_TYPE],
+ ])('should not create an override for %s', (_name, context, nodeType) => {
+ expect(makeOverrideValue(context, nodeType)).toBeNull();
+ });
+
+ it('should create an fromAI override', () => {
+ const result = makeOverrideValue(
+ makeContext(`={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('${DISPLAY_NAME}') }}`),
+ AI_NODE_TYPE,
+ );
+
+ expect(result).not.toBeNull();
+ expect(result?.type).toEqual('fromAI');
+ });
+
+ it('parses existing fromAI overrides', () => {
+ const description = 'a description';
+ const result = makeOverrideValue(
+ makeContext(
+ `={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('${DISPLAY_NAME}', \`${description}\`) }}`,
+ ),
+ AI_NODE_TYPE,
+ );
+
+ expect(result).toBeDefined();
+ expect(result?.extraPropValues.description).toEqual(description);
+ });
+
+ it('parses an existing fromAI override with default values without adding extraPropValue entry', () => {
+ const result = makeOverrideValue(
+ makeContext("={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('aName', ``) }}"),
+ AI_NODE_TYPE,
+ );
+
+ expect(result).toBeDefined();
+ expect(result?.extraPropValues.description).not.toBeDefined();
+ });
+});
+
+describe('FromAiOverride', () => {
+ it('correctly identifies override values', () => {
+ expect(isFromAIOverrideValue('={{ $fromAI() }}')).toBe(false);
+ expect(isFromAIOverrideValue('={{ /*n8n-auto-generated-fromAI-override*/ $fromAI() }}')).toBe(
+ true,
+ );
+ });
+
+ it('should parseOverrides as expected', () => {
+ expect(parseOverrides("={{ $fromAI('aKey' }}")).toBeNull();
+ expect(parseOverrides("={{ $fromAI('aKey') }}")).toEqual({
+ description: undefined,
+ });
+ expect(parseOverrides("={{ $fromAI('aKey', `a description`) }}")).toEqual({
+ description: 'a description',
+ });
+ expect(parseOverrides("={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('aKey') }}")).toEqual(
+ { description: undefined },
+ );
+ expect(
+ parseOverrides(
+ "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('aKey', `a description`) }}",
+ ),
+ ).toEqual({
+ description: 'a description',
+ });
+ });
+
+ test.each<[string, string, string]>([
+ ['none', '$fromAI("a", `b`)', 'b'],
+ ['a simple case of', '$fromAI("a", `\\``)', '`'],
+ // this is a bug in the current implementation
+ // see related comments in the main file
+ // We try to use different quote characters where possible
+ ['a complex case of', '$fromAI("a", `a \\` \\\\\\``)', 'a ` `'],
+ ])('should handle %s backtick escaping ', (_name, value, expected) => {
+ expect(parseOverrides(value)).toEqual({ description: expected });
+ });
+
+ it('should build a value from an override and carry over modification', () => {
+ const override: FromAIOverride = {
+ type: 'fromAI',
+ extraProps: fromAIExtraProps,
+ extraPropValues: {},
+ };
+ expect(buildValueFromOverride(override, makeContext(''), true)).toEqual(
+ `={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('${DISPLAY_NAME}', \`\`, 'string') }}`,
+ );
+ expect(buildValueFromOverride(override, makeContext(''), false)).toEqual(
+ `={{ $fromAI('${DISPLAY_NAME}', \`\`, 'string') }}`,
+ );
+
+ const description = 'a description';
+ override.extraPropValues.description = description;
+
+ expect(buildValueFromOverride(override, makeContext(''), true)).toEqual(
+ `={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('${DISPLAY_NAME}', \`${description}\`, 'string') }}`,
+ );
+ expect(buildValueFromOverride(override, makeContext(''), false)).toEqual(
+ `={{ $fromAI('${DISPLAY_NAME}', \`${description}\`, 'string') }}`,
+ );
+ });
+});
diff --git a/packages/editor-ui/src/utils/fromAIOverrideUtils.ts b/packages/editor-ui/src/utils/fromAIOverrideUtils.ts
new file mode 100644
index 0000000000..e5272de695
--- /dev/null
+++ b/packages/editor-ui/src/utils/fromAIOverrideUtils.ts
@@ -0,0 +1,190 @@
+import {
+ extractFromAICalls,
+ FROM_AI_AUTO_GENERATED_MARKER,
+ type INodeTypeDescription,
+ type NodeParameterValueType,
+ type NodePropertyTypes,
+} from 'n8n-workflow';
+import { i18n } from '@/plugins/i18n';
+
+export type OverrideContext = {
+ parameter: {
+ name: string;
+ displayName: string;
+ type: NodePropertyTypes;
+ noDataExpression?: boolean;
+ typeOptions?: { editor?: string };
+ };
+ value: NodeParameterValueType;
+ path: string;
+};
+
+type ExtraPropValue = {
+ initialValue: string;
+ tooltip: string;
+ type: NodePropertyTypes;
+ typeOptions?: { rows?: number };
+};
+
+type FromAIExtraProps = 'description';
+
+export type FromAIOverride = {
+ type: 'fromAI';
+ readonly extraProps: Record;
+ extraPropValues: Partial>;
+};
+
+function sanitizeFromAiParameterName(s: string) {
+ s = s.replace(/[^a-zA-Z0-9\-]/g, '_');
+
+ if (s.length >= 64) {
+ s = s.slice(0, 63);
+ }
+
+ return s;
+}
+
+const NODE_DENYLIST = ['toolCode', 'toolHttpRequest'];
+
+const PATH_DENYLIST = [
+ 'parameters.name',
+ 'parameters.description',
+ // This is used in e.g. the telegram node if the dropdown selects manual mode
+ 'parameters.toolDescription',
+];
+
+export const fromAIExtraProps: Record = {
+ description: {
+ initialValue: '',
+ type: 'string',
+ typeOptions: { rows: 2 },
+ tooltip: i18n.baseText('parameterOverride.descriptionTooltip'),
+ },
+};
+
+function isExtraPropKey(
+ extraProps: FromAIOverride['extraProps'],
+ key: PropertyKey,
+): key is keyof FromAIOverride['extraProps'] {
+ return extraProps.hasOwnProperty(key);
+}
+
+export function updateFromAIOverrideValues(override: FromAIOverride, expr: string) {
+ const { extraProps, extraPropValues } = override;
+ const overrides = parseOverrides(expr);
+ if (overrides) {
+ for (const [key, value] of Object.entries(overrides)) {
+ if (isExtraPropKey(extraProps, key)) {
+ if (extraProps[key].initialValue === value) {
+ delete extraPropValues[key];
+ } else {
+ extraPropValues[key] = value;
+ }
+ }
+ }
+ }
+}
+
+function fieldTypeToFromAiType(propType: NodePropertyTypes) {
+ switch (propType) {
+ case 'boolean':
+ case 'number':
+ case 'json':
+ return propType;
+ default:
+ return 'string';
+ }
+}
+
+export function isFromAIOverrideValue(s: string) {
+ return s.startsWith(`={{ ${FROM_AI_AUTO_GENERATED_MARKER} $fromAI(`);
+}
+
+function getBestQuoteChar(description: string) {
+ if (description.includes('\n')) return '`';
+
+ if (!description.includes('`')) return '`';
+ if (!description.includes('"')) return '"';
+ return "'";
+}
+
+export function buildValueFromOverride(
+ override: FromAIOverride,
+ props: Pick,
+ includeMarker: boolean,
+) {
+ const { extraPropValues, extraProps } = override;
+ const marker = includeMarker ? `${FROM_AI_AUTO_GENERATED_MARKER} ` : '';
+ const key = sanitizeFromAiParameterName(props.parameter.displayName);
+ const description =
+ extraPropValues?.description?.toString() ?? extraProps.description.initialValue;
+
+ // We want to escape these characters here as the generated formula needs to be valid JS code, and we risk
+ // closing the string prematurely without it.
+ // If we don't escape \ then as would end up as 'my \\' description' in
+ // the generated code, causing an unescaped quoteChar to close the string.
+ // We try to minimize this by looking for an unused quote char first
+ const quoteChar = getBestQuoteChar(description);
+ const sanitizedDescription = description
+ .replaceAll(/\\/g, '\\\\')
+ .replaceAll(quoteChar, `\\${quoteChar}`);
+ const type = fieldTypeToFromAiType(props.parameter.type);
+
+ return `={{ ${marker}$fromAI('${key}', ${quoteChar}${sanitizedDescription}${quoteChar}, '${type}') }}`;
+}
+
+export function parseOverrides(
+ expression: string,
+): Record | null {
+ try {
+ // `extractFromAICalls` has different escape semantics from JS strings
+ // Specifically it makes \ escape any following character.
+ // So we need to escape our \ so we don't drop them accidentally
+ // However ` used in the description cause \` to appear here, which would break
+ // So we take the hit and expose the bug for backticks only, turning \` into `.
+ const preparedExpression = expression.replace(/\\[^`]/g, '\\\\');
+
+ const calls = extractFromAICalls(preparedExpression);
+ if (calls.length === 1) {
+ return {
+ description: calls[0].description,
+ };
+ }
+ } catch (e) {}
+
+ return null;
+}
+
+export function canBeContentOverride(
+ props: Pick,
+ nodeType: INodeTypeDescription | null,
+) {
+ if (NODE_DENYLIST.some((x) => nodeType?.name?.endsWith(x) ?? false)) return false;
+
+ if (PATH_DENYLIST.includes(props.path)) return false;
+
+ const codex = nodeType?.codex;
+ if (!codex?.categories?.includes('AI') || !codex?.subcategories?.AI?.includes('Tools'))
+ return false;
+
+ return !props.parameter.noDataExpression && 'options' !== props.parameter.type;
+}
+
+export function makeOverrideValue(
+ context: OverrideContext,
+ nodeType: INodeTypeDescription | null | undefined,
+): FromAIOverride | null {
+ if (!nodeType) return null;
+
+ if (canBeContentOverride(context, nodeType)) {
+ const fromAiOverride: FromAIOverride = {
+ type: 'fromAI',
+ extraProps: fromAIExtraProps,
+ extraPropValues: {},
+ };
+ updateFromAIOverrideValues(fromAiOverride, context.value?.toString() ?? '');
+ return fromAiOverride;
+ }
+
+ return null;
+}
diff --git a/packages/workflow/src/Constants.ts b/packages/workflow/src/Constants.ts
index 453b9be748..59e3c47d47 100644
--- a/packages/workflow/src/Constants.ts
+++ b/packages/workflow/src/Constants.ts
@@ -99,3 +99,5 @@ export const TRIMMED_TASK_DATA_CONNECTIONS_KEY = '__isTrimmedManualExecutionData
export const OPEN_AI_API_CREDENTIAL_TYPE = 'openAiApi';
export const FREE_AI_CREDITS_ERROR_TYPE = 'free_ai_credits_request_error';
export const FREE_AI_CREDITS_USED_ALL_CREDITS_ERROR_CODE = 400;
+
+export const FROM_AI_AUTO_GENERATED_MARKER = '/*n8n-auto-generated-fromAI-override*/';
diff --git a/packages/workflow/src/TelemetryHelpers.ts b/packages/workflow/src/TelemetryHelpers.ts
index 51d56ef154..cbe9a0bb54 100644
--- a/packages/workflow/src/TelemetryHelpers.ts
+++ b/packages/workflow/src/TelemetryHelpers.ts
@@ -6,6 +6,7 @@ import {
EXECUTE_WORKFLOW_NODE_TYPE,
FREE_AI_CREDITS_ERROR_TYPE,
FREE_AI_CREDITS_USED_ALL_CREDITS_ERROR_CODE,
+ FROM_AI_AUTO_GENERATED_MARKER,
HTTP_REQUEST_NODE_TYPE,
HTTP_REQUEST_TOOL_LANGCHAIN_NODE_TYPE,
LANGCHAIN_CUSTOM_TOOLS,
@@ -525,3 +526,60 @@ export const userInInstanceRanOutOfFreeAiCredits = (runData: IRun): boolean => {
return false;
};
+
+export type FromAICount = {
+ aiNodeCount: number;
+ aiToolCount: number;
+ fromAIOverrideCount: number;
+ fromAIExpressionCount: number;
+};
+
+export function resolveAIMetrics(nodes: INode[], nodeTypes: INodeTypes): FromAICount | {} {
+ const resolvedNodes = nodes
+ .map((x) => [x, nodeTypes.getByNameAndVersion(x.type, x.typeVersion)] as const)
+ .filter((x) => !!x[1]?.description);
+
+ const aiNodeCount = resolvedNodes.reduce(
+ (acc, x) => acc + Number(x[1].description.codex?.categories?.includes('AI')),
+ 0,
+ );
+
+ if (aiNodeCount === 0) return {};
+
+ let fromAIOverrideCount = 0;
+ let fromAIExpressionCount = 0;
+
+ const tools = resolvedNodes.filter((node) =>
+ node[1].description.codex?.subcategories?.AI?.includes('Tools'),
+ );
+
+ for (const [node, _] of tools) {
+ // FlatMap to support values in resourceLocators
+ const values = Object.values(node.parameters).flatMap((param) => {
+ if (param && typeof param === 'object' && 'value' in param) param = param.value;
+ return typeof param === 'string' ? param : [];
+ });
+
+ // Note that we don't match the i in `fromAI` to support lower case i (though we miss fromai)
+ const overrides = values.reduce(
+ (acc, value) => acc + Number(value.startsWith(`={{ ${FROM_AI_AUTO_GENERATED_MARKER} $fromA`)),
+ 0,
+ );
+
+ fromAIOverrideCount += overrides;
+ // check for = to avoid scanning lengthy text fields
+ // this will re-count overrides
+ fromAIExpressionCount +=
+ values.reduce(
+ (acc, value) => acc + Number(value[0] === '=' && value.includes('$fromA', 2)),
+ 0,
+ ) - overrides;
+ }
+
+ return {
+ aiNodeCount,
+ aiToolCount: tools.length,
+ fromAIOverrideCount,
+ fromAIExpressionCount,
+ };
+}
diff --git a/packages/workflow/test/FromAIParseUtils.test.ts b/packages/workflow/test/FromAIParseUtils.test.ts
index 4c07f33fca..bfe1a52fb0 100644
--- a/packages/workflow/test/FromAIParseUtils.test.ts
+++ b/packages/workflow/test/FromAIParseUtils.test.ts
@@ -10,6 +10,9 @@ describe('extractFromAICalls', () => {
test.each<[string, [unknown, unknown, unknown, unknown]]>([
['$fromAI("a", "b", "string")', ['a', 'b', 'string', undefined]],
['$fromAI("a", "b", "number", 5)', ['a', 'b', 'number', 5]],
+ ['$fromAI("a", "`", "number", 5)', ['a', '`', 'number', 5]],
+ ['$fromAI("a", "\\`", "number", 5)', ['a', '`', 'number', 5]], // this is a bit surprising, but intended
+ ['$fromAI("a", "\\n", "number", 5)', ['a', 'n', 'number', 5]], // this is a bit surprising, but intended
['{{ $fromAI("a", "b", "boolean") }}', ['a', 'b', 'boolean', undefined]],
])('should parse args as expected for %s', (formula, [key, description, type, defaultValue]) => {
expect(extractFromAICalls(formula)).toEqual([
diff --git a/packages/workflow/test/TelemetryHelpers.test.ts b/packages/workflow/test/TelemetryHelpers.test.ts
index 4cb923bb9d..f7cd8c6190 100644
--- a/packages/workflow/test/TelemetryHelpers.test.ts
+++ b/packages/workflow/test/TelemetryHelpers.test.ts
@@ -3,7 +3,7 @@ import { v5 as uuidv5, v3 as uuidv3, v4 as uuidv4, v1 as uuidv1 } from 'uuid';
import { STICKY_NODE_TYPE } from '@/Constants';
import { ApplicationError, ExpressionError, NodeApiError } from '@/errors';
-import type { IRun, IRunData } from '@/Interfaces';
+import type { INode, INodeTypeDescription, IRun, IRunData } from '@/Interfaces';
import { NodeConnectionType, type IWorkflowBase } from '@/Interfaces';
import * as nodeHelpers from '@/NodeHelpers';
import {
@@ -12,11 +12,13 @@ import {
generateNodesGraph,
getDomainBase,
getDomainPath,
+ resolveAIMetrics,
userInInstanceRanOutOfFreeAiCredits,
} from '@/TelemetryHelpers';
import { randomInt } from '@/utils';
import { nodeTypes } from './ExpressionExtensions/Helpers';
+import type { NodeTypes } from './NodeTypes';
describe('getDomainBase should return protocol plus domain', () => {
test('in valid URLs', () => {
@@ -1541,3 +1543,109 @@ function generateTestWorkflowAndRunData(): { workflow: Partial; r
return { workflow, runData };
}
+
+describe('makeAIMetrics', () => {
+ const makeNode = (parameters: object, type: string) =>
+ ({
+ parameters,
+ type,
+ typeVersion: 2.1,
+ id: '7cb0b373-715c-4a89-8bbb-3f238907bc86',
+ name: 'a name',
+ position: [0, 0],
+ }) as INode;
+
+ it('should count applicable nodes and parameters', async () => {
+ const nodes = [
+ makeNode(
+ {
+ sendTo: "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('To', ``, 'string') }}",
+ sendTwo: "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('To', ``, 'string') }}",
+ subject: "={{ $fromAI('Subject', ``, 'string') }}",
+ },
+ 'n8n-nodes-base.gmailTool',
+ ),
+ makeNode(
+ {
+ subject: "={{ $fromAI('Subject', ``, 'string') }}",
+ verb: "={{ $fromAI('Verb', ``, 'string') }}",
+ },
+ 'n8n-nodes-base.gmailTool',
+ ),
+ makeNode(
+ {
+ subject: "'A Subject'",
+ },
+ 'n8n-nodes-base.gmailTool',
+ ),
+ ];
+
+ const nodeTypes = mock({
+ getByNameAndVersion: () => ({
+ description: {
+ codex: {
+ categories: ['AI'],
+ subcategories: { AI: ['Tools'] },
+ },
+ } as unknown as INodeTypeDescription,
+ }),
+ });
+
+ const result = resolveAIMetrics(nodes, nodeTypes);
+ expect(result).toMatchObject({
+ aiNodeCount: 3,
+ aiToolCount: 3,
+ fromAIOverrideCount: 2,
+ fromAIExpressionCount: 3,
+ });
+ });
+
+ it('should not count non-applicable nodes and parameters', async () => {
+ const nodes = [
+ makeNode(
+ {
+ sendTo: 'someone',
+ },
+ 'n8n-nodes-base.gmail',
+ ),
+ ];
+
+ const nodeTypes = mock({
+ getByNameAndVersion: () => ({
+ description: {} as unknown as INodeTypeDescription,
+ }),
+ });
+
+ const result = resolveAIMetrics(nodes, nodeTypes);
+ expect(result).toMatchObject({});
+ });
+
+ it('should count ai nodes without tools', async () => {
+ const nodes = [
+ makeNode(
+ {
+ sendTo: 'someone',
+ },
+ 'n8n-nodes-base.gmailTool',
+ ),
+ ];
+
+ const nodeTypes = mock({
+ getByNameAndVersion: () => ({
+ description: {
+ codex: {
+ categories: ['AI'],
+ },
+ } as unknown as INodeTypeDescription,
+ }),
+ });
+
+ const result = resolveAIMetrics(nodes, nodeTypes);
+ expect(result).toMatchObject({
+ aiNodeCount: 1,
+ aiToolCount: 0,
+ fromAIOverrideCount: 0,
+ fromAIExpressionCount: 0,
+ });
+ });
+});