mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-14 08:34:07 -08:00
Merge remote-tracking branch 'origin/master' into pay-2001-add-universal-create-resource-button
# Conflicts: # packages/editor-ui/src/components/layouts/ResourcesListLayout.vue # packages/editor-ui/src/views/CredentialsView.vue
This commit is contained in:
commit
7ee45dac19
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
|
@ -11,6 +11,8 @@ Photos and videos are recommended.
|
|||
Include links to **Linear ticket** or Github issue or Community forum post.
|
||||
Important in order to close *automatically* and provide context to reviewers.
|
||||
-->
|
||||
<!-- Use "closes #<issue-number>", "fixes #<issue-number>", or "resolves #<issue-number>" to automatically close issues when the PR is merged. -->
|
||||
|
||||
|
||||
## Review / Merge checklist
|
||||
|
||||
|
|
1
.github/workflows/chromatic.yml
vendored
1
.github/workflows/chromatic.yml
vendored
|
@ -65,6 +65,7 @@ jobs:
|
|||
continue-on-error: true
|
||||
with:
|
||||
workingDir: packages/design-system
|
||||
onlyChanged: true
|
||||
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
exitZeroOnChanges: false
|
||||
|
||||
|
|
11
.github/workflows/release-publish.yml
vendored
11
.github/workflows/release-publish.yml
vendored
|
@ -42,7 +42,7 @@ jobs:
|
|||
uses: actions/cache/save@v4.0.0
|
||||
with:
|
||||
path: ./packages/**/dist
|
||||
key: ${{ github.sha }}-base:build
|
||||
key: ${{ github.sha }}-release:build
|
||||
|
||||
- name: Dry-run publishing
|
||||
run: pnpm publish -r --no-git-checks --dry-run
|
||||
|
@ -126,7 +126,7 @@ jobs:
|
|||
body: ${{github.event.pull_request.body}}
|
||||
|
||||
create-sentry-release:
|
||||
name: Create release on Sentry
|
||||
name: Create a Sentry Release
|
||||
needs: [publish-to-npm, publish-to-docker-hub]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.merged == true
|
||||
|
@ -136,18 +136,19 @@ jobs:
|
|||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4.1.1
|
||||
- name: Restore cached build artifacts
|
||||
uses: actions/cache/restore@v4.0.0
|
||||
with:
|
||||
path: ./packages/**/dist
|
||||
key: ${{ github.sha }}:db-tests
|
||||
key: ${{ github.sha }}-release:build
|
||||
|
||||
- name: Create a frontend release
|
||||
uses: getsentry/action-release@v1.7.0
|
||||
continue-on-error: true
|
||||
with:
|
||||
projects: ${{ secrets.SENTRY_FRONTEND_PROJECT }}
|
||||
version: {{ needs.publish-to-npm.outputs.release }}
|
||||
version: ${{ needs.publish-to-npm.outputs.release }}
|
||||
sourcemaps: packages/editor-ui/dist
|
||||
|
||||
- name: Create a backend release
|
||||
|
@ -155,7 +156,7 @@ jobs:
|
|||
continue-on-error: true
|
||||
with:
|
||||
projects: ${{ secrets.SENTRY_BACKEND_PROJECT }}
|
||||
version: {{ needs.publish-to-npm.outputs.release }}
|
||||
version: ${{ needs.publish-to-npm.outputs.release }}
|
||||
sourcemaps: packages/cli/dist packages/core/dist packages/nodes-base/dist packages/@n8n/n8n-nodes-langchain/dist
|
||||
|
||||
trigger-release-note:
|
||||
|
|
36
CHANGELOG.md
36
CHANGELOG.md
|
@ -1,3 +1,39 @@
|
|||
# [1.67.0](https://github.com/n8n-io/n8n/compare/n8n@1.66.0...n8n@1.67.0) (2024-11-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Bring back nodes panel telemetry events ([#11456](https://github.com/n8n-io/n8n/issues/11456)) ([130c942](https://github.com/n8n-io/n8n/commit/130c942f633788d1b2f937d6fea342d4450c6e3d))
|
||||
* **core:** Account for double quotes in instance base URL ([#11495](https://github.com/n8n-io/n8n/issues/11495)) ([c5191e6](https://github.com/n8n-io/n8n/commit/c5191e697a9a9ebfa2b67587cd01b5835ebf6ea8))
|
||||
* **core:** Do not delete waiting executions when saving of successful executions is disabled ([#11458](https://github.com/n8n-io/n8n/issues/11458)) ([e8757e5](https://github.com/n8n-io/n8n/commit/e8757e58f69e091ac3d2a2f8e8c8e33ac57c1e47))
|
||||
* **core:** Don't send a `executionFinished` event to the browser with no run data if the execution has already been cleaned up ([#11502](https://github.com/n8n-io/n8n/issues/11502)) ([d1153f5](https://github.com/n8n-io/n8n/commit/d1153f51e80911cbc8f34ba5f038f349b75295c3))
|
||||
* **core:** Include `projectId` in range query middleware ([#11590](https://github.com/n8n-io/n8n/issues/11590)) ([a6070af](https://github.com/n8n-io/n8n/commit/a6070afdda29631fd36e5213f52bf815268bcda4))
|
||||
* **core:** Save exeution progress for waiting executions, even when progress saving is disabled ([#11535](https://github.com/n8n-io/n8n/issues/11535)) ([6b9353c](https://github.com/n8n-io/n8n/commit/6b9353c80f61ab36945fff434d98242dc1cab7b3))
|
||||
* **core:** Use the correct docs URL for regular nodes when used as tools ([#11529](https://github.com/n8n-io/n8n/issues/11529)) ([a092b8e](https://github.com/n8n-io/n8n/commit/a092b8e972e1253d92df416f19096a045858e7c1))
|
||||
* **Edit Image Node:** Fix Text operation by setting Arial as default font ([#11125](https://github.com/n8n-io/n8n/issues/11125)) ([60c1ace](https://github.com/n8n-io/n8n/commit/60c1ace64be29d651ce7b777fbb576598e38b9d7))
|
||||
* **editor:** Auto focus first fields on SignIn, SignUp and ForgotMyPassword views ([#11445](https://github.com/n8n-io/n8n/issues/11445)) ([5b5bd72](https://github.com/n8n-io/n8n/commit/5b5bd7291dde17880b7699f7e6832938599ffd8f))
|
||||
* **editor:** Do not overwrite the webhookId in the new canvas ([#11562](https://github.com/n8n-io/n8n/issues/11562)) ([dfd785b](https://github.com/n8n-io/n8n/commit/dfd785bc0894257eb6e62b0dd8f71248c27aae53))
|
||||
* **editor:** Ensure Enter key on Cancel button correctly cancels node rename ([#11563](https://github.com/n8n-io/n8n/issues/11563)) ([be05ae3](https://github.com/n8n-io/n8n/commit/be05ae36e7790156cb48b317fc254ae46a3b2d8c))
|
||||
* **editor:** Fix emitting `n8nReady` notification via `postmessage` on new canvas ([#11558](https://github.com/n8n-io/n8n/issues/11558)) ([463d101](https://github.com/n8n-io/n8n/commit/463d101f3592e6df4afd66c4d0fde0cb4aec34cc))
|
||||
* **editor:** Fix run index input for RunData view in sub-nodes ([#11538](https://github.com/n8n-io/n8n/issues/11538)) ([434d31c](https://github.com/n8n-io/n8n/commit/434d31ce928342d52b6ab8b78639afd7829216d4))
|
||||
* **editor:** Fix selected credential being overwritten in NDV ([#11496](https://github.com/n8n-io/n8n/issues/11496)) ([a26c0e2](https://github.com/n8n-io/n8n/commit/a26c0e2c3c7da87bfaba9737a967aa0070810d85))
|
||||
* **editor:** Keep workflow pristine after load on new canvas ([#11579](https://github.com/n8n-io/n8n/issues/11579)) ([7254359](https://github.com/n8n-io/n8n/commit/7254359855b89769613cd5cc24dbb4f45a7cc76f))
|
||||
* Show Pinned data in demo mode ([#11490](https://github.com/n8n-io/n8n/issues/11490)) ([ca2a583](https://github.com/n8n-io/n8n/commit/ca2a583b5cbb0cba3ecb694261806de16547aa91))
|
||||
* Toast not aligned to the bottom when AI assistant disable ([#11549](https://github.com/n8n-io/n8n/issues/11549)) ([e80f7e0](https://github.com/n8n-io/n8n/commit/e80f7e0a02a972379f73af6a44de11768081086e))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add Rapid7 InsightVm credentials ([#11462](https://github.com/n8n-io/n8n/issues/11462)) ([46eceab](https://github.com/n8n-io/n8n/commit/46eceabc27ac219b11b85c16c533a2cff848c5dd))
|
||||
* **AI Transform Node:** UX improvements ([#11280](https://github.com/n8n-io/n8n/issues/11280)) ([8a48407](https://github.com/n8n-io/n8n/commit/8a484077af3d3e1fe2d1b90b1ea9edf4ba41fcb6))
|
||||
* **Anthropic Chat Model Node:** Add support for Haiku 3.5 ([#11551](https://github.com/n8n-io/n8n/issues/11551)) ([8b39825](https://github.com/n8n-io/n8n/commit/8b398256a81594a52f20f8eb8adf8ff205209bc1))
|
||||
* **Convert to File Node:** Add delimiter convert to csv ([#11556](https://github.com/n8n-io/n8n/issues/11556)) ([63d454b](https://github.com/n8n-io/n8n/commit/63d454b776c092ff8c6c521a7e083774adb8f649))
|
||||
* **editor:** Update panning and selection keybindings on new canvas ([#11534](https://github.com/n8n-io/n8n/issues/11534)) ([5e2e205](https://github.com/n8n-io/n8n/commit/5e2e205394adf76faf02aee2d4f21df71848e1d4))
|
||||
* **Gmail Trigger Node:** Add filter option to include drafts ([#11441](https://github.com/n8n-io/n8n/issues/11441)) ([7a2be77](https://github.com/n8n-io/n8n/commit/7a2be77f384a32ede3acad8fe24fb89227c058bf))
|
||||
* **Intercom Node:** Update credential to new style ([#11485](https://github.com/n8n-io/n8n/issues/11485)) ([b137e13](https://github.com/n8n-io/n8n/commit/b137e13845f0714ebf7421c837f5ab104b66709b))
|
||||
|
||||
|
||||
|
||||
# [1.66.0](https://github.com/n8n-io/n8n/compare/n8n@1.65.0...n8n@1.66.0) (2024-10-31)
|
||||
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ export const getAddProjectButton = () =>
|
|||
export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a');
|
||||
export const getProjectTabWorkflows = () => getProjectTabs().filter('a[href$="/workflows"]');
|
||||
export const getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]');
|
||||
export const getProjectTabExecutions = () => getProjectTabs().filter('a[href$="/executions"]');
|
||||
export const getProjectTabSettings = () => getProjectTabs().filter('a[href$="/settings"]');
|
||||
export const getProjectSettingsNameInput = () =>
|
||||
cy.getByTestId('project-settings-name-input').find('input');
|
||||
|
|
|
@ -26,6 +26,22 @@ const nodeDetailsView = new NDV();
|
|||
const NEW_CREDENTIAL_NAME = 'Something else';
|
||||
const NEW_CREDENTIAL_NAME2 = 'Something else entirely';
|
||||
|
||||
function createNotionCredential() {
|
||||
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME);
|
||||
workflowPage.actions.openNode(NOTION_NODE_NAME);
|
||||
workflowPage.getters.nodeCredentialsSelect().click();
|
||||
getVisibleSelect().find('li').last().click();
|
||||
credentialsModal.actions.fillCredentialsForm();
|
||||
cy.get('body').type('{esc}');
|
||||
workflowPage.actions.deleteNode(NOTION_NODE_NAME);
|
||||
}
|
||||
|
||||
function deleteSelectedCredential() {
|
||||
workflowPage.getters.nodeCredentialsEditButton().click();
|
||||
credentialsModal.getters.deleteButton().click();
|
||||
cy.get('.el-message-box').find('button').contains('Yes').click();
|
||||
}
|
||||
|
||||
describe('Credentials', () => {
|
||||
beforeEach(() => {
|
||||
cy.visit(credentialsPage.url);
|
||||
|
@ -229,6 +245,40 @@ describe('Credentials', () => {
|
|||
.should('have.value', NEW_CREDENTIAL_NAME);
|
||||
});
|
||||
|
||||
it('should set a default credential when adding nodes', () => {
|
||||
workflowPage.actions.visit();
|
||||
|
||||
createNotionCredential();
|
||||
|
||||
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
|
||||
workflowPage.getters
|
||||
.nodeCredentialsSelect()
|
||||
.find('input')
|
||||
.should('have.value', NEW_NOTION_ACCOUNT_NAME);
|
||||
|
||||
deleteSelectedCredential();
|
||||
});
|
||||
|
||||
it('should set a default credential when editing a node', () => {
|
||||
workflowPage.actions.visit();
|
||||
|
||||
createNotionCredential();
|
||||
|
||||
workflowPage.actions.addNodeToCanvas(HTTP_REQUEST_NODE_NAME, true, true);
|
||||
nodeDetailsView.getters.parameterInput('authentication').click();
|
||||
getVisibleSelect().find('li').contains('Predefined').click();
|
||||
|
||||
nodeDetailsView.getters.parameterInput('nodeCredentialType').click();
|
||||
getVisibleSelect().find('li').contains('Notion API').click();
|
||||
|
||||
workflowPage.getters
|
||||
.nodeCredentialsSelect()
|
||||
.find('input')
|
||||
.should('have.value', NEW_NOTION_ACCOUNT_NAME);
|
||||
|
||||
deleteSelectedCredential();
|
||||
});
|
||||
|
||||
it('should setup generic authentication for HTTP node', () => {
|
||||
workflowPage.actions.visit();
|
||||
workflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
|
|
|
@ -44,6 +44,7 @@ import {
|
|||
openNode,
|
||||
getConnectionBySourceAndTarget,
|
||||
} from '../composables/workflow';
|
||||
import { NDV, WorkflowPage } from '../pages';
|
||||
import { createMockNodeExecutionData, runMockWorkflowExecution } from '../utils';
|
||||
|
||||
describe('Langchain Integration', () => {
|
||||
|
@ -232,95 +233,96 @@ describe('Langchain Integration', () => {
|
|||
|
||||
const inputMessage = 'Hello!';
|
||||
const outputMessage = 'Hi there! How can I assist you today?';
|
||||
const runData = [
|
||||
createMockNodeExecutionData(MANUAL_CHAT_TRIGGER_NODE_NAME, {
|
||||
jsonData: {
|
||||
main: { input: inputMessage },
|
||||
},
|
||||
}),
|
||||
createMockNodeExecutionData(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, {
|
||||
jsonData: {
|
||||
ai_languageModel: {
|
||||
response: {
|
||||
generations: [
|
||||
{
|
||||
text: `{
|
||||
"action": "Final Answer",
|
||||
"action_input": "${outputMessage}"
|
||||
}`,
|
||||
message: {
|
||||
lc: 1,
|
||||
type: 'constructor',
|
||||
id: ['langchain', 'schema', 'AIMessage'],
|
||||
kwargs: {
|
||||
content: `{
|
||||
"action": "Final Answer",
|
||||
"action_input": "${outputMessage}"
|
||||
}`,
|
||||
additional_kwargs: {},
|
||||
},
|
||||
},
|
||||
generationInfo: { finish_reason: 'stop' },
|
||||
},
|
||||
],
|
||||
llmOutput: {
|
||||
tokenUsage: {
|
||||
completionTokens: 26,
|
||||
promptTokens: 519,
|
||||
totalTokens: 545,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }],
|
||||
},
|
||||
inputOverride: {
|
||||
ai_languageModel: [
|
||||
[
|
||||
{
|
||||
json: {
|
||||
messages: [
|
||||
{
|
||||
lc: 1,
|
||||
type: 'constructor',
|
||||
id: ['langchain', 'schema', 'SystemMessage'],
|
||||
kwargs: {
|
||||
content:
|
||||
'Assistant is a large language model trained by OpenAI.\n\nAssistant is designed to be able to assist with a wide range of tasks, from answering simple questions to providing in-depth explanations and discussions on a wide range of topics. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand.\n\nAssistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide accurate and informative responses to a wide range of questions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in discussions and provide explanations and descriptions on a wide range of topics.\n\nOverall, Assistant is a powerful system that can help with a wide range of tasks and provide valuable insights and information on a wide range of topics. Whether you need help with a specific question or just want to have a conversation about a particular topic, Assistant is here to assist. However, above all else, all responses must adhere to the format of RESPONSE FORMAT INSTRUCTIONS.',
|
||||
additional_kwargs: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
lc: 1,
|
||||
type: 'constructor',
|
||||
id: ['langchain', 'schema', 'HumanMessage'],
|
||||
kwargs: {
|
||||
content:
|
||||
'TOOLS\n------\nAssistant can ask the user to use tools to look up information that may be helpful in answering the users original question. The tools the human can use are:\n\n\n\nRESPONSE FORMAT INSTRUCTIONS\n----------------------------\n\nOutput a JSON markdown code snippet containing a valid JSON object in one of two formats:\n\n**Option 1:**\nUse this if you want the human to use a tool.\nMarkdown code snippet formatted in the following schema:\n\n```json\n{\n "action": string, // The action to take. Must be one of []\n "action_input": string // The input to the action. May be a stringified object.\n}\n```\n\n**Option #2:**\nUse this if you want to respond directly and conversationally to the human. Markdown code snippet formatted in the following schema:\n\n```json\n{\n "action": "Final Answer",\n "action_input": string // You should put what you want to return to use here and make sure to use valid json newline characters.\n}\n```\n\nFor both options, remember to always include the surrounding markdown code snippet delimiters (begin with "```json" and end with "```")!\n\n\nUSER\'S INPUT\n--------------------\nHere is the user\'s input (remember to respond with a markdown code snippet of a json blob with a single action, and NOTHING else):\n\nHello!',
|
||||
additional_kwargs: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
options: { stop: ['Observation:'], promptIndex: 0 },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
createMockNodeExecutionData(AGENT_NODE_NAME, {
|
||||
jsonData: {
|
||||
main: { output: 'Hi there! How can I assist you today?' },
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
runMockWorkflowExecution({
|
||||
trigger: () => {
|
||||
sendManualChatMessage(inputMessage);
|
||||
},
|
||||
runData: [
|
||||
createMockNodeExecutionData(MANUAL_CHAT_TRIGGER_NODE_NAME, {
|
||||
jsonData: {
|
||||
main: { input: inputMessage },
|
||||
},
|
||||
}),
|
||||
createMockNodeExecutionData(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, {
|
||||
jsonData: {
|
||||
ai_languageModel: {
|
||||
response: {
|
||||
generations: [
|
||||
{
|
||||
text: `{
|
||||
"action": "Final Answer",
|
||||
"action_input": "${outputMessage}"
|
||||
}`,
|
||||
message: {
|
||||
lc: 1,
|
||||
type: 'constructor',
|
||||
id: ['langchain', 'schema', 'AIMessage'],
|
||||
kwargs: {
|
||||
content: `{
|
||||
"action": "Final Answer",
|
||||
"action_input": "${outputMessage}"
|
||||
}`,
|
||||
additional_kwargs: {},
|
||||
},
|
||||
},
|
||||
generationInfo: { finish_reason: 'stop' },
|
||||
},
|
||||
],
|
||||
llmOutput: {
|
||||
tokenUsage: {
|
||||
completionTokens: 26,
|
||||
promptTokens: 519,
|
||||
totalTokens: 545,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }],
|
||||
},
|
||||
inputOverride: {
|
||||
ai_languageModel: [
|
||||
[
|
||||
{
|
||||
json: {
|
||||
messages: [
|
||||
{
|
||||
lc: 1,
|
||||
type: 'constructor',
|
||||
id: ['langchain', 'schema', 'SystemMessage'],
|
||||
kwargs: {
|
||||
content:
|
||||
'Assistant is a large language model trained by OpenAI.\n\nAssistant is designed to be able to assist with a wide range of tasks, from answering simple questions to providing in-depth explanations and discussions on a wide range of topics. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand.\n\nAssistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide accurate and informative responses to a wide range of questions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in discussions and provide explanations and descriptions on a wide range of topics.\n\nOverall, Assistant is a powerful system that can help with a wide range of tasks and provide valuable insights and information on a wide range of topics. Whether you need help with a specific question or just want to have a conversation about a particular topic, Assistant is here to assist. However, above all else, all responses must adhere to the format of RESPONSE FORMAT INSTRUCTIONS.',
|
||||
additional_kwargs: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
lc: 1,
|
||||
type: 'constructor',
|
||||
id: ['langchain', 'schema', 'HumanMessage'],
|
||||
kwargs: {
|
||||
content:
|
||||
'TOOLS\n------\nAssistant can ask the user to use tools to look up information that may be helpful in answering the users original question. The tools the human can use are:\n\n\n\nRESPONSE FORMAT INSTRUCTIONS\n----------------------------\n\nOutput a JSON markdown code snippet containing a valid JSON object in one of two formats:\n\n**Option 1:**\nUse this if you want the human to use a tool.\nMarkdown code snippet formatted in the following schema:\n\n```json\n{\n "action": string, // The action to take. Must be one of []\n "action_input": string // The input to the action. May be a stringified object.\n}\n```\n\n**Option #2:**\nUse this if you want to respond directly and conversationally to the human. Markdown code snippet formatted in the following schema:\n\n```json\n{\n "action": "Final Answer",\n "action_input": string // You should put what you want to return to use here and make sure to use valid json newline characters.\n}\n```\n\nFor both options, remember to always include the surrounding markdown code snippet delimiters (begin with "```json" and end with "```")!\n\n\nUSER\'S INPUT\n--------------------\nHere is the user\'s input (remember to respond with a markdown code snippet of a json blob with a single action, and NOTHING else):\n\nHello!',
|
||||
additional_kwargs: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
options: { stop: ['Observation:'], promptIndex: 0 },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
createMockNodeExecutionData(AGENT_NODE_NAME, {
|
||||
jsonData: {
|
||||
main: { output: 'Hi there! How can I assist you today?' },
|
||||
},
|
||||
}),
|
||||
],
|
||||
runData,
|
||||
lastNodeExecuted: AGENT_NODE_NAME,
|
||||
});
|
||||
|
||||
|
@ -357,4 +359,56 @@ describe('Langchain Integration', () => {
|
|||
getConnectionBySourceAndTarget(CHAT_TRIGGER_NODE_DISPLAY_NAME, AGENT_NODE_NAME).should('exist');
|
||||
getNodes().should('have.length', 3);
|
||||
});
|
||||
it('should render runItems for sub-nodes and allow switching between them', () => {
|
||||
const workflowPage = new WorkflowPage();
|
||||
const ndv = new NDV();
|
||||
|
||||
cy.visit(workflowPage.url);
|
||||
cy.createFixtureWorkflow('In_memory_vector_store_fake_embeddings.json');
|
||||
workflowPage.actions.zoomToFit();
|
||||
|
||||
workflowPage.actions.executeNode('Populate VS');
|
||||
cy.get('[data-label="25 items"]').should('exist');
|
||||
|
||||
const assertInputOutputText = (text: string, assertion: 'exist' | 'not.exist') => {
|
||||
ndv.getters.outputPanel().contains(text).should(assertion);
|
||||
ndv.getters.inputPanel().contains(text).should(assertion);
|
||||
};
|
||||
|
||||
workflowPage.actions.openNode('Character Text Splitter');
|
||||
ndv.getters.outputRunSelector().should('exist');
|
||||
ndv.getters.inputRunSelector().should('exist');
|
||||
ndv.getters.inputRunSelector().find('input').should('include.value', '3 of 3');
|
||||
ndv.getters.outputRunSelector().find('input').should('include.value', '3 of 3');
|
||||
assertInputOutputText('Kyiv', 'exist');
|
||||
assertInputOutputText('Berlin', 'not.exist');
|
||||
assertInputOutputText('Prague', 'not.exist');
|
||||
|
||||
ndv.actions.changeOutputRunSelector('2 of 3');
|
||||
assertInputOutputText('Berlin', 'exist');
|
||||
assertInputOutputText('Kyiv', 'not.exist');
|
||||
assertInputOutputText('Prague', 'not.exist');
|
||||
|
||||
ndv.actions.changeOutputRunSelector('1 of 3');
|
||||
assertInputOutputText('Prague', 'exist');
|
||||
assertInputOutputText('Berlin', 'not.exist');
|
||||
assertInputOutputText('Kyiv', 'not.exist');
|
||||
|
||||
ndv.actions.toggleInputRunLinking();
|
||||
ndv.actions.changeOutputRunSelector('2 of 3');
|
||||
ndv.getters.inputRunSelector().find('input').should('include.value', '1 of 3');
|
||||
ndv.getters.outputRunSelector().find('input').should('include.value', '2 of 3');
|
||||
ndv.getters.inputPanel().contains('Prague').should('exist');
|
||||
ndv.getters.inputPanel().contains('Berlin').should('not.exist');
|
||||
|
||||
ndv.getters.outputPanel().contains('Berlin').should('exist');
|
||||
ndv.getters.outputPanel().contains('Prague').should('not.exist');
|
||||
|
||||
ndv.actions.toggleInputRunLinking();
|
||||
ndv.getters.inputRunSelector().find('input').should('include.value', '1 of 3');
|
||||
ndv.getters.outputRunSelector().find('input').should('include.value', '1 of 3');
|
||||
assertInputOutputText('Prague', 'exist');
|
||||
assertInputOutputText('Berlin', 'not.exist');
|
||||
assertInputOutputText('Kyiv', 'not.exist');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -51,7 +51,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
});
|
||||
|
||||
projects.getHomeButton().click();
|
||||
projects.getProjectTabs().should('have.length', 2);
|
||||
projects.getProjectTabs().should('have.length', 3);
|
||||
|
||||
projects.getProjectTabCredentials().click();
|
||||
credentialsPage.getters.credentialCards().should('not.have.length');
|
||||
|
@ -101,7 +101,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
|
||||
projects.getMenuItems().first().click();
|
||||
workflowsPage.getters.workflowCards().should('not.have.length');
|
||||
projects.getProjectTabs().should('have.length', 3);
|
||||
projects.getProjectTabs().should('have.length', 4);
|
||||
|
||||
workflowsPage.getters.newWorkflowButtonCard().click();
|
||||
|
||||
|
@ -441,9 +441,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
.should('contain.text', 'Notion account personal project');
|
||||
});
|
||||
|
||||
// Skip flaky test
|
||||
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
|
||||
it.skip('should move resources between projects', () => {
|
||||
it('should move resources between projects', () => {
|
||||
cy.signinAsOwner();
|
||||
cy.visit(workflowsPage.url);
|
||||
|
||||
|
@ -686,9 +684,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
.should('have.length', 1);
|
||||
});
|
||||
|
||||
// Skip flaky test
|
||||
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
|
||||
it.skip('should allow to change inaccessible credential when the workflow was moved to a team project', () => {
|
||||
it('should allow to change inaccessible credential when the workflow was moved to a team project', () => {
|
||||
cy.signinAsOwner();
|
||||
cy.visit(workflowsPage.url);
|
||||
|
||||
|
@ -701,9 +697,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
projects.getHomeButton().click();
|
||||
workflowsPage.getters.workflowCards().should('not.have.length');
|
||||
workflowsPage.getters.newWorkflowButtonCard().click();
|
||||
workflowsPage.getters.workflowCards().should('not.have.length');
|
||||
|
||||
workflowsPage.getters.newWorkflowButtonCard().click();
|
||||
workflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
|
||||
ndv.getters.backToCanvas().click();
|
||||
|
@ -789,7 +783,8 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
cy.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password);
|
||||
cy.getByTestId('form-submit-button').click();
|
||||
|
||||
mainSidebar.getters.executions().click();
|
||||
projects.getMenuItems().last().click();
|
||||
projects.getProjectTabExecutions().click();
|
||||
cy.getByTestId('global-execution-list-item').first().find('td:last button').click();
|
||||
getVisibleDropdown()
|
||||
.find('li')
|
||||
|
|
|
@ -4,6 +4,7 @@ import { clickCreateNewCredential, openCredentialSelect } from '../composables/n
|
|||
import { GMAIL_NODE_NAME, SCHEDULE_TRIGGER_NODE_NAME } from '../constants';
|
||||
import { CredentialsModal, CredentialsPage, NDV, WorkflowPage } from '../pages';
|
||||
import { AIAssistant } from '../pages/features/ai-assistant';
|
||||
import { NodeCreator } from '../pages/features/node-creator';
|
||||
import { getVisibleSelect } from '../utils';
|
||||
|
||||
const wf = new WorkflowPage();
|
||||
|
@ -11,6 +12,7 @@ const ndv = new NDV();
|
|||
const aiAssistant = new AIAssistant();
|
||||
const credentialsPage = new CredentialsPage();
|
||||
const credentialsModal = new CredentialsModal();
|
||||
const nodeCreatorFeature = new NodeCreator();
|
||||
|
||||
describe('AI Assistant::disabled', () => {
|
||||
beforeEach(() => {
|
||||
|
@ -222,6 +224,54 @@ describe('AI Assistant::enabled', () => {
|
|||
.should('contain.text', 'item.json.myNewField = 1');
|
||||
});
|
||||
|
||||
it('Should ignore node execution success and error messages after the node run successfully once', () => {
|
||||
const getParameter = () => ndv.getters.parameterInput('jsCode').should('be.visible');
|
||||
|
||||
const getEditor = () => getParameter().find('.cm-content').should('exist');
|
||||
|
||||
cy.intercept('POST', '/rest/ai/chat', {
|
||||
statusCode: 200,
|
||||
fixture: 'aiAssistant/responses/code_diff_suggestion_response.json',
|
||||
}).as('chatRequest');
|
||||
|
||||
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
|
||||
wf.actions.openNode('Code');
|
||||
ndv.getters.nodeExecuteButton().click();
|
||||
aiAssistant.getters.nodeErrorViewAssistantButton().click({ force: true });
|
||||
cy.wait('@chatRequest');
|
||||
|
||||
cy.intercept('POST', '/rest/ai/chat', {
|
||||
statusCode: 200,
|
||||
fixture: 'aiAssistant/responses/node_execution_succeeded_response.json',
|
||||
}).as('chatRequest2');
|
||||
|
||||
getEditor()
|
||||
.type('{selectall}')
|
||||
.paste(
|
||||
'for (const item of $input.all()) {\n item.json.myNewField = 1;\n}\n\nreturn $input.all();',
|
||||
);
|
||||
|
||||
ndv.getters.nodeExecuteButton().click();
|
||||
|
||||
getEditor()
|
||||
.type('{selectall}')
|
||||
.paste(
|
||||
'for (const item of $input.all()) {\n item.json.myNewField = 1aaaa!;\n}\n\nreturn $input.all();',
|
||||
);
|
||||
|
||||
ndv.getters.nodeExecuteButton().click();
|
||||
|
||||
aiAssistant.getters.chatMessagesAssistant().should('have.length', 3);
|
||||
|
||||
aiAssistant.getters
|
||||
.chatMessagesAssistant()
|
||||
.eq(2)
|
||||
.should(
|
||||
'contain.text',
|
||||
'Code node ran successfully, did my solution help resolve your issue?\nQuick reply 👇Yes, thanksNo, I am still stuck',
|
||||
);
|
||||
});
|
||||
|
||||
it('should end chat session when `end_session` event is received', () => {
|
||||
cy.intercept('POST', '/rest/ai/chat', {
|
||||
statusCode: 200,
|
||||
|
@ -280,6 +330,20 @@ describe('AI Assistant::enabled', () => {
|
|||
wf.getters.isWorkflowSaved();
|
||||
aiAssistant.getters.placeholderMessage().should('not.exist');
|
||||
});
|
||||
|
||||
it('should send message via enter even with global NodeCreator panel opened', () => {
|
||||
cy.intercept('POST', '/rest/ai/chat', {
|
||||
statusCode: 200,
|
||||
fixture: 'aiAssistant/responses/simple_message_response.json',
|
||||
}).as('chatRequest');
|
||||
|
||||
wf.actions.addInitialNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
aiAssistant.actions.openChat();
|
||||
nodeCreatorFeature.actions.openNodeCreator();
|
||||
aiAssistant.getters.chatInput().type('Hello{Enter}');
|
||||
|
||||
aiAssistant.getters.placeholderMessage().should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI Assistant Credential Help', () => {
|
||||
|
|
|
@ -795,4 +795,46 @@ describe('NDV', () => {
|
|||
.find('[data-test-id=run-data-schema-item]')
|
||||
.should('contain.text', 'onlyOnItem3');
|
||||
});
|
||||
|
||||
it('should keep search expanded after Test step node run', () => {
|
||||
cy.createFixtureWorkflow('Test_ndv_search.json');
|
||||
workflowPage.actions.zoomToFit();
|
||||
workflowPage.actions.executeWorkflow();
|
||||
workflowPage.actions.openNode('Edit Fields');
|
||||
ndv.getters.outputPanel().should('be.visible');
|
||||
ndv.getters.outputPanel().findChildByTestId('ndv-search').click().type('US');
|
||||
ndv.getters.outputTableRow(1).find('mark').should('have.text', 'US');
|
||||
|
||||
ndv.actions.execute();
|
||||
ndv.getters
|
||||
.outputPanel()
|
||||
.findChildByTestId('ndv-search')
|
||||
.should('be.visible')
|
||||
.should('have.value', 'US');
|
||||
});
|
||||
|
||||
it('should not show items count when seaching in schema view', () => {
|
||||
cy.createFixtureWorkflow('Test_ndv_search.json');
|
||||
workflowPage.actions.zoomToFit();
|
||||
workflowPage.actions.openNode('Edit Fields');
|
||||
ndv.getters.outputPanel().should('be.visible');
|
||||
ndv.actions.execute();
|
||||
ndv.actions.switchOutputMode('Schema');
|
||||
ndv.getters.outputPanel().find('[data-test-id=ndv-search]').click().type('US');
|
||||
ndv.getters.outputPanel().find('[data-test-id=ndv-items-count]').should('not.exist');
|
||||
});
|
||||
|
||||
it('should show additional tooltip when seaching in schema view if no matches', () => {
|
||||
cy.createFixtureWorkflow('Test_ndv_search.json');
|
||||
workflowPage.actions.zoomToFit();
|
||||
workflowPage.actions.openNode('Edit Fields');
|
||||
ndv.getters.outputPanel().should('be.visible');
|
||||
ndv.actions.execute();
|
||||
ndv.actions.switchOutputMode('Schema');
|
||||
ndv.getters.outputPanel().find('[data-test-id=ndv-search]').click().type('foo');
|
||||
ndv.getters
|
||||
.outputPanel()
|
||||
.contains('To search field contents rather than just names, use Table or JSON view')
|
||||
.should('exist');
|
||||
});
|
||||
});
|
||||
|
|
347
cypress/fixtures/In_memory_vector_store_fake_embeddings.json
Normal file
347
cypress/fixtures/In_memory_vector_store_fake_embeddings.json
Normal file
File diff suppressed because one or more lines are too long
135
cypress/fixtures/Test_ndv_search.json
Normal file
135
cypress/fixtures/Test_ndv_search.json
Normal file
|
@ -0,0 +1,135 @@
|
|||
{
|
||||
"name": "NDV search bugs (introduced by schema view?)",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "55635c7b-92ee-4d2d-a0c0-baff9ab071da",
|
||||
"name": "When clicking ‘Test workflow’",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"position": [
|
||||
800,
|
||||
380
|
||||
],
|
||||
"typeVersion": 1
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "getAllPeople"
|
||||
},
|
||||
"id": "4737af43-e49b-4c92-b76f-32605c047114",
|
||||
"name": "Customer Datastore (n8n training)",
|
||||
"type": "n8n-nodes-base.n8nTrainingCustomerDatastore",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
1020,
|
||||
380
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": []
|
||||
},
|
||||
"includeOtherFields": true,
|
||||
"options": {}
|
||||
},
|
||||
"id": "8cc9b374-1856-4f3f-9315-08e6e27840d8",
|
||||
"name": "Edit Fields",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
1240,
|
||||
380
|
||||
]
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"Customer Datastore (n8n training)": [
|
||||
{
|
||||
"json": {
|
||||
"id": "23423532",
|
||||
"name": "Jay Gatsby",
|
||||
"email": "gatsby@west-egg.com",
|
||||
"notes": "Keeps asking about a green light??",
|
||||
"country": "US",
|
||||
"created": "1925-04-10"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": "23423533",
|
||||
"name": "José Arcadio Buendía",
|
||||
"email": "jab@macondo.co",
|
||||
"notes": "Lots of people named after him. Very confusing",
|
||||
"country": "CO",
|
||||
"created": "1967-05-05"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": "23423534",
|
||||
"name": "Max Sendak",
|
||||
"email": "info@in-and-out-of-weeks.org",
|
||||
"notes": "Keeps rolling his terrible eyes",
|
||||
"country": "US",
|
||||
"created": "1963-04-09"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": "23423535",
|
||||
"name": "Zaphod Beeblebrox",
|
||||
"email": "captain@heartofgold.com",
|
||||
"notes": "Felt like I was talking to more than one person",
|
||||
"country": null,
|
||||
"created": "1979-10-12"
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"id": "23423536",
|
||||
"name": "Edmund Pevensie",
|
||||
"email": "edmund@narnia.gov",
|
||||
"notes": "Passionate sailor",
|
||||
"country": "UK",
|
||||
"created": "1950-10-16"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking ‘Test workflow’": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Customer Datastore (n8n training)",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Customer Datastore (n8n training)": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Edit Fields",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"versionId": "20178044-fb64-4443-88dd-e941517520d0",
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true,
|
||||
"instanceId": "be251a83c052a9862eeac953816fbb1464f89dfbf79d7ac490a8e336a8cc8bfd"
|
||||
},
|
||||
"id": "aBVnTRON9Y2cSmse",
|
||||
"tags": []
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"sessionId": "1",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"type": "message",
|
||||
"text": "**Code** node ran successfully, did my solution help resolve your issue?",
|
||||
"quickReplies": [
|
||||
{
|
||||
"text": "Yes, thanks",
|
||||
"type": "all-good",
|
||||
"isFeedback": true
|
||||
},
|
||||
{
|
||||
"text": "No, I am still stuck",
|
||||
"type": "still-stuck",
|
||||
"isFeedback": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
|
@ -16,7 +16,7 @@ export function createMockNodeExecutionData(
|
|||
return {
|
||||
[name]: {
|
||||
startTime: new Date().getTime(),
|
||||
executionTime: 0,
|
||||
executionTime: 1,
|
||||
executionStatus,
|
||||
data: jsonData
|
||||
? Object.keys(jsonData).reduce((acc, key) => {
|
||||
|
@ -33,6 +33,7 @@ export function createMockNodeExecutionData(
|
|||
}, {} as ITaskDataConnections)
|
||||
: data,
|
||||
source: [null],
|
||||
inputOverride,
|
||||
...rest,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "1.66.0",
|
||||
"version": "1.67.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20.15",
|
||||
|
@ -45,6 +45,7 @@
|
|||
"@types/jest": "^29.5.3",
|
||||
"@types/node": "*",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"jest": "^29.6.2",
|
||||
"jest-environment-jsdom": "^29.6.2",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"name": "@n8n/chat",
|
||||
"version": "0.29.0",
|
||||
"version": "0.30.0",
|
||||
"scripts": {
|
||||
"dev": "pnpm run storybook",
|
||||
"build": "pnpm build:vite && pnpm build:bundle",
|
||||
"build:vite": "vite build",
|
||||
"build:bundle": "INCLUDE_VUE=true vite build",
|
||||
"build:vite": "cross-env vite build",
|
||||
"build:bundle": "cross-env INCLUDE_VUE=true vite build",
|
||||
"preview": "vite preview",
|
||||
"test:dev": "vitest",
|
||||
"test": "vitest run",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/config",
|
||||
"version": "1.16.0",
|
||||
"version": "1.17.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
@ -7,6 +7,7 @@ export const LOG_SCOPES = [
|
|||
'external-secrets',
|
||||
'license',
|
||||
'multi-main-setup',
|
||||
'pruning',
|
||||
'pubsub',
|
||||
'redis',
|
||||
'scaling',
|
||||
|
|
|
@ -10,9 +10,8 @@ export type TaskRunnerMode = 'internal_childprocess' | 'internal_launcher' | 'ex
|
|||
|
||||
@Config
|
||||
export class TaskRunnersConfig {
|
||||
// Defaults to true for now
|
||||
@Env('N8N_RUNNERS_DISABLED')
|
||||
disabled: boolean = true;
|
||||
@Env('N8N_RUNNERS_ENABLED')
|
||||
enabled: boolean = false;
|
||||
|
||||
// Defaults to true for now
|
||||
@Env('N8N_RUNNERS_MODE')
|
||||
|
@ -50,4 +49,8 @@ export class TaskRunnersConfig {
|
|||
/** How many concurrent tasks can a runner execute at a time */
|
||||
@Env('N8N_RUNNERS_MAX_CONCURRENCY')
|
||||
maxConcurrency: number = 5;
|
||||
|
||||
/** Should the output of deduplication be asserted for correctness */
|
||||
@Env('N8N_RUNNERS_ASSERT_DEDUPLICATION_OUTPUT')
|
||||
assertDeduplicationOutput: boolean = false;
|
||||
}
|
||||
|
|
|
@ -222,7 +222,7 @@ describe('GlobalConfig', () => {
|
|||
},
|
||||
},
|
||||
taskRunners: {
|
||||
disabled: true,
|
||||
enabled: false,
|
||||
mode: 'internal_childprocess',
|
||||
path: '/runners',
|
||||
authToken: '',
|
||||
|
@ -233,6 +233,7 @@ describe('GlobalConfig', () => {
|
|||
launcherRunner: 'javascript',
|
||||
maxOldSpaceSize: '',
|
||||
maxConcurrency: 5,
|
||||
assertDeduplicationOutput: false,
|
||||
},
|
||||
sentry: {
|
||||
backendDsn: '',
|
||||
|
|
|
@ -21,7 +21,7 @@ import { sqlAgentAgentProperties } from './agents/SqlAgent/description';
|
|||
import { sqlAgentAgentExecute } from './agents/SqlAgent/execute';
|
||||
import { toolsAgentProperties } from './agents/ToolsAgent/description';
|
||||
import { toolsAgentExecute } from './agents/ToolsAgent/execute';
|
||||
import { promptTypeOptions, textInput } from '../../../utils/descriptions';
|
||||
import { promptTypeOptions, textFromPreviousNode, textInput } from '../../../utils/descriptions';
|
||||
|
||||
// Function used in the inputs expression to figure out which inputs to
|
||||
// display based on the agent type
|
||||
|
@ -341,6 +341,17 @@ export class Agent implements INodeType {
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...textFromPreviousNode,
|
||||
displayOptions: {
|
||||
show: { promptType: ['auto'], '@version': [{ _cnd: { gte: 1.7 } }] },
|
||||
// SQL Agent has data source and credentials parameters so we need to include this input there manually
|
||||
// to preserve the order
|
||||
hide: {
|
||||
agent: ['sqlAgent'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...textInput,
|
||||
displayOptions: {
|
||||
|
|
|
@ -14,7 +14,7 @@ import {
|
|||
import { getOptionalOutputParsers } from '../../../../../utils/output_parsers/N8nOutputParser';
|
||||
import { throwIfToolSchema } from '../../../../../utils/schemaParsing';
|
||||
import { getTracingConfig } from '../../../../../utils/tracing';
|
||||
import { extractParsedOutput } from '../utils';
|
||||
import { checkForStructuredTools, extractParsedOutput } from '../utils';
|
||||
|
||||
export async function conversationalAgentExecute(
|
||||
this: IExecuteFunctions,
|
||||
|
@ -34,6 +34,8 @@ export async function conversationalAgentExecute(
|
|||
const tools = await getConnectedTools(this, nodeVersion >= 1.5);
|
||||
const outputParsers = await getOptionalOutputParsers(this);
|
||||
|
||||
await checkForStructuredTools(tools, this.getNode(), 'Conversational Agent');
|
||||
|
||||
// TODO: Make it possible in the future to use values for other items than just 0
|
||||
const options = this.getNodeParameter('options', 0, {}) as {
|
||||
systemMessage?: string;
|
||||
|
|
|
@ -14,7 +14,7 @@ import { getConnectedTools, getPromptInputByType } from '../../../../../utils/he
|
|||
import { getOptionalOutputParsers } from '../../../../../utils/output_parsers/N8nOutputParser';
|
||||
import { throwIfToolSchema } from '../../../../../utils/schemaParsing';
|
||||
import { getTracingConfig } from '../../../../../utils/tracing';
|
||||
import { extractParsedOutput } from '../utils';
|
||||
import { checkForStructuredTools, extractParsedOutput } from '../utils';
|
||||
|
||||
export async function planAndExecuteAgentExecute(
|
||||
this: IExecuteFunctions,
|
||||
|
@ -28,6 +28,7 @@ export async function planAndExecuteAgentExecute(
|
|||
|
||||
const tools = await getConnectedTools(this, nodeVersion >= 1.5);
|
||||
|
||||
await checkForStructuredTools(tools, this.getNode(), 'Plan & Execute Agent');
|
||||
const outputParsers = await getOptionalOutputParsers(this);
|
||||
|
||||
const options = this.getNodeParameter('options', 0, {}) as {
|
||||
|
|
|
@ -19,7 +19,7 @@ import {
|
|||
import { getOptionalOutputParsers } from '../../../../../utils/output_parsers/N8nOutputParser';
|
||||
import { throwIfToolSchema } from '../../../../../utils/schemaParsing';
|
||||
import { getTracingConfig } from '../../../../../utils/tracing';
|
||||
import { extractParsedOutput } from '../utils';
|
||||
import { checkForStructuredTools, extractParsedOutput } from '../utils';
|
||||
|
||||
export async function reActAgentAgentExecute(
|
||||
this: IExecuteFunctions,
|
||||
|
@ -33,6 +33,8 @@ export async function reActAgentAgentExecute(
|
|||
|
||||
const tools = await getConnectedTools(this, nodeVersion >= 1.5);
|
||||
|
||||
await checkForStructuredTools(tools, this.getNode(), 'ReAct Agent');
|
||||
|
||||
const outputParsers = await getOptionalOutputParsers(this);
|
||||
|
||||
const options = this.getNodeParameter('options', 0, {}) as {
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
import type { INodeProperties } from 'n8n-workflow';
|
||||
import { promptTypeOptions, textInput } from '../../../../../utils/descriptions';
|
||||
|
||||
import { SQL_PREFIX, SQL_SUFFIX } from './other/prompts';
|
||||
import {
|
||||
promptTypeOptions,
|
||||
textFromPreviousNode,
|
||||
textInput,
|
||||
} from '../../../../../utils/descriptions';
|
||||
|
||||
const dataSourceOptions: INodeProperties = {
|
||||
displayName: 'Data Source',
|
||||
|
@ -114,6 +119,12 @@ export const sqlAgentAgentProperties: INodeProperties[] = [
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...textFromPreviousNode,
|
||||
displayOptions: {
|
||||
show: { promptType: ['auto'], '@version': [{ _cnd: { gte: 1.7 } }], agent: ['sqlAgent'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
...textInput,
|
||||
displayOptions: {
|
||||
|
|
|
@ -1,3 +1,9 @@
|
|||
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import type { DataSource } from '@n8n/typeorm';
|
||||
import type { SqlCreatePromptArgs } from 'langchain/agents/toolkits/sql';
|
||||
import { SqlToolkit, createSqlAgent } from 'langchain/agents/toolkits/sql';
|
||||
import { SqlDatabase } from 'langchain/sql_db';
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
|
@ -6,19 +12,12 @@ import {
|
|||
type IDataObject,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { SqlDatabase } from 'langchain/sql_db';
|
||||
import type { SqlCreatePromptArgs } from 'langchain/agents/toolkits/sql';
|
||||
import { SqlToolkit, createSqlAgent } from 'langchain/agents/toolkits/sql';
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
|
||||
import type { DataSource } from '@n8n/typeorm';
|
||||
|
||||
import { getMysqlDataSource } from './other/handlers/mysql';
|
||||
import { getPostgresDataSource } from './other/handlers/postgres';
|
||||
import { getSqliteDataSource } from './other/handlers/sqlite';
|
||||
import { SQL_PREFIX, SQL_SUFFIX } from './other/prompts';
|
||||
import { getPromptInputByType, serializeChatHistory } from '../../../../../utils/helpers';
|
||||
import { getTracingConfig } from '../../../../../utils/tracing';
|
||||
import { getSqliteDataSource } from './other/handlers/sqlite';
|
||||
import { getPostgresDataSource } from './other/handlers/postgres';
|
||||
import { SQL_PREFIX, SQL_SUFFIX } from './other/prompts';
|
||||
import { getMysqlDataSource } from './other/handlers/mysql';
|
||||
|
||||
const parseTablesString = (tablesString: string) =>
|
||||
tablesString
|
||||
|
|
|
@ -206,10 +206,28 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeE
|
|||
// If the steps are an AgentFinish and the outputParser is defined it must mean that the LLM didn't use `format_final_response` tool so we will try to parse the output manually
|
||||
if (outputParser && typeof steps === 'object' && (steps as AgentFinish).returnValues) {
|
||||
const finalResponse = (steps as AgentFinish).returnValues;
|
||||
const returnValues = (await outputParser.parse(finalResponse as unknown as string)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
let parserInput: string;
|
||||
|
||||
if (finalResponse instanceof Object) {
|
||||
if ('output' in finalResponse) {
|
||||
try {
|
||||
// If the output is an object, we will try to parse it as JSON
|
||||
// this is because parser expects stringified JSON object like { "output": { .... } }
|
||||
// so we try to parse the output before wrapping it and then stringify it
|
||||
parserInput = JSON.stringify({ output: jsonParse(finalResponse.output) });
|
||||
} catch (error) {
|
||||
// If parsing of the output fails, we will use the raw output
|
||||
parserInput = finalResponse.output;
|
||||
}
|
||||
} else {
|
||||
// If the output is not an object, we will stringify it as it is
|
||||
parserInput = JSON.stringify(finalResponse);
|
||||
}
|
||||
} else {
|
||||
parserInput = finalResponse;
|
||||
}
|
||||
|
||||
const returnValues = (await outputParser.parse(parserInput)) as Record<string, unknown>;
|
||||
return handleParsedStepOutput(returnValues);
|
||||
}
|
||||
return handleAgentFinishOutput(steps);
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import type { ZodObjectAny } from '@langchain/core/dist/types/zod';
|
||||
import type { BaseOutputParser } from '@langchain/core/output_parsers';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import type { DynamicStructuredTool, Tool } from 'langchain/tools';
|
||||
import { NodeOperationError, type IExecuteFunctions, type INode } from 'n8n-workflow';
|
||||
|
||||
export async function extractParsedOutput(
|
||||
ctx: IExecuteFunctions,
|
||||
|
@ -17,3 +19,24 @@ export async function extractParsedOutput(
|
|||
// with fallback to the original output if it's not present
|
||||
return parsedOutput?.output ?? parsedOutput;
|
||||
}
|
||||
|
||||
export async function checkForStructuredTools(
|
||||
tools: Array<Tool | DynamicStructuredTool<ZodObjectAny>>,
|
||||
node: INode,
|
||||
currentAgentType: string,
|
||||
) {
|
||||
const dynamicStructuredTools = tools.filter(
|
||||
(tool) => tool.constructor.name === 'DynamicStructuredTool',
|
||||
);
|
||||
if (dynamicStructuredTools.length > 0) {
|
||||
const getToolName = (tool: Tool | DynamicStructuredTool) => `"${tool.name}"`;
|
||||
throw new NodeOperationError(
|
||||
node,
|
||||
`The selected tools are not supported by "${currentAgentType}", please use "Tools Agent" instead`,
|
||||
{
|
||||
itemIndex: 0,
|
||||
description: `Incompatible connected tools: ${dynamicStructuredTools.map(getToolName).join(', ')}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
import type { Tool } from 'langchain/tools';
|
||||
import { DynamicStructuredTool } from 'langchain/tools';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { checkForStructuredTools } from '../agents/utils';
|
||||
|
||||
describe('checkForStructuredTools', () => {
|
||||
let mockNode: INode;
|
||||
|
||||
beforeEach(() => {
|
||||
mockNode = {
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: 'test',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
});
|
||||
|
||||
it('should not throw error when no DynamicStructuredTools are present', async () => {
|
||||
const tools = [
|
||||
{
|
||||
name: 'regular-tool',
|
||||
constructor: { name: 'Tool' },
|
||||
} as Tool,
|
||||
];
|
||||
|
||||
await expect(
|
||||
checkForStructuredTools(tools, mockNode, 'Conversation Agent'),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError when DynamicStructuredTools are present', async () => {
|
||||
const dynamicTool = new DynamicStructuredTool({
|
||||
name: 'dynamic-tool',
|
||||
description: 'test tool',
|
||||
schema: z.object({}),
|
||||
func: async () => 'result',
|
||||
});
|
||||
|
||||
const tools: Array<Tool | DynamicStructuredTool> = [dynamicTool];
|
||||
|
||||
await expect(checkForStructuredTools(tools, mockNode, 'Conversation Agent')).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
|
||||
await expect(
|
||||
checkForStructuredTools(tools, mockNode, 'Conversation Agent'),
|
||||
).rejects.toMatchObject({
|
||||
message:
|
||||
'The selected tools are not supported by "Conversation Agent", please use "Tools Agent" instead',
|
||||
description: 'Incompatible connected tools: "dynamic-tool"',
|
||||
});
|
||||
});
|
||||
|
||||
it('should list multiple dynamic tools in error message', async () => {
|
||||
const dynamicTool1 = new DynamicStructuredTool({
|
||||
name: 'dynamic-tool-1',
|
||||
description: 'test tool 1',
|
||||
schema: z.object({}),
|
||||
func: async () => 'result',
|
||||
});
|
||||
|
||||
const dynamicTool2 = new DynamicStructuredTool({
|
||||
name: 'dynamic-tool-2',
|
||||
description: 'test tool 2',
|
||||
schema: z.object({}),
|
||||
func: async () => 'result',
|
||||
});
|
||||
|
||||
const tools = [dynamicTool1, dynamicTool2];
|
||||
|
||||
await expect(
|
||||
checkForStructuredTools(tools, mockNode, 'Conversation Agent'),
|
||||
).rejects.toMatchObject({
|
||||
description: 'Incompatible connected tools: "dynamic-tool-1", "dynamic-tool-2"',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error with mixed tool types and list only dynamic tools in error message', async () => {
|
||||
const regularTool = {
|
||||
name: 'regular-tool',
|
||||
constructor: { name: 'Tool' },
|
||||
} as Tool;
|
||||
|
||||
const dynamicTool = new DynamicStructuredTool({
|
||||
name: 'dynamic-tool',
|
||||
description: 'test tool',
|
||||
schema: z.object({}),
|
||||
func: async () => 'result',
|
||||
});
|
||||
|
||||
const tools = [regularTool, dynamicTool];
|
||||
|
||||
await expect(
|
||||
checkForStructuredTools(tools, mockNode, 'Conversation Agent'),
|
||||
).rejects.toMatchObject({
|
||||
message:
|
||||
'The selected tools are not supported by "Conversation Agent", please use "Tools Agent" instead',
|
||||
description: 'Incompatible connected tools: "dynamic-tool"',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,5 +1,5 @@
|
|||
import { AgentExecutor } from 'langchain/agents';
|
||||
import { OpenAI as OpenAIClient } from 'openai';
|
||||
import type { OpenAIToolType } from 'langchain/dist/experimental/openai_assistant/schema';
|
||||
import { OpenAIAssistantRunnable } from 'langchain/experimental/openai_assistant';
|
||||
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
||||
import type {
|
||||
|
@ -8,10 +8,11 @@ import type {
|
|||
INodeType,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import type { OpenAIToolType } from 'langchain/dist/experimental/openai_assistant/schema';
|
||||
import { OpenAI as OpenAIClient } from 'openai';
|
||||
|
||||
import { formatToOpenAIAssistantTool } from './utils';
|
||||
import { getConnectedTools } from '../../../utils/helpers';
|
||||
import { getTracingConfig } from '../../../utils/tracing';
|
||||
import { formatToOpenAIAssistantTool } from './utils';
|
||||
|
||||
export class OpenAiAssistant implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
|
|
@ -36,6 +36,7 @@ import {
|
|||
getCustomErrorMessage as getCustomOpenAiErrorMessage,
|
||||
isOpenAiError,
|
||||
} from '../../vendors/OpenAi/helpers/error-handling';
|
||||
import { promptTypeOptions, textFromPreviousNode } from '../../../utils/descriptions';
|
||||
|
||||
interface MessagesTemplate {
|
||||
type: string;
|
||||
|
@ -253,7 +254,7 @@ export class ChainLlm implements INodeType {
|
|||
name: 'chainLlm',
|
||||
icon: 'fa:link',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2, 1.3, 1.4],
|
||||
version: [1, 1.1, 1.2, 1.3, 1.4, 1.5],
|
||||
description: 'A simple chain to prompt a large language model',
|
||||
defaults: {
|
||||
name: 'Basic LLM Chain',
|
||||
|
@ -315,30 +316,16 @@ export class ChainLlm implements INodeType {
|
|||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'promptType',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'Take from previous node automatically',
|
||||
value: 'auto',
|
||||
description: 'Looks for an input field called chatInput',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'Define below',
|
||||
value: 'define',
|
||||
description:
|
||||
'Use an expression to reference data in previous nodes or enter static text',
|
||||
},
|
||||
],
|
||||
...promptTypeOptions,
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [1, 1.1, 1.2, 1.3],
|
||||
},
|
||||
},
|
||||
default: 'auto',
|
||||
},
|
||||
{
|
||||
...textFromPreviousNode,
|
||||
displayOptions: { show: { promptType: ['auto'], '@version': [{ _cnd: { gte: 1.5 } }] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Text',
|
||||
|
|
|
@ -1,3 +1,12 @@
|
|||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import {
|
||||
ChatPromptTemplate,
|
||||
SystemMessagePromptTemplate,
|
||||
HumanMessagePromptTemplate,
|
||||
PromptTemplate,
|
||||
} from '@langchain/core/prompts';
|
||||
import type { BaseRetriever } from '@langchain/core/retrievers';
|
||||
import { RetrievalQAChain } from 'langchain/chains';
|
||||
import {
|
||||
NodeConnectionType,
|
||||
type IExecuteFunctions,
|
||||
|
@ -7,20 +16,12 @@ import {
|
|||
NodeOperationError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { RetrievalQAChain } from 'langchain/chains';
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import type { BaseRetriever } from '@langchain/core/retrievers';
|
||||
import {
|
||||
ChatPromptTemplate,
|
||||
SystemMessagePromptTemplate,
|
||||
HumanMessagePromptTemplate,
|
||||
PromptTemplate,
|
||||
} from '@langchain/core/prompts';
|
||||
import { getTemplateNoticeField } from '../../../utils/sharedFields';
|
||||
import { promptTypeOptions, textFromPreviousNode } from '../../../utils/descriptions';
|
||||
import { getPromptInputByType, isChatInstance } from '../../../utils/helpers';
|
||||
import { getTemplateNoticeField } from '../../../utils/sharedFields';
|
||||
import { getTracingConfig } from '../../../utils/tracing';
|
||||
|
||||
const SYSTEM_PROMPT_TEMPLATE = `Use the following pieces of context to answer the users question.
|
||||
const SYSTEM_PROMPT_TEMPLATE = `Use the following pieces of context to answer the users question.
|
||||
If you don't know the answer, just say that you don't know, don't try to make up an answer.
|
||||
----------------
|
||||
{context}`;
|
||||
|
@ -31,7 +32,7 @@ export class ChainRetrievalQa implements INodeType {
|
|||
name: 'chainRetrievalQa',
|
||||
icon: 'fa:link',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2, 1.3],
|
||||
version: [1, 1.1, 1.2, 1.3, 1.4],
|
||||
description: 'Answer questions about retrieved documents',
|
||||
defaults: {
|
||||
name: 'Question and Answer Chain',
|
||||
|
@ -108,30 +109,16 @@ export class ChainRetrievalQa implements INodeType {
|
|||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
name: 'promptType',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'Take from previous node automatically',
|
||||
value: 'auto',
|
||||
description: 'Looks for an input field called chatInput',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'Define below',
|
||||
value: 'define',
|
||||
description:
|
||||
'Use an expression to reference data in previous nodes or enter static text',
|
||||
},
|
||||
],
|
||||
...promptTypeOptions,
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [{ _cnd: { lte: 1.2 } }],
|
||||
},
|
||||
},
|
||||
default: 'auto',
|
||||
},
|
||||
{
|
||||
...textFromPreviousNode,
|
||||
displayOptions: { show: { promptType: ['auto'], '@version': [{ _cnd: { gte: 1.4 } }] } },
|
||||
},
|
||||
{
|
||||
displayName: 'Text',
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
|
||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||
import { N8nLlmTracing } from '../N8nLlmTracing';
|
||||
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
|
||||
|
||||
const modelField: INodeProperties = {
|
||||
displayName: 'Model',
|
||||
|
@ -214,6 +215,7 @@ export class LmChatAnthropic implements INodeType {
|
|||
topK: options.topK,
|
||||
topP: options.topP,
|
||||
callbacks: [new N8nLlmTracing(this, { tokensUsageParser })],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -12,6 +12,7 @@ import { ChatOllama } from '@langchain/ollama';
|
|||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||
import { ollamaModel, ollamaOptions, ollamaDescription } from '../LMOllama/description';
|
||||
import { N8nLlmTracing } from '../N8nLlmTracing';
|
||||
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
|
||||
|
||||
export class LmChatOllama implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
@ -64,6 +65,7 @@ export class LmChatOllama implements INodeType {
|
|||
model: modelName,
|
||||
format: options.format === 'default' ? undefined : options.format,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -1,19 +1,18 @@
|
|||
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
||||
|
||||
import { ChatOpenAI, type ClientOptions } from '@langchain/openai';
|
||||
import {
|
||||
NodeConnectionType,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
type JsonObject,
|
||||
NodeApiError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ChatOpenAI, type ClientOptions } from '@langchain/openai';
|
||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||
import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling';
|
||||
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
|
||||
import { N8nLlmTracing } from '../N8nLlmTracing';
|
||||
import { RateLimitError } from 'openai';
|
||||
import { getCustomErrorMessage } from '../../vendors/OpenAi/helpers/error-handling';
|
||||
|
||||
export class LmChatOpenAi implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
@ -276,25 +275,7 @@ export class LmChatOpenAi implements INodeType {
|
|||
response_format: { type: options.responseFormat },
|
||||
}
|
||||
: undefined,
|
||||
onFailedAttempt: (error: any) => {
|
||||
// If the error is a rate limit error, we want to handle it differently
|
||||
// because OpenAI has multiple different rate limit errors
|
||||
if (error instanceof RateLimitError) {
|
||||
const errorCode = error?.code;
|
||||
if (errorCode) {
|
||||
const customErrorMessage = getCustomErrorMessage(errorCode);
|
||||
|
||||
const apiError = new NodeApiError(this.getNode(), error as unknown as JsonObject);
|
||||
if (customErrorMessage) {
|
||||
apiError.message = customErrorMessage;
|
||||
}
|
||||
|
||||
throw apiError;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
},
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, openAiFailedAttemptHandler),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { Cohere } from '@langchain/cohere';
|
||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||
import { N8nLlmTracing } from '../N8nLlmTracing';
|
||||
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
|
||||
|
||||
export class LmCohere implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
@ -99,6 +100,7 @@ export class LmCohere implements INodeType {
|
|||
apiKey: credentials.apiKey as string,
|
||||
...options,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -11,6 +11,7 @@ import { Ollama } from '@langchain/community/llms/ollama';
|
|||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||
import { N8nLlmTracing } from '../N8nLlmTracing';
|
||||
import { ollamaDescription, ollamaModel, ollamaOptions } from './description';
|
||||
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
|
||||
|
||||
export class LmOllama implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
@ -62,6 +63,7 @@ export class LmOllama implements INodeType {
|
|||
model: modelName,
|
||||
...options,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -10,6 +10,7 @@ import type {
|
|||
|
||||
import { OpenAI, type ClientOptions } from '@langchain/openai';
|
||||
import { N8nLlmTracing } from '../N8nLlmTracing';
|
||||
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
|
||||
|
||||
type LmOpenAiOptions = {
|
||||
baseURL?: string;
|
||||
|
@ -260,6 +261,7 @@ export class LmOpenAi implements INodeType {
|
|||
timeout: options.timeout ?? 60000,
|
||||
maxRetries: options.maxRetries ?? 2,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { HuggingFaceInference } from '@langchain/community/llms/hf';
|
||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||
import { N8nLlmTracing } from '../N8nLlmTracing';
|
||||
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
|
||||
|
||||
export class LmOpenHuggingFaceInference implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
@ -143,6 +144,7 @@ export class LmOpenHuggingFaceInference implements INodeType {
|
|||
apiKey: credentials.apiKey as string,
|
||||
...options,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
|
||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||
import { N8nLlmTracing } from '../N8nLlmTracing';
|
||||
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
|
||||
|
||||
export class LmChatAwsBedrock implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
@ -151,6 +152,7 @@ export class LmChatAwsBedrock implements INodeType {
|
|||
sessionToken: credentials.sessionToken as string,
|
||||
},
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||
import { N8nLlmTracing } from '../N8nLlmTracing';
|
||||
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
|
||||
|
||||
export class LmChatAzureOpenAi implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
@ -195,6 +196,7 @@ export class LmChatAzureOpenAi implements INodeType {
|
|||
response_format: { type: options.responseFormat },
|
||||
}
|
||||
: undefined,
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -11,6 +11,7 @@ import type { SafetySetting } from '@google/generative-ai';
|
|||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||
import { N8nLlmTracing } from '../N8nLlmTracing';
|
||||
import { additionalOptions } from '../gemini-common/additional-options';
|
||||
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
|
||||
|
||||
export class LmChatGoogleGemini implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
@ -144,6 +145,7 @@ export class LmChatGoogleGemini implements INodeType {
|
|||
maxOutputTokens: options.maxOutputTokens,
|
||||
safetySettings,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -17,6 +17,7 @@ import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
|||
import { N8nLlmTracing } from '../N8nLlmTracing';
|
||||
import { additionalOptions } from '../gemini-common/additional-options';
|
||||
import { makeErrorFromStatus } from './error-handling';
|
||||
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
|
||||
|
||||
export class LmChatGoogleVertex implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
@ -170,7 +171,8 @@ export class LmChatGoogleVertex implements INodeType {
|
|||
safetySettings,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
// Handle ChatVertexAI invocation errors to provide better error messages
|
||||
onFailedAttempt: (error: any) => {
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, (error: any) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
const customError = makeErrorFromStatus(Number(error?.response?.status), {
|
||||
modelName,
|
||||
});
|
||||
|
@ -180,7 +182,7 @@ export class LmChatGoogleVertex implements INodeType {
|
|||
}
|
||||
|
||||
throw error;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { ChatGroq } from '@langchain/groq';
|
||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||
import { N8nLlmTracing } from '../N8nLlmTracing';
|
||||
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
|
||||
|
||||
export class LmChatGroq implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
@ -144,6 +145,7 @@ export class LmChatGroq implements INodeType {
|
|||
maxTokens: options.maxTokensToSample,
|
||||
temperature: options.temperature,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -11,6 +11,7 @@ import type { ChatMistralAIInput } from '@langchain/mistralai';
|
|||
import { ChatMistralAI } from '@langchain/mistralai';
|
||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||
import { N8nLlmTracing } from '../N8nLlmTracing';
|
||||
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
|
||||
|
||||
export class LmChatMistralCloud implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
@ -190,6 +191,7 @@ export class LmChatMistralCloud implements INodeType {
|
|||
modelName,
|
||||
...options,
|
||||
callbacks: [new N8nLlmTracing(this)],
|
||||
onFailedAttempt: makeN8nLlmFailedAttemptHandler(this),
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
@ -1,17 +1,18 @@
|
|||
import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
|
||||
import type { SerializedFields } from '@langchain/core/dist/load/map_keys';
|
||||
import { getModelNameForTiktoken } from '@langchain/core/language_models/base';
|
||||
import { encodingForModel } from '@langchain/core/utils/tiktoken';
|
||||
import type {
|
||||
Serialized,
|
||||
SerializedNotImplemented,
|
||||
SerializedSecret,
|
||||
} from '@langchain/core/load/serializable';
|
||||
import type { LLMResult } from '@langchain/core/outputs';
|
||||
import type { IDataObject, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
import { pick } from 'lodash';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import type { SerializedFields } from '@langchain/core/dist/load/map_keys';
|
||||
import type { LLMResult } from '@langchain/core/outputs';
|
||||
import { encodingForModel } from '@langchain/core/utils/tiktoken';
|
||||
import type { IDataObject, ISupplyDataFunctions, JsonObject } from 'n8n-workflow';
|
||||
import { pick } from 'lodash';
|
||||
import { NodeConnectionType, NodeError, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { logAiEvent } from '../../utils/helpers';
|
||||
|
||||
type TokensUsageParser = (llmOutput: LLMResult['llmOutput']) => {
|
||||
|
@ -30,6 +31,10 @@ const TIKTOKEN_ESTIMATE_MODEL = 'gpt-4o';
|
|||
export class N8nLlmTracing extends BaseCallbackHandler {
|
||||
name = 'N8nLlmTracing';
|
||||
|
||||
// This flag makes sure that LangChain will wait for the handlers to finish before continuing
|
||||
// This is crucial for the handleLLMError handler to work correctly (it should be called before the error is propagated to the root node)
|
||||
awaitHandlers = true;
|
||||
|
||||
connectionType = NodeConnectionType.AiLanguageModel;
|
||||
|
||||
promptTokensEstimate = 0;
|
||||
|
@ -135,6 +140,7 @@ export class N8nLlmTracing extends BaseCallbackHandler {
|
|||
this.executionFunctions.addOutputData(this.connectionType, runDetails.index, [
|
||||
[{ json: { ...response } }],
|
||||
]);
|
||||
|
||||
logAiEvent(this.executionFunctions, 'ai-llm-generated-output', {
|
||||
messages: parsedMessages,
|
||||
options: runDetails.options,
|
||||
|
@ -172,6 +178,8 @@ export class N8nLlmTracing extends BaseCallbackHandler {
|
|||
runId: string,
|
||||
parentRunId?: string | undefined,
|
||||
) {
|
||||
const runDetails = this.runsMap[runId] ?? { index: Object.keys(this.runsMap).length };
|
||||
|
||||
// Filter out non-x- headers to avoid leaking sensitive information in logs
|
||||
if (typeof error === 'object' && error?.hasOwnProperty('headers')) {
|
||||
const errorWithHeaders = error as { headers: Record<string, unknown> };
|
||||
|
@ -183,6 +191,19 @@ export class N8nLlmTracing extends BaseCallbackHandler {
|
|||
});
|
||||
}
|
||||
|
||||
if (error instanceof NodeError) {
|
||||
this.executionFunctions.addOutputData(this.connectionType, runDetails.index, error);
|
||||
} else {
|
||||
// If the error is not a NodeError, we wrap it in a NodeOperationError
|
||||
this.executionFunctions.addOutputData(
|
||||
this.connectionType,
|
||||
runDetails.index,
|
||||
new NodeOperationError(this.executionFunctions.getNode(), error as JsonObject, {
|
||||
functionality: 'configuration-node',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
logAiEvent(this.executionFunctions, 'ai-llm-errored', {
|
||||
error: Object.keys(error).length === 0 ? error.toString() : error,
|
||||
runId,
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
import { n8nDefaultFailedAttemptHandler } from './n8nDefaultFailedAttemptHandler';
|
||||
|
||||
class MockHttpError extends Error {
|
||||
response: { status: number };
|
||||
|
||||
constructor(message: string, code: number) {
|
||||
super(message);
|
||||
this.response = { status: code };
|
||||
}
|
||||
}
|
||||
|
||||
describe('n8nDefaultFailedAttemptHandler', () => {
|
||||
it('should throw error if message starts with "Cancel"', () => {
|
||||
const error = new Error('Cancel operation');
|
||||
expect(() => n8nDefaultFailedAttemptHandler(error)).toThrow(error);
|
||||
});
|
||||
|
||||
it('should throw error if message starts with "AbortError"', () => {
|
||||
const error = new Error('AbortError occurred');
|
||||
expect(() => n8nDefaultFailedAttemptHandler(error)).toThrow(error);
|
||||
});
|
||||
|
||||
it('should throw error if name is "AbortError"', () => {
|
||||
class MockAbortError extends Error {
|
||||
constructor() {
|
||||
super('Some error');
|
||||
this.name = 'AbortError';
|
||||
}
|
||||
}
|
||||
|
||||
const error = new MockAbortError();
|
||||
|
||||
expect(() => n8nDefaultFailedAttemptHandler(error)).toThrow(error);
|
||||
});
|
||||
|
||||
it('should throw error if code is "ECONNABORTED"', () => {
|
||||
class MockAbortError extends Error {
|
||||
code: string;
|
||||
|
||||
constructor() {
|
||||
super('Some error');
|
||||
this.code = 'ECONNABORTED';
|
||||
}
|
||||
}
|
||||
|
||||
const error = new MockAbortError();
|
||||
expect(() => n8nDefaultFailedAttemptHandler(error)).toThrow(error);
|
||||
});
|
||||
|
||||
it('should throw error if status is in STATUS_NO_RETRY', () => {
|
||||
const error = new MockHttpError('Some error', 400);
|
||||
expect(() => n8nDefaultFailedAttemptHandler(error)).toThrow(error);
|
||||
});
|
||||
|
||||
it('should not throw error if status is not in STATUS_NO_RETRY', () => {
|
||||
const error = new MockHttpError('Some error', 500);
|
||||
error.response = { status: 500 };
|
||||
|
||||
expect(() => n8nDefaultFailedAttemptHandler(error)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw error if no conditions are met', () => {
|
||||
const error = new Error('Some random error');
|
||||
expect(() => n8nDefaultFailedAttemptHandler(error)).not.toThrow();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,41 @@
|
|||
const STATUS_NO_RETRY = [
|
||||
400, // Bad Request
|
||||
401, // Unauthorized
|
||||
402, // Payment Required
|
||||
403, // Forbidden
|
||||
404, // Not Found
|
||||
405, // Method Not Allowed
|
||||
406, // Not Acceptable
|
||||
407, // Proxy Authentication Required
|
||||
409, // Conflict
|
||||
];
|
||||
|
||||
/**
|
||||
* This function is used as a default handler for failed attempts in all LLMs.
|
||||
* It is based on a default handler from the langchain core package.
|
||||
* It throws an error when it encounters a known error that should not be retried.
|
||||
* @param error
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const n8nDefaultFailedAttemptHandler = (error: any) => {
|
||||
if (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-call
|
||||
error?.message?.startsWith?.('Cancel') ||
|
||||
error?.message?.startsWith?.('AbortError') ||
|
||||
error?.name === 'AbortError'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access
|
||||
if (error?.code === 'ECONNABORTED') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const status =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access
|
||||
error?.response?.status ?? error?.status;
|
||||
if (status && STATUS_NO_RETRY.includes(+status)) {
|
||||
throw error;
|
||||
}
|
||||
};
|
|
@ -0,0 +1,65 @@
|
|||
import { mock } from 'jest-mock-extended';
|
||||
import type { ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import { ApplicationError, NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import { makeN8nLlmFailedAttemptHandler } from './n8nLlmFailedAttemptHandler';
|
||||
|
||||
describe('makeN8nLlmFailedAttemptHandler', () => {
|
||||
const ctx = mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(),
|
||||
});
|
||||
|
||||
it('should throw a wrapped error, when NO custom handler is provided', () => {
|
||||
const handler = makeN8nLlmFailedAttemptHandler(ctx);
|
||||
|
||||
expect(() => handler(new Error('Test error'))).toThrow(NodeApiError);
|
||||
});
|
||||
|
||||
it('should wrapped error when custom handler is provided', () => {
|
||||
const customHandler = jest.fn();
|
||||
const handler = makeN8nLlmFailedAttemptHandler(ctx, customHandler);
|
||||
|
||||
expect(() => handler(new Error('Test error'))).toThrow(NodeApiError);
|
||||
expect(customHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw wrapped exception from custom handler', () => {
|
||||
const customHandler = jest.fn(() => {
|
||||
throw new ApplicationError('Custom handler error');
|
||||
});
|
||||
const handler = makeN8nLlmFailedAttemptHandler(ctx, customHandler);
|
||||
|
||||
expect(() => handler(new Error('Test error'))).toThrow('Custom handler error');
|
||||
expect(customHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not throw if retries are left', () => {
|
||||
const customHandler = jest.fn();
|
||||
const handler = makeN8nLlmFailedAttemptHandler(ctx, customHandler);
|
||||
|
||||
const error = new Error('Test error');
|
||||
(error as any).retriesLeft = 1;
|
||||
|
||||
expect(() => handler(error)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw NodeApiError if no retries are left', () => {
|
||||
const handler = makeN8nLlmFailedAttemptHandler(ctx);
|
||||
|
||||
const error = new Error('Test error');
|
||||
(error as any).retriesLeft = 0;
|
||||
|
||||
expect(() => handler(error)).toThrow(NodeApiError);
|
||||
});
|
||||
|
||||
it('should throw NodeApiError if no retries are left with custom handler', () => {
|
||||
const customHandler = jest.fn();
|
||||
const handler = makeN8nLlmFailedAttemptHandler(ctx, customHandler);
|
||||
|
||||
const error = new Error('Test error');
|
||||
(error as any).retriesLeft = 0;
|
||||
|
||||
expect(() => handler(error)).toThrow(NodeApiError);
|
||||
expect(customHandler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,46 @@
|
|||
import type { FailedAttemptHandler } from '@langchain/core/dist/utils/async_caller';
|
||||
import type { ISupplyDataFunctions, JsonObject } from 'n8n-workflow';
|
||||
import { NodeApiError } from 'n8n-workflow';
|
||||
|
||||
import { n8nDefaultFailedAttemptHandler } from './n8nDefaultFailedAttemptHandler';
|
||||
|
||||
/**
|
||||
* This function returns a custom failed attempt handler for using with LangChain models.
|
||||
* It first tries to use a custom handler passed as an argument, and if that doesn't throw an error, it uses the default handler.
|
||||
* It always wraps the error in a NodeApiError.
|
||||
* It throws an error ONLY if there are no retries left.
|
||||
*/
|
||||
export const makeN8nLlmFailedAttemptHandler = (
|
||||
ctx: ISupplyDataFunctions,
|
||||
handler?: FailedAttemptHandler,
|
||||
): FailedAttemptHandler => {
|
||||
return (error: any) => {
|
||||
try {
|
||||
// Try custom error handler first
|
||||
handler?.(error);
|
||||
|
||||
// If it didn't throw an error, use the default handler
|
||||
n8nDefaultFailedAttemptHandler(error);
|
||||
} catch (e) {
|
||||
// Wrap the error in a NodeApiError
|
||||
const apiError = new NodeApiError(ctx.getNode(), e as unknown as JsonObject, {
|
||||
functionality: 'configuration-node',
|
||||
});
|
||||
|
||||
throw apiError;
|
||||
}
|
||||
|
||||
// If no error was thrown, check if it is the last retry
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
if (error?.retriesLeft > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there are no retries left, throw the error wrapped in a NodeApiError
|
||||
const apiError = new NodeApiError(ctx.getNode(), error as unknown as JsonObject, {
|
||||
functionality: 'configuration-node',
|
||||
});
|
||||
|
||||
throw apiError;
|
||||
};
|
||||
};
|
|
@ -1,4 +1,6 @@
|
|||
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
||||
import type { BufferWindowMemoryInput } from 'langchain/memory';
|
||||
import { BufferWindowMemory } from 'langchain/memory';
|
||||
import {
|
||||
NodeConnectionType,
|
||||
type INodeType,
|
||||
|
@ -6,12 +8,16 @@ import {
|
|||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
import type { BufferWindowMemoryInput } from 'langchain/memory';
|
||||
import { BufferWindowMemory } from 'langchain/memory';
|
||||
|
||||
import { getSessionId } from '../../../utils/helpers';
|
||||
import { logWrapper } from '../../../utils/logWrapper';
|
||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||
import { sessionIdOption, sessionKeyProperty, contextWindowLengthProperty } from '../descriptions';
|
||||
import { getSessionId } from '../../../utils/helpers';
|
||||
import {
|
||||
sessionIdOption,
|
||||
sessionKeyProperty,
|
||||
contextWindowLengthProperty,
|
||||
expressionSessionKeyProperty,
|
||||
} from '../descriptions';
|
||||
|
||||
class MemoryChatBufferSingleton {
|
||||
private static instance: MemoryChatBufferSingleton;
|
||||
|
@ -72,7 +78,7 @@ export class MemoryBufferWindow implements INodeType {
|
|||
name: 'memoryBufferWindow',
|
||||
icon: 'fa:database',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2],
|
||||
version: [1, 1.1, 1.2, 1.3],
|
||||
description: 'Stores in n8n memory, so no credentials required',
|
||||
defaults: {
|
||||
name: 'Window Buffer Memory',
|
||||
|
@ -129,6 +135,7 @@ export class MemoryBufferWindow implements INodeType {
|
|||
},
|
||||
},
|
||||
},
|
||||
expressionSessionKeyProperty(1.3),
|
||||
sessionKeyProperty,
|
||||
contextWindowLengthProperty,
|
||||
],
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
||||
import { MotorheadMemory } from '@langchain/community/memory/motorhead_memory';
|
||||
import {
|
||||
NodeConnectionType,
|
||||
type INodeType,
|
||||
|
@ -7,11 +8,10 @@ import {
|
|||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { MotorheadMemory } from '@langchain/community/memory/motorhead_memory';
|
||||
import { getSessionId } from '../../../utils/helpers';
|
||||
import { logWrapper } from '../../../utils/logWrapper';
|
||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||
import { sessionIdOption, sessionKeyProperty } from '../descriptions';
|
||||
import { getSessionId } from '../../../utils/helpers';
|
||||
import { expressionSessionKeyProperty, sessionIdOption, sessionKeyProperty } from '../descriptions';
|
||||
|
||||
export class MemoryMotorhead implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
@ -19,7 +19,7 @@ export class MemoryMotorhead implements INodeType {
|
|||
name: 'memoryMotorhead',
|
||||
icon: 'fa:file-export',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2],
|
||||
version: [1, 1.1, 1.2, 1.3],
|
||||
description: 'Use Motorhead Memory',
|
||||
defaults: {
|
||||
name: 'Motorhead',
|
||||
|
@ -82,6 +82,7 @@ export class MemoryMotorhead implements INodeType {
|
|||
},
|
||||
},
|
||||
},
|
||||
expressionSessionKeyProperty(1.3),
|
||||
sessionKeyProperty,
|
||||
],
|
||||
};
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
||||
import { PostgresChatMessageHistory } from '@langchain/community/stores/message/postgres';
|
||||
import { BufferMemory, BufferWindowMemory } from 'langchain/memory';
|
||||
import type { PostgresNodeCredentials } from 'n8n-nodes-base/dist/nodes/Postgres/v2/helpers/interfaces';
|
||||
import { postgresConnectionTest } from 'n8n-nodes-base/dist/nodes/Postgres/v2/methods/credentialTest';
|
||||
import { configurePostgres } from 'n8n-nodes-base/dist/nodes/Postgres/v2/transport';
|
||||
import type {
|
||||
ISupplyDataFunctions,
|
||||
INodeType,
|
||||
|
@ -6,16 +11,17 @@ import type {
|
|||
SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
import { BufferMemory, BufferWindowMemory } from 'langchain/memory';
|
||||
import { PostgresChatMessageHistory } from '@langchain/community/stores/message/postgres';
|
||||
import type pg from 'pg';
|
||||
import { configurePostgres } from 'n8n-nodes-base/dist/nodes/Postgres/v2/transport';
|
||||
import type { PostgresNodeCredentials } from 'n8n-nodes-base/dist/nodes/Postgres/v2/helpers/interfaces';
|
||||
import { postgresConnectionTest } from 'n8n-nodes-base/dist/nodes/Postgres/v2/methods/credentialTest';
|
||||
|
||||
import { getSessionId } from '../../../utils/helpers';
|
||||
import { logWrapper } from '../../../utils/logWrapper';
|
||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||
import { sessionIdOption, sessionKeyProperty, contextWindowLengthProperty } from '../descriptions';
|
||||
import { getSessionId } from '../../../utils/helpers';
|
||||
import {
|
||||
sessionIdOption,
|
||||
sessionKeyProperty,
|
||||
contextWindowLengthProperty,
|
||||
expressionSessionKeyProperty,
|
||||
} from '../descriptions';
|
||||
|
||||
export class MemoryPostgresChat implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
@ -23,7 +29,7 @@ export class MemoryPostgresChat implements INodeType {
|
|||
name: 'memoryPostgresChat',
|
||||
icon: 'file:postgres.svg',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1],
|
||||
version: [1, 1.1, 1.2, 1.3],
|
||||
description: 'Stores the chat history in Postgres table.',
|
||||
defaults: {
|
||||
name: 'Postgres Chat Memory',
|
||||
|
@ -56,6 +62,7 @@ export class MemoryPostgresChat implements INodeType {
|
|||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionType.AiAgent]),
|
||||
sessionIdOption,
|
||||
expressionSessionKeyProperty(1.2),
|
||||
sessionKeyProperty,
|
||||
{
|
||||
displayName: 'Table Name',
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
||||
import type { RedisChatMessageHistoryInput } from '@langchain/redis';
|
||||
import { RedisChatMessageHistory } from '@langchain/redis';
|
||||
import { BufferMemory, BufferWindowMemory } from 'langchain/memory';
|
||||
import {
|
||||
NodeOperationError,
|
||||
type INodeType,
|
||||
|
@ -7,15 +10,18 @@ import {
|
|||
type SupplyData,
|
||||
NodeConnectionType,
|
||||
} from 'n8n-workflow';
|
||||
import { BufferMemory, BufferWindowMemory } from 'langchain/memory';
|
||||
import type { RedisChatMessageHistoryInput } from '@langchain/redis';
|
||||
import { RedisChatMessageHistory } from '@langchain/redis';
|
||||
import type { RedisClientOptions } from 'redis';
|
||||
import { createClient } from 'redis';
|
||||
|
||||
import { getSessionId } from '../../../utils/helpers';
|
||||
import { logWrapper } from '../../../utils/logWrapper';
|
||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||
import { sessionIdOption, sessionKeyProperty, contextWindowLengthProperty } from '../descriptions';
|
||||
import { getSessionId } from '../../../utils/helpers';
|
||||
import {
|
||||
sessionIdOption,
|
||||
sessionKeyProperty,
|
||||
contextWindowLengthProperty,
|
||||
expressionSessionKeyProperty,
|
||||
} from '../descriptions';
|
||||
|
||||
export class MemoryRedisChat implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
@ -23,7 +29,7 @@ export class MemoryRedisChat implements INodeType {
|
|||
name: 'memoryRedisChat',
|
||||
icon: 'file:redis.svg',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2, 1.3],
|
||||
version: [1, 1.1, 1.2, 1.3, 1.4],
|
||||
description: 'Stores the chat history in Redis.',
|
||||
defaults: {
|
||||
name: 'Redis Chat Memory',
|
||||
|
@ -86,6 +92,7 @@ export class MemoryRedisChat implements INodeType {
|
|||
},
|
||||
},
|
||||
},
|
||||
expressionSessionKeyProperty(1.4),
|
||||
sessionKeyProperty,
|
||||
{
|
||||
displayName: 'Session Time To Live',
|
||||
|
@ -120,6 +127,7 @@ export class MemoryRedisChat implements INodeType {
|
|||
socket: {
|
||||
host: credentials.host as string,
|
||||
port: credentials.port as number,
|
||||
tls: credentials.ssl === true,
|
||||
},
|
||||
database: credentials.database as number,
|
||||
};
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
||||
import { XataChatMessageHistory } from '@langchain/community/stores/message/xata';
|
||||
import { BaseClient } from '@xata.io/client';
|
||||
import { BufferMemory, BufferWindowMemory } from 'langchain/memory';
|
||||
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
||||
import type {
|
||||
ISupplyDataFunctions,
|
||||
|
@ -6,13 +9,16 @@ import type {
|
|||
INodeTypeDescription,
|
||||
SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
import { XataChatMessageHistory } from '@langchain/community/stores/message/xata';
|
||||
import { BufferMemory, BufferWindowMemory } from 'langchain/memory';
|
||||
import { BaseClient } from '@xata.io/client';
|
||||
|
||||
import { getSessionId } from '../../../utils/helpers';
|
||||
import { logWrapper } from '../../../utils/logWrapper';
|
||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||
import { sessionIdOption, sessionKeyProperty, contextWindowLengthProperty } from '../descriptions';
|
||||
import { getSessionId } from '../../../utils/helpers';
|
||||
import {
|
||||
sessionIdOption,
|
||||
sessionKeyProperty,
|
||||
contextWindowLengthProperty,
|
||||
expressionSessionKeyProperty,
|
||||
} from '../descriptions';
|
||||
|
||||
export class MemoryXata implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
|
@ -20,7 +26,7 @@ export class MemoryXata implements INodeType {
|
|||
name: 'memoryXata',
|
||||
icon: 'file:xata.svg',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2, 1.3],
|
||||
version: [1, 1.1, 1.2, 1.3, 1.4],
|
||||
description: 'Use Xata Memory',
|
||||
defaults: {
|
||||
name: 'Xata',
|
||||
|
@ -86,6 +92,7 @@ export class MemoryXata implements INodeType {
|
|||
},
|
||||
},
|
||||
sessionKeyProperty,
|
||||
expressionSessionKeyProperty(1.4),
|
||||
{
|
||||
...contextWindowLengthProperty,
|
||||
displayOptions: { hide: { '@version': [{ _cnd: { lt: 1.3 } }] } },
|
||||
|
|
|
@ -12,7 +12,7 @@ import { ZepCloudMemory } from '@langchain/community/memory/zep_cloud';
|
|||
|
||||
import { logWrapper } from '../../../utils/logWrapper';
|
||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||
import { sessionIdOption, sessionKeyProperty } from '../descriptions';
|
||||
import { expressionSessionKeyProperty, sessionIdOption, sessionKeyProperty } from '../descriptions';
|
||||
import { getSessionId } from '../../../utils/helpers';
|
||||
import type { BaseChatMemory } from '@langchain/community/dist/memory/chat_memory';
|
||||
import type { InputValues, MemoryVariables } from '@langchain/core/memory';
|
||||
|
@ -36,7 +36,7 @@ export class MemoryZep implements INodeType {
|
|||
// eslint-disable-next-line n8n-nodes-base/node-class-description-icon-not-svg
|
||||
icon: 'file:zep.png',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2],
|
||||
version: [1, 1.1, 1.2, 1.3],
|
||||
description: 'Use Zep Memory',
|
||||
defaults: {
|
||||
name: 'Zep',
|
||||
|
@ -99,6 +99,7 @@ export class MemoryZep implements INodeType {
|
|||
},
|
||||
},
|
||||
},
|
||||
expressionSessionKeyProperty(1.3),
|
||||
sessionKeyProperty,
|
||||
],
|
||||
};
|
||||
|
|
|
@ -21,6 +21,20 @@ export const sessionIdOption: INodeProperties = {
|
|||
default: 'fromInput',
|
||||
};
|
||||
|
||||
export const expressionSessionKeyProperty = (fromVersion: number): INodeProperties => ({
|
||||
displayName: 'Session Key From Previous Node',
|
||||
name: 'sessionKey',
|
||||
type: 'string',
|
||||
default: '={{ $json.sessionId }}',
|
||||
disabledOptions: { show: { sessionIdType: ['fromInput'] } },
|
||||
displayOptions: {
|
||||
show: {
|
||||
sessionIdType: ['fromInput'],
|
||||
'@version': [{ _cnd: { gte: fromVersion } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const sessionKeyProperty: INodeProperties = {
|
||||
displayName: 'Key',
|
||||
name: 'sessionKey',
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
import { PromptTemplate } from '@langchain/core/prompts';
|
||||
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
||||
import type {
|
||||
ISupplyDataFunctions,
|
||||
INodeType,
|
||||
|
@ -7,6 +8,7 @@ import type {
|
|||
SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { NAIVE_FIX_PROMPT } from './prompt';
|
||||
import {
|
||||
N8nOutputFixingParser,
|
||||
type N8nStructuredOutputParser,
|
||||
|
@ -65,6 +67,27 @@ export class OutputParserAutofixing implements INodeType {
|
|||
default: '',
|
||||
},
|
||||
getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]),
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Option',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Retry Prompt',
|
||||
name: 'prompt',
|
||||
type: 'string',
|
||||
default: NAIVE_FIX_PROMPT,
|
||||
typeOptions: {
|
||||
rows: 10,
|
||||
},
|
||||
hint: 'Should include "{error}", "{instructions}", and "{completion}" placeholders',
|
||||
description:
|
||||
'Prompt template used for fixing the output. Uses placeholders: "{instructions}" for parsing rules, "{completion}" for the failed attempt, and "{error}" for the validation error message.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
@ -77,8 +100,20 @@ export class OutputParserAutofixing implements INodeType {
|
|||
NodeConnectionType.AiOutputParser,
|
||||
itemIndex,
|
||||
)) as N8nStructuredOutputParser;
|
||||
const prompt = this.getNodeParameter('options.prompt', itemIndex, NAIVE_FIX_PROMPT) as string;
|
||||
|
||||
const parser = new N8nOutputFixingParser(this, model, outputParser);
|
||||
if (prompt.length === 0 || !prompt.includes('{error}')) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Auto-fixing parser prompt has to contain {error} placeholder',
|
||||
);
|
||||
}
|
||||
const parser = new N8nOutputFixingParser(
|
||||
this,
|
||||
model,
|
||||
outputParser,
|
||||
PromptTemplate.fromTemplate(prompt),
|
||||
);
|
||||
|
||||
return {
|
||||
response: parser,
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
export const NAIVE_FIX_PROMPT = `Instructions:
|
||||
--------------
|
||||
{instructions}
|
||||
--------------
|
||||
Completion:
|
||||
--------------
|
||||
{completion}
|
||||
--------------
|
||||
|
||||
Above, the Completion did not satisfy the constraints given in the Instructions.
|
||||
Error:
|
||||
--------------
|
||||
{error}
|
||||
--------------
|
||||
|
||||
Please try again. Please only respond with an answer that satisfies the constraints laid out in the Instructions:`;
|
|
@ -1,15 +1,19 @@
|
|||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
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 { ApplicationError, NodeConnectionType } from 'n8n-workflow';
|
||||
import { ApplicationError, NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { N8nOutputFixingParser } from '../../../../utils/output_parsers/N8nOutputParser';
|
||||
import type { N8nStructuredOutputParser } from '../../../../utils/output_parsers/N8nOutputParser';
|
||||
import type {
|
||||
N8nOutputFixingParser,
|
||||
N8nStructuredOutputParser,
|
||||
} from '../../../../utils/output_parsers/N8nOutputParser';
|
||||
import { OutputParserAutofixing } from '../OutputParserAutofixing.node';
|
||||
import { NAIVE_FIX_PROMPT } from '../prompt';
|
||||
|
||||
describe('OutputParserAutofixing', () => {
|
||||
let outputParser: OutputParserAutofixing;
|
||||
|
@ -34,6 +38,13 @@ describe('OutputParserAutofixing', () => {
|
|||
|
||||
throw new ApplicationError('Unexpected connection type');
|
||||
});
|
||||
thisArg.getNodeParameter.mockReset();
|
||||
thisArg.getNodeParameter.mockImplementation((parameterName) => {
|
||||
if (parameterName === 'options.prompt') {
|
||||
return NAIVE_FIX_PROMPT;
|
||||
}
|
||||
throw new ApplicationError('Not implemented');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -48,73 +59,132 @@ describe('OutputParserAutofixing', () => {
|
|||
});
|
||||
}
|
||||
|
||||
it('should successfully parse valid output without needing to fix it', async () => {
|
||||
const validOutput = { name: 'Alice', age: 25 };
|
||||
describe('Configuration', () => {
|
||||
it('should throw error when prompt template does not contain {error} placeholder', async () => {
|
||||
thisArg.getNodeParameter.mockImplementation((parameterName) => {
|
||||
if (parameterName === 'options.prompt') {
|
||||
return 'Invalid prompt without error placeholder';
|
||||
}
|
||||
throw new ApplicationError('Not implemented');
|
||||
});
|
||||
|
||||
mockStructuredOutputParser.parse.mockResolvedValueOnce(validOutput);
|
||||
await expect(outputParser.supplyData.call(thisArg, 0)).rejects.toThrow(
|
||||
new NodeOperationError(
|
||||
thisArg.getNode(),
|
||||
'Auto-fixing parser prompt has to contain {error} placeholder',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const { response } = (await outputParser.supplyData.call(thisArg, 0)) as {
|
||||
response: N8nOutputFixingParser;
|
||||
};
|
||||
it('should throw error when prompt template is empty', async () => {
|
||||
thisArg.getNodeParameter.mockImplementation((parameterName) => {
|
||||
if (parameterName === 'options.prompt') {
|
||||
return '';
|
||||
}
|
||||
throw new ApplicationError('Not implemented');
|
||||
});
|
||||
|
||||
// Ensure the response contains the output-fixing parser
|
||||
expect(response).toBeDefined();
|
||||
expect(response).toBeInstanceOf(N8nOutputFixingParser);
|
||||
await expect(outputParser.supplyData.call(thisArg, 0)).rejects.toThrow(
|
||||
new NodeOperationError(
|
||||
thisArg.getNode(),
|
||||
'Auto-fixing parser prompt has to contain {error} placeholder',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const result = await response.parse('{"name": "Alice", "age": 25}');
|
||||
it('should use default prompt when none specified', async () => {
|
||||
thisArg.getNodeParameter.mockImplementation((parameterName) => {
|
||||
if (parameterName === 'options.prompt') {
|
||||
return NAIVE_FIX_PROMPT;
|
||||
}
|
||||
throw new ApplicationError('Not implemented');
|
||||
});
|
||||
|
||||
// Validate that the parser succeeds without retry
|
||||
expect(result).toEqual(validOutput);
|
||||
expect(mockStructuredOutputParser.parse).toHaveBeenCalledTimes(1); // Only one call to parse
|
||||
const { response } = (await outputParser.supplyData.call(thisArg, 0)) as {
|
||||
response: N8nOutputFixingParser;
|
||||
};
|
||||
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw an error when both structured parser and fixing parser fail', async () => {
|
||||
mockStructuredOutputParser.parse
|
||||
.mockRejectedValueOnce(new Error('Invalid JSON')) // First attempt fails
|
||||
.mockRejectedValueOnce(new Error('Fixing attempt failed')); // Second attempt fails
|
||||
describe('Parsing', () => {
|
||||
it('should successfully parse valid output without needing to fix it', async () => {
|
||||
const validOutput = { name: 'Alice', age: 25 };
|
||||
|
||||
const { response } = (await outputParser.supplyData.call(thisArg, 0)) as {
|
||||
response: N8nOutputFixingParser;
|
||||
};
|
||||
mockStructuredOutputParser.parse.mockResolvedValueOnce(validOutput);
|
||||
|
||||
response.getRetryChain = getMockedRetryChain('{}');
|
||||
const { response } = (await outputParser.supplyData.call(thisArg, 0)) as {
|
||||
response: N8nOutputFixingParser;
|
||||
};
|
||||
|
||||
await expect(response.parse('Invalid JSON string')).rejects.toThrow('Fixing attempt failed');
|
||||
expect(mockStructuredOutputParser.parse).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
const result = await response.parse('{"name": "Alice", "age": 25}');
|
||||
|
||||
it('should reject on the first attempt and succeed on retry with the parsed content', async () => {
|
||||
const validOutput = { name: 'Bob', age: 28 };
|
||||
expect(result).toEqual(validOutput);
|
||||
expect(mockStructuredOutputParser.parse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
mockStructuredOutputParser.parse.mockRejectedValueOnce(new Error('Invalid JSON'));
|
||||
it('should not retry on non-OutputParserException errors', async () => {
|
||||
const error = new Error('Some other error');
|
||||
mockStructuredOutputParser.parse.mockRejectedValueOnce(error);
|
||||
|
||||
const { response } = (await outputParser.supplyData.call(thisArg, 0)) as {
|
||||
response: N8nOutputFixingParser;
|
||||
};
|
||||
const { response } = (await outputParser.supplyData.call(thisArg, 0)) as {
|
||||
response: N8nOutputFixingParser;
|
||||
};
|
||||
|
||||
response.getRetryChain = getMockedRetryChain(JSON.stringify(validOutput));
|
||||
await expect(response.parse('Invalid JSON string')).rejects.toThrow(error);
|
||||
expect(mockStructuredOutputParser.parse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
mockStructuredOutputParser.parse.mockResolvedValueOnce(validOutput);
|
||||
it('should retry on OutputParserException and succeed', async () => {
|
||||
const validOutput = { name: 'Bob', age: 28 };
|
||||
|
||||
const result = await response.parse('Invalid JSON string');
|
||||
mockStructuredOutputParser.parse
|
||||
.mockRejectedValueOnce(new OutputParserException('Invalid JSON'))
|
||||
.mockResolvedValueOnce(validOutput);
|
||||
|
||||
expect(result).toEqual(validOutput);
|
||||
expect(mockStructuredOutputParser.parse).toHaveBeenCalledTimes(2); // First fails, second succeeds
|
||||
});
|
||||
const { response } = (await outputParser.supplyData.call(thisArg, 0)) as {
|
||||
response: N8nOutputFixingParser;
|
||||
};
|
||||
|
||||
it('should handle non-JSON formatted response from fixing parser', async () => {
|
||||
mockStructuredOutputParser.parse.mockRejectedValueOnce(new Error('Invalid JSON'));
|
||||
response.getRetryChain = getMockedRetryChain(JSON.stringify(validOutput));
|
||||
|
||||
const { response } = (await outputParser.supplyData.call(thisArg, 0)) as {
|
||||
response: N8nOutputFixingParser;
|
||||
};
|
||||
const result = await response.parse('Invalid JSON string');
|
||||
|
||||
response.getRetryChain = getMockedRetryChain('This is not JSON');
|
||||
expect(result).toEqual(validOutput);
|
||||
expect(mockStructuredOutputParser.parse).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
mockStructuredOutputParser.parse.mockRejectedValueOnce(new Error('Unexpected token'));
|
||||
it('should handle failed retry attempt', async () => {
|
||||
mockStructuredOutputParser.parse
|
||||
.mockRejectedValueOnce(new OutputParserException('Invalid JSON'))
|
||||
.mockRejectedValueOnce(new Error('Still invalid JSON'));
|
||||
|
||||
// Expect the structured parser to throw an error on invalid JSON from retry
|
||||
await expect(response.parse('Invalid JSON string')).rejects.toThrow('Unexpected token');
|
||||
expect(mockStructuredOutputParser.parse).toHaveBeenCalledTimes(2); // First fails, second tries and fails
|
||||
const { response } = (await outputParser.supplyData.call(thisArg, 0)) as {
|
||||
response: N8nOutputFixingParser;
|
||||
};
|
||||
|
||||
response.getRetryChain = getMockedRetryChain('Still not valid JSON');
|
||||
|
||||
await expect(response.parse('Invalid JSON string')).rejects.toThrow('Still invalid JSON');
|
||||
expect(mockStructuredOutputParser.parse).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should throw non-OutputParserException errors immediately without retry', async () => {
|
||||
const customError = new Error('Database connection error');
|
||||
const retryChainSpy = jest.fn();
|
||||
|
||||
mockStructuredOutputParser.parse.mockRejectedValueOnce(customError);
|
||||
|
||||
const { response } = (await outputParser.supplyData.call(thisArg, 0)) as {
|
||||
response: N8nOutputFixingParser;
|
||||
};
|
||||
|
||||
response.getRetryChain = retryChainSpy;
|
||||
|
||||
await expect(response.parse('Some input')).rejects.toThrow('Database connection error');
|
||||
expect(mockStructuredOutputParser.parse).toHaveBeenCalledTimes(1);
|
||||
expect(retryChainSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -35,6 +35,7 @@ describe('OutputParserItemList', () => {
|
|||
|
||||
const { response } = await outputParser.supplyData.call(thisArg, 0);
|
||||
expect(response).toBeInstanceOf(N8nItemListOutputParser);
|
||||
expect((response as any).numberOfItems).toBe(3);
|
||||
});
|
||||
|
||||
it('should create a parser with custom number of items', async () => {
|
||||
|
@ -50,6 +51,20 @@ describe('OutputParserItemList', () => {
|
|||
expect((response as any).numberOfItems).toBe(5);
|
||||
});
|
||||
|
||||
it('should create a parser with unlimited number of items', async () => {
|
||||
thisArg.getNodeParameter.mockImplementation((parameterName) => {
|
||||
if (parameterName === 'options') {
|
||||
return { numberOfItems: -1 };
|
||||
}
|
||||
|
||||
throw new ApplicationError('Not implemented');
|
||||
});
|
||||
|
||||
const { response } = await outputParser.supplyData.call(thisArg, 0);
|
||||
expect(response).toBeInstanceOf(N8nItemListOutputParser);
|
||||
expect((response as any).numberOfItems).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should create a parser with custom separator', async () => {
|
||||
thisArg.getNodeParameter.mockImplementation((parameterName) => {
|
||||
if (parameterName === 'options') {
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
} from 'n8n-workflow';
|
||||
import { OpenAI as OpenAIClient } from 'openai';
|
||||
|
||||
import { promptTypeOptions, textFromPreviousNode } from '../../../../../utils/descriptions';
|
||||
import { getConnectedTools } from '../../../../../utils/helpers';
|
||||
import { getTracingConfig } from '../../../../../utils/tracing';
|
||||
import { formatToOpenAIAssistantTool } from '../../helpers/utils';
|
||||
|
@ -26,24 +27,18 @@ import { assistantRLC } from '../descriptions';
|
|||
const properties: INodeProperties[] = [
|
||||
assistantRLC,
|
||||
{
|
||||
displayName: 'Prompt',
|
||||
...promptTypeOptions,
|
||||
name: 'prompt',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'Take from previous node automatically',
|
||||
value: 'auto',
|
||||
description: 'Looks for an input field called chatInput',
|
||||
},
|
||||
{
|
||||
...textFromPreviousNode,
|
||||
disabledOptions: { show: { prompt: ['auto'] } },
|
||||
displayOptions: {
|
||||
show: {
|
||||
prompt: ['auto'],
|
||||
'@version': [{ _cnd: { gte: 1.7 } }],
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'Define below',
|
||||
value: 'define',
|
||||
description: 'Use an expression to reference data in previous nodes or enter static text',
|
||||
},
|
||||
],
|
||||
default: 'auto',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Text',
|
||||
|
|
|
@ -77,7 +77,7 @@ export const versionDescription: INodeTypeDescription = {
|
|||
name: 'openAi',
|
||||
icon: { light: 'file:openAi.svg', dark: 'file:openAi.dark.svg' },
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6],
|
||||
version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7],
|
||||
subtitle: `={{(${prettifyOperation})($parameter.resource, $parameter.operation)}}`,
|
||||
description: 'Message an assistant or GPT, analyze images, generate audio, etc.',
|
||||
defaults: {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { OpenAIError } from 'openai/error';
|
||||
import { RateLimitError } from 'openai';
|
||||
|
||||
const errorMap: Record<string, string> = {
|
||||
insufficient_quota: 'OpenAI: Insufficient quota',
|
||||
|
@ -12,3 +13,20 @@ export function getCustomErrorMessage(errorCode: string): string | undefined {
|
|||
export function isOpenAiError(error: any): error is OpenAIError {
|
||||
return error instanceof OpenAIError;
|
||||
}
|
||||
|
||||
export const openAiFailedAttemptHandler = (error: any) => {
|
||||
if (error instanceof RateLimitError) {
|
||||
// If the error is a rate limit error, we want to handle it differently
|
||||
// because OpenAI has multiple different rate limit errors
|
||||
const errorCode = error?.code;
|
||||
if (errorCode) {
|
||||
const customErrorMessage = getCustomErrorMessage(errorCode);
|
||||
|
||||
if (customErrorMessage) {
|
||||
error.message = customErrorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/n8n-nodes-langchain",
|
||||
"version": "1.66.0",
|
||||
"version": "1.67.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
|
|
@ -66,7 +66,7 @@ export const inputSchemaField: INodeProperties = {
|
|||
};
|
||||
|
||||
export const promptTypeOptions: INodeProperties = {
|
||||
displayName: 'Prompt',
|
||||
displayName: 'Prompt Source',
|
||||
name: 'promptType',
|
||||
type: 'options',
|
||||
options: [
|
||||
|
@ -97,3 +97,15 @@ export const textInput: INodeProperties = {
|
|||
rows: 2,
|
||||
},
|
||||
};
|
||||
|
||||
export const textFromPreviousNode: INodeProperties = {
|
||||
displayName: 'Text From Previous Node',
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '={{ $json.chatInput }}',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
disabledOptions: { show: { promptType: ['auto'] } },
|
||||
};
|
||||
|
|
|
@ -17,13 +17,6 @@ import { logAiEvent, isToolsInstance, isBaseChatMemory, isBaseChatMessageHistory
|
|||
import { N8nBinaryLoader } from './N8nBinaryLoader';
|
||||
import { N8nJsonLoader } from './N8nJsonLoader';
|
||||
|
||||
const errorsMap: { [key: string]: { message: string; description: string } } = {
|
||||
'You exceeded your current quota, please check your plan and billing details.': {
|
||||
message: 'OpenAI quota exceeded',
|
||||
description: 'You exceeded your current quota, please check your plan and billing details.',
|
||||
},
|
||||
};
|
||||
|
||||
export async function callMethodAsync<T>(
|
||||
this: T,
|
||||
parameters: {
|
||||
|
@ -37,30 +30,25 @@ export async function callMethodAsync<T>(
|
|||
try {
|
||||
return await parameters.method.call(this, ...parameters.arguments);
|
||||
} catch (e) {
|
||||
// Propagate errors from sub-nodes
|
||||
if (e.functionality === 'configuration-node') throw e;
|
||||
const connectedNode = parameters.executeFunctions.getNode();
|
||||
|
||||
const error = new NodeOperationError(connectedNode, e, {
|
||||
functionality: 'configuration-node',
|
||||
});
|
||||
|
||||
if (errorsMap[error.message]) {
|
||||
error.description = errorsMap[error.message].description;
|
||||
error.message = errorsMap[error.message].message;
|
||||
}
|
||||
|
||||
parameters.executeFunctions.addOutputData(
|
||||
parameters.connectionType,
|
||||
parameters.currentNodeRunIndex,
|
||||
error,
|
||||
);
|
||||
|
||||
if (error.message) {
|
||||
if (!error.description) {
|
||||
error.description = error.message;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new NodeOperationError(
|
||||
connectedNode,
|
||||
`Error on node "${connectedNode.name}" which is connected via input "${parameters.connectionType}"`,
|
||||
|
@ -82,8 +70,6 @@ export function callMethodSync<T>(
|
|||
try {
|
||||
return parameters.method.call(this, ...parameters.arguments);
|
||||
} catch (e) {
|
||||
// Propagate errors from sub-nodes
|
||||
if (e.functionality === 'configuration-node') throw e;
|
||||
const connectedNode = parameters.executeFunctions.getNode();
|
||||
const error = new NodeOperationError(connectedNode, e);
|
||||
parameters.executeFunctions.addOutputData(
|
||||
|
@ -91,6 +77,7 @@ export function callMethodSync<T>(
|
|||
parameters.currentNodeRunIndex,
|
||||
error,
|
||||
);
|
||||
|
||||
throw new NodeOperationError(
|
||||
connectedNode,
|
||||
`Error on node "${connectedNode.name}" which is connected via input "${parameters.connectionType}"`,
|
||||
|
|
|
@ -3,16 +3,21 @@ import { BaseOutputParser, OutputParserException } from '@langchain/core/output_
|
|||
export class N8nItemListOutputParser extends BaseOutputParser<string[]> {
|
||||
lc_namespace = ['n8n-nodes-langchain', 'output_parsers', 'list_items'];
|
||||
|
||||
private numberOfItems: number = 3;
|
||||
private numberOfItems: number | undefined;
|
||||
|
||||
private separator: string;
|
||||
|
||||
constructor(options: { numberOfItems?: number; separator?: string }) {
|
||||
super();
|
||||
if (options.numberOfItems && options.numberOfItems > 0) {
|
||||
this.numberOfItems = options.numberOfItems;
|
||||
|
||||
const { numberOfItems = 3, separator = '\n' } = options;
|
||||
|
||||
if (numberOfItems && numberOfItems > 0) {
|
||||
this.numberOfItems = numberOfItems;
|
||||
}
|
||||
this.separator = options.separator ?? '\\n';
|
||||
|
||||
this.separator = separator;
|
||||
|
||||
if (this.separator === '\\n') {
|
||||
this.separator = '\n';
|
||||
}
|
||||
|
@ -39,7 +44,7 @@ export class N8nItemListOutputParser extends BaseOutputParser<string[]> {
|
|||
this.numberOfItems ? this.numberOfItems + ' ' : ''
|
||||
}items separated by`;
|
||||
|
||||
const numberOfExamples = this.numberOfItems;
|
||||
const numberOfExamples = this.numberOfItems ?? 3; // Default number of examples in case numberOfItems is not set
|
||||
|
||||
const examples: string[] = [];
|
||||
for (let i = 1; i <= numberOfExamples; i++) {
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import type { Callbacks } from '@langchain/core/callbacks/manager';
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import type { AIMessage } from '@langchain/core/messages';
|
||||
import { BaseOutputParser } from '@langchain/core/output_parsers';
|
||||
import { BaseOutputParser, OutputParserException } from '@langchain/core/output_parsers';
|
||||
import type { PromptTemplate } from '@langchain/core/prompts';
|
||||
import type { ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
|
||||
import type { N8nStructuredOutputParser } from './N8nStructuredOutputParser';
|
||||
import { NAIVE_FIX_PROMPT } from './prompt';
|
||||
import { logAiEvent } from '../helpers';
|
||||
|
||||
export class N8nOutputFixingParser extends BaseOutputParser {
|
||||
|
@ -16,12 +16,13 @@ export class N8nOutputFixingParser extends BaseOutputParser {
|
|||
private context: ISupplyDataFunctions,
|
||||
private model: BaseLanguageModel,
|
||||
private outputParser: N8nStructuredOutputParser,
|
||||
private fixPromptTemplate: PromptTemplate,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
getRetryChain() {
|
||||
return NAIVE_FIX_PROMPT.pipe(this.model);
|
||||
return this.fixPromptTemplate.pipe(this.model);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -47,11 +48,14 @@ export class N8nOutputFixingParser extends BaseOutputParser {
|
|||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (!(error instanceof OutputParserException)) {
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
// Second attempt: use retry chain to fix the output
|
||||
const result = (await this.getRetryChain().invoke({
|
||||
completion,
|
||||
error,
|
||||
error: error.message,
|
||||
instructions: this.getFormatInstructions(),
|
||||
})) as AIMessage;
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/task-runner",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"start": "node dist/start.js",
|
||||
|
|
|
@ -63,4 +63,35 @@ describe('TaskRunnerNodeTypes', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addNodeTypeDescriptions', () => {
|
||||
it('should add new node types', () => {
|
||||
const nodeTypes = new TaskRunnerNodeTypes(TYPES);
|
||||
|
||||
const nodeTypeDescriptions = [
|
||||
{ name: 'new-type', version: 1 },
|
||||
{ name: 'new-type', version: 2 },
|
||||
] as INodeTypeDescription[];
|
||||
|
||||
nodeTypes.addNodeTypeDescriptions(nodeTypeDescriptions);
|
||||
|
||||
expect(nodeTypes.getByNameAndVersion('new-type', 1)).toEqual({
|
||||
description: { name: 'new-type', version: 1 },
|
||||
});
|
||||
expect(nodeTypes.getByNameAndVersion('new-type', 2)).toEqual({
|
||||
description: { name: 'new-type', version: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('onlyUnknown', () => {
|
||||
it('should return only unknown node types', () => {
|
||||
const nodeTypes = new TaskRunnerNodeTypes(TYPES);
|
||||
|
||||
const candidate = { name: 'unknown', version: 1 };
|
||||
|
||||
expect(nodeTypes.onlyUnknown([candidate])).toEqual([candidate]);
|
||||
expect(nodeTypes.onlyUnknown([SINGLE_VERSIONED])).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import type { IExecuteData, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import type { DataRequestResponse } from '@/runner-types';
|
||||
|
||||
/**
|
||||
* Reconstructs data from a DataRequestResponse to the initial
|
||||
* data structures.
|
||||
*/
|
||||
export class DataRequestResponseReconstruct {
|
||||
/**
|
||||
* Reconstructs `connectionInputData` from a DataRequestResponse
|
||||
*/
|
||||
reconstructConnectionInputData(
|
||||
inputData: DataRequestResponse['inputData'],
|
||||
): INodeExecutionData[] {
|
||||
return inputData?.main?.[0] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct `executeData` from a DataRequestResponse
|
||||
*/
|
||||
reconstructExecuteData(response: DataRequestResponse): IExecuteData {
|
||||
return {
|
||||
data: response.inputData,
|
||||
node: response.node,
|
||||
source: response.connectionInputSource,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
export * from './task-runner';
|
||||
export * from './runner-types';
|
||||
export * from './message-types';
|
||||
export * from './data-request/data-request-response-reconstruct';
|
||||
|
|
|
@ -3,15 +3,21 @@ import type { CodeExecutionMode, IDataObject } from 'n8n-workflow';
|
|||
import fs from 'node:fs';
|
||||
import { builtinModules } from 'node:module';
|
||||
|
||||
import type { JsRunnerConfig } from '@/config/js-runner-config';
|
||||
import { MainConfig } from '@/config/main-config';
|
||||
import { ExecutionError } from '@/js-task-runner/errors/execution-error';
|
||||
import { ValidationError } from '@/js-task-runner/errors/validation-error';
|
||||
import type { DataRequestResponse, JSExecSettings } from '@/js-task-runner/js-task-runner';
|
||||
import type { JSExecSettings } from '@/js-task-runner/js-task-runner';
|
||||
import { JsTaskRunner } from '@/js-task-runner/js-task-runner';
|
||||
import type { DataRequestResponse } from '@/runner-types';
|
||||
import type { Task } from '@/task-runner';
|
||||
|
||||
import { newCodeTaskData, newTaskWithSettings, withPairedItem, wrapIntoJson } from './test-data';
|
||||
import type { JsRunnerConfig } from '../../config/js-runner-config';
|
||||
import { MainConfig } from '../../config/main-config';
|
||||
import { ExecutionError } from '../errors/execution-error';
|
||||
import {
|
||||
newDataRequestResponse,
|
||||
newTaskWithSettings,
|
||||
withPairedItem,
|
||||
wrapIntoJson,
|
||||
} from './test-data';
|
||||
|
||||
jest.mock('ws');
|
||||
|
||||
|
@ -68,7 +74,7 @@ describe('JsTaskRunner', () => {
|
|||
nodeMode: 'runOnceForAllItems',
|
||||
...settings,
|
||||
}),
|
||||
taskData: newCodeTaskData(inputItems.map(wrapIntoJson)),
|
||||
taskData: newDataRequestResponse(inputItems.map(wrapIntoJson)),
|
||||
runner,
|
||||
});
|
||||
};
|
||||
|
@ -91,7 +97,7 @@ describe('JsTaskRunner', () => {
|
|||
nodeMode: 'runOnceForEachItem',
|
||||
...settings,
|
||||
}),
|
||||
taskData: newCodeTaskData(inputItems.map(wrapIntoJson)),
|
||||
taskData: newDataRequestResponse(inputItems.map(wrapIntoJson)),
|
||||
runner,
|
||||
});
|
||||
};
|
||||
|
@ -108,7 +114,7 @@ describe('JsTaskRunner', () => {
|
|||
|
||||
await execTaskWithParams({
|
||||
task,
|
||||
taskData: newCodeTaskData([wrapIntoJson({})]),
|
||||
taskData: newDataRequestResponse([wrapIntoJson({})]),
|
||||
});
|
||||
|
||||
expect(defaultTaskRunner.makeRpcCall).toHaveBeenCalledWith(task.taskId, 'logNodeOutput', [
|
||||
|
@ -243,7 +249,7 @@ describe('JsTaskRunner', () => {
|
|||
code: 'return { val: $env.VAR1 }',
|
||||
nodeMode: 'runOnceForAllItems',
|
||||
}),
|
||||
taskData: newCodeTaskData(inputItems.map(wrapIntoJson), {
|
||||
taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), {
|
||||
envProviderState: {
|
||||
isEnvAccessBlocked: false,
|
||||
isProcessAvailable: true,
|
||||
|
@ -262,7 +268,7 @@ describe('JsTaskRunner', () => {
|
|||
code: 'return { val: $env.VAR1 }',
|
||||
nodeMode: 'runOnceForAllItems',
|
||||
}),
|
||||
taskData: newCodeTaskData(inputItems.map(wrapIntoJson), {
|
||||
taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), {
|
||||
envProviderState: {
|
||||
isEnvAccessBlocked: true,
|
||||
isProcessAvailable: true,
|
||||
|
@ -279,7 +285,7 @@ describe('JsTaskRunner', () => {
|
|||
code: 'return Object.values($env).concat(Object.keys($env))',
|
||||
nodeMode: 'runOnceForAllItems',
|
||||
}),
|
||||
taskData: newCodeTaskData(inputItems.map(wrapIntoJson), {
|
||||
taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), {
|
||||
envProviderState: {
|
||||
isEnvAccessBlocked: false,
|
||||
isProcessAvailable: true,
|
||||
|
@ -298,7 +304,7 @@ describe('JsTaskRunner', () => {
|
|||
code: 'return { val: $env.N8N_RUNNERS_N8N_URI }',
|
||||
nodeMode: 'runOnceForAllItems',
|
||||
}),
|
||||
taskData: newCodeTaskData(inputItems.map(wrapIntoJson), {
|
||||
taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), {
|
||||
envProviderState: undefined,
|
||||
}),
|
||||
});
|
||||
|
@ -313,7 +319,7 @@ describe('JsTaskRunner', () => {
|
|||
code: 'return { val: Buffer.from("test-buffer").toString() }',
|
||||
nodeMode: 'runOnceForAllItems',
|
||||
}),
|
||||
taskData: newCodeTaskData(inputItems.map(wrapIntoJson), {
|
||||
taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), {
|
||||
envProviderState: undefined,
|
||||
}),
|
||||
});
|
||||
|
@ -325,7 +331,7 @@ describe('JsTaskRunner', () => {
|
|||
code: 'return { val: Buffer.from("test-buffer").toString() }',
|
||||
nodeMode: 'runOnceForEachItem',
|
||||
}),
|
||||
taskData: newCodeTaskData(inputItems.map(wrapIntoJson), {
|
||||
taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), {
|
||||
envProviderState: undefined,
|
||||
}),
|
||||
});
|
||||
|
@ -771,7 +777,7 @@ describe('JsTaskRunner', () => {
|
|||
code: 'unknown',
|
||||
nodeMode,
|
||||
}),
|
||||
taskData: newCodeTaskData([wrapIntoJson({ a: 1 })]),
|
||||
taskData: newDataRequestResponse([wrapIntoJson({ a: 1 })]),
|
||||
}),
|
||||
).rejects.toThrow(ExecutionError);
|
||||
},
|
||||
|
@ -793,7 +799,7 @@ describe('JsTaskRunner', () => {
|
|||
jest.spyOn(runner, 'sendOffers').mockImplementation(() => {});
|
||||
jest
|
||||
.spyOn(runner, 'requestData')
|
||||
.mockResolvedValue(newCodeTaskData([wrapIntoJson({ a: 1 })]));
|
||||
.mockResolvedValue(newDataRequestResponse([wrapIntoJson({ a: 1 })]));
|
||||
|
||||
await runner.receivedSettings(taskId, task.settings);
|
||||
|
||||
|
|
|
@ -2,7 +2,8 @@ import type { IDataObject, INode, INodeExecutionData, ITaskData } from 'n8n-work
|
|||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import type { DataRequestResponse, JSExecSettings } from '@/js-task-runner/js-task-runner';
|
||||
import type { JSExecSettings } from '@/js-task-runner/js-task-runner';
|
||||
import type { DataRequestResponse } from '@/runner-types';
|
||||
import type { Task } from '@/task-runner';
|
||||
|
||||
/**
|
||||
|
@ -46,10 +47,10 @@ export const newTaskData = (opts: Partial<ITaskData> & Pick<ITaskData, 'source'>
|
|||
});
|
||||
|
||||
/**
|
||||
* Creates a new all code task data with the given options
|
||||
* Creates a new data request response with the given options
|
||||
*/
|
||||
export const newCodeTaskData = (
|
||||
codeNodeInputData: INodeExecutionData[],
|
||||
export const newDataRequestResponse = (
|
||||
inputData: INodeExecutionData[],
|
||||
opts: Partial<DataRequestResponse> = {},
|
||||
): DataRequestResponse => {
|
||||
const codeNode = newNode({
|
||||
|
@ -83,9 +84,8 @@ export const newCodeTaskData = (
|
|||
nodes: [manualTriggerNode, codeNode],
|
||||
},
|
||||
inputData: {
|
||||
main: [codeNodeInputData],
|
||||
main: [inputData],
|
||||
},
|
||||
connectionInputData: codeNodeInputData,
|
||||
node: codeNode,
|
||||
runExecutionData: {
|
||||
startData: {},
|
||||
|
@ -95,7 +95,7 @@ export const newCodeTaskData = (
|
|||
newTaskData({
|
||||
source: [],
|
||||
data: {
|
||||
main: [codeNodeInputData],
|
||||
main: [inputData],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
@ -137,14 +137,13 @@ export const newCodeTaskData = (
|
|||
var: 'value',
|
||||
},
|
||||
},
|
||||
executeData: {
|
||||
node: codeNode,
|
||||
data: {
|
||||
main: [codeNodeInputData],
|
||||
},
|
||||
source: {
|
||||
main: [{ previousNode: manualTriggerNode.name }],
|
||||
},
|
||||
connectionInputSource: {
|
||||
main: [
|
||||
{
|
||||
previousNode: 'Trigger',
|
||||
previousNodeOutput: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
...opts,
|
||||
};
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
import { getAdditionalKeys } from 'n8n-core';
|
||||
import type { IDataObject, INodeType, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteData,
|
||||
INodeType,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
} from 'n8n-workflow';
|
||||
import { Workflow, WorkflowDataProxy } from 'n8n-workflow';
|
||||
|
||||
import { newCodeTaskData } from '../../__tests__/test-data';
|
||||
import { newDataRequestResponse } from '../../__tests__/test-data';
|
||||
import { BuiltInsParser } from '../built-ins-parser';
|
||||
import { BuiltInsParserState } from '../built-ins-parser-state';
|
||||
|
||||
|
@ -57,6 +62,15 @@ describe('BuiltInsParser', () => {
|
|||
|
||||
expect(state).toEqual(new BuiltInsParserState({ needs$input: true }));
|
||||
});
|
||||
|
||||
test.each([['items'], ['item']])(
|
||||
'should mark input as needed when %s is used',
|
||||
(identifier) => {
|
||||
const state = parseAndExpectOk(`return ${identifier};`);
|
||||
|
||||
expect(state).toEqual(new BuiltInsParserState({ needs$input: true }));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('$(...)', () => {
|
||||
|
@ -130,6 +144,13 @@ describe('BuiltInsParser', () => {
|
|||
);
|
||||
});
|
||||
|
||||
describe('$node', () => {
|
||||
it('should require all nodes when $node is used', () => {
|
||||
const state = parseAndExpectOk('return $node["name"];');
|
||||
expect(state).toEqual(new BuiltInsParserState({ needsAllNodes: true, needs$input: true }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ECMAScript syntax', () => {
|
||||
describe('ES2020', () => {
|
||||
it('should parse optional chaining', () => {
|
||||
|
@ -159,7 +180,12 @@ describe('BuiltInsParser', () => {
|
|||
|
||||
describe('WorkflowDataProxy built-ins', () => {
|
||||
it('should have a known list of built-ins', () => {
|
||||
const data = newCodeTaskData([]);
|
||||
const data = newDataRequestResponse([]);
|
||||
const executeData: IExecuteData = {
|
||||
data: {},
|
||||
node: data.node,
|
||||
source: data.connectionInputSource,
|
||||
};
|
||||
const dataProxy = new WorkflowDataProxy(
|
||||
new Workflow({
|
||||
...data.workflow,
|
||||
|
@ -179,7 +205,7 @@ describe('BuiltInsParser', () => {
|
|||
data.runIndex,
|
||||
0,
|
||||
data.activeNodeName,
|
||||
data.connectionInputData,
|
||||
[],
|
||||
data.siblingParameters,
|
||||
data.mode,
|
||||
getAdditionalKeys(
|
||||
|
@ -187,7 +213,7 @@ describe('BuiltInsParser', () => {
|
|||
data.mode,
|
||||
data.runExecutionData,
|
||||
),
|
||||
data.executeData,
|
||||
executeData,
|
||||
data.defaultReturnRunIndex,
|
||||
data.selfData,
|
||||
data.contextNodeName,
|
||||
|
|
|
@ -125,8 +125,19 @@ export class BuiltInsParser {
|
|||
private visitIdentifier = (node: Identifier, state: BuiltInsParserState) => {
|
||||
if (node.name === '$env') {
|
||||
state.markEnvAsNeeded();
|
||||
} else if (node.name === '$input' || node.name === '$json') {
|
||||
} else if (
|
||||
node.name === '$input' ||
|
||||
node.name === '$json' ||
|
||||
node.name === 'items' ||
|
||||
// item is deprecated but we still need to support it
|
||||
node.name === 'item'
|
||||
) {
|
||||
state.markInputAsNeeded();
|
||||
} else if (node.name === '$node') {
|
||||
// $node is legacy way of accessing any node's output. We need to
|
||||
// support it for backward compatibility, but we're not gonna
|
||||
// implement any optimizations
|
||||
state.markNeedsAllNodes();
|
||||
} else if (node.name === '$execution') {
|
||||
state.markExecutionAsNeeded();
|
||||
} else if (node.name === '$prevNode') {
|
||||
|
|
|
@ -1,27 +1,25 @@
|
|||
import { getAdditionalKeys } from 'n8n-core';
|
||||
import {
|
||||
WorkflowDataProxy,
|
||||
// type IWorkflowDataProxyAdditionalKeys,
|
||||
Workflow,
|
||||
} from 'n8n-workflow';
|
||||
import { WorkflowDataProxy, Workflow } from 'n8n-workflow';
|
||||
import type {
|
||||
CodeExecutionMode,
|
||||
INode,
|
||||
ITaskDataConnections,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
WorkflowParameters,
|
||||
IDataObject,
|
||||
IExecuteData,
|
||||
INodeExecutionData,
|
||||
INodeParameters,
|
||||
IRunExecutionData,
|
||||
WorkflowExecuteMode,
|
||||
WorkflowParameters,
|
||||
ITaskDataConnections,
|
||||
INode,
|
||||
IRunExecutionData,
|
||||
EnvProviderState,
|
||||
IExecuteData,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
import * as a from 'node:assert';
|
||||
import { runInNewContext, type Context } from 'node:vm';
|
||||
|
||||
import type { TaskResultData } from '@/runner-types';
|
||||
import type { MainConfig } from '@/config/main-config';
|
||||
import type { DataRequestResponse, PartialAdditionalData, TaskResultData } from '@/runner-types';
|
||||
import { type Task, TaskRunner } from '@/task-runner';
|
||||
|
||||
import { BuiltInsParser } from './built-ins-parser/built-ins-parser';
|
||||
|
@ -32,7 +30,7 @@ import { makeSerializable } from './errors/serializable-error';
|
|||
import type { RequireResolver } from './require-resolver';
|
||||
import { createRequireResolver } from './require-resolver';
|
||||
import { validateRunForAllItemsOutput, validateRunForEachItemOutput } from './result-validation';
|
||||
import type { MainConfig } from '../config/main-config';
|
||||
import { DataRequestResponseReconstruct } from '../data-request/data-request-response-reconstruct';
|
||||
|
||||
export interface JSExecSettings {
|
||||
code: string;
|
||||
|
@ -44,34 +42,19 @@ export interface JSExecSettings {
|
|||
mode: WorkflowExecuteMode;
|
||||
}
|
||||
|
||||
export interface PartialAdditionalData {
|
||||
executionId?: string;
|
||||
restartExecutionId?: string;
|
||||
restApiUrl: string;
|
||||
instanceBaseUrl: string;
|
||||
formWaitingBaseUrl: string;
|
||||
webhookBaseUrl: string;
|
||||
webhookWaitingBaseUrl: string;
|
||||
webhookTestBaseUrl: string;
|
||||
currentNodeParameters?: INodeParameters;
|
||||
executionTimeoutTimestamp?: number;
|
||||
userId?: string;
|
||||
variables: IDataObject;
|
||||
}
|
||||
|
||||
export interface DataRequestResponse {
|
||||
export interface JsTaskData {
|
||||
workflow: Omit<WorkflowParameters, 'nodeTypes'>;
|
||||
inputData: ITaskDataConnections;
|
||||
connectionInputData: INodeExecutionData[];
|
||||
node: INode;
|
||||
|
||||
runExecutionData: IRunExecutionData;
|
||||
runIndex: number;
|
||||
itemIndex: number;
|
||||
activeNodeName: string;
|
||||
connectionInputData: INodeExecutionData[];
|
||||
siblingParameters: INodeParameters;
|
||||
mode: WorkflowExecuteMode;
|
||||
envProviderState?: EnvProviderState;
|
||||
envProviderState: EnvProviderState;
|
||||
executeData?: IExecuteData;
|
||||
defaultReturnRunIndex: number;
|
||||
selfData: IDataObject;
|
||||
|
@ -88,6 +71,8 @@ export class JsTaskRunner extends TaskRunner {
|
|||
|
||||
private readonly builtInsParser = new BuiltInsParser();
|
||||
|
||||
private readonly taskDataReconstruct = new DataRequestResponseReconstruct();
|
||||
|
||||
constructor(config: MainConfig, name = 'JS Task Runner') {
|
||||
super({
|
||||
taskType: 'javascript',
|
||||
|
@ -114,11 +99,15 @@ export class JsTaskRunner extends TaskRunner {
|
|||
? neededBuiltInsResult.result
|
||||
: BuiltInsParserState.newNeedsAllDataState();
|
||||
|
||||
const data = await this.requestData<DataRequestResponse>(
|
||||
const dataResponse = await this.requestData<DataRequestResponse>(
|
||||
task.taskId,
|
||||
neededBuiltIns.toDataRequestParams(),
|
||||
);
|
||||
|
||||
const data = this.reconstructTaskData(dataResponse);
|
||||
|
||||
await this.requestNodeTypeIfNeeded(neededBuiltIns, data.workflow, task.taskId);
|
||||
|
||||
const workflowParams = data.workflow;
|
||||
const workflow = new Workflow({
|
||||
...workflowParams,
|
||||
|
@ -177,7 +166,7 @@ export class JsTaskRunner extends TaskRunner {
|
|||
private async runForAllItems(
|
||||
taskId: string,
|
||||
settings: JSExecSettings,
|
||||
data: DataRequestResponse,
|
||||
data: JsTaskData,
|
||||
workflow: Workflow,
|
||||
customConsole: CustomConsole,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
|
@ -224,7 +213,7 @@ export class JsTaskRunner extends TaskRunner {
|
|||
private async runForEachItem(
|
||||
taskId: string,
|
||||
settings: JSExecSettings,
|
||||
data: DataRequestResponse,
|
||||
data: JsTaskData,
|
||||
workflow: Workflow,
|
||||
customConsole: CustomConsole,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
|
@ -291,7 +280,7 @@ export class JsTaskRunner extends TaskRunner {
|
|||
return returnData;
|
||||
}
|
||||
|
||||
private createDataProxy(data: DataRequestResponse, workflow: Workflow, itemIndex: number) {
|
||||
private createDataProxy(data: JsTaskData, workflow: Workflow, itemIndex: number) {
|
||||
return new WorkflowDataProxy(
|
||||
workflow,
|
||||
data.runExecutionData,
|
||||
|
@ -335,4 +324,43 @@ export class JsTaskRunner extends TaskRunner {
|
|||
|
||||
return new ExecutionError({ message: JSON.stringify(error) });
|
||||
}
|
||||
|
||||
private reconstructTaskData(response: DataRequestResponse): JsTaskData {
|
||||
return {
|
||||
...response,
|
||||
connectionInputData: this.taskDataReconstruct.reconstructConnectionInputData(
|
||||
response.inputData,
|
||||
),
|
||||
executeData: this.taskDataReconstruct.reconstructExecuteData(response),
|
||||
};
|
||||
}
|
||||
|
||||
private async requestNodeTypeIfNeeded(
|
||||
neededBuiltIns: BuiltInsParserState,
|
||||
workflow: JsTaskData['workflow'],
|
||||
taskId: string,
|
||||
) {
|
||||
/**
|
||||
* We request node types only when we know a task needs all nodes, because
|
||||
* needing all nodes means that the task relies on paired item functionality,
|
||||
* which is the same requirement for needing node types.
|
||||
*/
|
||||
if (neededBuiltIns.needsAllNodes) {
|
||||
const uniqueNodeTypes = new Map(
|
||||
workflow.nodes.map((node) => [
|
||||
`${node.type}|${node.typeVersion}`,
|
||||
{ name: node.type, version: node.typeVersion },
|
||||
]),
|
||||
);
|
||||
|
||||
const unknownNodeTypes = this.nodeTypes.onlyUnknown([...uniqueNodeTypes.values()]);
|
||||
|
||||
const nodeTypes = await this.requestNodeTypes<INodeTypeDescription[]>(
|
||||
taskId,
|
||||
unknownNodeTypes,
|
||||
);
|
||||
|
||||
this.nodeTypes.addNodeTypeDescriptions(nodeTypes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
import type { INodeTypeBaseDescription } from 'n8n-workflow';
|
||||
|
||||
import type { RPC_ALLOW_LIST, TaskDataRequestParams, TaskResultData } from './runner-types';
|
||||
import type {
|
||||
NeededNodeType,
|
||||
RPC_ALLOW_LIST,
|
||||
TaskDataRequestParams,
|
||||
TaskResultData,
|
||||
} from './runner-types';
|
||||
|
||||
export namespace BrokerMessage {
|
||||
export namespace ToRunner {
|
||||
|
@ -47,6 +52,8 @@ export namespace BrokerMessage {
|
|||
|
||||
export interface NodeTypes {
|
||||
type: 'broker:nodetypes';
|
||||
taskId: string;
|
||||
requestId: string;
|
||||
nodeTypes: INodeTypeBaseDescription[];
|
||||
}
|
||||
|
||||
|
@ -87,6 +94,13 @@ export namespace BrokerMessage {
|
|||
requestParams: TaskDataRequestParams;
|
||||
}
|
||||
|
||||
export interface NodeTypesRequest {
|
||||
type: 'broker:nodetypesrequest';
|
||||
taskId: string;
|
||||
requestId: string;
|
||||
requestParams: NeededNodeType[];
|
||||
}
|
||||
|
||||
export interface RPC {
|
||||
type: 'broker:rpc';
|
||||
callId: string;
|
||||
|
@ -95,7 +109,7 @@ export namespace BrokerMessage {
|
|||
params: unknown[];
|
||||
}
|
||||
|
||||
export type All = TaskReady | TaskDone | TaskError | TaskDataRequest | RPC;
|
||||
export type All = TaskReady | TaskDone | TaskError | TaskDataRequest | NodeTypesRequest | RPC;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -120,6 +134,13 @@ export namespace RequesterMessage {
|
|||
data: unknown;
|
||||
}
|
||||
|
||||
export interface NodeTypesResponse {
|
||||
type: 'requester:nodetypesresponse';
|
||||
taskId: string;
|
||||
requestId: string;
|
||||
nodeTypes: INodeTypeBaseDescription[];
|
||||
}
|
||||
|
||||
export interface RPCResponse {
|
||||
type: 'requester:rpcresponse';
|
||||
taskId: string;
|
||||
|
@ -134,7 +155,13 @@ export namespace RequesterMessage {
|
|||
taskType: string;
|
||||
}
|
||||
|
||||
export type All = TaskSettings | TaskCancel | RPCResponse | TaskDataResponse | TaskRequest;
|
||||
export type All =
|
||||
| TaskSettings
|
||||
| TaskCancel
|
||||
| RPCResponse
|
||||
| TaskDataResponse
|
||||
| NodeTypesResponse
|
||||
| TaskRequest;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -183,6 +210,25 @@ export namespace RunnerMessage {
|
|||
requestParams: TaskDataRequestParams;
|
||||
}
|
||||
|
||||
export interface NodeTypesRequest {
|
||||
type: 'runner:nodetypesrequest';
|
||||
taskId: string;
|
||||
requestId: string;
|
||||
|
||||
/**
|
||||
* Which node types should be included in the runner's node types request.
|
||||
*
|
||||
* Node types are needed only when the script relies on paired item functionality.
|
||||
* If so, we need only the node types not already cached in the runner.
|
||||
*
|
||||
* TODO: In future we can trim this down to only node types in the paired item chain,
|
||||
* rather than assuming we need all node types in the workflow.
|
||||
*
|
||||
* @example [{ name: 'n8n-nodes-base.httpRequest', version: 1 }]
|
||||
*/
|
||||
requestParams: NeededNodeType[];
|
||||
}
|
||||
|
||||
export interface RPC {
|
||||
type: 'runner:rpc';
|
||||
callId: string;
|
||||
|
@ -199,6 +245,7 @@ export namespace RunnerMessage {
|
|||
| TaskRejected
|
||||
| TaskOffer
|
||||
| RPC
|
||||
| TaskDataRequest;
|
||||
| TaskDataRequest
|
||||
| NodeTypesRequest;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,8 @@ import {
|
|||
type IVersionedNodeType,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { NeededNodeType } from './runner-types';
|
||||
|
||||
type VersionedTypes = Map<number, INodeTypeDescription>;
|
||||
|
||||
export const DEFAULT_NODETYPE_VERSION = 1;
|
||||
|
@ -61,4 +63,30 @@ export class TaskRunnerNodeTypes implements INodeTypes {
|
|||
getKnownTypes(): IDataObject {
|
||||
throw new ApplicationError('Unimplemented `getKnownTypes`', { level: 'error' });
|
||||
}
|
||||
|
||||
addNodeTypeDescriptions(nodeTypeDescriptions: INodeTypeDescription[]) {
|
||||
const newNodeTypes = this.parseNodeTypes(nodeTypeDescriptions);
|
||||
|
||||
for (const [name, newVersions] of newNodeTypes.entries()) {
|
||||
if (!this.nodeTypesByVersion.has(name)) {
|
||||
this.nodeTypesByVersion.set(name, newVersions);
|
||||
} else {
|
||||
const existingVersions = this.nodeTypesByVersion.get(name)!;
|
||||
for (const [version, nodeType] of newVersions.entries()) {
|
||||
existingVersions.set(version, nodeType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Filter out node type versions that are already registered. */
|
||||
onlyUnknown(nodeTypes: NeededNodeType[]) {
|
||||
return nodeTypes.filter(({ name, version }) => {
|
||||
const existingVersions = this.nodeTypesByVersion.get(name);
|
||||
|
||||
if (!existingVersions) return true;
|
||||
|
||||
return !existingVersions.has(version);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import type {
|
|||
INodeParameters,
|
||||
IRunExecutionData,
|
||||
ITaskDataConnections,
|
||||
ITaskDataConnectionsSource,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowExecuteMode,
|
||||
|
@ -29,17 +30,16 @@ export interface TaskDataRequestParams {
|
|||
export interface DataRequestResponse {
|
||||
workflow: Omit<WorkflowParameters, 'nodeTypes'>;
|
||||
inputData: ITaskDataConnections;
|
||||
connectionInputSource: ITaskDataConnectionsSource | null;
|
||||
node: INode;
|
||||
|
||||
runExecutionData: IRunExecutionData;
|
||||
runIndex: number;
|
||||
itemIndex: number;
|
||||
activeNodeName: string;
|
||||
connectionInputData: INodeExecutionData[];
|
||||
siblingParameters: INodeParameters;
|
||||
mode: WorkflowExecuteMode;
|
||||
envProviderState: EnvProviderState;
|
||||
executeData?: IExecuteData;
|
||||
defaultReturnRunIndex: number;
|
||||
selfData: IDataObject;
|
||||
contextNodeName: string;
|
||||
|
@ -112,3 +112,6 @@ export const RPC_ALLOW_LIST = [
|
|||
'helpers.httpRequest',
|
||||
'logNodeOutput',
|
||||
] as const;
|
||||
|
||||
/** Node types needed for the runner to execute a task. */
|
||||
export type NeededNodeType = { name: string; version: number };
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ApplicationError, type INodeTypeDescription } from 'n8n-workflow';
|
||||
import { ApplicationError, ensureError } from 'n8n-workflow';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { type MessageEvent, WebSocket } from 'ws';
|
||||
|
||||
|
@ -25,6 +25,12 @@ interface DataRequest {
|
|||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
interface NodeTypesRequest {
|
||||
requestId: string;
|
||||
resolve: (data: unknown) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
interface RPCCall {
|
||||
callId: string;
|
||||
resolve: (data: unknown) => void;
|
||||
|
@ -58,6 +64,8 @@ export abstract class TaskRunner {
|
|||
|
||||
dataRequests: Map<DataRequest['requestId'], DataRequest> = new Map();
|
||||
|
||||
nodeTypesRequests: Map<NodeTypesRequest['requestId'], NodeTypesRequest> = new Map();
|
||||
|
||||
rpcCalls: Map<RPCCall['callId'], RPCCall> = new Map();
|
||||
|
||||
nodeTypes: TaskRunnerNodeTypes = new TaskRunnerNodeTypes([]);
|
||||
|
@ -80,6 +88,24 @@ export abstract class TaskRunner {
|
|||
},
|
||||
maxPayload: opts.maxPayloadSize,
|
||||
});
|
||||
|
||||
this.ws.addEventListener('error', (event) => {
|
||||
const error = ensureError(event.error);
|
||||
|
||||
if (
|
||||
'code' in error &&
|
||||
typeof error.code === 'string' &&
|
||||
['ECONNREFUSED', 'ENOTFOUND'].some((code) => code === error.code)
|
||||
) {
|
||||
console.error(
|
||||
`Error: Failed to connect to n8n. Please ensure n8n is reachable at: ${opts.n8nUri}`,
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.error(`Error: Failed to connect to n8n at ${opts.n8nUri}`);
|
||||
console.error('Details:', event.message || 'Unknown error');
|
||||
}
|
||||
});
|
||||
this.ws.addEventListener('message', this.receiveMessage);
|
||||
this.ws.addEventListener('close', this.stopTaskOffers);
|
||||
}
|
||||
|
@ -168,15 +194,11 @@ export abstract class TaskRunner {
|
|||
this.handleRpcResponse(message.callId, message.status, message.data);
|
||||
break;
|
||||
case 'broker:nodetypes':
|
||||
this.setNodeTypes(message.nodeTypes as unknown as INodeTypeDescription[]);
|
||||
this.processNodeTypesResponse(message.requestId, message.nodeTypes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setNodeTypes(nodeTypes: INodeTypeDescription[]) {
|
||||
this.nodeTypes = new TaskRunnerNodeTypes(nodeTypes);
|
||||
}
|
||||
|
||||
processDataResponse(requestId: string, data: unknown) {
|
||||
const request = this.dataRequests.get(requestId);
|
||||
if (!request) {
|
||||
|
@ -187,6 +209,16 @@ export abstract class TaskRunner {
|
|||
request.resolve(data);
|
||||
}
|
||||
|
||||
processNodeTypesResponse(requestId: string, nodeTypes: unknown) {
|
||||
const request = this.nodeTypesRequests.get(requestId);
|
||||
|
||||
if (!request) return;
|
||||
|
||||
// Deleting of the request is handled in `requestNodeTypes`, using a
|
||||
// `finally` wrapped around the return
|
||||
request.resolve(nodeTypes);
|
||||
}
|
||||
|
||||
hasOpenTasks() {
|
||||
return Object.values(this.runningTasks).length < this.maxConcurrency;
|
||||
}
|
||||
|
@ -282,6 +314,34 @@ export abstract class TaskRunner {
|
|||
throw new ApplicationError('Unimplemented');
|
||||
}
|
||||
|
||||
async requestNodeTypes<T = unknown>(
|
||||
taskId: Task['taskId'],
|
||||
requestParams: RunnerMessage.ToBroker.NodeTypesRequest['requestParams'],
|
||||
) {
|
||||
const requestId = nanoid();
|
||||
|
||||
const nodeTypesPromise = new Promise<T>((resolve, reject) => {
|
||||
this.nodeTypesRequests.set(requestId, {
|
||||
requestId,
|
||||
resolve: resolve as (data: unknown) => void,
|
||||
reject,
|
||||
});
|
||||
});
|
||||
|
||||
this.send({
|
||||
type: 'runner:nodetypesrequest',
|
||||
taskId,
|
||||
requestId,
|
||||
requestParams,
|
||||
});
|
||||
|
||||
try {
|
||||
return await nodeTypesPromise;
|
||||
} finally {
|
||||
this.nodeTypesRequests.delete(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
async requestData<T = unknown>(
|
||||
taskId: Task['taskId'],
|
||||
requestParams: RunnerMessage.ToBroker.TaskDataRequest['requestParams'],
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n",
|
||||
"version": "1.66.0",
|
||||
"version": "1.67.0",
|
||||
"description": "n8n Workflow Automation Tool",
|
||||
"main": "dist/index",
|
||||
"types": "dist/index.d.ts",
|
||||
|
|
|
@ -27,7 +27,7 @@ import { Subscriber } from '@/scaling/pubsub/subscriber.service';
|
|||
import { Server } from '@/server';
|
||||
import { OrchestrationService } from '@/services/orchestration.service';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
import { PruningService } from '@/services/pruning.service';
|
||||
import { PruningService } from '@/services/pruning/pruning.service';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
import { WaitTracker } from '@/wait-tracker';
|
||||
import { WorkflowRunner } from '@/workflow-runner';
|
||||
|
@ -221,7 +221,7 @@ export class Start extends BaseCommand {
|
|||
}
|
||||
|
||||
const { taskRunners: taskRunnerConfig } = this.globalConfig;
|
||||
if (!taskRunnerConfig.disabled) {
|
||||
if (taskRunnerConfig.enabled) {
|
||||
const { TaskRunnerModule } = await import('@/runners/task-runner-module');
|
||||
const taskRunnerModule = Container.get(TaskRunnerModule);
|
||||
await taskRunnerModule.start();
|
||||
|
|
|
@ -113,7 +113,7 @@ export class Worker extends BaseCommand {
|
|||
);
|
||||
|
||||
const { taskRunners: taskRunnerConfig } = this.globalConfig;
|
||||
if (!taskRunnerConfig.disabled) {
|
||||
if (taskRunnerConfig.enabled) {
|
||||
const { TaskRunnerModule } = await import('@/runners/task-runner-module');
|
||||
const taskRunnerModule = Container.get(TaskRunnerModule);
|
||||
await taskRunnerModule.start();
|
||||
|
|
|
@ -127,6 +127,9 @@ export const TIME = {
|
|||
* Eventually this will superseed `TIME` above
|
||||
*/
|
||||
export const Time = {
|
||||
milliseconds: {
|
||||
toMinutes: 1 / (60 * 1000),
|
||||
},
|
||||
seconds: {
|
||||
toMilliseconds: 1000,
|
||||
},
|
||||
|
|
|
@ -50,8 +50,8 @@ export class CreateTable extends TableOperation {
|
|||
ref: {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
onDelete?: 'CASCADE';
|
||||
onUpdate?: 'CASCADE';
|
||||
onDelete?: 'RESTRICT' | 'CASCADE' | 'NO ACTION' | 'SET NULL';
|
||||
onUpdate?: 'RESTRICT' | 'CASCADE' | 'NO ACTION' | 'SET NULL';
|
||||
name?: string;
|
||||
},
|
||||
) {
|
||||
|
|
|
@ -31,6 +31,11 @@ export class ExecutionEntity {
|
|||
@PrimaryColumn({ transformer: idStringifier })
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Whether the execution finished sucessfully.
|
||||
*
|
||||
* @deprecated Use `status` instead
|
||||
*/
|
||||
@Column()
|
||||
finished: boolean;
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ import { Settings } from './settings';
|
|||
import { SharedCredentials } from './shared-credentials';
|
||||
import { SharedWorkflow } from './shared-workflow';
|
||||
import { TagEntity } from './tag-entity';
|
||||
import { TestDefinition } from './test-definition.ee';
|
||||
import { User } from './user';
|
||||
import { Variables } from './variables';
|
||||
import { WebhookEntity } from './webhook-entity';
|
||||
|
@ -58,4 +59,5 @@ export const entities = {
|
|||
ProjectRelation,
|
||||
ApiKey,
|
||||
ProcessedData,
|
||||
TestDefinition,
|
||||
};
|
||||
|
|
65
packages/cli/src/databases/entities/test-definition.ee.ts
Normal file
65
packages/cli/src/databases/entities/test-definition.ee.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Generated,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
RelationId,
|
||||
} from '@n8n/typeorm';
|
||||
import { Length } from 'class-validator';
|
||||
|
||||
import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.ee';
|
||||
import { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||
|
||||
import { WithTimestamps } from './abstract-entity';
|
||||
|
||||
/**
|
||||
* Entity representing a Test Definition
|
||||
* It combines:
|
||||
* - the workflow under test
|
||||
* - the workflow used to evaluate the results of test execution
|
||||
* - the filter used to select test cases from previous executions of the workflow under test - annotation tag
|
||||
*/
|
||||
@Entity()
|
||||
@Index(['workflow'])
|
||||
@Index(['evaluationWorkflow'])
|
||||
export class TestDefinition extends WithTimestamps {
|
||||
@Generated()
|
||||
@PrimaryColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ length: 255 })
|
||||
@Length(1, 255, {
|
||||
message: 'Test definition name must be $constraint1 to $constraint2 characters long.',
|
||||
})
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Relation to the workflow under test
|
||||
*/
|
||||
@ManyToOne('WorkflowEntity', 'tests')
|
||||
workflow: WorkflowEntity;
|
||||
|
||||
@RelationId((test: TestDefinition) => test.workflow)
|
||||
workflowId: string;
|
||||
|
||||
/**
|
||||
* Relation to the workflow used to evaluate the results of test execution
|
||||
*/
|
||||
@ManyToOne('WorkflowEntity', 'evaluationTests')
|
||||
evaluationWorkflow: WorkflowEntity;
|
||||
|
||||
@RelationId((test: TestDefinition) => test.evaluationWorkflow)
|
||||
evaluationWorkflowId: string;
|
||||
|
||||
/**
|
||||
* Relation to the annotation tag associated with the test
|
||||
* This tag will be used to select the test cases to run from previous executions
|
||||
*/
|
||||
@ManyToOne('AnnotationTagEntity', 'test')
|
||||
annotationTag: AnnotationTagEntity;
|
||||
|
||||
@RelationId((test: TestDefinition) => test.annotationTag)
|
||||
annotationTagId: string;
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import type { MigrationContext, ReversibleMigration } from '@/databases/types';
|
||||
|
||||
const testEntityTableName = 'test_definition';
|
||||
|
||||
export class CreateTestDefinitionTable1730386903556 implements ReversibleMigration {
|
||||
async up({ schemaBuilder: { createTable, column } }: MigrationContext) {
|
||||
await createTable(testEntityTableName)
|
||||
.withColumns(
|
||||
column('id').int.notNull.primary.autoGenerate,
|
||||
column('name').varchar(255).notNull,
|
||||
column('workflowId').varchar(36).notNull,
|
||||
column('evaluationWorkflowId').varchar(36),
|
||||
column('annotationTagId').varchar(16),
|
||||
)
|
||||
.withIndexOn('workflowId')
|
||||
.withIndexOn('evaluationWorkflowId')
|
||||
.withForeignKey('workflowId', {
|
||||
tableName: 'workflow_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
.withForeignKey('evaluationWorkflowId', {
|
||||
tableName: 'workflow_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
.withForeignKey('annotationTagId', {
|
||||
tableName: 'annotation_tag_entity',
|
||||
columnName: 'id',
|
||||
onDelete: 'SET NULL',
|
||||
}).withTimestamps;
|
||||
}
|
||||
|
||||
async down({ schemaBuilder: { dropTable } }: MigrationContext) {
|
||||
await dropTable(testEntityTableName);
|
||||
}
|
||||
}
|
|
@ -68,6 +68,7 @@ import { CreateProcessedDataTable1726606152711 } from '../common/1726606152711-C
|
|||
import { SeparateExecutionCreationFromStart1727427440136 } from '../common/1727427440136-SeparateExecutionCreationFromStart';
|
||||
import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from '../common/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping';
|
||||
import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/1729607673464-UpdateProcessedDataValueColumnToText';
|
||||
import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable';
|
||||
|
||||
export const mysqlMigrations: Migration[] = [
|
||||
InitialMigration1588157391238,
|
||||
|
@ -138,4 +139,5 @@ export const mysqlMigrations: Migration[] = [
|
|||
CreateProcessedDataTable1726606152711,
|
||||
AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644,
|
||||
UpdateProcessedDataValueColumnToText1729607673464,
|
||||
CreateTestDefinitionTable1730386903556,
|
||||
];
|
||||
|
|
|
@ -68,6 +68,7 @@ import { CreateProcessedDataTable1726606152711 } from '../common/1726606152711-C
|
|||
import { SeparateExecutionCreationFromStart1727427440136 } from '../common/1727427440136-SeparateExecutionCreationFromStart';
|
||||
import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from '../common/1728659839644-AddMissingPrimaryKeyOnAnnotationTagMapping';
|
||||
import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/1729607673464-UpdateProcessedDataValueColumnToText';
|
||||
import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable';
|
||||
|
||||
export const postgresMigrations: Migration[] = [
|
||||
InitialMigration1587669153312,
|
||||
|
@ -138,4 +139,5 @@ export const postgresMigrations: Migration[] = [
|
|||
CreateProcessedDataTable1726606152711,
|
||||
AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644,
|
||||
UpdateProcessedDataValueColumnToText1729607673464,
|
||||
CreateTestDefinitionTable1730386903556,
|
||||
];
|
||||
|
|
|
@ -65,6 +65,7 @@ import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-Cre
|
|||
import { CreateProcessedDataTable1726606152711 } from '../common/1726606152711-CreateProcessedDataTable';
|
||||
import { SeparateExecutionCreationFromStart1727427440136 } from '../common/1727427440136-SeparateExecutionCreationFromStart';
|
||||
import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/1729607673464-UpdateProcessedDataValueColumnToText';
|
||||
import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable';
|
||||
|
||||
const sqliteMigrations: Migration[] = [
|
||||
InitialMigration1588102412422,
|
||||
|
@ -132,6 +133,7 @@ const sqliteMigrations: Migration[] = [
|
|||
CreateProcessedDataTable1726606152711,
|
||||
AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644,
|
||||
UpdateProcessedDataValueColumnToText1729607673464,
|
||||
CreateTestDefinitionTable1730386903556,
|
||||
];
|
||||
|
||||
export { sqliteMigrations };
|
||||
|
|
|
@ -513,7 +513,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
|||
.execute();
|
||||
}
|
||||
|
||||
async hardDeleteSoftDeletedExecutions() {
|
||||
async findSoftDeletedExecutions() {
|
||||
const date = new Date();
|
||||
date.setHours(date.getHours() - this.globalConfig.pruning.hardDeleteBuffer);
|
||||
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
import type { FindManyOptions, FindOptionsWhere } from '@n8n/typeorm';
|
||||
import { DataSource, In, Repository } from '@n8n/typeorm';
|
||||
import { Service } from 'typedi';
|
||||
|
||||
import { TestDefinition } from '@/databases/entities/test-definition.ee';
|
||||
import type { ListQuery } from '@/requests';
|
||||
|
||||
@Service()
|
||||
export class TestDefinitionRepository extends Repository<TestDefinition> {
|
||||
constructor(dataSource: DataSource) {
|
||||
super(TestDefinition, dataSource.manager);
|
||||
}
|
||||
|
||||
async getMany(accessibleWorkflowIds: string[], options?: ListQuery.Options) {
|
||||
if (accessibleWorkflowIds.length === 0) return { tests: [], count: 0 };
|
||||
|
||||
const where: FindOptionsWhere<TestDefinition> = {
|
||||
...options?.filter,
|
||||
workflow: {
|
||||
id: In(accessibleWorkflowIds),
|
||||
},
|
||||
};
|
||||
|
||||
const findManyOptions: FindManyOptions<TestDefinition> = {
|
||||
where,
|
||||
relations: ['annotationTag'],
|
||||
order: { createdAt: 'DESC' },
|
||||
};
|
||||
|
||||
if (options?.take) {
|
||||
findManyOptions.skip = options.skip;
|
||||
findManyOptions.take = options.take;
|
||||
}
|
||||
|
||||
const [testDefinitions, count] = await this.findAndCount(findManyOptions);
|
||||
|
||||
return { testDefinitions, count };
|
||||
}
|
||||
|
||||
async getOne(id: number, accessibleWorkflowIds: string[]) {
|
||||
return await this.findOne({
|
||||
where: {
|
||||
id,
|
||||
workflow: {
|
||||
id: In(accessibleWorkflowIds),
|
||||
},
|
||||
},
|
||||
relations: ['annotationTag'],
|
||||
});
|
||||
}
|
||||
|
||||
async deleteById(id: number, accessibleWorkflowIds: string[]) {
|
||||
return await this.delete({
|
||||
id,
|
||||
workflow: {
|
||||
id: In(accessibleWorkflowIds),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
17
packages/cli/src/evaluation/test-definition.schema.ts
Normal file
17
packages/cli/src/evaluation/test-definition.schema.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export const testDefinitionCreateRequestBodySchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
workflowId: z.string().min(1),
|
||||
evaluationWorkflowId: z.string().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const testDefinitionPatchRequestBodySchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
evaluationWorkflowId: z.string().min(1).optional(),
|
||||
annotationTagId: z.string().min(1).optional(),
|
||||
})
|
||||
.strict();
|
124
packages/cli/src/evaluation/test-definition.service.ee.ts
Normal file
124
packages/cli/src/evaluation/test-definition.service.ee.ts
Normal file
|
@ -0,0 +1,124 @@
|
|||
import { Service } from 'typedi';
|
||||
|
||||
import type { TestDefinition } from '@/databases/entities/test-definition.ee';
|
||||
import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository.ee';
|
||||
import { TestDefinitionRepository } from '@/databases/repositories/test-definition.repository.ee';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { validateEntity } from '@/generic-helpers';
|
||||
import type { ListQuery } from '@/requests';
|
||||
|
||||
type TestDefinitionLike = Omit<
|
||||
Partial<TestDefinition>,
|
||||
'workflow' | 'evaluationWorkflow' | 'annotationTag'
|
||||
> & {
|
||||
workflow?: { id: string };
|
||||
evaluationWorkflow?: { id: string };
|
||||
annotationTag?: { id: string };
|
||||
};
|
||||
|
||||
@Service()
|
||||
export class TestDefinitionService {
|
||||
constructor(
|
||||
private testDefinitionRepository: TestDefinitionRepository,
|
||||
private annotationTagRepository: AnnotationTagRepository,
|
||||
) {}
|
||||
|
||||
private toEntityLike(attrs: {
|
||||
name?: string;
|
||||
workflowId?: string;
|
||||
evaluationWorkflowId?: string;
|
||||
annotationTagId?: string;
|
||||
id?: number;
|
||||
}) {
|
||||
const entity: TestDefinitionLike = {};
|
||||
|
||||
if (attrs.id) {
|
||||
entity.id = attrs.id;
|
||||
}
|
||||
|
||||
if (attrs.name) {
|
||||
entity.name = attrs.name?.trim();
|
||||
}
|
||||
|
||||
if (attrs.workflowId) {
|
||||
entity.workflow = {
|
||||
id: attrs.workflowId,
|
||||
};
|
||||
}
|
||||
|
||||
if (attrs.evaluationWorkflowId) {
|
||||
entity.evaluationWorkflow = {
|
||||
id: attrs.evaluationWorkflowId,
|
||||
};
|
||||
}
|
||||
|
||||
if (attrs.annotationTagId) {
|
||||
entity.annotationTag = {
|
||||
id: attrs.annotationTagId,
|
||||
};
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
toEntity(attrs: {
|
||||
name?: string;
|
||||
workflowId?: string;
|
||||
evaluationWorkflowId?: string;
|
||||
annotationTagId?: string;
|
||||
id?: number;
|
||||
}) {
|
||||
const entity = this.toEntityLike(attrs);
|
||||
return this.testDefinitionRepository.create(entity);
|
||||
}
|
||||
|
||||
async findOne(id: number, accessibleWorkflowIds: string[]) {
|
||||
return await this.testDefinitionRepository.getOne(id, accessibleWorkflowIds);
|
||||
}
|
||||
|
||||
async save(test: TestDefinition) {
|
||||
await validateEntity(test);
|
||||
|
||||
return await this.testDefinitionRepository.save(test);
|
||||
}
|
||||
|
||||
async update(id: number, attrs: TestDefinitionLike) {
|
||||
if (attrs.name) {
|
||||
const updatedTest = this.toEntity(attrs);
|
||||
await validateEntity(updatedTest);
|
||||
}
|
||||
|
||||
// Check if the annotation tag exists
|
||||
if (attrs.annotationTagId) {
|
||||
const annotationTagExists = await this.annotationTagRepository.exists({
|
||||
where: {
|
||||
id: attrs.annotationTagId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!annotationTagExists) {
|
||||
throw new BadRequestError('Annotation tag not found');
|
||||
}
|
||||
}
|
||||
|
||||
// Update the test definition
|
||||
const queryResult = await this.testDefinitionRepository.update(id, this.toEntityLike(attrs));
|
||||
|
||||
if (queryResult.affected === 0) {
|
||||
throw new NotFoundError('Test definition not found');
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: number, accessibleWorkflowIds: string[]) {
|
||||
const deleteResult = await this.testDefinitionRepository.deleteById(id, accessibleWorkflowIds);
|
||||
|
||||
if (deleteResult.affected === 0) {
|
||||
throw new NotFoundError('Test definition not found');
|
||||
}
|
||||
}
|
||||
|
||||
async getMany(options: ListQuery.Options, accessibleWorkflowIds: string[] = []) {
|
||||
return await this.testDefinitionRepository.getMany(accessibleWorkflowIds, options);
|
||||
}
|
||||
}
|
138
packages/cli/src/evaluation/test-definitions.controller.ee.ts
Normal file
138
packages/cli/src/evaluation/test-definitions.controller.ee.ts
Normal file
|
@ -0,0 +1,138 @@
|
|||
import express from 'express';
|
||||
import assert from 'node:assert';
|
||||
|
||||
import { Get, Post, Patch, RestController, Delete } from '@/decorators';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import {
|
||||
testDefinitionCreateRequestBodySchema,
|
||||
testDefinitionPatchRequestBodySchema,
|
||||
} from '@/evaluation/test-definition.schema';
|
||||
import { listQueryMiddleware } from '@/middlewares';
|
||||
import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service';
|
||||
import { isPositiveInteger } from '@/utils';
|
||||
|
||||
import { TestDefinitionService } from './test-definition.service.ee';
|
||||
import { TestDefinitionsRequest } from './test-definitions.types.ee';
|
||||
|
||||
@RestController('/evaluation/test-definitions')
|
||||
export class TestDefinitionsController {
|
||||
private validateId(id: string) {
|
||||
if (!isPositiveInteger(id)) {
|
||||
throw new BadRequestError('Test ID is not a number');
|
||||
}
|
||||
|
||||
return Number(id);
|
||||
}
|
||||
|
||||
constructor(private readonly testDefinitionService: TestDefinitionService) {}
|
||||
|
||||
@Get('/', { middlewares: listQueryMiddleware })
|
||||
async getMany(req: TestDefinitionsRequest.GetMany) {
|
||||
const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
|
||||
|
||||
return await this.testDefinitionService.getMany(
|
||||
req.listQueryOptions,
|
||||
userAccessibleWorkflowIds,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('/:id')
|
||||
async getOne(req: TestDefinitionsRequest.GetOne) {
|
||||
const testDefinitionId = this.validateId(req.params.id);
|
||||
|
||||
const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
|
||||
|
||||
const testDefinition = await this.testDefinitionService.findOne(
|
||||
testDefinitionId,
|
||||
userAccessibleWorkflowIds,
|
||||
);
|
||||
|
||||
if (!testDefinition) throw new NotFoundError('Test definition not found');
|
||||
|
||||
return testDefinition;
|
||||
}
|
||||
|
||||
@Post('/')
|
||||
async create(req: TestDefinitionsRequest.Create, res: express.Response) {
|
||||
const bodyParseResult = testDefinitionCreateRequestBodySchema.safeParse(req.body);
|
||||
if (!bodyParseResult.success) {
|
||||
res.status(400).json({ errors: bodyParseResult.error.errors });
|
||||
return;
|
||||
}
|
||||
|
||||
const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
|
||||
|
||||
if (!userAccessibleWorkflowIds.includes(req.body.workflowId)) {
|
||||
throw new ForbiddenError('User does not have access to the workflow');
|
||||
}
|
||||
|
||||
if (
|
||||
req.body.evaluationWorkflowId &&
|
||||
!userAccessibleWorkflowIds.includes(req.body.evaluationWorkflowId)
|
||||
) {
|
||||
throw new ForbiddenError('User does not have access to the evaluation workflow');
|
||||
}
|
||||
|
||||
return await this.testDefinitionService.save(
|
||||
this.testDefinitionService.toEntity(bodyParseResult.data),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('/:id')
|
||||
async delete(req: TestDefinitionsRequest.Delete) {
|
||||
const testDefinitionId = this.validateId(req.params.id);
|
||||
|
||||
const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
|
||||
|
||||
if (userAccessibleWorkflowIds.length === 0)
|
||||
throw new ForbiddenError('User does not have access to any workflows');
|
||||
|
||||
await this.testDefinitionService.delete(testDefinitionId, userAccessibleWorkflowIds);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Patch('/:id')
|
||||
async patch(req: TestDefinitionsRequest.Patch, res: express.Response) {
|
||||
const testDefinitionId = this.validateId(req.params.id);
|
||||
|
||||
const bodyParseResult = testDefinitionPatchRequestBodySchema.safeParse(req.body);
|
||||
if (!bodyParseResult.success) {
|
||||
res.status(400).json({ errors: bodyParseResult.error.errors });
|
||||
return;
|
||||
}
|
||||
|
||||
const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']);
|
||||
|
||||
// Fail fast if no workflows are accessible
|
||||
if (userAccessibleWorkflowIds.length === 0)
|
||||
throw new ForbiddenError('User does not have access to any workflows');
|
||||
|
||||
const existingTest = await this.testDefinitionService.findOne(
|
||||
testDefinitionId,
|
||||
userAccessibleWorkflowIds,
|
||||
);
|
||||
if (!existingTest) throw new NotFoundError('Test definition not found');
|
||||
|
||||
if (
|
||||
req.body.evaluationWorkflowId &&
|
||||
!userAccessibleWorkflowIds.includes(req.body.evaluationWorkflowId)
|
||||
) {
|
||||
throw new ForbiddenError('User does not have access to the evaluation workflow');
|
||||
}
|
||||
|
||||
await this.testDefinitionService.update(testDefinitionId, req.body);
|
||||
|
||||
// Respond with the updated test definition
|
||||
const testDefinition = await this.testDefinitionService.findOne(
|
||||
testDefinitionId,
|
||||
userAccessibleWorkflowIds,
|
||||
);
|
||||
|
||||
assert(testDefinition, 'Test definition not found');
|
||||
|
||||
return testDefinition;
|
||||
}
|
||||
}
|
33
packages/cli/src/evaluation/test-definitions.types.ee.ts
Normal file
33
packages/cli/src/evaluation/test-definitions.types.ee.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import type { AuthenticatedRequest, ListQuery } from '@/requests';
|
||||
|
||||
// ----------------------------------
|
||||
// /test-definitions
|
||||
// ----------------------------------
|
||||
|
||||
export declare namespace TestDefinitionsRequest {
|
||||
namespace RouteParams {
|
||||
type TestId = {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
type GetOne = AuthenticatedRequest<RouteParams.TestId>;
|
||||
|
||||
type GetMany = AuthenticatedRequest<{}, {}, {}, ListQuery.Params & { includeScopes?: string }> & {
|
||||
listQueryOptions: ListQuery.Options;
|
||||
};
|
||||
|
||||
type Create = AuthenticatedRequest<
|
||||
{},
|
||||
{},
|
||||
{ name: string; workflowId: string; evaluationWorkflowId?: string }
|
||||
>;
|
||||
|
||||
type Patch = AuthenticatedRequest<
|
||||
RouteParams.TestId,
|
||||
{},
|
||||
{ name?: string; evaluationWorkflowId?: string; annotationTagId?: string }
|
||||
>;
|
||||
|
||||
type Delete = AuthenticatedRequest<RouteParams.TestId>;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue