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 bd7a501fdc..a720fdcb7b 100644
--- a/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts
+++ b/packages/cli/src/__tests__/load-nodes-and-credentials.test.ts
@@ -318,6 +318,7 @@ describe('LoadNodesAndCredentials', () => {
group: ['input'],
inputs: [],
outputs: ['ai_tool'],
+ usableAsTool: true,
properties: [
{
default: 'A test node',
@@ -370,6 +371,7 @@ describe('LoadNodesAndCredentials', () => {
inputs: [],
outputs: ['ai_tool'],
description: 'A test node',
+ usableAsTool: true,
properties: [
{
displayName: 'Description',
diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts
index fd189a845f..ad076c53e5 100644
--- a/packages/cli/src/load-nodes-and-credentials.ts
+++ b/packages/cli/src/load-nodes-and-credentials.ts
@@ -317,6 +317,8 @@ export class LoadNodesAndCredentials {
} as INodeTypeBaseDescription)
: deepCopy(usableNode);
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.known.nodes[wrapped.name] = { ...this.known.nodes[usableNode.name] };
diff --git a/packages/frontend/editor-ui/src/components/FromAiParametersModal.test.ts b/packages/frontend/editor-ui/src/components/FromAiParametersModal.test.ts
new file mode 100644
index 0000000000..2098f5ead2
--- /dev/null
+++ b/packages/frontend/editor-ui/src/components/FromAiParametersModal.test.ts
@@ -0,0 +1,198 @@
+import { createTestingPinia } from '@pinia/testing';
+import { createComponentRenderer } from '@/__tests__/render';
+import FromAiParametersModal from '@/components/FromAiParametersModal.vue';
+import { FROM_AI_PARAMETERS_MODAL_KEY, STORES } from '@/constants';
+import userEvent from '@testing-library/user-event';
+import { useWorkflowsStore } from '@/stores/workflows.store';
+import { useParameterOverridesStore } from '@/stores/parameterOverrides.store';
+import { useRouter } from 'vue-router';
+import { NodeConnectionTypes } from 'n8n-workflow';
+
+const ModalStub = {
+ template: `
+
+
+
+
+
+
+ `,
+};
+
+vi.mock('vue-router');
+
+vi.mocked(useRouter);
+
+const mockNode = {
+ id: 'id1',
+ name: 'Test Node',
+ parameters: {
+ testBoolean: "={{ $fromAI('testBoolean', ``, 'boolean') }}",
+ testParam: "={{ $fromAi('testParam', ``, 'string') }}",
+ },
+};
+
+const mockParentNode = {
+ name: 'Parent Node',
+};
+
+const mockRunData = {
+ data: {
+ resultData: {
+ runData: {
+ ['Test Node']: [
+ {
+ inputOverride: {
+ [NodeConnectionTypes.AiTool]: [[{ json: { testParam: 'override' } }]],
+ },
+ },
+ ],
+ },
+ },
+ },
+};
+
+const mockWorkflow = {
+ id: 'test-workflow',
+ getChildNodes: () => ['Parent Node'],
+};
+
+const renderModal = createComponentRenderer(FromAiParametersModal);
+let pinia: ReturnType;
+let workflowsStore: ReturnType;
+let parameterOverridesStore: ReturnType;
+describe('FromAiParametersModal', () => {
+ beforeEach(() => {
+ pinia = createTestingPinia({
+ initialState: {
+ [STORES.UI]: {
+ modalsById: {
+ [FROM_AI_PARAMETERS_MODAL_KEY]: {
+ open: true,
+ data: {
+ nodeName: 'Test Node',
+ },
+ },
+ },
+ modalStack: [FROM_AI_PARAMETERS_MODAL_KEY],
+ },
+ [STORES.WORKFLOWS]: {
+ workflow: mockWorkflow,
+ workflowExecutionData: mockRunData,
+ },
+ },
+ });
+ workflowsStore = useWorkflowsStore();
+ workflowsStore.getNodeByName = vi
+ .fn()
+ .mockImplementation((name: string) => (name === 'Test Node' ? mockNode : mockParentNode));
+ workflowsStore.getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
+ parameterOverridesStore = useParameterOverridesStore();
+ parameterOverridesStore.clearParameterOverrides = vi.fn();
+ parameterOverridesStore.addParameterOverrides = vi.fn();
+ parameterOverridesStore.substituteParameters = vi.fn();
+ });
+
+ it('renders correctly with node data', () => {
+ const { getByTitle } = renderModal({
+ props: {
+ modalName: FROM_AI_PARAMETERS_MODAL_KEY,
+ data: {
+ nodeName: 'Test Node',
+ },
+ },
+ global: {
+ stubs: {
+ Modal: ModalStub,
+ },
+ },
+ pinia,
+ });
+
+ expect(getByTitle('Test Test Node')).toBeTruthy();
+ });
+
+ it('uses run data when available as initial values', async () => {
+ const { getByTestId } = renderModal({
+ props: {
+ modalName: FROM_AI_PARAMETERS_MODAL_KEY,
+ data: {
+ nodeName: 'Test Node',
+ },
+ },
+ global: {
+ stubs: {
+ Modal: ModalStub,
+ },
+ },
+ pinia,
+ });
+
+ await userEvent.click(getByTestId('execute-workflow-button'));
+
+ expect(parameterOverridesStore.addParameterOverrides).toHaveBeenCalledWith(
+ 'test-workflow',
+ 'id1',
+ {
+ testBoolean: true,
+ testParam: 'override',
+ },
+ );
+ });
+
+ it('clears parameter overrides when modal is executed', async () => {
+ const { getByTestId } = renderModal({
+ props: {
+ modalName: FROM_AI_PARAMETERS_MODAL_KEY,
+ data: {
+ nodeName: 'Test Node',
+ },
+ },
+ global: {
+ stubs: {
+ Modal: ModalStub,
+ },
+ },
+ pinia,
+ });
+
+ await userEvent.click(getByTestId('execute-workflow-button'));
+
+ expect(parameterOverridesStore.clearParameterOverrides).toHaveBeenCalledWith(
+ 'test-workflow',
+ 'id1',
+ );
+ });
+
+ it('adds substitutes for parameters when executed', async () => {
+ const { getByTestId } = renderModal({
+ props: {
+ modalName: FROM_AI_PARAMETERS_MODAL_KEY,
+ data: {
+ nodeName: 'Test Node',
+ },
+ },
+ global: {
+ stubs: {
+ Modal: ModalStub,
+ },
+ },
+ pinia,
+ });
+
+ const inputs = getByTestId('from-ai-parameters-modal-inputs');
+ await userEvent.click(inputs.querySelector('input[value="testBoolean"]') as Element);
+ await userEvent.clear(inputs.querySelector('input[name="testParam"]') as Element);
+ await userEvent.type(inputs.querySelector('input[name="testParam"]') as Element, 'given value');
+ await userEvent.click(getByTestId('execute-workflow-button'));
+
+ expect(parameterOverridesStore.addParameterOverrides).toHaveBeenCalledWith(
+ 'test-workflow',
+ 'id1',
+ {
+ testBoolean: false,
+ testParam: 'given value',
+ },
+ );
+ });
+});
diff --git a/packages/frontend/editor-ui/src/components/FromAiParametersModal.vue b/packages/frontend/editor-ui/src/components/FromAiParametersModal.vue
new file mode 100644
index 0000000000..98647895e5
--- /dev/null
+++ b/packages/frontend/editor-ui/src/components/FromAiParametersModal.vue
@@ -0,0 +1,203 @@
+
+
+
+
+
+
+
+
+ {{
+ i18n.baseText('fromAiParametersModal.description', {
+ interpolate: { parentNodeName: parentNode || '' },
+ })
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/components/Modals.vue b/packages/frontend/editor-ui/src/components/Modals.vue
index e23bf0e3aa..75f32f082b 100644
--- a/packages/frontend/editor-ui/src/components/Modals.vue
+++ b/packages/frontend/editor-ui/src/components/Modals.vue
@@ -36,6 +36,7 @@ import {
DELETE_FOLDER_MODAL_KEY,
MOVE_FOLDER_MODAL_KEY,
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
+ FROM_AI_PARAMETERS_MODAL_KEY,
} from '@/constants';
import AboutModal from '@/components/AboutModal.vue';
@@ -73,6 +74,7 @@ import NewAssistantSessionModal from '@/components/AskAssistant/Chat/NewAssistan
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentModal.vue';
import WorkflowActivationConflictingWebhookModal from '@/components/WorkflowActivationConflictingWebhookModal.vue';
+import FromAiParametersModal from '@/components/FromAiParametersModal.vue';
import type { EventBus } from '@n8n/utils/event-bus';
@@ -302,5 +304,11 @@ import type { EventBus } from '@n8n/utils/event-bus';
+
+
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/components/NodeExecuteButton.vue b/packages/frontend/editor-ui/src/components/NodeExecuteButton.vue
index 607e9a4af2..7359287212 100644
--- a/packages/frontend/editor-ui/src/components/NodeExecuteButton.vue
+++ b/packages/frontend/editor-ui/src/components/NodeExecuteButton.vue
@@ -6,6 +6,7 @@ import {
MODAL_CONFIRM,
FORM_TRIGGER_NODE_TYPE,
CHAT_TRIGGER_NODE_TYPE,
+ FROM_AI_PARAMETERS_MODAL_KEY,
} from '@/constants';
import {
AI_TRANSFORM_CODE_GENERATED_FOR_PROMPT,
@@ -27,6 +28,7 @@ import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { type IUpdateInformation } from '@/Interface';
import { generateCodeForAiTransform } from '@/components/ButtonParameter/utils';
+import { hasFromAiExpressions } from '@/utils/nodes/nodeTransforms';
const NODE_TEST_STEP_POPUP_COUNT_KEY = 'N8N_NODE_TEST_STEP_POPUP_COUNT';
const MAX_POPUP_COUNT = 10;
@@ -341,22 +343,31 @@ async function onClick() {
}
if (!pinnedData.hasData.value || shouldUnpinAndExecute) {
- const telemetryPayload = {
- node_type: nodeType.value ? nodeType.value.name : null,
- workflow_id: workflowsStore.workflowId,
- source: props.telemetrySource,
- push_ref: ndvStore.pushRef,
- };
+ if (node.value && hasFromAiExpressions(node.value)) {
+ uiStore.openModalWithData({
+ name: FROM_AI_PARAMETERS_MODAL_KEY,
+ data: {
+ nodeName: props.nodeName,
+ },
+ });
+ } else {
+ const telemetryPayload = {
+ node_type: nodeType.value ? nodeType.value.name : null,
+ workflow_id: workflowsStore.workflowId,
+ source: props.telemetrySource,
+ push_ref: ndvStore.pushRef,
+ };
- telemetry.track('User clicked execute node button', telemetryPayload);
- await externalHooks.run('nodeExecuteButton.onClick', telemetryPayload);
+ telemetry.track('User clicked execute node button', telemetryPayload);
+ await externalHooks.run('nodeExecuteButton.onClick', telemetryPayload);
- await runWorkflow({
- destinationNode: props.nodeName,
- source: 'RunData.ExecuteNodeButton',
- });
+ await runWorkflow({
+ destinationNode: props.nodeName,
+ source: 'RunData.ExecuteNodeButton',
+ });
- emit('execute');
+ emit('execute');
+ }
}
}
}
diff --git a/packages/frontend/editor-ui/src/components/NodeSettings.vue b/packages/frontend/editor-ui/src/components/NodeSettings.vue
index c90594b54a..0192c85942 100644
--- a/packages/frontend/editor-ui/src/components/NodeSettings.vue
+++ b/packages/frontend/editor-ui/src/components/NodeSettings.vue
@@ -130,6 +130,10 @@ const node = computed(() => ndvStore.activeNode);
const isTriggerNode = computed(() => !!node.value && nodeTypesStore.isTriggerNode(node.value.type));
+const isNodesAsToolNode = computed(
+ () => !!node.value && nodeTypesStore.isNodesAsToolNode(node.value.type),
+);
+
const isExecutable = computed(() => {
if (props.nodeType && node.value) {
const workflowNode = currentWorkflowInstance.value.getNode(node.value.name);
@@ -140,7 +144,11 @@ const isExecutable = computed(() => {
);
const inputNames = NodeHelpers.getConnectionTypes(inputs);
- if (!inputNames.includes(NodeConnectionTypes.Main) && !isTriggerNode.value) {
+ if (
+ !inputNames.includes(NodeConnectionTypes.Main) &&
+ !isNodesAsToolNode.value &&
+ !isTriggerNode.value
+ ) {
return false;
}
}
diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.test.ts b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.test.ts
index 05416e1995..196128f4b4 100644
--- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.test.ts
+++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.test.ts
@@ -4,12 +4,21 @@ import CanvasNodeToolbar from '@/components/canvas/elements/nodes/CanvasNodeTool
import { createComponentRenderer } from '@/__tests__/render';
import { createCanvasNodeProvide, createCanvasProvide } from '@/__tests__/data';
import { CanvasNodeRenderType } from '@/types';
+import { createPinia, setActivePinia, type Pinia } from 'pinia';
const renderComponent = createComponentRenderer(CanvasNodeToolbar);
describe('CanvasNodeToolbar', () => {
+ let pinia: Pinia;
+
+ beforeEach(() => {
+ pinia = createPinia();
+ setActivePinia(pinia);
+ });
+
it('should render execute node button when renderType is not configuration', async () => {
const { getByTestId } = renderComponent({
+ pinia,
global: {
provide: {
...createCanvasNodeProvide(),
@@ -23,6 +32,7 @@ describe('CanvasNodeToolbar', () => {
it('should render disabled execute node button when canvas is executing', () => {
const { getByTestId } = renderComponent({
+ pinia,
global: {
provide: {
...createCanvasNodeProvide(),
@@ -38,6 +48,7 @@ describe('CanvasNodeToolbar', () => {
it('should render disabled execute node button when node is deactivated', async () => {
const { getByTestId, getByRole } = renderComponent({
+ pinia,
global: {
provide: {
...createCanvasNodeProvide({
@@ -96,6 +107,7 @@ describe('CanvasNodeToolbar', () => {
it('should emit "toggle" when disable node button is clicked', async () => {
const { getByTestId, emitted } = renderComponent({
+ pinia,
global: {
provide: {
...createCanvasNodeProvide(),
@@ -111,6 +123,7 @@ describe('CanvasNodeToolbar', () => {
it('should emit "delete" when delete node button is clicked', async () => {
const { getByTestId, emitted } = renderComponent({
+ pinia,
global: {
provide: {
...createCanvasNodeProvide(),
@@ -126,6 +139,7 @@ describe('CanvasNodeToolbar', () => {
it('should emit "open:contextmenu" when overflow node button is clicked', async () => {
const { getByTestId, emitted } = renderComponent({
+ pinia,
global: {
provide: {
...createCanvasNodeProvide(),
@@ -141,6 +155,7 @@ describe('CanvasNodeToolbar', () => {
it('should emit "update" when sticky note color is changed', async () => {
const { getAllByTestId, getByTestId, emitted } = renderComponent({
+ pinia,
global: {
provide: {
...createCanvasNodeProvide({
@@ -164,6 +179,7 @@ describe('CanvasNodeToolbar', () => {
it('should have "forceVisible" class when hovered', async () => {
const { getByTestId } = renderComponent({
+ pinia,
global: {
provide: {
...createCanvasNodeProvide(),
@@ -181,6 +197,7 @@ describe('CanvasNodeToolbar', () => {
it('should have "forceVisible" class when sticky color picker is visible', async () => {
const { getByTestId } = renderComponent({
+ pinia,
global: {
provide: {
...createCanvasNodeProvide({
diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.vue b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.vue
index ae47fe8d01..72f7c89d1d 100644
--- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.vue
+++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/CanvasNodeToolbar.vue
@@ -4,6 +4,8 @@ import { useI18n } from '@/composables/useI18n';
import { useCanvasNode } from '@/composables/useCanvasNode';
import { CanvasNodeRenderType } from '@/types';
import { useCanvas } from '@/composables/useCanvas';
+import { useNodeTypesStore } from '@/stores/nodeTypes.store';
+import { useWorkflowsStore } from '@/stores/workflows.store';
const emit = defineEmits<{
delete: [];
@@ -21,7 +23,15 @@ const $style = useCssModule();
const i18n = useI18n();
const { isExecuting } = useCanvas();
-const { isDisabled, render } = useCanvasNode();
+const { isDisabled, render, name } = useCanvasNode();
+
+const workflowsStore = useWorkflowsStore();
+const nodeTypesStore = useNodeTypesStore();
+
+const node = computed(() => !!name.value && workflowsStore.getNodeByName(name.value));
+const isNodesAsToolNode = computed(
+ () => !!node.value && nodeTypesStore.isNodesAsToolNode(node.value.type),
+);
const nodeDisabledTitle = computed(() => {
return isDisabled.value ? i18n.baseText('node.enable') : i18n.baseText('node.disable');
@@ -41,7 +51,7 @@ const isExecuteNodeVisible = computed(() => {
!props.readOnly &&
render.value.type === CanvasNodeRenderType.Default &&
'configuration' in render.value.options &&
- !render.value.options.configuration
+ (!render.value.options.configuration || isNodesAsToolNode.value)
);
});
diff --git a/packages/frontend/editor-ui/src/composables/useRunWorkflow.test.ts b/packages/frontend/editor-ui/src/composables/useRunWorkflow.test.ts
index 00eb5f3cb7..1c2aa2e0c3 100644
--- a/packages/frontend/editor-ui/src/composables/useRunWorkflow.test.ts
+++ b/packages/frontend/editor-ui/src/composables/useRunWorkflow.test.ts
@@ -10,6 +10,7 @@ import type {
IExecuteData,
ITaskData,
INodeConnections,
+ INode,
} from 'n8n-workflow';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
@@ -24,6 +25,7 @@ import { useSettingsStore } from '@/stores/settings.store';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
import { waitFor } from '@testing-library/vue';
+import { useParameterOverridesStore } from '@/stores/parameterOverrides.store';
vi.mock('@/stores/workflows.store', () => {
const storeState: Partial> & {
@@ -40,7 +42,11 @@ vi.mock('@/stores/workflows.store', () => {
nodesIssuesExist: false,
executionWaitingForWebhook: false,
getCurrentWorkflow: vi.fn().mockReturnValue({ id: '123' }),
- getNodeByName: vi.fn(),
+ getNodeByName: vi
+ .fn()
+ .mockImplementation((name) =>
+ name === 'Test node' ? { name: 'Test node', id: 'Test id' } : undefined,
+ ),
getExecution: vi.fn(),
checkIfNodeHasChatParent: vi.fn(),
getParametersLastUpdate: vi.fn(),
@@ -59,6 +65,16 @@ vi.mock('@/stores/workflows.store', () => {
};
});
+vi.mock('@/stores/parameterOverrides.store', () => {
+ const storeState: Partial> & {} = {
+ parameterOverrides: {},
+ substituteParameters: vi.fn(),
+ };
+ return {
+ useParameterOverridesStore: vi.fn().mockReturnValue(storeState),
+ };
+});
+
vi.mock('@/stores/pushConnection.store', () => ({
usePushConnectionStore: vi.fn().mockReturnValue({
isConnected: true,
@@ -122,6 +138,7 @@ describe('useRunWorkflow({ router })', () => {
let router: ReturnType;
let workflowHelpers: ReturnType;
let settingsStore: ReturnType;
+ let parameterOverridesStore: ReturnType;
beforeEach(() => {
const pinia = createTestingPinia({ stubActions: false });
@@ -132,6 +149,7 @@ describe('useRunWorkflow({ router })', () => {
uiStore = useUIStore();
workflowsStore = useWorkflowsStore();
settingsStore = useSettingsStore();
+ parameterOverridesStore = useParameterOverridesStore();
router = useRouter();
workflowHelpers = useWorkflowHelpers({ router });
@@ -494,6 +512,69 @@ describe('useRunWorkflow({ router })', () => {
});
});
+ it('does substituteParameters on partial execution if `partialExecutionVersion` is set to 2', async () => {
+ // ARRANGE
+ const mockExecutionResponse = { executionId: '123' };
+ const mockRunData = { nodeName: [] };
+ const { runWorkflow } = useRunWorkflow({ router });
+ const dataCaptor = captor();
+
+ const workflow = mock({
+ name: 'Test Workflow',
+ id: 'WorkflowId',
+ nodes: {
+ 'Test node': {
+ id: 'Test id',
+ name: 'Test node',
+ parameters: {
+ param: '0',
+ },
+ },
+ },
+ });
+
+ const workflowData = {
+ id: 'workflowId',
+ nodes: [
+ {
+ id: 'Test id',
+ name: 'Test node',
+ parameters: {
+ param: '0',
+ },
+ position: [0, 0],
+ type: 'n8n-nodes-base.test',
+ typeVersion: 1,
+ } as INode,
+ ],
+ connections: {},
+ };
+
+ workflow.getParentNodes.mockReturnValue([]);
+
+ vi.mocked(settingsStore).partialExecutionVersion = 2;
+ vi.mocked(pushConnectionStore).isConnected = true;
+ vi.mocked(workflowsStore).runWorkflow.mockResolvedValue(mockExecutionResponse);
+ vi.mocked(workflowsStore).nodesIssuesExist = false;
+ vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(workflow);
+ vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue(workflowData);
+ vi.mocked(workflowsStore).getWorkflowRunData = mockRunData;
+
+ // ACT
+ const result = await runWorkflow({ destinationNode: 'Test node' });
+
+ // ASSERT
+ expect(parameterOverridesStore.substituteParameters).toHaveBeenCalledWith(
+ 'WorkflowId',
+ 'Test id',
+ { param: '0' },
+ );
+ expect(result).toEqual(mockExecutionResponse);
+ expect(workflowsStore.setWorkflowExecutionData).toHaveBeenCalledTimes(1);
+ expect(workflowsStore.setWorkflowExecutionData).toHaveBeenCalledWith(dataCaptor);
+ expect(dataCaptor.value).toMatchObject({ data: { resultData: { runData: mockRunData } } });
+ });
+
it('retains the original run data if `partialExecutionVersion` is set to 2', async () => {
// ARRANGE
const mockExecutionResponse = { executionId: '123' };
diff --git a/packages/frontend/editor-ui/src/composables/useRunWorkflow.ts b/packages/frontend/editor-ui/src/composables/useRunWorkflow.ts
index d5084e6626..be393dee4d 100644
--- a/packages/frontend/editor-ui/src/composables/useRunWorkflow.ts
+++ b/packages/frontend/editor-ui/src/composables/useRunWorkflow.ts
@@ -42,6 +42,7 @@ import { useTelemetry } from './useTelemetry';
import { useSettingsStore } from '@/stores/settings.store';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
import { useNodeDirtiness } from '@/composables/useNodeDirtiness';
+import { useParameterOverridesStore } from '@/stores/parameterOverrides.store';
export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType }) {
const nodeHelpers = useNodeHelpers();
@@ -51,6 +52,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType nodeData.id === nodeId);
+ if (node?.parameters) {
+ node.parameters = parameterOverridesStore.substituteParameters(
+ workflow.id,
+ nodeId,
+ node?.parameters,
+ );
+ }
+ }
}
if (startRunData.runData) {
diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts
index bd6f08ca2c..6f6d294ecf 100644
--- a/packages/frontend/editor-ui/src/constants.ts
+++ b/packages/frontend/editor-ui/src/constants.ts
@@ -80,6 +80,7 @@ export const DELETE_FOLDER_MODAL_KEY = 'deleteFolder';
export const MOVE_FOLDER_MODAL_KEY = 'moveFolder';
export const WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY =
'workflowActivationConflictingWebhook';
+export const FROM_AI_PARAMETERS_MODAL_KEY = 'fromAiParameters';
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
UNINSTALL: 'uninstall',
diff --git a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json
index 3a4919667d..e527e51b78 100644
--- a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json
+++ b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json
@@ -834,6 +834,10 @@
"executionsList.debug.paywall.content": "Debug in Editor allows you to debug a previous execution with the actual data pinned, right in your editor.",
"executionsList.debug.paywall.link.text": "Read more in the docs",
"executionsList.debug.paywall.link.url": "https://docs.n8n.io/workflows/executions/debug/",
+ "fromAiParametersModal.title": "Test {nodeName}",
+ "fromAiParametersModal.execute": "Test step",
+ "fromAiParametersModal.description": "Provide the data that would normally come from the \"{parentNodeName}\" node",
+ "fromAiParametersModal.cancel": "Cancel",
"workerList.pageTitle": "Workers",
"workerList.empty": "No workers are responding or available",
"workerList.item.lastUpdated": "Last updated",
diff --git a/packages/frontend/editor-ui/src/stores/nodeTypes.store.ts b/packages/frontend/editor-ui/src/stores/nodeTypes.store.ts
index 5fa6fdb400..af7a43d15b 100644
--- a/packages/frontend/editor-ui/src/stores/nodeTypes.store.ts
+++ b/packages/frontend/editor-ui/src/stores/nodeTypes.store.ts
@@ -122,6 +122,17 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
};
});
+ const isNodesAsToolNode = computed(() => {
+ return (nodeTypeName: string) => {
+ const nodeType = getNodeType.value(nodeTypeName);
+ return !!(
+ nodeType &&
+ nodeType.outputs.includes(NodeConnectionTypes.AiTool) &&
+ nodeType.usableAsTool
+ );
+ };
+ });
+
const isCoreNodeType = computed(() => {
return (nodeType: INodeTypeDescription) => {
return nodeType.codex?.categories?.includes('Core Nodes');
@@ -328,6 +339,7 @@ export const useNodeTypesStore = defineStore(STORES.NODE_TYPES, () => {
getCredentialOnlyNodeType,
isConfigNode,
isTriggerNode,
+ isNodesAsToolNode,
isCoreNodeType,
visibleNodeTypes,
nativelyNumberSuffixedDefaults,
diff --git a/packages/frontend/editor-ui/src/stores/parameterOverrides.store.ts b/packages/frontend/editor-ui/src/stores/parameterOverrides.store.ts
new file mode 100644
index 0000000000..b7fb600490
--- /dev/null
+++ b/packages/frontend/editor-ui/src/stores/parameterOverrides.store.ts
@@ -0,0 +1,222 @@
+import { type INodeParameters, type NodeParameterValueType } from 'n8n-workflow';
+import { defineStore } from 'pinia';
+import { ref, watch } from 'vue';
+
+interface IParameterOverridesStoreState {
+ [workflowId: string]: {
+ [nodeName: string]: INodeParameters;
+ };
+}
+
+const STORAGE_KEY = 'n8n-parameter-overrides';
+
+export const useParameterOverridesStore = defineStore('parameterOverrides', () => {
+ // State
+ const parameterOverrides = ref(loadFromLocalStorage());
+
+ // Load initial state from localStorage
+ function loadFromLocalStorage(): IParameterOverridesStoreState {
+ try {
+ const storedData = localStorage.getItem(STORAGE_KEY);
+ return storedData ? JSON.parse(storedData) : {};
+ } catch (error) {
+ return {};
+ }
+ }
+
+ // Save state to localStorage whenever it changes
+ watch(
+ parameterOverrides,
+ (newValue) => {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(newValue));
+ } catch (error) {
+ console.error('Failed to save parameter overrides to localStorage:', error);
+ }
+ },
+ { deep: true },
+ );
+
+ // Helper function to ensure workflow and node entries exist
+ const ensureWorkflowAndNodeExist = (workflowId: string, nodeId: string): void => {
+ if (!parameterOverrides.value[workflowId]) {
+ parameterOverrides.value[workflowId] = {};
+ }
+
+ if (!parameterOverrides.value[workflowId][nodeId]) {
+ parameterOverrides.value[workflowId][nodeId] = {};
+ }
+ };
+
+ // Getters
+ const getParameterOverrides = (workflowId: string, nodeId: string): INodeParameters => {
+ return parameterOverrides.value[workflowId]?.[nodeId] || {};
+ };
+
+ const getParameterOverride = (
+ workflowId: string,
+ nodeId: string,
+ paramName: string,
+ ): NodeParameterValueType | undefined => {
+ return parameterOverrides.value[workflowId]?.[nodeId]?.[paramName];
+ };
+
+ // Actions
+ const addParameterOverride = (
+ workflowId: string,
+ nodeId: string,
+ paramName: string,
+ paramValues: NodeParameterValueType,
+ ): INodeParameters => {
+ ensureWorkflowAndNodeExist(workflowId, nodeId);
+
+ parameterOverrides.value[workflowId][nodeId] = {
+ ...parameterOverrides.value[workflowId][nodeId],
+ [paramName]: paramValues,
+ };
+
+ return parameterOverrides.value[workflowId][nodeId];
+ };
+
+ const addParameterOverrides = (
+ workflowId: string,
+ nodeId: string,
+ params: INodeParameters,
+ ): void => {
+ ensureWorkflowAndNodeExist(workflowId, nodeId);
+
+ parameterOverrides.value[workflowId][nodeId] = {
+ ...parameterOverrides.value[workflowId][nodeId],
+ ...params,
+ };
+ };
+
+ const clearParameterOverrides = (workflowId: string, nodeId: string): void => {
+ if (parameterOverrides.value[workflowId]) {
+ parameterOverrides.value[workflowId][nodeId] = {};
+ }
+ };
+
+ const clearAllParameterOverrides = (workflowId?: string): void => {
+ if (workflowId) {
+ // Clear overrides for a specific workflow
+ parameterOverrides.value[workflowId] = {};
+ } else {
+ // Clear all overrides
+ parameterOverrides.value = {};
+ }
+ };
+
+ function parsePath(path: string): string[] {
+ return path.split('.').reduce((acc: string[], part) => {
+ if (part.includes('[')) {
+ const [arrayName, index] = part.split('[');
+ if (arrayName) acc.push(arrayName);
+ if (index) acc.push(index.replace(']', ''));
+ } else {
+ acc.push(part);
+ }
+ return acc;
+ }, []);
+ }
+
+ function buildOverrideObject(path: string[], value: NodeParameterValueType): INodeParameters {
+ const result: INodeParameters = {};
+ let current = result;
+
+ for (let i = 0; i < path.length - 1; i++) {
+ const part = path[i];
+ const nextPart = path[i + 1];
+ const isArrayIndex = nextPart && !isNaN(Number(nextPart));
+
+ if (isArrayIndex) {
+ if (!current[part]) {
+ current[part] = [];
+ }
+ while ((current[part] as NodeParameterValueType[]).length <= Number(nextPart)) {
+ (current[part] as NodeParameterValueType[]).push({});
+ }
+ } else if (!current[part]) {
+ current[part] = {};
+ }
+
+ current = current[part] as INodeParameters;
+ }
+
+ current[path[path.length - 1]] = value;
+ return result;
+ }
+
+ // Helper function to deep merge objects
+ function deepMerge(target: INodeParameters, source: INodeParameters): INodeParameters {
+ const result = { ...target };
+
+ for (const key in source) {
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
+ // Recursively merge nested objects
+ result[key] = deepMerge(
+ (result[key] as INodeParameters) || {},
+ source[key] as INodeParameters,
+ );
+ } else if (Array.isArray(source[key])) {
+ // For arrays, merge by index
+ if (Array.isArray(result[key])) {
+ const targetArray = result[key] as NodeParameterValueType[];
+ const sourceArray = source[key] as NodeParameterValueType[];
+
+ // Ensure target array has enough elements
+ while (targetArray.length < sourceArray.length) {
+ targetArray.push({});
+ }
+
+ // Merge each array item
+ sourceArray.forEach((item, index) => {
+ if (item && typeof item === 'object') {
+ targetArray[index] = deepMerge(
+ (targetArray[index] as INodeParameters) || {},
+ item as INodeParameters,
+ ) as NodeParameterValueType;
+ } else {
+ targetArray[index] = item;
+ }
+ });
+ } else {
+ result[key] = source[key];
+ }
+ } else {
+ // For primitive values, use source value
+ result[key] = source[key];
+ }
+ }
+
+ return result;
+ }
+
+ const substituteParameters = (
+ workflowId: string,
+ nodeId: string,
+ nodeParameters: INodeParameters,
+ ): INodeParameters => {
+ if (!nodeParameters) return {};
+
+ const nodeOverrides = parameterOverrides.value[workflowId]?.[nodeId] || {};
+
+ const overrideParams = Object.entries(nodeOverrides).reduce(
+ (acc, [path, value]) => deepMerge(acc, buildOverrideObject(parsePath(path), value)),
+ {} as INodeParameters,
+ );
+
+ return deepMerge(nodeParameters, overrideParams);
+ };
+
+ return {
+ parameterOverrides,
+ getParameterOverrides,
+ getParameterOverride,
+ addParameterOverride,
+ addParameterOverrides,
+ clearParameterOverrides,
+ clearAllParameterOverrides,
+ substituteParameters,
+ };
+});
diff --git a/packages/frontend/editor-ui/src/stores/parameterOverrides.test.ts b/packages/frontend/editor-ui/src/stores/parameterOverrides.test.ts
new file mode 100644
index 0000000000..74746b663b
--- /dev/null
+++ b/packages/frontend/editor-ui/src/stores/parameterOverrides.test.ts
@@ -0,0 +1,265 @@
+import { setActivePinia, createPinia } from 'pinia';
+import { useParameterOverridesStore } from './parameterOverrides.store';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { nextTick } from 'vue';
+
+// Mock localStorage
+const localStorageMock = {
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ clear: vi.fn(),
+};
+
+Object.defineProperty(window, 'localStorage', {
+ value: localStorageMock,
+ writable: true,
+});
+
+describe('parameterOverrides.store', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ localStorageMock.getItem.mockReset();
+ localStorageMock.setItem.mockReset();
+ localStorageMock.clear.mockReset();
+ });
+
+ describe('Initialization', () => {
+ it('initializes with empty state when localStorage is empty', () => {
+ localStorageMock.getItem.mockReturnValue(null);
+ const store = useParameterOverridesStore();
+ expect(store.parameterOverrides).toEqual({});
+ });
+
+ it('initializes with data from localStorage', () => {
+ const mockData = {
+ 'workflow-1': {
+ 'node-1': { param1: 'value1' },
+ },
+ };
+ localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
+ const store = useParameterOverridesStore();
+ expect(store.parameterOverrides).toEqual(mockData);
+ });
+
+ it('handles localStorage errors gracefully', () => {
+ localStorageMock.getItem.mockImplementation(() => {
+ throw new Error('Storage error');
+ });
+ const store = useParameterOverridesStore();
+ expect(store.parameterOverrides).toEqual({});
+ });
+ });
+
+ describe('Getters', () => {
+ it('gets parameter overrides for a node', () => {
+ const mockData = {
+ 'workflow-1': {
+ 'node-1': { param1: 'value1', param2: 'value2' },
+ },
+ };
+ localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
+ const store = useParameterOverridesStore();
+
+ const overrides = store.getParameterOverrides('workflow-1', 'node-1');
+ expect(overrides).toEqual({ param1: 'value1', param2: 'value2' });
+ });
+
+ it('returns empty object for non-existent workflow/node', () => {
+ const store = useParameterOverridesStore();
+
+ const overrides = store.getParameterOverrides('non-existent', 'node-1');
+ expect(overrides).toEqual({});
+ });
+
+ it('gets a specific parameter override', () => {
+ const mockData = {
+ 'workflow-1': {
+ 'node-1': { param1: 'value1', param2: 'value2' },
+ },
+ };
+ localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
+ const store = useParameterOverridesStore();
+
+ const override = store.getParameterOverride('workflow-1', 'node-1', 'param1');
+ expect(override).toBe('value1');
+ });
+
+ it('returns undefined for non-existent parameter', () => {
+ const mockData = {
+ 'workflow-1': {
+ 'node-1': { param1: 'value1' },
+ },
+ };
+ localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
+ const store = useParameterOverridesStore();
+
+ const override = store.getParameterOverride('workflow-1', 'node-1', 'non-existent');
+ expect(override).toBeUndefined();
+ });
+ });
+
+ describe('Actions', () => {
+ it('adds a parameter override', () => {
+ const store = useParameterOverridesStore();
+
+ store.addParameterOverride('workflow-1', 'node-1', 'param1', 'value1');
+
+ expect(store.parameterOverrides['workflow-1']['node-1']['param1']).toBe('value1');
+ });
+
+ it('adds multiple parameter overrides', () => {
+ const store = useParameterOverridesStore();
+
+ store.addParameterOverrides('workflow-1', 'node-1', {
+ param1: 'value1',
+ param2: 'value2',
+ });
+
+ expect(store.parameterOverrides['workflow-1']['node-1']).toEqual({
+ param1: 'value1',
+ param2: 'value2',
+ });
+ });
+
+ it('clears parameter overrides for a node', () => {
+ const mockData = {
+ 'workflow-1': {
+ 'node-1': { param1: 'value1', param2: 'value2' },
+ 'node-2': { param3: 'value3' },
+ },
+ };
+ localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
+ const store = useParameterOverridesStore();
+
+ store.clearParameterOverrides('workflow-1', 'node-1');
+
+ expect(store.parameterOverrides['workflow-1']['node-1']).toEqual({});
+ expect(store.parameterOverrides['workflow-1']['node-2']).toEqual({ param3: 'value3' });
+ });
+
+ it('clears all parameter overrides for a workflow', () => {
+ const mockData = {
+ 'workflow-1': {
+ 'node-1': { param1: 'value1' },
+ 'node-2': { param2: 'value2' },
+ },
+ 'workflow-2': {
+ 'node-3': { param3: 'value3' },
+ },
+ };
+ localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
+ const store = useParameterOverridesStore();
+
+ store.clearAllParameterOverrides('workflow-1');
+
+ expect(store.parameterOverrides['workflow-1']).toEqual({});
+ expect(store.parameterOverrides['workflow-2']).toEqual({
+ 'node-3': { param3: 'value3' },
+ });
+ });
+
+ it('clears all parameter overrides when no workflowId is provided', () => {
+ const mockData = {
+ 'workflow-1': {
+ 'node-1': { param1: 'value1' },
+ },
+ 'workflow-2': {
+ 'node-2': { param2: 'value2' },
+ },
+ };
+ localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
+ const store = useParameterOverridesStore();
+
+ store.clearAllParameterOverrides();
+
+ expect(store.parameterOverrides).toEqual({});
+ });
+ });
+
+ describe('substituteParameters', () => {
+ it('substitutes parameters in a node', () => {
+ const store = useParameterOverridesStore();
+
+ store.addParameterOverrides('workflow-1', 'id1', {
+ param1: 'override1',
+ 'parent.child': 'override2',
+ 'parent.array[0].value': 'overrideArray1',
+ 'parent.array[1].value': 'overrideArray2',
+ });
+
+ const nodeParameters = {
+ 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({
+ param1: 'override1',
+ parent: {
+ child: 'override2',
+ array: [
+ {
+ name: 'name',
+ value: 'overrideArray1',
+ },
+ {
+ name: 'name2',
+ value: 'overrideArray2',
+ },
+ ],
+ },
+ });
+ });
+ });
+
+ describe('Persistence', () => {
+ it('saves to localStorage when state changes', async () => {
+ const store = useParameterOverridesStore();
+
+ localStorageMock.setItem.mockReset();
+
+ store.addParameterOverride('workflow-1', 'node-1', 'param1', 'value1');
+
+ // Wait for the next tick to allow the watch to execute
+ await nextTick();
+
+ expect(localStorageMock.setItem).toHaveBeenCalledWith(
+ 'n8n-parameter-overrides',
+ JSON.stringify({
+ 'workflow-1': {
+ 'node-1': { param1: 'value1' },
+ },
+ }),
+ );
+ });
+
+ it('should handle localStorage errors when saving', async () => {
+ const store = useParameterOverridesStore();
+
+ localStorageMock.setItem.mockReset();
+
+ localStorageMock.setItem.mockImplementation(() => {
+ throw new Error('Storage error');
+ });
+
+ store.addParameterOverride('workflow-1', 'node-1', 'param1', 'value1');
+
+ await nextTick();
+
+ expect(store.parameterOverrides['workflow-1']['node-1'].param1).toBe('value1');
+ });
+ });
+});
diff --git a/packages/frontend/editor-ui/src/stores/ui.store.ts b/packages/frontend/editor-ui/src/stores/ui.store.ts
index 40dd74d568..8e30bcb832 100644
--- a/packages/frontend/editor-ui/src/stores/ui.store.ts
+++ b/packages/frontend/editor-ui/src/stores/ui.store.ts
@@ -40,6 +40,7 @@ import {
DELETE_FOLDER_MODAL_KEY,
MOVE_FOLDER_MODAL_KEY,
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
+ FROM_AI_PARAMETERS_MODAL_KEY,
} from '@/constants';
import type {
INodeUi,
@@ -192,6 +193,12 @@ export const useUIStore = defineStore(STORES.UI, () => {
node: '',
},
},
+ [FROM_AI_PARAMETERS_MODAL_KEY]: {
+ open: false,
+ data: {
+ nodeName: undefined,
+ },
+ },
});
const modalStack = ref([]);
diff --git a/packages/frontend/editor-ui/src/utils/nodes/nodeTransforms.ts b/packages/frontend/editor-ui/src/utils/nodes/nodeTransforms.ts
index a49dc8e652..5a0cd09965 100644
--- a/packages/frontend/editor-ui/src/utils/nodes/nodeTransforms.ts
+++ b/packages/frontend/editor-ui/src/utils/nodes/nodeTransforms.ts
@@ -1,7 +1,7 @@
import type { INodeUi } from '@/Interface';
import type { NodeTypeProvider } from '@/utils/nodeTypes/nodeTypeTransforms';
-import type { INodeCredentialDescription } from 'n8n-workflow';
-import { NodeHelpers } from 'n8n-workflow';
+import type { INodeCredentialDescription, FromAIArgument } from 'n8n-workflow';
+import { NodeHelpers, traverseNodeParameters } from 'n8n-workflow';
/**
* Returns the credentials that are displayable for the given node.
@@ -77,3 +77,12 @@ export function doesNodeHaveAllCredentialsFilled(
return requiredCredentials.every((cred) => hasNodeCredentialFilled(node, cred.name));
}
+
+/**
+ * Checks if the given node has any fromAi expressions in its parameters.
+ */
+export function hasFromAiExpressions(node: Pick) {
+ const collectedArgs: FromAIArgument[] = [];
+ traverseNodeParameters(node.parameters, collectedArgs);
+ return collectedArgs.length > 0;
+}
diff --git a/packages/frontend/editor-ui/src/views/NodeView.vue b/packages/frontend/editor-ui/src/views/NodeView.vue
index 91c39f9e22..8190bbf925 100644
--- a/packages/frontend/editor-ui/src/views/NodeView.vue
+++ b/packages/frontend/editor-ui/src/views/NodeView.vue
@@ -53,6 +53,7 @@ import {
CHAT_TRIGGER_NODE_TYPE,
DRAG_EVENT_DATA_KEY,
EnterpriseEditionFeature,
+ FROM_AI_PARAMETERS_MODAL_KEY,
MAIN_HEADER_TABS,
MANUAL_CHAT_TRIGGER_NODE_TYPE,
MODAL_CONFIRM,
@@ -117,6 +118,8 @@ import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
import { useBuilderStore } from '@/stores/builder.store';
import { useFoldersStore } from '@/stores/folders.store';
+import { useParameterOverridesStore } from '@/stores/parameterOverrides.store';
+import { hasFromAiExpressions } from '@/utils/nodes/nodeTransforms';
defineOptions({
name: 'NodeView',
@@ -169,6 +172,7 @@ const ndvStore = useNDVStore();
const templatesStore = useTemplatesStore();
const builderStore = useBuilderStore();
const foldersStore = useFoldersStore();
+const parameterOverridesStore = useParameterOverridesStore();
const canvasEventBus = createEventBus();
@@ -1161,9 +1165,19 @@ async function onRunWorkflowToNode(id: string) {
const node = workflowsStore.getNodeById(id);
if (!node) return;
- trackRunWorkflowToNode(node);
+ if (hasFromAiExpressions(node) && nodeTypesStore.isNodesAsToolNode(node.type)) {
+ uiStore.openModalWithData({
+ name: FROM_AI_PARAMETERS_MODAL_KEY,
+ data: {
+ nodeName: node.name,
+ },
+ });
+ } else {
+ trackRunWorkflowToNode(node);
+ parameterOverridesStore.clearParameterOverrides(workflowsStore.workflowId, node.id);
- void runWorkflow({ destinationNode: node.name, source: 'Node.executeNode' });
+ void runWorkflow({ destinationNode: node.name, source: 'Node.executeNode' });
+ }
}
function trackRunWorkflowToNode(node: INodeUi) {
diff --git a/packages/workflow/src/FromAIParseUtils.ts b/packages/workflow/src/FromAIParseUtils.ts
index b96c91ec25..8ad82f9e09 100644
--- a/packages/workflow/src/FromAIParseUtils.ts
+++ b/packages/workflow/src/FromAIParseUtils.ts
@@ -315,3 +315,22 @@ export function traverseNodeParameters(payload: unknown, collectedArgs: FromAIAr
Object.values(payload).forEach((value) => traverseNodeParameters(value, collectedArgs));
}
}
+
+export function traverseNodeParametersWithParamNames(
+ payload: unknown,
+ collectedArgs: Map,
+ name?: string,
+) {
+ if (typeof payload === 'string') {
+ const fromAICalls = extractFromAICalls(payload);
+ fromAICalls.forEach((call) => collectedArgs.set(name as string, call));
+ } else if (Array.isArray(payload)) {
+ payload.forEach((item: unknown, index: number) =>
+ traverseNodeParametersWithParamNames(item, collectedArgs, name + `[${index}]`),
+ );
+ } else if (typeof payload === 'object' && payload !== null) {
+ for (const [key, value] of Object.entries(payload)) {
+ traverseNodeParametersWithParamNames(value, collectedArgs, name ? name + '.' + key : key);
+ }
+ }
+}
diff --git a/packages/workflow/src/NodeHelpers.ts b/packages/workflow/src/NodeHelpers.ts
index eeb04ea5c8..5ec0db2650 100644
--- a/packages/workflow/src/NodeHelpers.ts
+++ b/packages/workflow/src/NodeHelpers.ts
@@ -1556,5 +1556,9 @@ export function isTriggerNode(nodeTypeData: INodeTypeDescription) {
export function isExecutable(workflow: Workflow, node: INode, nodeTypeData: INodeTypeDescription) {
const outputs = getNodeOutputs(workflow, node, nodeTypeData);
const outputNames = getConnectionTypes(outputs);
- return outputNames.includes(NodeConnectionTypes.Main) || isTriggerNode(nodeTypeData);
+ return (
+ outputNames.includes(NodeConnectionTypes.Main) ||
+ isTriggerNode(nodeTypeData) ||
+ nodeTypeData.usableAsTool === true
+ );
}