mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge branch 'n8n-io:master' into master
This commit is contained in:
commit
633b000ddb
4
.github/scripts/trim-fe-packageJson.js
vendored
4
.github/scripts/trim-fe-packageJson.js
vendored
|
@ -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');
|
||||
|
|
16
.github/workflows/release-publish.yml
vendored
16
.github/workflows/release-publish.yml
vendored
|
@ -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
|
||||
|
|
41
.github/workflows/release-push-to-channel.yml
vendored
41
.github/workflows/release-push-to-channel.yml
vendored
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -356,5 +356,5 @@ export function openContextMenu(
|
|||
}
|
||||
|
||||
export function clickContextMenuAction(action: string) {
|
||||
getContextMenuAction(action).click();
|
||||
getContextMenuAction(action).click({ force: true });
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -14,6 +14,8 @@ module.exports = defineConfig({
|
|||
experimentalMemoryManagement: true,
|
||||
e2e: {
|
||||
baseUrl: BASE_URL,
|
||||
viewportWidth: 1536,
|
||||
viewportHeight: 960,
|
||||
video: true,
|
||||
screenshotOnRunFailure: true,
|
||||
experimentalInteractiveRunEvents: true,
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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"
|
||||
|
|
74
cypress/fixtures/Test_workflow_partial_execution_v2.json
Normal file
74
cypress/fixtures/Test_workflow_partial_execution_v2.json
Normal 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"
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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: () =>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'],
|
||||
};
|
||||
|
||||
|
|
11
package.json
11
package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
|
|
|
@ -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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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(),
|
||||
}) {}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import { Z } from 'zod-class';
|
||||
|
||||
import { folderId } from '../../schemas/folder.schema';
|
||||
|
||||
export class DeleteFolderDto extends Z.class({
|
||||
transferToFolderId: folderId.optional(),
|
||||
}) {}
|
|
@ -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(),
|
||||
}) {}
|
|
@ -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';
|
||||
|
|
4
packages/@n8n/api-types/src/schemas/folder.schema.ts
Normal file
4
packages/@n8n/api-types/src/schemas/folder.schema.ts
Normal 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);
|
|
@ -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),
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -173,6 +173,7 @@ describe('GlobalConfig', () => {
|
|||
includeApiStatusCodeLabel: false,
|
||||
includeQueueMetrics: false,
|
||||
queueMetricsInterval: 20,
|
||||
activeWorkflowCountInterval: 60,
|
||||
},
|
||||
additionalNonUIRoutes: '',
|
||||
disableProductionWebhooksOnMainProcess: false,
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
@ -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",
|
|
@ -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),
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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[] = [];
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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'],
|
||||
|
|
|
@ -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>();
|
||||
|
|
|
@ -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() });
|
||||
|
|
|
@ -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() });
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
`;
|
|
@ -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">
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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),
|
||||
|
||||
|
|
10
packages/@n8n/utils/.eslintrc.cjs
Normal file
10
packages/@n8n/utils/.eslintrc.cjs
Normal 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
24
packages/@n8n/utils/.gitignore
vendored
Normal 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?
|
25
packages/@n8n/utils/README.md
Normal file
25
packages/@n8n/utils/README.md
Normal 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).
|
4
packages/@n8n/utils/biome.jsonc
Normal file
4
packages/@n8n/utils/biome.jsonc
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"$schema": "../../../node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"extends": ["../../../biome.jsonc"]
|
||||
}
|
40
packages/@n8n/utils/package.json
Normal file
40
packages/@n8n/utils/package.json
Normal 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"
|
||||
}
|
1
packages/@n8n/utils/src/__tests__/setup.ts
Normal file
1
packages/@n8n/utils/src/__tests__/setup.ts
Normal file
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom';
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
|
@ -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
1
packages/@n8n/utils/src/shims.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
|
@ -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 },
|
12
packages/@n8n/utils/src/sort/sortByProperty.ts
Normal file
12
packages/@n8n/utils/src/sort/sortByProperty.ts
Normal 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;
|
||||
});
|
15
packages/@n8n/utils/src/string/truncate.test.ts
Normal file
15
packages/@n8n/utils/src/string/truncate.test.ts
Normal 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 ...',
|
||||
);
|
||||
});
|
||||
});
|
11
packages/@n8n/utils/tsconfig.json
Normal file
11
packages/@n8n/utils/tsconfig.json
Normal 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"]
|
||||
}
|
11
packages/@n8n/utils/tsup.config.ts
Normal file
11
packages/@n8n/utils/tsup.config.ts
Normal 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,
|
||||
});
|
4
packages/@n8n/utils/vite.config.ts
Normal file
4
packages/@n8n/utils/vite.config.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
import { defineConfig, mergeConfig } from 'vite';
|
||||
import { vitestConfig } from '@n8n/vitest-config/frontend';
|
||||
|
||||
export default mergeConfig(defineConfig({}), vitestConfig);
|
|
@ -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"
|
||||
},
|
||||
"./*": "./*"
|
||||
},
|
|
@ -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
Loading…
Reference in a new issue