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 @@ + + + + + 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 + ); }