Merge branch 'n8n-io:master' into master

This commit is contained in:
rqtqp 2025-03-01 16:22:52 +03:00 committed by GitHub
commit 633b000ddb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
1989 changed files with 14350 additions and 3315 deletions

View file

@ -14,5 +14,5 @@ const trimPackageJson = (packageName) => {
};
trimPackageJson('frontend/@n8n/chat');
trimPackageJson('design-system');
trimPackageJson('editor-ui');
trimPackageJson('frontend/@n8n/design-system');
trimPackageJson('frontend/editor-ui');

View file

@ -51,14 +51,20 @@ jobs:
- name: Dry-run publishing
run: pnpm publish -r --no-git-checks --dry-run
- name: Publish to NPM
- name: Pre publishing changes
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
node .github/scripts/trim-fe-packageJson.js
node .github/scripts/ensure-provenance-fields.mjs
cp README.md packages/cli/README.md
sed -i "s/default: 'dev'/default: 'stable'/g" packages/cli/dist/config/schema.js
pnpm publish -r --publish-branch ${{github.event.pull_request.base.ref}} --access public --tag rc --no-git-checks
npm dist-tag rm n8n rc
- name: Publish to NPM
run: pnpm publish -r --publish-branch ${{github.event.pull_request.base.ref}} --access public --tag rc --no-git-checks
- name: Cleanup rc tag
run: npm dist-tag rm n8n rc
continue-on-error: true
- id: set-release
run: echo "release=${{ env.RELEASE }}" >> $GITHUB_OUTPUT
@ -68,7 +74,7 @@ jobs:
needs: [publish-to-npm]
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
timeout-minutes: 10
timeout-minutes: 15
steps:
- name: Checkout
@ -156,7 +162,7 @@ jobs:
with:
projects: ${{ secrets.SENTRY_FRONTEND_PROJECT }}
version: ${{ needs.publish-to-npm.outputs.release }}
sourcemaps: packages/editor-ui/dist
sourcemaps: packages/frontend/editor-ui/dist
- name: Create a backend release
uses: getsentry/action-release@v1.7.0

View file

@ -11,10 +11,10 @@ on:
description: 'Release channel'
required: true
type: choice
default: 'next'
default: 'beta'
options:
- next
- latest
- beta
- stable
jobs:
release-to-npm:
@ -25,9 +25,18 @@ jobs:
- uses: actions/setup-node@v4.2.0
with:
node-version: 20.x
- run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
npm dist-tag add n8n@${{ github.event.inputs.version }} ${{ github.event.inputs.release-channel }}
- run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
- if: github.event.inputs.release-channel == 'beta'
run: |
npm dist-tag add n8n@${{ github.event.inputs.version }} next
npm dist-tag add n8n@${{ github.event.inputs.version }} beta
- if: github.event.inputs.release-channel == 'stable'
run: |
npm dist-tag add n8n@${{ github.event.inputs.version }} latest
npm dist-tag add n8n@${{ github.event.inputs.version }} stable
release-to-docker-hub:
name: Release to DockerHub
@ -39,7 +48,15 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- run: docker buildx imagetools create -t ${{ secrets.DOCKER_USERNAME }}/n8n:${{ github.event.inputs.release-channel }} ${{ secrets.DOCKER_USERNAME }}/n8n:${{ github.event.inputs.version }}
- if: github.event.inputs.release-channel == 'stable'
run: |
docker buildx imagetools create -t ${{ secrets.DOCKER_USERNAME }}/n8n:stable ${{ secrets.DOCKER_USERNAME }}/n8n:${{ github.event.inputs.version }}
docker buildx imagetools create -t ${{ secrets.DOCKER_USERNAME }}/n8n:latest ${{ secrets.DOCKER_USERNAME }}/n8n:${{ github.event.inputs.version }}
- if: github.event.inputs.release-channel == 'beta'
run: |
docker buildx imagetools create -t ${{ secrets.DOCKER_USERNAME }}/n8n:beta ${{ secrets.DOCKER_USERNAME }}/n8n:${{ github.event.inputs.version }}
docker buildx imagetools create -t ${{ secrets.DOCKER_USERNAME }}/n8n:next ${{ secrets.DOCKER_USERNAME }}/n8n:${{ github.event.inputs.version }}
release-to-github-container-registry:
name: Release to GitHub Container Registry
@ -52,7 +69,15 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- run: docker buildx imagetools create -t ghcr.io/${{ github.repository_owner }}/n8n:${{ github.event.inputs.release-channel }} ghcr.io/${{ github.repository_owner }}/n8n:${{ github.event.inputs.version }}
- if: github.event.inputs.release-channel == 'stable'
run: |
docker buildx imagetools create -t ghcr.io/${{ github.repository_owner }}/n8n:stable ghcr.io/${{ github.repository_owner }}/n8n:${{ github.event.inputs.version }}
docker buildx imagetools create -t ghcr.io/${{ github.repository_owner }}/n8n:latest ghcr.io/${{ github.repository_owner }}/n8n:${{ github.event.inputs.version }}
- if: github.event.inputs.release-channel == 'beta'
run: |
docker buildx imagetools create -t ghcr.io/${{ github.repository_owner }}/n8n:beta ghcr.io/${{ github.repository_owner }}/n8n:${{ github.event.inputs.version }}
docker buildx imagetools create -t ghcr.io/${{ github.repository_owner }}/n8n:next ghcr.io/${{ github.repository_owner }}/n8n:${{ github.event.inputs.version }}
update-docs:
name: Update latest and next in the docs

View file

@ -2,7 +2,7 @@ coverage
dist
package.json
pnpm-lock.yaml
packages/editor-ui/index.html
packages/frontend/editor-ui/index.html
packages/nodes-base/nodes/**/test
packages/cli/templates/form-trigger.handlebars
packages/cli/templates/form-trigger-completion.handlebars

View file

@ -49,8 +49,8 @@ The most important directories:
execution, active webhooks and
workflows. **Contact n8n before
starting on any changes here**
- [/packages/design-system](/packages/design-system) - Vue frontend components
- [/packages/editor-ui](/packages/editor-ui) - Vue frontend workflow editor
- [/packages/frontend/@n8n/design-system](/packages/design-system) - Vue frontend components
- [/packages/frontend/editor-ui](/packages/editor-ui) - Vue frontend workflow editor
- [/packages/node-dev](/packages/node-dev) - CLI to create new n8n-nodes
- [/packages/nodes-base](/packages/nodes-base) - Base n8n nodes
- [/packages/workflow](/packages/workflow) - Workflow code with interfaces which

View file

@ -43,8 +43,6 @@ component_management:
name: Frontend
paths:
- packages/@n8n/codemirror-lang/**
- packages/design-system/**
- packages/editor-ui/**
- packages/frontend/**
- component_id: nodes_packages
name: Nodes

View file

@ -1,10 +1,10 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
const sharedOptions = require('@n8n/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n_io/eslint-config/base', 'plugin:cypress/recommended'],
extends: ['@n8n/eslint-config/base', 'plugin:cypress/recommended'],
...sharedOptions(__dirname),

View file

@ -206,6 +206,10 @@ export function clickWorkflowCardContent(workflowName: string) {
getWorkflowCardContent(workflowName).click();
}
export function clickAssignmentCollectionAdd() {
cy.getByTestId('assignment-collection-drop-area').click();
}
export function assertNodeOutputHintExists() {
getNodeOutputHint().should('exist');
}

View file

@ -356,5 +356,5 @@ export function openContextMenu(
}
export function clickContextMenuAction(action: string) {
getContextMenuAction(action).click();
getContextMenuAction(action).click({ force: true });
}

View file

@ -52,7 +52,7 @@ export const PIPEDRIVE_NODE_NAME = 'Pipedrive';
export const HTTP_REQUEST_NODE_NAME = 'HTTP Request';
export const AGENT_NODE_NAME = 'AI Agent';
export const BASIC_LLM_CHAIN_NODE_NAME = 'Basic LLM Chain';
export const AI_MEMORY_WINDOW_BUFFER_MEMORY_NODE_NAME = 'Window Buffer Memory';
export const AI_MEMORY_WINDOW_BUFFER_MEMORY_NODE_NAME = 'Simple Memory';
export const AI_TOOL_CALCULATOR_NODE_NAME = 'Calculator';
export const AI_TOOL_CODE_NODE_NAME = 'Code Tool';
export const AI_TOOL_WIKIPEDIA_NODE_NAME = 'Wikipedia';

View file

@ -14,6 +14,8 @@ module.exports = defineConfig({
experimentalMemoryManagement: true,
e2e: {
baseUrl: BASE_URL,
viewportWidth: 1536,
viewportHeight: 960,
video: true,
screenshotOnRunFailure: true,
experimentalInteractiveRunEvents: true,

View file

@ -1,9 +1,11 @@
import { WorkflowSharingModal } from '../pages';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows';
import { getUniqueWorkflowName } from '../utils/workflowUtils';
const WorkflowsPage = new WorkflowsPageClass();
const WorkflowPage = new WorkflowPageClass();
const workflowSharingModal = new WorkflowSharingModal();
const multipleWorkflowsCount = 5;
@ -138,4 +140,10 @@ describe('Workflows', () => {
cy.url().should('include', 'sort=lastCreated');
cy.url().should('include', 'pageSize=25');
});
it('should be able to share workflows from workflows list', () => {
WorkflowsPage.getters.workflowCardActions('Empty State Card Workflow').click();
WorkflowsPage.getters.workflowActionItem('share').click();
workflowSharingModal.getters.modal().should('be.visible');
});
});

View file

@ -22,7 +22,11 @@ describe('Undo/Redo', () => {
it('should undo/redo deleting node using context menu', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_NAME);
WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_NAME, {
method: 'right-click',
anchor: 'topLeft',
});
WorkflowPage.getters.canvasNodes().should('have.have.length', 1);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
WorkflowPage.actions.hitUndo();

View file

@ -17,6 +17,7 @@ import {
openContextMenu,
} from '../composables/workflow';
import { NDV, WorkflowExecutionsTab } from '../pages';
import { clearNotifications, successToast } from '../pages/notifications';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
const WorkflowPage = new WorkflowPageClass();
@ -235,7 +236,11 @@ describe('Canvas Node Manipulation and Navigation', () => {
it('should delete node using context menu', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_NAME);
WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.deleteNodeFromContextMenu(CODE_NODE_NAME, {
method: 'right-click',
anchor: 'topLeft',
});
WorkflowPage.getters.canvasNodes().should('have.length', 1);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
@ -379,6 +384,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
// At this point last added node should be off-screen
WorkflowPage.getters.canvasNodes().last().should('not.be.visible');
WorkflowPage.getters.zoomToFitButton().click();
@ -485,6 +491,9 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.executeWorkflow();
successToast().should('contain.text', 'Workflow executed successfully');
clearNotifications();
ExecutionsTab.actions.switchToExecutionsTab();
ExecutionsTab.getters.successfulExecutionListItems().should('have.length', 1);

View file

@ -27,8 +27,8 @@ describe('Workflow Executions', () => {
executionsTab.getters.executionsList().scrollTo(0, 500).wait(0);
executionsTab.getters.executionListItems().should('have.length', 11);
executionsTab.getters.successfulExecutionListItems().should('have.length', 9);
executionsTab.getters.executionListItems().should('have.length', 30);
executionsTab.getters.successfulExecutionListItems().should('have.length', 28);
executionsTab.getters.failedExecutionListItems().should('have.length', 2);
executionsTab.getters
.executionListItems()
@ -185,8 +185,9 @@ describe('Workflow Executions', () => {
.invoke('attr', 'title')
.should('eq', newWorkflowName);
});
it('should load items and auto scroll after filter change', () => {
// This should be a component test. Abstracting this away into to ensure our lists work.
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
it.skip('should load items and auto scroll after filter change', () => {
createMockExecutions();
createMockExecutions();
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
@ -289,15 +290,20 @@ describe('Workflow Executions', () => {
});
const createMockExecutions = () => {
executionsTab.actions.createManualExecutions(5);
executionsTab.actions.createManualExecutions(15);
// This wait is added to allow time for the notifications to expire
cy.wait(2000);
// Make some failed executions by enabling Code node with syntax error
executionsTab.actions.toggleNodeEnabled('Error');
workflowPage.getters.disabledNodes().should('have.length', 0);
executionsTab.actions.createManualExecutions(2);
// This wait is added to allow time for the notifications to expire
cy.wait(2000);
// Then add some more successful ones
executionsTab.actions.toggleNodeEnabled('Error');
workflowPage.getters.disabledNodes().should('have.length', 1);
executionsTab.actions.createManualExecutions(4);
executionsTab.actions.createManualExecutions(15);
};
const checkMainHeaderELements = () => {

View file

@ -54,11 +54,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
cy.changeQuota('maxTeamProjects', -1);
});
/**
* @TODO: New Canvas - Fix this test
*/
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
it.skip('should filter credentials by project ID when creating new workflow or hard reloading an opened workflow', () => {
it('should filter credentials by project ID when creating new workflow or hard reloading an opened workflow', () => {
cy.signinAsOwner();
cy.visit(workflowsPage.url);
@ -453,6 +449,8 @@ describe('Projects', { disableAutoLogin: true }, () => {
});
it('should allow to change inaccessible credential when the workflow was moved to a team project', () => {
cy.intercept('GET', /\/rest\/(workflows|credentials).*/).as('getResources');
cy.signinAsOwner();
cy.visit(workflowsPage.url);
@ -474,15 +472,14 @@ describe('Projects', { disableAutoLogin: true }, () => {
// Create a project and add a user to it
projects.createProject('Project 1');
projects.addProjectMember(INSTANCE_MEMBERS[0].email);
clearNotifications();
projects.getProjectSettingsSaveButton().click();
// Move the workflow from Home to Project 1
projects.getHomeButton().click();
workflowsPage.getters
.workflowCards()
.should('have.length', 1)
.filter(':contains("Personal")')
.should('exist');
workflowsPage.getters.workflowCards().should('have.length', 1);
workflowsPage.getters.workflowCards().filter(':contains("Personal")').should('exist');
workflowsPage.getters.workflowCardActions('My workflow').click();
workflowsPage.getters.workflowMoveButton().click();
@ -492,13 +489,13 @@ describe('Projects', { disableAutoLogin: true }, () => {
.contains('button', 'Move workflow')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 4)
.filter(':contains("Project 1")')
.click();
getVisibleSelect().find('li').should('have.length', 4);
getVisibleSelect().find('li').filter(':contains("Project 1")').click();
projects.getResourceMoveModal().contains('button', 'Move workflow').click();
clearNotifications();
cy.wait('@getResources');
workflowsPage.getters
.workflowCards()
.should('have.length', 1)

View file

@ -1,3 +1,16 @@
import {
clickAssignmentCollectionAdd,
clickGetBackToCanvas,
getNodeRunInfoStale,
getOutputTbodyCell,
} from '../composables/ndv';
import {
clickExecuteWorkflowButton,
getNodeByName,
getZoomToFitButton,
navigateToNewWorkflowPage,
openNode,
} from '../composables/workflow';
import { NDV, WorkflowPage } from '../pages';
const canvas = new WorkflowPage();
@ -26,4 +39,53 @@ describe('Manual partial execution', () => {
ndv.getters.nodeRunTooltipIndicator().should('not.exist');
ndv.getters.outputRunSelector().should('not.exist');
});
describe('partial execution v2', () => {
beforeEach(() => {
cy.window().then((win) => {
win.localStorage.setItem('PartialExecution.version', '2');
});
navigateToNewWorkflowPage();
});
it('should execute from the first dirty node up to the current node', () => {
cy.createFixtureWorkflow('Test_workflow_partial_execution_v2.json');
getZoomToFitButton().click();
// First, execute the whole workflow
clickExecuteWorkflowButton();
getNodeByName('A').findChildByTestId('canvas-node-status-success').should('be.visible');
getNodeByName('B').findChildByTestId('canvas-node-status-success').should('be.visible');
getNodeByName('C').findChildByTestId('canvas-node-status-success').should('be.visible');
openNode('A');
getOutputTbodyCell(1, 0).invoke('text').as('before', { type: 'static' });
clickGetBackToCanvas();
// Change parameter of the node in the middle
openNode('B');
clickAssignmentCollectionAdd();
getNodeRunInfoStale().should('be.visible');
clickGetBackToCanvas();
getNodeByName('A').findChildByTestId('canvas-node-status-success').should('be.visible');
getNodeByName('B').findChildByTestId('canvas-node-status-warning').should('be.visible');
getNodeByName('C').findChildByTestId('canvas-node-status-success').should('be.visible');
// Partial execution
getNodeByName('C').findChildByTestId('execute-node-button').click();
getNodeByName('A').findChildByTestId('canvas-node-status-success').should('be.visible');
getNodeByName('B').findChildByTestId('canvas-node-status-success').should('be.visible');
getNodeByName('C').findChildByTestId('canvas-node-status-success').should('be.visible');
openNode('A');
getOutputTbodyCell(1, 0).invoke('text').as('after', { type: 'static' });
// Assert that 'A' ran only once by comparing its output
cy.get('@before').then((before) =>
cy.get('@after').then((after) => expect(before).to.equal(after)),
);
});
});
});

View file

@ -25,6 +25,18 @@
"value": "test",
"type": "string"
},
{
"id": "85095836-4e94-442f-9270-e1a89008c125",
"name": "test",
"value": "test",
"type": "string"
},
{
"id": "85095836-4e94-442f-9270-e1a89008c121",
"name": "test",
"value": "test",
"type": "string"
},
{
"id": "b6163f8a-bca6-4364-8b38-182df37c55cd",
"name": "=should be visible!",
@ -50,6 +62,10 @@
"blocksUi": "blocks",
"text": "=should be visible",
"otherOptions": {
"includeLinkToWorkflow": true,
"link_names": false,
"mrkdwn": true,
"unfurl_links": false,
"sendAsUser": "=not visible"
}
},
@ -67,6 +83,7 @@
"parameters": {
"rule": {
"interval": [
{},
{},
{
"field": "=should be visible"

View file

@ -0,0 +1,74 @@
{
"nodes": [
{
"parameters": {
"rule": {
"interval": [{}]
}
},
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [0, 0],
"id": "dcc1c5e1-c6c1-45f8-80d5-65c88d66d56e",
"name": "A"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "3d8f0810-84f0-41ce-a81b-0e7f04fd88cb",
"name": "",
"value": "",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [220, 0],
"id": "097ffa30-d37b-4de6-bd5c-ccd945f31df1",
"name": "B"
},
{
"parameters": {
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [440, 0],
"id": "dc44e635-916f-4f76-a745-1add5762f730",
"name": "C"
}
],
"connections": {
"A": {
"main": [
[
{
"node": "B",
"type": "main",
"index": 0
}
]
]
},
"B": {
"main": [
[
{
"node": "C",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"meta": {
"instanceId": "b0d9447cff9c96796e4ac4f00fcd899b03cfac3ab3d4f748ae686d34881eae0c"
}
}

View file

@ -16,9 +16,11 @@ export const clearNotifications = () => {
const notificationSelector = '.el-notification:has(.el-notification--success)';
cy.get('body').then(($body) => {
if ($body.find(notificationSelector).length) {
cy.get(notificationSelector)
.find('.el-notification__closeBtn')
.click({ multiple: true, force: true });
cy.get(notificationSelector).each(($el) => {
if ($el.find('.el-notification__closeBtn').length) {
cy.wrap($el).find('.el-notification__closeBtn').click({ force: true });
}
});
}
});
};

View file

@ -306,8 +306,8 @@ export class WorkflowPage extends BasePage {
this.actions.openContextMenu(nodeTypeName);
clickContextMenuAction('duplicate');
},
deleteNodeFromContextMenu: (nodeTypeName: string) => {
this.actions.openContextMenu(nodeTypeName);
deleteNodeFromContextMenu: (nodeTypeName: string, options?: OpenContextMenuOptions) => {
this.actions.openContextMenu(nodeTypeName, options);
clickContextMenuAction('delete');
},
executeNode: (nodeTypeName: string, options?: OpenContextMenuOptions) => {

View file

@ -35,6 +35,7 @@ export class WorkflowsPage extends BasePage {
this.getters.workflowActivator(workflowName).findChildByTestId('workflow-activator-status'),
workflowCardActions: (workflowName: string) =>
this.getters.workflowCard(workflowName).findChildByTestId('workflow-card-actions'),
workflowActionItem: (action: string) => cy.getByTestId(`action-${action}`).filter(':visible'),
workflowDeleteButton: () =>
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete'),
workflowMoveButton: () =>

View file

@ -20,7 +20,7 @@ RUN set -eux; \
npm install -g --omit=dev n8n@${N8N_VERSION} --ignore-scripts && \
npm rebuild --prefix=/usr/local/lib/node_modules/n8n sqlite3 && \
rm -rf /usr/local/lib/node_modules/n8n/node_modules/@n8n/chat && \
rm -rf /usr/local/lib/node_modules/n8n/node_modules/n8n-design-system && \
rm -rf /usr/local/lib/node_modules/n8n/node_modules/@n8n/design-system && \
rm -rf /usr/local/lib/node_modules/n8n/node_modules/n8n-editor-ui/node_modules && \
find /usr/local/lib/node_modules/n8n -type f -name "*.ts" -o -name "*.js.map" -o -name "*.vue" | xargs rm -f && \
rm -rf /root/.npm

View file

@ -12,6 +12,8 @@ const tsJestOptions = {
const { baseUrl, paths } = require('get-tsconfig').getTsconfig().config?.compilerOptions;
const isCoverageEnabled = process.env.COVERAGE_ENABLED === 'true';
/** @type {import('jest').Config} */
const config = {
verbose: true,
@ -32,8 +34,8 @@ const config = {
return acc;
}, {}),
setupFilesAfterEnv: ['jest-expect-message'],
collectCoverage: process.env.COVERAGE_ENABLED === 'true',
coverageReporters: ['text-summary'],
collectCoverage: isCoverageEnabled,
coverageReporters: ['text-summary', 'lcov', 'html-spa'],
collectCoverageFrom: ['src/**/*.ts'],
};

View file

@ -15,10 +15,10 @@
"build:frontend": "turbo run build:frontend",
"build:nodes": "turbo run build:nodes",
"typecheck": "turbo typecheck",
"dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner",
"dev:be": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui",
"dev": "turbo run dev --parallel --env-mode=loose --filter=!@n8n/design-system --filter=!@n8n/chat --filter=!@n8n/task-runner",
"dev:be": "turbo run dev --parallel --env-mode=loose --filter=!@n8n/design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui",
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core",
"dev:fe": "run-p start \"dev:fe:editor --filter=n8n-design-system\"",
"dev:fe": "run-p start \"dev:fe:editor --filter=@n8n/design-system\"",
"dev:fe:editor": "turbo run dev --parallel --env-mode=loose --filter=n8n-editor-ui",
"dev:e2e": "cd cypress && pnpm run test:e2e:dev",
"dev:e2e:v1": "cd cypress && pnpm run test:e2e:dev:v1",
@ -47,7 +47,7 @@
},
"devDependencies": {
"@biomejs/biome": "^1.9.0",
"@n8n_io/eslint-config": "workspace:*",
"@n8n/eslint-config": "workspace:*",
"@types/jest": "^29.5.3",
"@types/node": "*",
"@types/supertest": "^6.0.2",
@ -96,7 +96,8 @@
"@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch",
"@types/ws@8.5.4": "patches/@types__ws@8.5.4.patch",
"@types/uuencode@0.0.3": "patches/@types__uuencode@0.0.3.patch",
"vue-tsc@2.1.10": "patches/vue-tsc@2.1.10.patch"
"vue-tsc@2.1.10": "patches/vue-tsc@2.1.10.patch",
"eslint-plugin-n8n-local-rules": "patches/eslint-plugin-n8n-local-rules.patch"
}
}
}

View file

@ -1,7 +1,7 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
const sharedOptions = require('@n8n/eslint-config/shared');
/** @type {import('@types/eslint').ESLint.ConfigData} */
module.exports = {
extends: ['@n8n_io/eslint-config/base'],
extends: ['@n8n/eslint-config/base'],
...sharedOptions(__dirname),
};

View file

@ -0,0 +1,63 @@
import { UpdateFolderDto } from '../update-folder.dto';
describe('UpdateFolderDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'name',
request: {
name: 'test',
},
},
{
name: 'tagIds',
request: {
tagIds: ['1', '2'],
},
},
{
name: 'empty tagIds',
request: {
tagIds: [],
},
},
])('should validate $name', ({ request }) => {
const result = UpdateFolderDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'empty name',
request: {
name: '',
},
expectedErrorPath: ['name'],
},
{
name: 'non string tagIds',
request: {
tagIds: [0],
},
expectedErrorPath: ['tagIds'],
},
{
name: 'non array tagIds',
request: {
tagIds: 0,
},
expectedErrorPath: ['tagIds'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = UpdateFolderDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path[0]).toEqual(expectedErrorPath[0]);
}
});
});
});

View file

@ -1,7 +1,8 @@
import { z } from 'zod';
import { Z } from 'zod-class';
import { folderNameSchema, folderId } from '../../schemas/folder.schema';
export class CreateFolderDto extends Z.class({
name: z.string().trim().min(1).max(128),
parentFolderId: z.string().optional(),
name: folderNameSchema,
parentFolderId: folderId.optional(),
}) {}

View file

@ -0,0 +1,7 @@
import { Z } from 'zod-class';
import { folderId } from '../../schemas/folder.schema';
export class DeleteFolderDto extends Z.class({
transferToFolderId: folderId.optional(),
}) {}

View file

@ -0,0 +1,8 @@
import { z } from 'zod';
import { Z } from 'zod-class';
import { folderNameSchema } from '../../schemas/folder.schema';
export class UpdateFolderDto extends Z.class({
name: folderNameSchema.optional(),
tagIds: z.array(z.string().max(24)).optional(),
}) {}

View file

@ -56,3 +56,5 @@ export { UpdateApiKeyRequestDto } from './api-keys/update-api-key-request.dto';
export { CreateApiKeyRequestDto } from './api-keys/create-api-key-request.dto';
export { CreateFolderDto } from './folders/create-folder.dto';
export { UpdateFolderDto } from './folders/update-folder.dto';
export { DeleteFolderDto } from './folders/delete-folder.dto';

View file

@ -0,0 +1,4 @@
import { z } from 'zod';
export const folderNameSchema = z.string().trim().min(1).max(128);
export const folderId = z.string().max(36);

View file

@ -1,10 +1,10 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
const sharedOptions = require('@n8n/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n_io/eslint-config/node'],
extends: ['@n8n/eslint-config/node'],
...sharedOptions(__dirname),

View file

@ -37,9 +37,9 @@ ENV DOCKER_BUILD=true
RUN pnpm install --frozen-lockfile
# TS config files
COPY --chown=node:node ./tsconfig.json /app/tsconfig.json
COPY --chown=node:node ./tsconfig.build.json /app/tsconfig.build.json
COPY --chown=node:node ./tsconfig.backend.json /app/tsconfig.backend.json
COPY --chown=node:node ./packages/@n8n/typescript-config/tsconfig.common.json /app/packages/@n8n/typescript-config/tsconfig.common.json
COPY --chown=node:node ./packages/@n8n/typescript-config/tsconfig.build.json /app/packages/@n8n/typescript-config/tsconfig.build.json
COPY --chown=node:node ./packages/@n8n/typescript-config/tsconfig.backend.json /app/packages/@n8n/typescript-config/tsconfig.backend.json
COPY --chown=node:node ./packages/@n8n/benchmark/tsconfig.json /app/packages/@n8n/benchmark/tsconfig.json
COPY --chown=node:node ./packages/@n8n/benchmark/tsconfig.build.json /app/packages/@n8n/benchmark/tsconfig.build.json

View file

@ -1,10 +1,10 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
const sharedOptions = require('@n8n/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n_io/eslint-config/base'],
extends: ['@n8n/eslint-config/base'],
...sharedOptions(__dirname),

View file

@ -1,10 +1,10 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
const sharedOptions = require('@n8n/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n_io/eslint-config/base'],
extends: ['@n8n/eslint-config/base'],
...sharedOptions(__dirname),

View file

@ -1,10 +1,10 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
const sharedOptions = require('@n8n/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n_io/eslint-config/node'],
extends: ['@n8n/eslint-config/node'],
...sharedOptions(__dirname),

View file

@ -57,6 +57,10 @@ class PrometheusMetricsConfig {
/** How often (in seconds) to update queue metrics. */
@Env('N8N_METRICS_QUEUE_METRICS_INTERVAL')
queueMetricsInterval: number = 20;
/** How often (in seconds) to update active workflow metric */
@Env('N8N_METRICS_ACTIVE_WORKFLOW_METRIC_INTERVAL')
activeWorkflowCountInterval: number = 60;
}
@Config

View file

@ -173,6 +173,7 @@ describe('GlobalConfig', () => {
includeApiStatusCodeLabel: false,
includeQueueMetrics: false,
queueMetricsInterval: 20,
activeWorkflowCountInterval: 60,
},
additionalNonUIRoutes: '',
disableProductionWebhooksOnMainProcess: false,

View file

@ -1,7 +1,7 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
const sharedOptions = require('@n8n/eslint-config/shared');
/** @type {import('@types/eslint').ESLint.ConfigData} */
module.exports = {
extends: ['@n8n_io/eslint-config/base'],
extends: ['@n8n/eslint-config/base'],
...sharedOptions(__dirname),
};

View file

@ -320,7 +320,7 @@ module.exports = {
const LOCALE_NAMESPACE = '$locale';
const LOCALE_FILEPATH = cwd.endsWith('editor-ui')
? path.join(cwd, locale)
: path.join(cwd, 'packages/editor-ui', locale);
: path.join(cwd, 'packages/frontend/editor-ui', locale);
let LOCALE_MAP;

View file

@ -1,7 +1,14 @@
{
"name": "@n8n_io/eslint-config",
"name": "@n8n/eslint-config",
"private": true,
"version": "0.0.1",
"exports": {
"./base": "./base.js",
"./frontend": "./frontend.js",
"./local-rules": "./local-rules.js",
"./node": "./node.js",
"./shared": "./shared.js"
},
"devDependencies": {
"@types/eslint": "^8.56.5",
"@typescript-eslint/eslint-plugin": "^7.2.0",

View file

@ -1,10 +1,10 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
const sharedOptions = require('@n8n/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n_io/eslint-config/base'],
extends: ['@n8n/eslint-config/base'],
...sharedOptions(__dirname),

View file

@ -1,10 +1,10 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
const sharedOptions = require('@n8n/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n_io/eslint-config/node'],
extends: ['@n8n/eslint-config/node'],
...sharedOptions(__dirname),

View file

@ -1,10 +1,10 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
const sharedOptions = require('@n8n/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n_io/eslint-config/node'],
extends: ['@n8n/eslint-config/node'],
...sharedOptions(__dirname),

View file

@ -27,7 +27,13 @@ import { toolsAgentExecute } from './agents/ToolsAgent/execute';
// Function used in the inputs expression to figure out which inputs to
// display based on the agent type
function getInputs(
agent: 'toolsAgent' | 'conversationalAgent' | 'openAiFunctionsAgent' | 'reActAgent' | 'sqlAgent',
agent:
| 'toolsAgent'
| 'conversationalAgent'
| 'openAiFunctionsAgent'
| 'planAndExecuteAgent'
| 'reActAgent'
| 'sqlAgent',
hasOutputParser?: boolean,
): Array<NodeConnectionType | INodeInputConfiguration> {
interface SpecialInput {
@ -256,7 +262,7 @@ export class Agent implements INodeType {
icon: 'fa:robot',
iconColor: 'black',
group: ['transform'],
version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7],
version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8],
description: 'Generates an action plan and executes it. Can use external tools.',
subtitle:
"={{ { toolsAgent: 'Tools Agent', conversationalAgent: 'Conversational Agent', openAiFunctionsAgent: 'OpenAI Functions Agent', reActAgent: 'ReAct Agent', sqlAgent: 'SQL Agent', planAndExecuteAgent: 'Plan and Execute Agent' }[$parameter.agent] }}",
@ -322,6 +328,24 @@ export class Agent implements INodeType {
},
},
},
{
displayName:
"This node is using Agent that has been deprecated. Please switch to using 'Tools Agent' instead.",
name: 'deprecated',
type: 'notice',
default: '',
displayOptions: {
show: {
agent: [
'conversationalAgent',
'openAiFunctionsAgent',
'planAndExecuteAgent',
'reActAgent',
'sqlAgent',
],
},
},
},
// Make Conversational Agent the default agent for versions 1.5 and below
{
...agentTypeProperty,
@ -331,10 +355,17 @@ export class Agent implements INodeType {
displayOptions: { show: { '@version': [{ _cnd: { lte: 1.5 } }] } },
default: 'conversationalAgent',
},
// Make Tools Agent the default agent for versions 1.6 and above
// Make Tools Agent the default agent for versions 1.6 and 1.7
{
...agentTypeProperty,
displayOptions: { show: { '@version': [{ _cnd: { gte: 1.6 } }] } },
displayOptions: { show: { '@version': [{ _cnd: { between: { from: 1.6, to: 1.7 } } }] } },
default: 'toolsAgent',
},
// Make Tools Agent the only agent option for versions 1.8 and above
{
...agentTypeProperty,
type: 'hidden',
displayOptions: { show: { '@version': [{ _cnd: { gte: 1.8 } }] } },
default: 'toolsAgent',
},
{

View file

@ -96,6 +96,13 @@ export const reActAgentAgentProperties: INodeProperties[] = [
rows: 6,
},
},
{
displayName: 'Max Iterations',
name: 'maxIterations',
type: 'number',
default: 10,
description: 'The maximum number of iterations the agent will run before stopping',
},
{
displayName: 'Return Intermediate Steps',
name: 'returnIntermediateSteps',

View file

@ -38,6 +38,7 @@ export async function reActAgentAgentExecute(
prefix?: string;
suffix?: string;
suffixChat?: string;
maxIterations?: number;
humanMessageTemplate?: string;
returnIntermediateSteps?: boolean;
};
@ -60,6 +61,7 @@ export async function reActAgentAgentExecute(
agent,
tools,
returnIntermediateSteps: options?.returnIntermediateSteps === true,
maxIterations: options.maxIterations ?? 10,
});
const returnData: INodeExecutionData[] = [];

View file

@ -392,13 +392,14 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeE
const returnData: INodeExecutionData[] = [];
const items = this.getInputData();
const outputParsers = await getOptionalOutputParsers(this);
const outputParser = outputParsers?.[0];
const tools = await getTools(this, outputParser);
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
try {
const model = await getChatModel(this);
const memory = await getOptionalMemory(this);
const outputParsers = await getOptionalOutputParsers(this);
const outputParser = outputParsers?.[0];
const tools = await getTools(this, outputParser);
const input = getPromptInputByType({
ctx: this,

View file

@ -14,6 +14,7 @@ import {
import { getConnectionHintNoticeField } from '@utils/sharedFields';
import { searchModels } from './methods/searchModels';
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
import { N8nLlmTracing } from '../N8nLlmTracing';
@ -69,15 +70,23 @@ const modelField: INodeProperties = {
default: 'claude-2',
};
const MIN_THINKING_BUDGET = 1024;
const DEFAULT_MAX_TOKENS = 4096;
export class LmChatAnthropic implements INodeType {
methods = {
listSearch: {
searchModels,
},
};
description: INodeTypeDescription = {
displayName: 'Anthropic Chat Model',
// eslint-disable-next-line n8n-nodes-base/node-class-description-name-miscased
name: 'lmChatAnthropic',
icon: 'file:anthropic.svg',
group: ['transform'],
version: [1, 1.1, 1.2],
defaultVersion: 1.2,
version: [1, 1.1, 1.2, 1.3],
defaultVersion: 1.3,
description: 'Language Model Anthropic',
defaults: {
name: 'Anthropic Chat Model',
@ -135,7 +144,43 @@ export class LmChatAnthropic implements INodeType {
),
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.2 } }],
'@version': [{ _cnd: { lte: 1.2 } }],
},
},
},
{
displayName: 'Model',
name: 'model',
type: 'resourceLocator',
default: {
mode: 'list',
value: 'claude-3-7-sonnet-20250219',
cachedResultName: 'Claude 3.7 Sonnet',
},
required: true,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
placeholder: 'Select a model...',
typeOptions: {
searchListMethod: 'searchModels',
searchable: true,
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
placeholder: 'Claude Sonnet',
},
],
description:
'The model. Choose from the list, or specify an ID. <a href="https://docs.anthropic.com/claude/docs/models-overview">Learn more</a>.',
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.3 } }],
},
},
},
@ -150,7 +195,7 @@ export class LmChatAnthropic implements INodeType {
{
displayName: 'Maximum Number of Tokens',
name: 'maxTokensToSample',
default: 4096,
default: DEFAULT_MAX_TOKENS,
description: 'The maximum number of tokens to generate in the completion',
type: 'number',
},
@ -162,6 +207,11 @@ export class LmChatAnthropic implements INodeType {
description:
'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.',
type: 'number',
displayOptions: {
hide: {
thinking: [true],
},
},
},
{
displayName: 'Top K',
@ -171,6 +221,11 @@ export class LmChatAnthropic implements INodeType {
description:
'Used to remove "long tail" low probability responses. Defaults to -1, which disables it.',
type: 'number',
displayOptions: {
hide: {
thinking: [true],
},
},
},
{
displayName: 'Top P',
@ -180,6 +235,30 @@ export class LmChatAnthropic implements INodeType {
description:
'Controls diversity via nucleus sampling: 0.5 means half of all likelihood-weighted options are considered. We generally recommend altering this or temperature but not both.',
type: 'number',
displayOptions: {
hide: {
thinking: [true],
},
},
},
{
displayName: 'Enable Thinking',
name: 'thinking',
type: 'boolean',
default: false,
description: 'Whether to enable thinking mode for the model',
},
{
displayName: 'Thinking Budget (Tokens)',
name: 'thinkingBudget',
type: 'number',
default: MIN_THINKING_BUDGET,
description: 'The maximum number of tokens to use for thinking',
displayOptions: {
show: {
thinking: [true],
},
},
},
],
},
@ -189,13 +268,21 @@ export class LmChatAnthropic implements INodeType {
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const credentials = await this.getCredentials('anthropicApi');
const modelName = this.getNodeParameter('model', itemIndex) as string;
const version = this.getNode().typeVersion;
const modelName =
version >= 1.3
? (this.getNodeParameter('model.value', itemIndex) as string)
: (this.getNodeParameter('model', itemIndex) as string);
const options = this.getNodeParameter('options', itemIndex, {}) as {
maxTokensToSample?: number;
temperature: number;
topK: number;
topP: number;
topK?: number;
topP?: number;
thinking?: boolean;
thinkingBudget?: number;
};
let invocationKwargs = {};
const tokensUsageParser = (llmOutput: LLMResult['llmOutput']) => {
const usage = (llmOutput?.usage as { input_tokens: number; output_tokens: number }) ?? {
@ -208,6 +295,27 @@ export class LmChatAnthropic implements INodeType {
totalTokens: usage.input_tokens + usage.output_tokens,
};
};
if (options.thinking) {
invocationKwargs = {
thinking: {
type: 'enabled',
// If thinking is enabled, we need to set a budget.
// We fallback to 1024 as that is the minimum
budget_tokens: options.thinkingBudget ?? MIN_THINKING_BUDGET,
},
// The default Langchain max_tokens is -1 (no limit) but Anthropic requires a number
// higher than budget_tokens
max_tokens: options.maxTokensToSample ?? DEFAULT_MAX_TOKENS,
// These need to be unset when thinking is enabled.
// Because the invocationKwargs will override the model options
// we can pass options to the model and then override them here
top_k: undefined,
top_p: undefined,
temperature: undefined,
};
}
const model = new ChatAnthropic({
anthropicApiKey: credentials.apiKey as string,
modelName,
@ -217,6 +325,7 @@ export class LmChatAnthropic implements INodeType {
topP: options.topP,
callbacks: [new N8nLlmTracing(this, { tokensUsageParser })],
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
invocationKwargs,
});
return {

View file

@ -0,0 +1,105 @@
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import { searchModels, type AnthropicModel } from '../searchModels';
describe('searchModels', () => {
let mockContext: jest.Mocked<ILoadOptionsFunctions>;
const mockModels: AnthropicModel[] = [
{
id: 'claude-3-opus-20240229',
display_name: 'Claude 3 Opus',
type: 'model',
created_at: '2024-02-29T00:00:00Z',
},
{
id: 'claude-3-sonnet-20240229',
display_name: 'Claude 3 Sonnet',
type: 'model',
created_at: '2024-02-29T00:00:00Z',
},
{
id: 'claude-3-haiku-20240307',
display_name: 'Claude 3 Haiku',
type: 'model',
created_at: '2024-03-07T00:00:00Z',
},
{
id: 'claude-2.1',
display_name: 'Claude 2.1',
type: 'model',
created_at: '2023-11-21T00:00:00Z',
},
{
id: 'claude-2.0',
display_name: 'Claude 2.0',
type: 'model',
created_at: '2023-07-11T00:00:00Z',
},
];
beforeEach(() => {
mockContext = {
helpers: {
httpRequestWithAuthentication: jest.fn().mockResolvedValue({
data: mockModels,
}),
},
} as unknown as jest.Mocked<ILoadOptionsFunctions>;
});
afterEach(() => {
jest.clearAllMocks();
});
it('should fetch models from Anthropic API', async () => {
const result = await searchModels.call(mockContext);
expect(mockContext.helpers.httpRequestWithAuthentication).toHaveBeenCalledWith('anthropicApi', {
url: 'https://api.anthropic.com/v1/models',
headers: {
'anthropic-version': '2023-06-01',
},
});
expect(result.results).toHaveLength(5);
});
it('should sort models by created_at date, most recent first', async () => {
const result = await searchModels.call(mockContext);
const sortedResults = result.results;
expect(sortedResults[0].value).toBe('claude-3-haiku-20240307');
expect(sortedResults[1].value).toBe('claude-3-opus-20240229');
expect(sortedResults[2].value).toBe('claude-3-sonnet-20240229');
expect(sortedResults[3].value).toBe('claude-2.1');
expect(sortedResults[4].value).toBe('claude-2.0');
});
it('should filter models based on search term', async () => {
const result = await searchModels.call(mockContext, 'claude-3');
expect(result.results).toHaveLength(3);
expect(result.results).toEqual([
{ name: 'Claude 3 Haiku', value: 'claude-3-haiku-20240307' },
{ name: 'Claude 3 Opus', value: 'claude-3-opus-20240229' },
{ name: 'Claude 3 Sonnet', value: 'claude-3-sonnet-20240229' },
]);
});
it('should handle case-insensitive search', async () => {
const result = await searchModels.call(mockContext, 'CLAUDE-3');
expect(result.results).toHaveLength(3);
expect(result.results).toEqual([
{ name: 'Claude 3 Haiku', value: 'claude-3-haiku-20240307' },
{ name: 'Claude 3 Opus', value: 'claude-3-opus-20240229' },
{ name: 'Claude 3 Sonnet', value: 'claude-3-sonnet-20240229' },
]);
});
it('should handle when no models match the filter', async () => {
const result = await searchModels.call(mockContext, 'nonexistent-model');
expect(result.results).toHaveLength(0);
});
});

View file

@ -0,0 +1,60 @@
import type {
ILoadOptionsFunctions,
INodeListSearchItems,
INodeListSearchResult,
} from 'n8n-workflow';
export interface AnthropicModel {
id: string;
display_name: string;
type: string;
created_at: string;
}
export async function searchModels(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
const response = (await this.helpers.httpRequestWithAuthentication.call(this, 'anthropicApi', {
url: 'https://api.anthropic.com/v1/models',
headers: {
'anthropic-version': '2023-06-01',
},
})) as { data: AnthropicModel[] };
const models = response.data || [];
let results: INodeListSearchItems[] = [];
if (filter) {
for (const model of models) {
if (model.id.toLowerCase().includes(filter.toLowerCase())) {
results.push({
name: model.display_name,
value: model.id,
});
}
}
} else {
results = models.map((model) => ({
name: model.display_name,
value: model.id,
}));
}
// Sort models with more recent ones first (claude-3 before claude-2)
results = results.sort((a, b) => {
const modelA = models.find((m) => m.id === a.value);
const modelB = models.find((m) => m.id === b.value);
if (!modelA || !modelB) return 0;
// Sort by created_at date, most recent first
const dateA = new Date(modelA.created_at);
const dateB = new Date(modelB.created_at);
return dateB.getTime() - dateA.getTime();
});
return {
results,
};
}

View file

@ -75,7 +75,7 @@ class MemoryChatBufferSingleton {
export class MemoryBufferWindow implements INodeType {
description: INodeTypeDescription = {
displayName: 'Window Buffer Memory (easiest)',
displayName: 'Simple Memory',
name: 'memoryBufferWindow',
icon: 'fa:database',
iconColor: 'black',
@ -83,7 +83,7 @@ export class MemoryBufferWindow implements INodeType {
version: [1, 1.1, 1.2, 1.3],
description: 'Stores in n8n memory, so no credentials required',
defaults: {
name: 'Window Buffer Memory',
name: 'Simple Memory',
},
codex: {
categories: ['AI'],

View file

@ -5,7 +5,7 @@ import { OutputParserException } from '@langchain/core/output_parsers';
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import { normalizeItems } from 'n8n-core';
import type { IExecuteFunctions, IWorkflowDataProxyData } from 'n8n-workflow';
import type { ISupplyDataFunctions, IWorkflowDataProxyData } from 'n8n-workflow';
import { ApplicationError, NodeConnectionType, NodeOperationError } from 'n8n-workflow';
import type {
@ -18,13 +18,13 @@ import { NAIVE_FIX_PROMPT } from '../prompt';
describe('OutputParserAutofixing', () => {
let outputParser: OutputParserAutofixing;
let thisArg: MockProxy<IExecuteFunctions>;
let thisArg: MockProxy<ISupplyDataFunctions>;
let mockModel: MockProxy<BaseLanguageModel>;
let mockStructuredOutputParser: MockProxy<N8nStructuredOutputParser>;
beforeEach(() => {
outputParser = new OutputParserAutofixing();
thisArg = mock<IExecuteFunctions>({
thisArg = mock<ISupplyDataFunctions>({
helpers: { normalizeItems },
});
mockModel = mock<BaseLanguageModel>();

View file

@ -2,7 +2,7 @@ import { mock } from 'jest-mock-extended';
import { normalizeItems } from 'n8n-core';
import {
ApplicationError,
type IExecuteFunctions,
type ISupplyDataFunctions,
type IWorkflowDataProxyData,
} from 'n8n-workflow';
@ -12,7 +12,7 @@ import { OutputParserItemList } from '../OutputParserItemList.node';
describe('OutputParserItemList', () => {
let outputParser: OutputParserItemList;
const thisArg = mock<IExecuteFunctions>({
const thisArg = mock<ISupplyDataFunctions>({
helpers: { normalizeItems },
});
const workflowDataProxy = mock<IWorkflowDataProxyData>({ $input: mock() });

View file

@ -2,8 +2,8 @@ import { mock } from 'jest-mock-extended';
import { normalizeItems } from 'n8n-core';
import {
jsonParse,
type IExecuteFunctions,
type INode,
type ISupplyDataFunctions,
type IWorkflowDataProxyData,
} from 'n8n-workflow';
@ -13,7 +13,7 @@ import { OutputParserStructured } from '../OutputParserStructured.node';
describe('OutputParserStructured', () => {
let outputParser: OutputParserStructured;
const thisArg = mock<IExecuteFunctions>({
const thisArg = mock<ISupplyDataFunctions>({
helpers: { normalizeItems },
});
const workflowDataProxy = mock<IWorkflowDataProxyData>({ $input: mock() });

View file

@ -1,5 +1,5 @@
import { mock } from 'jest-mock-extended';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
import { jsonParse } from 'n8n-workflow';
import type { N8nTool } from '@utils/N8nTool';
@ -8,8 +8,8 @@ import { ToolHttpRequest } from '../ToolHttpRequest.node';
describe('ToolHttpRequest', () => {
const httpTool = new ToolHttpRequest();
const helpers = mock<IExecuteFunctions['helpers']>();
const executeFunctions = mock<IExecuteFunctions>({ helpers });
const helpers = mock<ISupplyDataFunctions['helpers']>();
const executeFunctions = mock<ISupplyDataFunctions>({ helpers });
beforeEach(() => {
jest.resetAllMocks();

View file

@ -10,6 +10,7 @@ export class ToolWorkflow extends VersionedNodeType {
displayName: 'Call n8n Sub-Workflow Tool',
name: 'toolWorkflow',
icon: 'fa:network-wired',
iconColor: 'black',
group: ['transform'],
description:
'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.',

View file

@ -13,8 +13,6 @@ import { getConnectionHintNoticeField } from '../../../../utils/sharedFields';
export const versionDescription: INodeTypeDescription = {
displayName: 'Call n8n Workflow Tool',
name: 'toolWorkflow',
icon: 'fa:network-wired',
iconColor: 'black',
group: ['transform'],
version: [1, 1.1, 1.2, 1.3],
description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.',

View file

@ -11,12 +11,8 @@ import type {
import { WorkflowToolService } from './utils/WorkflowToolService';
type ISupplyDataFunctionsWithRunIndex = ISupplyDataFunctions & { runIndex: number };
// Mock ISupplyDataFunctions interface
function createMockContext(
overrides?: Partial<ISupplyDataFunctions>,
): ISupplyDataFunctionsWithRunIndex {
function createMockContext(overrides?: Partial<ISupplyDataFunctions>): ISupplyDataFunctions {
return {
runIndex: 0,
getNodeParameter: jest.fn(),
@ -33,6 +29,7 @@ function createMockContext(
getTimezone: jest.fn(),
getWorkflow: jest.fn(),
getWorkflowStaticData: jest.fn(),
cloneWith: jest.fn(),
logger: {
debug: jest.fn(),
error: jest.fn(),
@ -40,11 +37,11 @@ function createMockContext(
warn: jest.fn(),
},
...overrides,
} as ISupplyDataFunctionsWithRunIndex;
} as ISupplyDataFunctions;
}
describe('WorkflowTool::WorkflowToolService', () => {
let context: ISupplyDataFunctionsWithRunIndex;
let context: ISupplyDataFunctions;
let service: WorkflowToolService;
beforeEach(() => {
@ -92,13 +89,25 @@ describe('WorkflowTool::WorkflowToolService', () => {
$execution: { id: 'exec-id' },
$workflow: { id: 'workflow-id' },
} as unknown as IWorkflowDataProxyData);
jest.spyOn(context, 'cloneWith').mockReturnValue(context);
const tool = await service.createTool(toolParams);
const result = await tool.func('test query');
expect(result).toBe(JSON.stringify(TEST_RESPONSE, null, 2));
expect(context.addOutputData).toHaveBeenCalled();
expect(context.runIndex).toBe(1);
// Here we validate that the runIndex is correctly updated
expect(context.cloneWith).toHaveBeenCalledWith({
runIndex: 0,
inputData: [[{ json: { query: 'test query' } }]],
});
await tool.func('another query');
expect(context.cloneWith).toHaveBeenCalledWith({
runIndex: 1,
inputData: [[{ json: { query: 'another query' } }]],
});
});
it('should handle errors during tool execution', async () => {
@ -113,6 +122,7 @@ describe('WorkflowTool::WorkflowToolService', () => {
.mockRejectedValueOnce(new Error('Workflow execution failed'));
jest.spyOn(context, 'addInputData').mockReturnValue({ index: 0 });
jest.spyOn(context, 'getNodeParameter').mockReturnValue('database');
jest.spyOn(context, 'cloneWith').mockReturnValue(context);
const tool = await service.createTool(toolParams);
const result = await tool.func('test query');
@ -166,7 +176,12 @@ describe('WorkflowTool::WorkflowToolService', () => {
jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockResponse);
const result = await service['executeSubWorkflow'](workflowInfo, items, workflowProxyMock);
const result = await service['executeSubWorkflow'](
context,
workflowInfo,
items,
workflowProxyMock,
);
expect(result.response).toBe(TEST_RESPONSE);
expect(result.subExecutionId).toBe('test-execution');
@ -175,7 +190,7 @@ describe('WorkflowTool::WorkflowToolService', () => {
it('should throw error when workflow execution fails', async () => {
jest.spyOn(context, 'executeWorkflow').mockRejectedValueOnce(new Error('Execution failed'));
await expect(service['executeSubWorkflow']({}, [], {} as never)).rejects.toThrow(
await expect(service['executeSubWorkflow'](context, {}, [], {} as never)).rejects.toThrow(
NodeOperationError,
);
});
@ -188,7 +203,7 @@ describe('WorkflowTool::WorkflowToolService', () => {
jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockResponse);
await expect(service['executeSubWorkflow']({}, [], {} as never)).rejects.toThrow();
await expect(service['executeSubWorkflow'](context, {}, [], {} as never)).rejects.toThrow();
});
});
@ -202,7 +217,12 @@ describe('WorkflowTool::WorkflowToolService', () => {
jest.spyOn(context, 'getNodeParameter').mockReturnValueOnce({ value: 'workflow-id' });
const result = await service['getSubWorkflowInfo'](source, itemIndex, workflowProxyMock);
const result = await service['getSubWorkflowInfo'](
context,
source,
itemIndex,
workflowProxyMock,
);
expect(result.workflowInfo).toHaveProperty('id', 'workflow-id');
expect(result.subWorkflowId).toBe('workflow-id');
@ -218,7 +238,12 @@ describe('WorkflowTool::WorkflowToolService', () => {
jest.spyOn(context, 'getNodeParameter').mockReturnValueOnce(JSON.stringify(mockWorkflow));
const result = await service['getSubWorkflowInfo'](source, itemIndex, workflowProxyMock);
const result = await service['getSubWorkflowInfo'](
context,
source,
itemIndex,
workflowProxyMock,
);
expect(result.workflowInfo.code).toEqual(mockWorkflow);
expect(result.subWorkflowId).toBe('proxy-id');
@ -234,7 +259,7 @@ describe('WorkflowTool::WorkflowToolService', () => {
jest.spyOn(context, 'getNodeParameter').mockReturnValueOnce('invalid json');
await expect(
service['getSubWorkflowInfo'](source, itemIndex, workflowProxyMock),
service['getSubWorkflowInfo'](context, source, itemIndex, workflowProxyMock),
).rejects.toThrow(NodeOperationError);
});
});

View file

@ -43,8 +43,8 @@ export class WorkflowToolService {
// Sub-workflow execution id, will be set after the sub-workflow is executed
private subExecutionId: string | undefined;
constructor(private context: ISupplyDataFunctions) {
const subWorkflowInputs = this.context.getNode().parameters
constructor(private baseContext: ISupplyDataFunctions) {
const subWorkflowInputs = this.baseContext.getNode().parameters
.workflowInputs as ResourceMapperValue;
this.useSchema = (subWorkflowInputs?.schema ?? []).length > 0;
}
@ -59,18 +59,23 @@ export class WorkflowToolService {
description: string;
itemIndex: number;
}): Promise<DynamicTool | DynamicStructuredTool> {
let runIndex = 0;
// Handler for the tool execution, will be called when the tool is executed
// This function will execute the sub-workflow and return the response
const toolHandler = async (
query: string | IDataObject,
runManager?: CallbackManagerForToolRun,
): Promise<string> => {
const { index } = this.context.addInputData(NodeConnectionType.AiTool, [
[{ json: { query } }],
]);
const localRunIndex = runIndex++;
// We need to clone the context here to handle runIndex correctly
// Otherwise the runIndex will be shared between different executions
// Causing incorrect data to be passed to the sub-workflow and via $fromAI
const context = this.baseContext.cloneWith({
runIndex: localRunIndex,
inputData: [[{ json: { query } }]],
});
try {
const response = await this.runFunction(query, itemIndex, runManager);
const response = await this.runFunction(context, query, itemIndex, runManager);
const processedResponse = this.handleToolResponse(response);
// Once the sub-workflow is executed, add the output data to the context
@ -87,7 +92,12 @@ export class WorkflowToolService {
const json = jsonParse<IDataObject>(processedResponse, {
fallbackValue: { response: processedResponse },
});
void this.context.addOutputData(NodeConnectionType.AiTool, index, [[{ json }]], metadata);
void context.addOutputData(
NodeConnectionType.AiTool,
localRunIndex,
[[{ json }]],
metadata,
);
return processedResponse;
} catch (error) {
@ -95,11 +105,13 @@ export class WorkflowToolService {
const errorResponse = `There was an error: "${executionError.message}"`;
const metadata = parseErrorMetadata(error);
void this.context.addOutputData(NodeConnectionType.AiTool, index, executionError, metadata);
void context.addOutputData(
NodeConnectionType.AiTool,
localRunIndex,
executionError,
metadata,
);
return errorResponse;
} finally {
// @ts-expect-error this accesses a private member on the actual implementation to fix https://linear.app/n8n/issue/ADO-3186/bug-workflowtool-v2-always-uses-first-row-of-input-data
this.context.runIndex++;
}
};
@ -119,7 +131,7 @@ export class WorkflowToolService {
}
if (typeof response !== 'string') {
throw new NodeOperationError(this.context.getNode(), 'Wrong output type returned', {
throw new NodeOperationError(this.baseContext.getNode(), 'Wrong output type returned', {
description: `The response property should be a string, but it is an ${typeof response}`,
});
}
@ -131,6 +143,7 @@ export class WorkflowToolService {
* Executes specified sub-workflow with provided inputs
*/
private async executeSubWorkflow(
context: ISupplyDataFunctions,
workflowInfo: IExecuteWorkflowInfo,
items: INodeExecutionData[],
workflowProxy: IWorkflowDataProxyData,
@ -138,27 +151,22 @@ export class WorkflowToolService {
): Promise<{ response: string; subExecutionId: string }> {
let receivedData: ExecuteWorkflowData;
try {
receivedData = await this.context.executeWorkflow(
workflowInfo,
items,
runManager?.getChild(),
{
parentExecution: {
executionId: workflowProxy.$execution.id,
workflowId: workflowProxy.$workflow.id,
},
receivedData = await context.executeWorkflow(workflowInfo, items, runManager?.getChild(), {
parentExecution: {
executionId: workflowProxy.$execution.id,
workflowId: workflowProxy.$workflow.id,
},
);
});
// Set sub-workflow execution id so it can be used in other places
this.subExecutionId = receivedData.executionId;
} catch (error) {
throw new NodeOperationError(this.context.getNode(), error as Error);
throw new NodeOperationError(context.getNode(), error as Error);
}
const response: string | undefined = get(receivedData, 'data[0][0].json') as string | undefined;
if (response === undefined) {
throw new NodeOperationError(
this.context.getNode(),
context.getNode(),
'There was an error: "The workflow did not return a response"',
);
}
@ -171,20 +179,27 @@ export class WorkflowToolService {
* This function will be called as part of the tool execution (from the toolHandler)
*/
private async runFunction(
context: ISupplyDataFunctions,
query: string | IDataObject,
itemIndex: number,
runManager?: CallbackManagerForToolRun,
): Promise<string> {
const source = this.context.getNodeParameter('source', itemIndex) as string;
const workflowProxy = this.context.getWorkflowDataProxy(0);
const source = context.getNodeParameter('source', itemIndex) as string;
const workflowProxy = context.getWorkflowDataProxy(0);
const { workflowInfo } = await this.getSubWorkflowInfo(source, itemIndex, workflowProxy);
const rawData = this.prepareRawData(query, itemIndex);
const items = await this.prepareWorkflowItems(query, itemIndex, rawData);
const { workflowInfo } = await this.getSubWorkflowInfo(
context,
source,
itemIndex,
workflowProxy,
);
const rawData = this.prepareRawData(context, query, itemIndex);
const items = await this.prepareWorkflowItems(context, query, itemIndex, rawData);
this.subWorkflowId = workflowInfo.id;
const { response } = await this.executeSubWorkflow(
context,
workflowInfo,
items,
workflowProxy,
@ -197,6 +212,7 @@ export class WorkflowToolService {
* Gets the sub-workflow info based on the source (database or parameter)
*/
private async getSubWorkflowInfo(
context: ISupplyDataFunctions,
source: string,
itemIndex: number,
workflowProxy: IWorkflowDataProxyData,
@ -208,7 +224,7 @@ export class WorkflowToolService {
let subWorkflowId: string;
if (source === 'database') {
const { value } = this.context.getNodeParameter(
const { value } = context.getNodeParameter(
'workflowId',
itemIndex,
{},
@ -216,14 +232,14 @@ export class WorkflowToolService {
workflowInfo.id = value as string;
subWorkflowId = workflowInfo.id;
} else if (source === 'parameter') {
const workflowJson = this.context.getNodeParameter('workflowJson', itemIndex) as string;
const workflowJson = context.getNodeParameter('workflowJson', itemIndex) as string;
try {
workflowInfo.code = JSON.parse(workflowJson) as IWorkflowBase;
// subworkflow is same as parent workflow
subWorkflowId = workflowProxy.$workflow.id;
} catch (error) {
throw new NodeOperationError(
this.context.getNode(),
context.getNode(),
`The provided workflow is not valid JSON: "${(error as Error).message}"`,
{ itemIndex },
);
@ -233,9 +249,13 @@ export class WorkflowToolService {
return { workflowInfo, subWorkflowId: subWorkflowId! };
}
private prepareRawData(query: string | IDataObject, itemIndex: number): IDataObject {
private prepareRawData(
context: ISupplyDataFunctions,
query: string | IDataObject,
itemIndex: number,
): IDataObject {
const rawData: IDataObject = { query };
const workflowFieldsJson = this.context.getNodeParameter('fields.values', itemIndex, [], {
const workflowFieldsJson = context.getNodeParameter('fields.values', itemIndex, [], {
rawExpressions: true,
}) as SetField[];
@ -253,6 +273,7 @@ export class WorkflowToolService {
* Prepares the sub-workflow items for execution
*/
private async prepareWorkflowItems(
context: ISupplyDataFunctions,
query: string | IDataObject,
itemIndex: number,
rawData: IDataObject,
@ -261,17 +282,17 @@ export class WorkflowToolService {
let jsonData = typeof query === 'object' ? query : { query };
if (this.useSchema) {
const currentWorkflowInputs = getCurrentWorkflowInputData.call(this.context);
const currentWorkflowInputs = getCurrentWorkflowInputData.call(context);
jsonData = currentWorkflowInputs[itemIndex].json;
}
const newItem = await manual.execute.call(
this.context,
context,
{ json: jsonData },
itemIndex,
options,
rawData,
this.context.getNode(),
context.getNode(),
);
return [newItem] as INodeExecutionData[];
@ -299,7 +320,7 @@ export class WorkflowToolService {
private async extractFromAIParameters(): Promise<FromAIArgument[]> {
const collectedArguments: FromAIArgument[] = [];
traverseNodeParameters(this.context.getNode().parameters, collectedArguments);
traverseNodeParameters(this.baseContext.getNode().parameters, collectedArguments);
const uniqueArgsMap = new Map<string, FromAIArgument>();
for (const arg of collectedArguments) {

View file

@ -7,7 +7,6 @@ import { getConnectionHintNoticeField } from '../../../../utils/sharedFields';
export const versionDescription: INodeTypeDescription = {
displayName: 'Call n8n Workflow Tool',
name: 'toolWorkflow',
icon: 'fa:network-wired',
group: ['transform'],
description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.',
defaults: {

View file

@ -12,6 +12,7 @@ import type {
INodeProperties,
} from 'n8n-workflow';
import { cssVariables } from './constants';
import { validateAuth } from './GenericFunctions';
import { createPage } from './templates';
import type { LoadPreviousSessionChatOption } from './types';
@ -378,6 +379,29 @@ export class ChatTrigger extends Node {
placeholder: 'e.g. Welcome',
description: 'Shown at the top of the chat',
},
{
displayName: 'Custom Chat Styling',
name: 'customCss',
type: 'string',
typeOptions: {
rows: 10,
editor: 'cssEditor',
},
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
default: `
${cssVariables}
/* You can override any class styles, too. Right-click inspect in Chat UI to find class to override. */
.chat-message {
max-width: 50%;
}
`.trim(),
description: 'Override default styling of the public chat interface with CSS',
},
],
},
],
@ -466,6 +490,7 @@ export class ChatTrigger extends Node {
title?: string;
allowFileUploads?: boolean;
allowedFilesMimeTypes?: string;
customCss?: string;
};
const req = ctx.getRequestObject();
@ -517,6 +542,7 @@ export class ChatTrigger extends Node {
authentication,
allowFileUploads: options.allowFileUploads,
allowedFilesMimeTypes: options.allowedFilesMimeTypes,
customCss: options.customCss,
});
res.status(200).send(page).end();

View file

@ -0,0 +1,122 @@
// CSS Variables are defined in `@n8n/chat/src/css/_tokens.scss`
export const cssVariables = `
:root {
/* Colors */
--chat--color-primary: #e74266;
--chat--color-primary-shade-50: #db4061;
--chat--color-primary-shade-100: #cf3c5c;
--chat--color-secondary: #20b69e;
--chat--color-secondary-shade-50: #1ca08a;
--chat--color-white: #ffffff;
--chat--color-light: #f2f4f8;
--chat--color-light-shade-50: #e6e9f1;
--chat--color-light-shade-100: #c2c5cc;
--chat--color-medium: #d2d4d9;
--chat--color-dark: #101330;
--chat--color-disabled: #777980;
--chat--color-typing: #404040;
/* Base Layout */
--chat--spacing: 1rem;
--chat--border-radius: 0.25rem;
--chat--transition-duration: 0.15s;
--chat--font-family: (
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen-Sans,
Ubuntu,
Cantarell,
'Helvetica Neue',
sans-serif
);
/* Window Dimensions */
--chat--window--width: 400px;
--chat--window--height: 600px;
--chat--window--bottom: var(--chat--spacing);
--chat--window--right: var(--chat--spacing);
--chat--window--z-index: 9999;
--chat--window--border: 1px solid var(--chat--color-light-shade-50);
--chat--window--border-radius: var(--chat--border-radius);
--chat--window--margin-bottom: var(--chat--spacing);
/* Header Styles */
--chat--header-height: auto;
--chat--header--padding: var(--chat--spacing);
--chat--header--background: var(--chat--color-dark);
--chat--header--color: var(--chat--color-light);
--chat--header--border-top: none;
--chat--header--border-bottom: none;
--chat--header--border-left: none;
--chat--header--border-right: none;
--chat--heading--font-size: 2em;
--chat--subtitle--font-size: inherit;
--chat--subtitle--line-height: 1.8;
/* Message Styles */
--chat--message--font-size: 1rem;
--chat--message--padding: var(--chat--spacing);
--chat--message--border-radius: var(--chat--border-radius);
--chat--message-line-height: 1.5;
--chat--message--margin-bottom: calc(var(--chat--spacing) * 1);
--chat--message--bot--background: var(--chat--color-white);
--chat--message--bot--color: var(--chat--color-dark);
--chat--message--bot--border: none;
--chat--message--user--background: var(--chat--color-secondary);
--chat--message--user--color: var(--chat--color-white);
--chat--message--user--border: none;
--chat--message--pre--background: rgba(0, 0, 0, 0.05);
--chat--messages-list--padding: var(--chat--spacing);
/* Toggle Button */
--chat--toggle--size: 64px;
--chat--toggle--width: var(--chat--toggle--size);
--chat--toggle--height: var(--chat--toggle--size);
--chat--toggle--border-radius: 50%;
--chat--toggle--background: var(--chat--color-primary);
--chat--toggle--hover--background: var(--chat--color-primary-shade-50);
--chat--toggle--active--background: var(--chat--color-primary-shade-100);
--chat--toggle--color: var(--chat--color-white);
/* Input Area */
--chat--textarea--height: 50px;
--chat--textarea--max-height: 30rem;
--chat--input--font-size: inherit;
--chat--input--border: 0;
--chat--input--border-radius: 0;
--chat--input--padding: 0.8rem;
--chat--input--background: var(--chat--color-white);
--chat--input--text-color: initial;
--chat--input--line-height: 1.5;
--chat--input--placeholder--font-size: var(--chat--input--font-size);
--chat--input--border-active: 0;
--chat--input--left--panel--width: 2rem;
/* Button Styles */
--chat--button--color: var(--chat--color-light);
--chat--button--background: var(--chat--color-primary);
--chat--button--padding: calc(var(--chat--spacing) * 1 / 2) var(--chat--spacing);
--chat--button--border-radius: var(--chat--border-radius);
--chat--button--hover--color: var(--chat--color-light);
--chat--button--hover--background: var(--chat--color-primary-shade-50);
--chat--close--button--color-hover: var(--chat--color-primary);
/* Send and File Buttons */
--chat--input--send--button--background: var(--chat--color-white);
--chat--input--send--button--color: var(--chat--color-light);
--chat--input--send--button--background-hover: var(--chat--color-primary-shade-50);
--chat--input--send--button--color-hover: var(--chat--color-secondary-shade-50);
--chat--input--file--button--background: var(--chat--color-white);
--chat--input--file--button--color: var(--chat--color-secondary);
--chat--input--file--button--background-hover: var(--chat--input--file--button--background);
--chat--input--file--button--color-hover: var(--chat--color-secondary-shade-50);
--chat--files-spacing: 0.25rem;
/* Body and Footer */
--chat--body--background: var(--chat--color-light);
--chat--footer--background: var(--chat--color-light);
--chat--footer--color: var(--chat--color-dark);
}
`;

View file

@ -1,5 +1,6 @@
import type { AuthenticationChatOption, LoadPreviousSessionChatOption } from './types';
import sanitizeHtml from 'sanitize-html';
import type { AuthenticationChatOption, LoadPreviousSessionChatOption } from './types';
export function createPage({
instanceId,
webhookUrl,
@ -10,6 +11,7 @@ export function createPage({
authentication,
allowFileUploads,
allowedFilesMimeTypes,
customCss,
}: {
instanceId: string;
webhookUrl?: string;
@ -23,6 +25,7 @@ export function createPage({
authentication: AuthenticationChatOption;
allowFileUploads?: boolean;
allowedFilesMimeTypes?: string;
customCss?: string;
}) {
const validAuthenticationOptions: AuthenticationChatOption[] = [
'none',
@ -41,6 +44,11 @@ export function createPage({
const sanitizedShowWelcomeScreen = !!showWelcomeScreen;
const sanitizedAllowFileUploads = !!allowFileUploads;
const sanitizedAllowedFilesMimeTypes = allowedFilesMimeTypes?.toString() ?? '';
const sanitizedCustomCss = sanitizeHtml(`<style>${customCss?.toString() ?? ''}</style>`, {
allowedTags: ['style'],
allowedAttributes: false,
});
const sanitizedLoadPreviousSession = validLoadPreviousSessionOptions.includes(
loadPreviousSession as LoadPreviousSessionChatOption,
)
@ -63,6 +71,7 @@ export function createPage({
height: 100%;
}
</style>
${sanitizedCustomCss}
</head>
<body>
<script type="module">

View file

@ -67,13 +67,13 @@ export interface VectorStoreNodeConstructorArgs<T extends VectorStore = VectorSt
retrieveFields?: INodeProperties[];
updateFields?: INodeProperties[];
populateVectorStore: (
context: ISupplyDataFunctions,
context: IExecuteFunctions | ISupplyDataFunctions,
embeddings: Embeddings,
documents: Array<Document<Record<string, unknown>>>,
itemIndex: number,
) => Promise<void>;
getVectorStoreClient: (
context: ISupplyDataFunctions,
context: IExecuteFunctions | ISupplyDataFunctions,
filter: Record<string, never> | undefined,
embeddings: Embeddings,
itemIndex: number,

View file

@ -130,6 +130,7 @@
"@types/mime-types": "^2.1.0",
"@types/pg": "^8.11.6",
"@types/temp": "^0.9.1",
"@types/sanitize-html": "^2.11.0",
"n8n-core": "workspace:*"
},
"dependencies": {
@ -140,7 +141,7 @@
"@google-cloud/resource-manager": "5.3.0",
"@google/generative-ai": "0.21.0",
"@huggingface/inference": "2.8.0",
"@langchain/anthropic": "0.3.11",
"@langchain/anthropic": "0.3.14",
"@langchain/aws": "0.1.3",
"@langchain/cohere": "0.3.2",
"@langchain/community": "0.3.24",
@ -183,6 +184,7 @@
"pdf-parse": "1.1.1",
"pg": "8.12.0",
"redis": "4.6.12",
"sanitize-html": "2.12.1",
"sqlite3": "5.1.7",
"temp": "0.9.4",
"tmp-promise": "3.0.3",

View file

@ -1,6 +1,6 @@
import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
import type { INode } from 'n8n-workflow';
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
import { z } from 'zod';
import { N8nTool } from './N8nTool';
@ -20,7 +20,7 @@ describe('Test N8nTool wrapper as DynamicStructuredTool', () => {
it('should wrap a tool', () => {
const func = jest.fn();
const ctx = createMockExecuteFunction({}, mockNode);
const ctx = createMockExecuteFunction<ISupplyDataFunctions>({}, mockNode);
const tool = new N8nTool(ctx, {
name: 'Dummy Tool',
@ -39,7 +39,7 @@ describe('Test N8nTool wrapper - DynamicTool fallback', () => {
it('should convert the tool to a dynamic tool', () => {
const func = jest.fn();
const ctx = createMockExecuteFunction({}, mockNode);
const ctx = createMockExecuteFunction<ISupplyDataFunctions>({}, mockNode);
const tool = new N8nTool(ctx, {
name: 'Dummy Tool',
@ -58,7 +58,7 @@ describe('Test N8nTool wrapper - DynamicTool fallback', () => {
it('should format fallback description correctly', () => {
const func = jest.fn();
const ctx = createMockExecuteFunction({}, mockNode);
const ctx = createMockExecuteFunction<ISupplyDataFunctions>({}, mockNode);
const tool = new N8nTool(ctx, {
name: 'Dummy Tool',
@ -86,7 +86,7 @@ describe('Test N8nTool wrapper - DynamicTool fallback', () => {
it('should handle empty parameter list correctly', () => {
const func = jest.fn();
const ctx = createMockExecuteFunction({}, mockNode);
const ctx = createMockExecuteFunction<ISupplyDataFunctions>({}, mockNode);
const tool = new N8nTool(ctx, {
name: 'Dummy Tool',
@ -103,7 +103,7 @@ describe('Test N8nTool wrapper - DynamicTool fallback', () => {
it('should parse correct parameters', async () => {
const func = jest.fn();
const ctx = createMockExecuteFunction({}, mockNode);
const ctx = createMockExecuteFunction<ISupplyDataFunctions>({}, mockNode);
const tool = new N8nTool(ctx, {
name: 'Dummy Tool',
@ -127,7 +127,7 @@ describe('Test N8nTool wrapper - DynamicTool fallback', () => {
it('should recover when 1 parameter is passed directly', async () => {
const func = jest.fn();
const ctx = createMockExecuteFunction({}, mockNode);
const ctx = createMockExecuteFunction<ISupplyDataFunctions>({}, mockNode);
const tool = new N8nTool(ctx, {
name: 'Dummy Tool',
@ -150,7 +150,7 @@ describe('Test N8nTool wrapper - DynamicTool fallback', () => {
it('should recover when JS object is passed instead of JSON', async () => {
const func = jest.fn();
const ctx = createMockExecuteFunction({}, mockNode);
const ctx = createMockExecuteFunction<ISupplyDataFunctions>({}, mockNode);
const tool = new N8nTool(ctx, {
name: 'Dummy Tool',

View file

@ -1,7 +1,7 @@
import { DynamicTool, type Tool } from '@langchain/core/tools';
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
import { NodeOperationError } from 'n8n-workflow';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import type { ISupplyDataFunctions, IExecuteFunctions, INode } from 'n8n-workflow';
import { z } from 'zod';
import { escapeSingleCurlyBrackets, getConnectedTools } from '../helpers';
@ -171,7 +171,7 @@ describe('getConnectedTools', () => {
mockExecuteFunctions = createMockExecuteFunction({}, mockNode);
mockN8nTool = new N8nTool(mockExecuteFunctions, {
mockN8nTool = new N8nTool(mockExecuteFunctions as unknown as ISupplyDataFunctions, {
name: 'Dummy Tool',
description: 'A dummy tool for testing',
func: jest.fn(),

View file

@ -1,10 +1,10 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
const sharedOptions = require('@n8n/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n_io/eslint-config/base'],
extends: ['@n8n/eslint-config/base'],
...sharedOptions(__dirname),
};

View file

@ -22,5 +22,5 @@ export const RESOURCES = {
variable: [...DEFAULT_OPERATIONS] as const,
workersView: ['manage'] as const,
workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const,
folder: ['create', 'read'] as const,
folder: ['create', 'read', 'update', 'delete'] as const,
} as const;

View file

@ -1,10 +1,10 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
const sharedOptions = require('@n8n/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n_io/eslint-config/node'],
extends: ['@n8n/eslint-config/node'],
...sharedOptions(__dirname),

View file

@ -0,0 +1,10 @@
const sharedOptions = require('@n8n/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n/eslint-config/node'],
...sharedOptions(__dirname),
};

24
packages/@n8n/utils/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View file

@ -0,0 +1,25 @@
# @n8n/utils
A collection of utility functions that provide common functionality for both Front-End and Back-End packages.
## Table of Contents
- [Features](#features)
- [Contributing](#contributing)
- [License](#license)
## Features
- **Reusable Logic**: Build complex, stateful functionality using modular composable functions that you can easily reuse.
- **Consistent Patterns**: Enjoy a unified approach across n8n packages, making integration and maintenance a breeze.
- **Type-Safe & Reliable**: Benefit from TypeScript support, which improves the developer experience and code robustness.
- **Universal Functionality**: Designed to work seamlessly on both the front-end and back-end.
- **Easily Testable**: A modular design that simplifies testing, maintenance, and rapid development.
## Contributing
For more details, please read our [CONTRIBUTING.md](CONTRIBUTING.md).
## License
For more details, please read our [LICENSE.md](LICENSE.md).

View file

@ -0,0 +1,4 @@
{
"$schema": "../../../node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["../../../biome.jsonc"]
}

View file

@ -0,0 +1,40 @@
{
"name": "@n8n/utils",
"type": "module",
"version": "1.2.0",
"files": [
"dist"
],
"exports": {
"./*": {
"types": "./dist/*.d.ts",
"import": "./dist/*.js",
"require": "./dist/*.cjs"
}
},
"scripts": {
"dev": "vite",
"build": "pnpm run typecheck && tsup",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:dev": "vitest --silent=false",
"lint": "eslint src --ext .js,.ts,.vue --quiet",
"lintfix": "eslint src --ext .js,.ts,.vue --fix",
"format": "biome format --write . && prettier --write . --ignore-path ../../../.prettierignore",
"format:check": "biome ci . && prettier --check . --ignore-path ../../../.prettierignore"
},
"devDependencies": {
"@n8n/eslint-config": "workspace:*",
"@n8n/typescript-config": "workspace:*",
"@n8n/vitest-config": "workspace:*",
"@testing-library/jest-dom": "catalog:frontend",
"@testing-library/user-event": "catalog:frontend",
"tsup": "catalog:frontend",
"typescript": "catalog:frontend",
"vite": "catalog:frontend",
"vite-plugin-dts": "catalog:frontend",
"vitest": "catalog:frontend"
},
"license": "See LICENSE.md file in the root of the repository"
}

View file

@ -0,0 +1 @@
import '@testing-library/jest-dom';

View file

@ -3,6 +3,7 @@
*/
export function assert(condition: unknown, message?: string): asserts condition {
if (!condition) {
// eslint-disable-next-line n8n-local-rules/no-plain-errors
throw new Error(message ?? 'Assertion failed');
}
}

View file

@ -7,7 +7,6 @@ type Payloads<ListenerMap> = {
type Listener<Payload> = (payload: Payload) => void;
// TODO: Fix all usages of `createEventBus` and convert `any` to `unknown`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface EventBus<ListenerMap extends Payloads<ListenerMap> = Record<string, any>> {
on<EventName extends keyof ListenerMap & string>(
@ -42,7 +41,6 @@ export interface EventBus<ListenerMap extends Payloads<ListenerMap> = Record<str
* }>();
*/
export function createEventBus<
// TODO: Fix all usages of `createEventBus` and convert `any` to `unknown`
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ListenerMap extends Payloads<ListenerMap> = Record<string, any>,
>(): EventBus<ListenerMap> {
@ -77,7 +75,9 @@ export function createEventBus<
emit(eventName, event) {
const eventFns = handlers.get(eventName);
if (eventFns) {
eventFns.slice().forEach((handler) => handler(event));
eventFns.slice().forEach((handler) => {
handler(event);
});
}
},
};

View file

@ -1,8 +1,7 @@
/*
Constants and utility functions used for searching for node types in node creator component
*/
// based on https://github.com/forrestthewoods/lib_fts/blob/master/code/fts_fuzzy_match.js
* Constants and utility functions used for searching for node types in node creator component
* based on https://github.com/forrestthewoods/lib_fts/blob/master/code/fts_fuzzy_match.js
*/
const SEQUENTIAL_BONUS = 60; // bonus for adjacent matches
const SEPARATOR_BONUS = 30; // bonus if match occurs after a separator
@ -34,33 +33,6 @@ function fuzzyMatchSimple(pattern: string, target: string): boolean {
return pattern.length !== 0 && target.length !== 0 && patternIdx === pattern.length;
}
/**
* Does a fuzzy search to find pattern inside a string.
* @param {*} pattern string pattern to search for
* @param {*} target string string which is being searched
* @returns [boolean, number] a boolean which tells if pattern was
* found or not and a search score
*/
function fuzzyMatch(pattern: string, target: string): { matched: boolean; outScore: number } {
const recursionCount = 0;
const recursionLimit = 5;
const matches: number[] = [];
const maxMatches = 256;
return fuzzyMatchRecursive(
pattern,
target,
0 /* patternCurIndex */,
0 /* strCurrIndex */,
null /* srcMatces */,
matches,
maxMatches,
0 /* nextMatch */,
recursionCount,
recursionLimit,
);
}
function fuzzyMatchRecursive(
pattern: string,
target: string,
@ -195,6 +167,33 @@ function fuzzyMatchRecursive(
return { matched: false, outScore };
}
/**
* Does a fuzzy search to find pattern inside a string.
* @param {*} pattern string pattern to search for
* @param {*} target string string which is being searched
* @returns [boolean, number] a boolean which tells if pattern was
* found or not and a search score
*/
function fuzzyMatch(pattern: string, target: string): { matched: boolean; outScore: number } {
const recursionCount = 0;
const recursionLimit = 5;
const matches: number[] = [];
const maxMatches = 256;
return fuzzyMatchRecursive(
pattern,
target,
0 /* patternCurIndex */,
0 /* strCurrIndex */,
null /* srcMatces */,
matches,
maxMatches,
0 /* nextMatch */,
recursionCount,
recursionLimit,
);
}
// prop = 'key'
// prop = 'key1.key2'
// prop = ['key1', 'key2']
@ -225,6 +224,7 @@ export function sublimeSearch<T extends object>(
keys.forEach(({ key, weight }) => {
const value = getValue(item, key);
if (Array.isArray(value)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
values = values.concat(value.map((v) => ({ value: v, weight })));
} else if (typeof value === 'string') {
values.push({
@ -237,24 +237,24 @@ export function sublimeSearch<T extends object>(
// for each item, check every key and get maximum score
const itemMatch = values.reduce(
(
accu: null | { matched: boolean; outScore: number },
result: null | { matched: boolean; outScore: number },
{ value, weight }: { value: string; weight: number },
) => {
if (!fuzzyMatchSimple(filter, value)) {
return accu;
return result;
}
const match = fuzzyMatch(filter, value);
match.outScore *= weight;
const { matched, outScore } = match;
if (!accu && matched) {
if (!result && matched) {
return match;
}
if (matched && accu && outScore > accu.outScore) {
if (matched && result && outScore > result.outScore) {
return match;
}
return accu;
return result;
},
null,
);
@ -275,16 +275,3 @@ export function sublimeSearch<T extends object>(
return results;
}
export const sortByProperty = <T>(
property: keyof T,
arr: T[],
order: 'asc' | 'desc' = 'asc',
): T[] =>
arr.sort((a, b) => {
const result = String(a[property]).localeCompare(String(b[property]), undefined, {
numeric: true,
sensitivity: 'base',
});
return order === 'asc' ? result : -result;
});

1
packages/@n8n/utils/src/shims.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -1,4 +1,4 @@
import { sortByProperty } from '@/utils/sortUtils';
import { sortByProperty } from './sortByProperty';
const arrayOfObjects = [
{ name: 'Álvaro', age: 30 },
@ -7,8 +7,8 @@ const arrayOfObjects = [
{ name: 'Bob', age: 35 },
];
describe('sortUtils', () => {
it('"sortByProperty" should sort an array of objects by a property', () => {
describe('sortByProperty', () => {
it('should sort an array of objects by a property', () => {
const sortedArray = sortByProperty('name', arrayOfObjects);
expect(sortedArray).toEqual([
{ name: 'Álvaro', age: 30 },
@ -18,7 +18,7 @@ describe('sortUtils', () => {
]);
});
it('"sortByProperty" should sort an array of objects by a property in descending order', () => {
it('should sort an array of objects by a property in descending order', () => {
const sortedArray = sortByProperty('name', arrayOfObjects, 'desc');
expect(sortedArray).toEqual([
{ name: 'Željko', age: 25 },
@ -28,7 +28,7 @@ describe('sortUtils', () => {
]);
});
it('"sortByProperty" should sort an array of objects by a property if its number', () => {
it('should sort an array of objects by a property if its number', () => {
const sortedArray = sortByProperty('age', arrayOfObjects);
expect(sortedArray).toEqual([
{ name: 'Željko', age: 25 },
@ -38,7 +38,7 @@ describe('sortUtils', () => {
]);
});
it('"sortByProperty" should sort an array of objects by a property in descending order if its number', () => {
it('should sort an array of objects by a property in descending order if its number', () => {
const sortedArray = sortByProperty('age', arrayOfObjects, 'desc');
expect(sortedArray).toEqual([
{ name: 'Bob', age: 35 },

View file

@ -0,0 +1,12 @@
export const sortByProperty = <T>(
property: keyof T,
arr: T[],
order: 'asc' | 'desc' = 'asc',
): T[] =>
arr.sort((a, b) => {
const result = String(a[property]).localeCompare(String(b[property]), undefined, {
numeric: true,
sensitivity: 'base',
});
return order === 'asc' ? result : -result;
});

View file

@ -0,0 +1,15 @@
import { truncate } from './truncate';
describe('truncate', () => {
it('should truncate text to 30 chars by default', () => {
expect(truncate('This is a very long text that should be truncated')).toBe(
'This is a very long text that ...',
);
});
it('should truncate text to given length', () => {
expect(truncate('This is a very long text that should be truncated', 25)).toBe(
'This is a very long text ...',
);
});
});

View file

@ -0,0 +1,11 @@
{
"extends": "@n8n/typescript-config/tsconfig.frontend.json",
"compilerOptions": {
"baseUrl": ".",
"rootDir": ".",
"outDir": "dist",
"types": ["vite/client", "vitest/globals"],
"isolatedModules": true
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "tsup.config.ts"]
}

View file

@ -0,0 +1,11 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/**/*.ts', '!src/**/*.test.ts', '!src/**/*.d.ts', '!src/__tests__**/*'],
format: ['cjs', 'esm'],
clean: true,
dts: true,
cjsInterop: true,
splitting: true,
sourcemap: true,
});

View file

@ -0,0 +1,4 @@
import { defineConfig, mergeConfig } from 'vite';
import { vitestConfig } from '@n8n/vitest-config/frontend';
export default mergeConfig(defineConfig({}), vitestConfig);

View file

@ -1,5 +1,5 @@
{
"name": "@n8n/frontend-vitest-config",
"name": "@n8n/vitest-config",
"version": "1.1.0",
"type": "module",
"peerDependencies": {
@ -11,15 +11,13 @@
"vitest": "catalog:frontend"
},
"files": [
"index.mjs"
"frontend.mjs"
],
"main": "./index.mjs",
"module": "./index.mjs",
"exports": {
".": {
"import": "./index.mjs",
"require": "./index.mjs",
"types": "./index.d.ts"
"./frontend": {
"import": "./frontend.mjs",
"require": "./frontend.mjs",
"types": "./frontend.d.ts"
},
"./*": "./*"
},

View file

@ -1,10 +1,10 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
const sharedOptions = require('@n8n/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n_io/eslint-config/node'],
extends: ['@n8n/eslint-config/node'],
...sharedOptions(__dirname),

Some files were not shown because too many files have changed in this diff Show more