mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
fix(editor): Handle large payloads in the AI Assistant requests better (#12747)
This commit is contained in:
parent
60187cab9b
commit
eb4dea1ca8
|
@ -1,6 +1,9 @@
|
||||||
|
import { useAIAssistantHelpers } from '@/composables/useAIAssistantHelpers';
|
||||||
|
import { AI_ASSISTANT_MAX_CONTENT_LENGTH } from '@/constants';
|
||||||
import type { ICredentialsResponse, IRestApiContext } from '@/Interface';
|
import type { ICredentialsResponse, IRestApiContext } from '@/Interface';
|
||||||
import type { AskAiRequest, ChatRequest, ReplaceCodeRequest } from '@/types/assistant.types';
|
import type { AskAiRequest, ChatRequest, ReplaceCodeRequest } from '@/types/assistant.types';
|
||||||
import { makeRestApiRequest, streamRequest } from '@/utils/apiUtils';
|
import { makeRestApiRequest, streamRequest } from '@/utils/apiUtils';
|
||||||
|
import { getObjectSizeInKB } from '@/utils/objectUtils';
|
||||||
import type { IDataObject } from 'n8n-workflow';
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
|
|
||||||
export function chatWithAssistant(
|
export function chatWithAssistant(
|
||||||
|
@ -10,6 +13,15 @@ export function chatWithAssistant(
|
||||||
onDone: () => void,
|
onDone: () => void,
|
||||||
onError: (e: Error) => void,
|
onError: (e: Error) => void,
|
||||||
): void {
|
): void {
|
||||||
|
try {
|
||||||
|
const payloadSize = getObjectSizeInKB(payload.payload);
|
||||||
|
if (payloadSize > AI_ASSISTANT_MAX_CONTENT_LENGTH) {
|
||||||
|
useAIAssistantHelpers().trimPayloadSize(payload);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
onError(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void streamRequest<ChatRequest.ResponsePayload>(
|
void streamRequest<ChatRequest.ResponsePayload>(
|
||||||
ctx,
|
ctx,
|
||||||
'/ai/chat',
|
'/ai/chat',
|
||||||
|
|
|
@ -0,0 +1,430 @@
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
import type { ChatRequest } from '@/types/assistant.types';
|
||||||
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export const PAYLOAD_SIZE_FOR_1_PASS = 4;
|
||||||
|
export const PAYLOAD_SIZE_FOR_2_PASSES = 2;
|
||||||
|
|
||||||
|
export const ERROR_HELPER_TEST_PAYLOAD: ChatRequest.RequestPayload = {
|
||||||
|
payload: {
|
||||||
|
role: 'user',
|
||||||
|
type: 'init-error-helper',
|
||||||
|
user: {
|
||||||
|
firstName: 'Milorad',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
name: 'NodeOperationError',
|
||||||
|
message: "Referenced node doesn't exist",
|
||||||
|
description:
|
||||||
|
"The node <strong>'Hey'</strong> doesn't exist, but it's used in an expression here.",
|
||||||
|
},
|
||||||
|
node: {
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {
|
||||||
|
mode: 'manual',
|
||||||
|
duplicateItem: false,
|
||||||
|
assignments: {
|
||||||
|
assignments: {
|
||||||
|
'0': {
|
||||||
|
id: '0957fbdb-a021-413b-9d42-fc847666f999',
|
||||||
|
name: 'text',
|
||||||
|
value: 'Lorem ipsum dolor sit amet',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
'1': {
|
||||||
|
id: '8efecfa7-8df7-492e-83e7-3d517ad03e60',
|
||||||
|
name: 'foo',
|
||||||
|
value: {
|
||||||
|
value: "={{ $('Hey').json.name }}",
|
||||||
|
resolvedExpressionValue: 'Error in expression: "Referenced node doesn\'t exist"',
|
||||||
|
},
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
includeOtherFields: false,
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 3.4,
|
||||||
|
id: '6dc70bf3-ba54-4481-b9f5-ce255bdd5fb8',
|
||||||
|
name: 'This is fine',
|
||||||
|
},
|
||||||
|
executionSchema: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SUPPORT_CHAT_TEST_PAYLOAD: ChatRequest.RequestPayload = {
|
||||||
|
payload: {
|
||||||
|
role: 'user',
|
||||||
|
type: 'init-support-chat',
|
||||||
|
user: {
|
||||||
|
firstName: 'Milorad',
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
currentView: {
|
||||||
|
name: VIEWS.WORKFLOW,
|
||||||
|
description:
|
||||||
|
'The user is currently looking at the current workflow in n8n editor, without any specific node selected.',
|
||||||
|
},
|
||||||
|
activeNodeInfo: {
|
||||||
|
node: {
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {
|
||||||
|
mode: 'manual',
|
||||||
|
duplicateItem: false,
|
||||||
|
assignments: {
|
||||||
|
assignments: {
|
||||||
|
'0': {
|
||||||
|
id: '969e86d0-76de-44f6-b07d-44a8a953f564',
|
||||||
|
name: 'name',
|
||||||
|
value: {
|
||||||
|
value: "={{ $('Edit Fields 2').name }}",
|
||||||
|
resolvedExpressionValue:
|
||||||
|
'Error in expression: "Referenced node doesn\'t exist"',
|
||||||
|
},
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
includeOtherFields: false,
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 3.4,
|
||||||
|
id: '8eac1591-ddc6-4d93-bec7-998cbfe27cc7',
|
||||||
|
name: 'Edit Fields1',
|
||||||
|
},
|
||||||
|
executionStatus: {
|
||||||
|
status: 'error',
|
||||||
|
error: {
|
||||||
|
name: 'NodeOperationError',
|
||||||
|
message: "Referenced node doesn't exist",
|
||||||
|
stack:
|
||||||
|
"NodeOperationError: Referenced node doesn't exist\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/Set/v2/manual.mode.ts:256:9)\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/Set/v2/SetV2.node.ts:351:48)\n at WorkflowExecute.runNode (/Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1097:31)\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1505:38\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:2066:11",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
referencedNodes: [],
|
||||||
|
},
|
||||||
|
currentWorkflow: {
|
||||||
|
name: '🧪 Assistant context test',
|
||||||
|
active: false,
|
||||||
|
connections: {
|
||||||
|
'When clicking ‘Test workflow’': {
|
||||||
|
main: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
node: 'Edit Fields',
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'Edit Fields': {
|
||||||
|
main: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
node: 'Bad request no chat found',
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: 'Slack',
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: 'Edit Fields1',
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: 'Edit Fields2',
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
parameters: {
|
||||||
|
notice: '',
|
||||||
|
},
|
||||||
|
id: 'c457ff96-3b0c-4dbc-b47f-dc88396a46ae',
|
||||||
|
name: 'When clicking ‘Test workflow’',
|
||||||
|
type: 'n8n-nodes-base.manualTrigger',
|
||||||
|
position: [-60, 200],
|
||||||
|
typeVersion: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parameters: {
|
||||||
|
resource: 'chat',
|
||||||
|
operation: 'get',
|
||||||
|
chatId: '13',
|
||||||
|
},
|
||||||
|
id: '60ddc045-d4e3-4b62-9832-12ecf78937a6',
|
||||||
|
name: 'Bad request no chat found',
|
||||||
|
type: 'n8n-nodes-base.telegram',
|
||||||
|
typeVersion: 1.1,
|
||||||
|
position: [540, 0],
|
||||||
|
issues: {},
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parameters: {
|
||||||
|
mode: 'manual',
|
||||||
|
duplicateItem: false,
|
||||||
|
assignments: {
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
id: '70448b12-9b2b-4bfb-abee-6432c4c58de1',
|
||||||
|
name: 'name',
|
||||||
|
value: 'Joe',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
includeOtherFields: false,
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 3.4,
|
||||||
|
position: [200, 200],
|
||||||
|
id: '0a831739-13cd-4541-b20b-7db73abbcaf0',
|
||||||
|
name: 'Edit Fields',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parameters: {
|
||||||
|
authentication: 'oAuth2',
|
||||||
|
resource: 'channel',
|
||||||
|
operation: 'archive',
|
||||||
|
channelId: {
|
||||||
|
__rl: true,
|
||||||
|
mode: 'list',
|
||||||
|
value: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: 'n8n-nodes-base.slack',
|
||||||
|
typeVersion: 2.2,
|
||||||
|
position: [540, 200],
|
||||||
|
id: 'aff7471e-b2bc-4274-abe1-97897a17eaa6',
|
||||||
|
name: 'Slack',
|
||||||
|
webhookId: '7f8b574c-7729-4220-bbe9-bf5aa382406a',
|
||||||
|
credentials: {
|
||||||
|
slackOAuth2Api: {
|
||||||
|
id: 'mZRj4wi3gavIzu9b',
|
||||||
|
name: 'Slack account',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parameters: {
|
||||||
|
mode: 'manual',
|
||||||
|
duplicateItem: false,
|
||||||
|
assignments: {
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
id: '969e86d0-76de-44f6-b07d-44a8a953f564',
|
||||||
|
name: 'name',
|
||||||
|
value: "={{ $('Edit Fields 2').name }}",
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
includeOtherFields: false,
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 3.4,
|
||||||
|
position: [540, 400],
|
||||||
|
id: '8eac1591-ddc6-4d93-bec7-998cbfe27cc7',
|
||||||
|
name: 'Edit Fields1',
|
||||||
|
issues: {
|
||||||
|
execution: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parameters: {
|
||||||
|
mode: 'manual',
|
||||||
|
duplicateItem: false,
|
||||||
|
assignments: {
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
id: '9bdfc283-64f7-41c5-9a55-b8d8ccbe3e9d',
|
||||||
|
name: 'age',
|
||||||
|
value: '={{ $json.name }}',
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
includeOtherFields: false,
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 3.4,
|
||||||
|
position: [440, 560],
|
||||||
|
id: '34e56e14-d1a9-4a73-9208-15d39771a9ba',
|
||||||
|
name: 'Edit Fields2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
executionData: {
|
||||||
|
runData: {
|
||||||
|
'When clicking ‘Test workflow’': [
|
||||||
|
{
|
||||||
|
hints: [],
|
||||||
|
startTime: 1737540693122,
|
||||||
|
executionTime: 1,
|
||||||
|
source: [],
|
||||||
|
executionStatus: 'success',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Edit Fields': [
|
||||||
|
{
|
||||||
|
hints: [],
|
||||||
|
startTime: 1737540693124,
|
||||||
|
executionTime: 2,
|
||||||
|
source: [
|
||||||
|
{
|
||||||
|
previousNode: 'When clicking ‘Test workflow’',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
executionStatus: 'success',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Bad request no chat found': [
|
||||||
|
{
|
||||||
|
hints: [],
|
||||||
|
startTime: 1737540693126,
|
||||||
|
executionTime: 0,
|
||||||
|
source: [
|
||||||
|
{
|
||||||
|
previousNode: 'Edit Fields',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
executionStatus: 'success',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
Slack: [
|
||||||
|
{
|
||||||
|
hints: [],
|
||||||
|
startTime: 1737540693127,
|
||||||
|
executionTime: 0,
|
||||||
|
source: [
|
||||||
|
{
|
||||||
|
previousNode: 'Edit Fields',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
executionStatus: 'success',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'Edit Fields1': [
|
||||||
|
{
|
||||||
|
hints: [],
|
||||||
|
startTime: 1737540693127,
|
||||||
|
executionTime: 28,
|
||||||
|
source: [
|
||||||
|
{
|
||||||
|
previousNode: 'Edit Fields',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
executionStatus: 'error',
|
||||||
|
// @ts-expect-error Incomplete mock objects are expected
|
||||||
|
error: {
|
||||||
|
level: 'warning',
|
||||||
|
tags: {
|
||||||
|
packageName: 'workflow',
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
itemIndex: 0,
|
||||||
|
nodeCause: 'Edit Fields 2',
|
||||||
|
descriptionKey: 'nodeNotFound',
|
||||||
|
parameter: 'assignments',
|
||||||
|
},
|
||||||
|
functionality: 'regular',
|
||||||
|
name: 'NodeOperationError',
|
||||||
|
timestamp: 1737540693141,
|
||||||
|
node: {
|
||||||
|
parameters: {
|
||||||
|
mode: 'manual',
|
||||||
|
duplicateItem: false,
|
||||||
|
assignments: {
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
id: '969e86d0-76de-44f6-b07d-44a8a953f564',
|
||||||
|
name: 'name',
|
||||||
|
value: "={{ $('Edit Fields 2').name }}",
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
includeOtherFields: false,
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 3.4,
|
||||||
|
position: [540, 400],
|
||||||
|
id: '8eac1591-ddc6-4d93-bec7-998cbfe27cc7',
|
||||||
|
name: 'Edit Fields1',
|
||||||
|
},
|
||||||
|
messages: [],
|
||||||
|
message: "Referenced node doesn't exist",
|
||||||
|
stack:
|
||||||
|
"NodeOperationError: Referenced node doesn't exist\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/Set/v2/manual.mode.ts:256:9)\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/Set/v2/SetV2.node.ts:351:48)\n at WorkflowExecute.runNode (/Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1097:31)\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1505:38\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:2066:11",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// @ts-expect-error Incomplete mock objects are expected
|
||||||
|
error: {
|
||||||
|
level: 'warning',
|
||||||
|
tags: {
|
||||||
|
packageName: 'workflow',
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
itemIndex: 0,
|
||||||
|
nodeCause: 'Edit Fields 2',
|
||||||
|
descriptionKey: 'nodeNotFound',
|
||||||
|
parameter: 'assignments',
|
||||||
|
},
|
||||||
|
functionality: 'regular',
|
||||||
|
name: 'NodeOperationError',
|
||||||
|
timestamp: 1737540693141,
|
||||||
|
node: {
|
||||||
|
parameters: {
|
||||||
|
mode: 'manual',
|
||||||
|
duplicateItem: false,
|
||||||
|
assignments: {
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
id: '969e86d0-76de-44f6-b07d-44a8a953f564',
|
||||||
|
name: 'name',
|
||||||
|
value: "={{ $('Edit Fields 2').name }}",
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
includeOtherFields: false,
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 3.4,
|
||||||
|
position: [540, 400],
|
||||||
|
id: '8eac1591-ddc6-4d93-bec7-998cbfe27cc7',
|
||||||
|
name: 'Edit Fields1',
|
||||||
|
},
|
||||||
|
messages: [],
|
||||||
|
message: "Referenced node doesn't exist",
|
||||||
|
stack:
|
||||||
|
"NodeOperationError: Referenced node doesn't exist\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/Set/v2/manual.mode.ts:256:9)\n at ExecuteContext.execute (/Users/miloradfilipovic/workspace/n8n/packages/nodes-base/nodes/Set/v2/SetV2.node.ts:351:48)\n at WorkflowExecute.runNode (/Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1097:31)\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:1505:38\n at /Users/miloradfilipovic/workspace/n8n/packages/core/src/execution-engine/workflow-execute.ts:2066:11",
|
||||||
|
},
|
||||||
|
lastNodeExecuted: 'Edit Fields1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
question: 'Hey',
|
||||||
|
},
|
||||||
|
};
|
|
@ -4,6 +4,13 @@ import { useAIAssistantHelpers } from './useAIAssistantHelpers';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { setActivePinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
import type { IWorkflowDb } from '@/Interface';
|
import type { IWorkflowDb } from '@/Interface';
|
||||||
|
import type { ChatRequest } from '@/types/assistant.types';
|
||||||
|
import {
|
||||||
|
ERROR_HELPER_TEST_PAYLOAD,
|
||||||
|
PAYLOAD_SIZE_FOR_1_PASS,
|
||||||
|
PAYLOAD_SIZE_FOR_2_PASSES,
|
||||||
|
SUPPORT_CHAT_TEST_PAYLOAD,
|
||||||
|
} from './useAIAssistantHelpers.test.constants';
|
||||||
|
|
||||||
const referencedNodesTestCases: Array<{ caseName: string; node: INode; expected: string[] }> = [
|
const referencedNodesTestCases: Array<{ caseName: string; node: INode; expected: string[] }> = [
|
||||||
{
|
{
|
||||||
|
@ -549,3 +556,67 @@ describe('Simplify assistant payloads', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Trim Payload Size', () => {
|
||||||
|
let aiAssistantHelpers: ReturnType<typeof useAIAssistantHelpers>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createTestingPinia());
|
||||||
|
aiAssistantHelpers = useAIAssistantHelpers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should trim active node parameters in error helper payload', () => {
|
||||||
|
const payload = ERROR_HELPER_TEST_PAYLOAD;
|
||||||
|
aiAssistantHelpers.trimPayloadSize(payload);
|
||||||
|
expect((payload.payload as ChatRequest.InitErrorHelper).node.parameters).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should trim all node parameters in support chat', () => {
|
||||||
|
// Testing the scenario where only one trimming pass is needed
|
||||||
|
// (payload is under the limit after removing all node parameters and execution data)
|
||||||
|
const payload: ChatRequest.RequestPayload = SUPPORT_CHAT_TEST_PAYLOAD;
|
||||||
|
const supportPayload: ChatRequest.InitSupportChat =
|
||||||
|
payload.payload as ChatRequest.InitSupportChat;
|
||||||
|
|
||||||
|
// Trimming to 4kb should be successful
|
||||||
|
expect(() =>
|
||||||
|
aiAssistantHelpers.trimPayloadSize(payload, PAYLOAD_SIZE_FOR_1_PASS),
|
||||||
|
).not.toThrow();
|
||||||
|
// All active node parameters should be removed
|
||||||
|
expect(supportPayload?.context?.activeNodeInfo?.node?.parameters).toEqual({});
|
||||||
|
// Also, all node parameters in the workflow should be removed
|
||||||
|
supportPayload.context?.currentWorkflow?.nodes?.forEach((node) => {
|
||||||
|
expect(node.parameters).toEqual({});
|
||||||
|
});
|
||||||
|
// Node parameters in the execution data should be removed
|
||||||
|
expect(supportPayload.context?.executionData?.runData).toEqual({});
|
||||||
|
if (
|
||||||
|
supportPayload.context?.executionData?.error &&
|
||||||
|
'node' in supportPayload.context.executionData.error
|
||||||
|
) {
|
||||||
|
expect(supportPayload.context?.executionData?.error?.node?.parameters).toEqual({});
|
||||||
|
}
|
||||||
|
// Context object should still be there
|
||||||
|
expect(supportPayload.context).to.be.an('object');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should trim the whole context in support chat', () => {
|
||||||
|
// Testing the scenario where both trimming passes are needed
|
||||||
|
// (payload is over the limit after removing all node parameters and execution data)
|
||||||
|
const payload: ChatRequest.RequestPayload = SUPPORT_CHAT_TEST_PAYLOAD;
|
||||||
|
const supportPayload: ChatRequest.InitSupportChat =
|
||||||
|
payload.payload as ChatRequest.InitSupportChat;
|
||||||
|
|
||||||
|
// Trimming should be successful
|
||||||
|
expect(() =>
|
||||||
|
aiAssistantHelpers.trimPayloadSize(payload, PAYLOAD_SIZE_FOR_2_PASSES),
|
||||||
|
).not.toThrow();
|
||||||
|
// The whole context object should be removed
|
||||||
|
expect(supportPayload.context).not.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should throw an error if payload is too big after trimming', () => {
|
||||||
|
const payload = ERROR_HELPER_TEST_PAYLOAD;
|
||||||
|
expect(() => aiAssistantHelpers.trimPayloadSize(payload, 0.2)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -14,9 +14,10 @@ import { executionDataToJson, getMainAuthField, getNodeAuthOptions } from '@/uti
|
||||||
import type { ChatRequest } from '@/types/assistant.types';
|
import type { ChatRequest } from '@/types/assistant.types';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useDataSchema } from './useDataSchema';
|
import { useDataSchema } from './useDataSchema';
|
||||||
import { VIEWS } from '@/constants';
|
import { AI_ASSISTANT_MAX_CONTENT_LENGTH, VIEWS } from '@/constants';
|
||||||
import { useI18n } from './useI18n';
|
import { useI18n } from './useI18n';
|
||||||
import type { IWorkflowDb } from '@/Interface';
|
import type { IWorkflowDb } from '@/Interface';
|
||||||
|
import { getObjectSizeInKB } from '@/utils/objectUtils';
|
||||||
|
|
||||||
const CANVAS_VIEWS = [VIEWS.NEW_WORKFLOW, VIEWS.WORKFLOW, VIEWS.EXECUTION_DEBUG];
|
const CANVAS_VIEWS = [VIEWS.NEW_WORKFLOW, VIEWS.WORKFLOW, VIEWS.EXECUTION_DEBUG];
|
||||||
const EXECUTION_VIEWS = [VIEWS.EXECUTION_PREVIEW];
|
const EXECUTION_VIEWS = [VIEWS.EXECUTION_PREVIEW];
|
||||||
|
@ -251,6 +252,64 @@ export const useAIAssistantHelpers = () => {
|
||||||
nodes: workflow.nodes,
|
nodes: workflow.nodes,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces AI Assistant request payload size to make it fit the specified content length.
|
||||||
|
* If, after two passes, the payload is still too big, throws an error'
|
||||||
|
* @param payload The request payload to trim
|
||||||
|
* @param size The maximum size of the payload in KB
|
||||||
|
*/
|
||||||
|
const trimPayloadToSize = (
|
||||||
|
payload: ChatRequest.RequestPayload,
|
||||||
|
size = AI_ASSISTANT_MAX_CONTENT_LENGTH,
|
||||||
|
): void => {
|
||||||
|
const requestPayload = payload.payload;
|
||||||
|
// For support chat, remove parameters from the active node object and all nodes in the workflow
|
||||||
|
if (requestPayload.type === 'init-support-chat') {
|
||||||
|
if (requestPayload.context?.activeNodeInfo?.node) {
|
||||||
|
requestPayload.context.activeNodeInfo.node.parameters = {};
|
||||||
|
}
|
||||||
|
if (requestPayload.context?.currentWorkflow) {
|
||||||
|
requestPayload.context.currentWorkflow?.nodes?.forEach((node) => {
|
||||||
|
node.parameters = {};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (requestPayload.context?.executionData?.runData) {
|
||||||
|
requestPayload.context.executionData.runData = {};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
requestPayload.context?.executionData?.error &&
|
||||||
|
'node' in requestPayload.context?.executionData?.error
|
||||||
|
) {
|
||||||
|
if (requestPayload.context?.executionData?.error?.node) {
|
||||||
|
requestPayload.context.executionData.error.node.parameters = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If the payload is still too big, remove the whole context object
|
||||||
|
if (getRequestPayloadSize(payload) > size) {
|
||||||
|
requestPayload.context = undefined;
|
||||||
|
}
|
||||||
|
// For error helper, remove parameters from the active node object
|
||||||
|
// This will leave just the error, user info and basic node structure in the payload
|
||||||
|
} else if (requestPayload.type === 'init-error-helper') {
|
||||||
|
requestPayload.node.parameters = {};
|
||||||
|
}
|
||||||
|
// If the payload is still too big, throw an error that will be shown to the user
|
||||||
|
if (getRequestPayloadSize(payload) > size) {
|
||||||
|
throw new Error(locale.baseText('aiAssistant.payloadTooBig.message'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the size of the request payload in KB, returns 0 if the payload is not a valid object
|
||||||
|
*/
|
||||||
|
const getRequestPayloadSize = (payload: ChatRequest.RequestPayload): number => {
|
||||||
|
try {
|
||||||
|
return getObjectSizeInKB(payload.payload);
|
||||||
|
} catch (error) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
processNodeForAssistant,
|
processNodeForAssistant,
|
||||||
getNodeInfoForAssistant,
|
getNodeInfoForAssistant,
|
||||||
|
@ -261,5 +320,6 @@ export const useAIAssistantHelpers = () => {
|
||||||
getReferencedNodes,
|
getReferencedNodes,
|
||||||
simplifyResultData,
|
simplifyResultData,
|
||||||
simplifyWorkflowForAssistant,
|
simplifyWorkflowForAssistant,
|
||||||
|
trimPayloadSize: trimPayloadToSize,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -907,3 +907,5 @@ export const APP_MODALS_ELEMENT_ID = 'app-modals';
|
||||||
export const NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL = 'new-sample-sub-workflow-created';
|
export const NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL = 'new-sample-sub-workflow-created';
|
||||||
|
|
||||||
export const AI_NODES_PACKAGE_NAME = '@n8n/n8n-nodes-langchain';
|
export const AI_NODES_PACKAGE_NAME = '@n8n/n8n-nodes-langchain';
|
||||||
|
|
||||||
|
export const AI_ASSISTANT_MAX_CONTENT_LENGTH = 100; // in kilobytes
|
||||||
|
|
|
@ -155,7 +155,8 @@
|
||||||
"aiAssistant.newSessionModal.message": "You already have an active AI Assistant session. Starting a new session will clear your current conversation history.",
|
"aiAssistant.newSessionModal.message": "You already have an active AI Assistant session. Starting a new session will clear your current conversation history.",
|
||||||
"aiAssistant.newSessionModal.question": "Are you sure you want to start a new session?",
|
"aiAssistant.newSessionModal.question": "Are you sure you want to start a new session?",
|
||||||
"aiAssistant.newSessionModal.confirm": "Start new session",
|
"aiAssistant.newSessionModal.confirm": "Start new session",
|
||||||
"aiAssistant.serviceError.message": "Unable to connect to n8n's AI service",
|
"aiAssistant.serviceError.message": "Unable to connect to n8n's AI service ({message})",
|
||||||
|
"aiAssistant.payloadTooBig.message": "Payload size is too large",
|
||||||
"aiAssistant.codeUpdated.message.title": "Assistant modified workflow",
|
"aiAssistant.codeUpdated.message.title": "Assistant modified workflow",
|
||||||
"aiAssistant.codeUpdated.message.body1": "Open the",
|
"aiAssistant.codeUpdated.message.body1": "Open the",
|
||||||
"aiAssistant.codeUpdated.message.body2": "node to see the changes",
|
"aiAssistant.codeUpdated.message.body2": "node to see the changes",
|
||||||
|
|
|
@ -283,7 +283,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
stopStreaming();
|
stopStreaming();
|
||||||
assistantThinkingMessage.value = undefined;
|
assistantThinkingMessage.value = undefined;
|
||||||
addAssistantError(
|
addAssistantError(
|
||||||
`${locale.baseText('aiAssistant.serviceError.message')}: (${e.message})`,
|
locale.baseText('aiAssistant.serviceError.message', { interpolate: { message: e.message } }),
|
||||||
id,
|
id,
|
||||||
retry,
|
retry,
|
||||||
);
|
);
|
||||||
|
@ -487,10 +487,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
openChat();
|
openChat();
|
||||||
|
|
||||||
streaming.value = true;
|
streaming.value = true;
|
||||||
chatWithAssistant(
|
const payload: ChatRequest.RequestPayload['payload'] = {
|
||||||
rootStore.restApiContext,
|
|
||||||
{
|
|
||||||
payload: {
|
|
||||||
role: 'user',
|
role: 'user',
|
||||||
type: 'init-error-helper',
|
type: 'init-error-helper',
|
||||||
user: {
|
user: {
|
||||||
|
@ -504,7 +501,11 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
nodeInputData,
|
nodeInputData,
|
||||||
executionSchema: schemas,
|
executionSchema: schemas,
|
||||||
authType,
|
authType,
|
||||||
},
|
};
|
||||||
|
chatWithAssistant(
|
||||||
|
rootStore.restApiContext,
|
||||||
|
{
|
||||||
|
payload,
|
||||||
},
|
},
|
||||||
(msg) => onEachStreamingMessage(msg, id),
|
(msg) => onEachStreamingMessage(msg, id),
|
||||||
() => onDoneStreaming(id),
|
() => onDoneStreaming(id),
|
||||||
|
|
|
@ -58,7 +58,7 @@ export namespace ChatRequest {
|
||||||
user: {
|
user: {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
};
|
};
|
||||||
context?: UserContext;
|
context?: UserContext & WorkflowContext;
|
||||||
workflowContext?: WorkflowContext;
|
workflowContext?: WorkflowContext;
|
||||||
question: string;
|
question: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { STREAM_SEPERATOR, streamRequest } from './apiUtils';
|
import { ResponseError, STREAM_SEPERATOR, streamRequest } from './apiUtils';
|
||||||
|
|
||||||
describe('streamRequest', () => {
|
describe('streamRequest', () => {
|
||||||
it('should stream data from the API endpoint', async () => {
|
it('should stream data from the API endpoint', async () => {
|
||||||
|
@ -54,6 +54,54 @@ describe('streamRequest', () => {
|
||||||
expect(onErrorMock).not.toHaveBeenCalled();
|
expect(onErrorMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should stream error response from the API endpoint', async () => {
|
||||||
|
const testError = { code: 500, message: 'Error happened' };
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const mockResponse = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode(JSON.stringify(testError)));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
body: mockResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
const onChunkMock = vi.fn();
|
||||||
|
const onDoneMock = vi.fn();
|
||||||
|
const onErrorMock = vi.fn();
|
||||||
|
|
||||||
|
await streamRequest(
|
||||||
|
{
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
pushRef: '',
|
||||||
|
},
|
||||||
|
'/data',
|
||||||
|
{ key: 'value' },
|
||||||
|
onChunkMock,
|
||||||
|
onDoneMock,
|
||||||
|
onErrorMock,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith('https://api.example.com/data', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ key: 'value' }),
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'browser-id': expect.stringContaining('-'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onChunkMock).not.toHaveBeenCalled();
|
||||||
|
expect(onErrorMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onErrorMock).toHaveBeenCalledWith(new ResponseError(testError.message));
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle broken stream data', async () => {
|
it('should handle broken stream data', async () => {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const mockResponse = new ReadableStream({
|
const mockResponse = new ReadableStream({
|
||||||
|
|
|
@ -198,7 +198,7 @@ export function unflattenExecutionData(fullExecutionData: IExecutionFlattedRespo
|
||||||
return returnData;
|
return returnData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function streamRequest<T>(
|
export async function streamRequest<T extends object>(
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
apiEndpoint: string,
|
apiEndpoint: string,
|
||||||
payload: object,
|
payload: object,
|
||||||
|
@ -220,7 +220,7 @@ export async function streamRequest<T>(
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${context.baseUrl}${apiEndpoint}`, assistantRequest);
|
const response = await fetch(`${context.baseUrl}${apiEndpoint}`, assistantRequest);
|
||||||
|
|
||||||
if (response.ok && response.body) {
|
if (response.body) {
|
||||||
// Handle the streaming response
|
// Handle the streaming response
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder('utf-8');
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
@ -252,7 +252,18 @@ export async function streamRequest<T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (response.ok) {
|
||||||
|
// Call chunk callback if request was successful
|
||||||
onChunk?.(data);
|
onChunk?.(data);
|
||||||
|
} else {
|
||||||
|
// Otherwise, call error callback
|
||||||
|
const message = 'message' in data ? data.message : response.statusText;
|
||||||
|
onError?.(
|
||||||
|
new ResponseError(String(message), {
|
||||||
|
httpStatusCode: response.status,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
onError?.(e);
|
onError?.(e);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { isObjectOrArray, isObject, searchInObject } from '@/utils/objectUtils';
|
import { isObjectOrArray, isObject, searchInObject, getObjectSizeInKB } from '@/utils/objectUtils';
|
||||||
|
|
||||||
const testData = [1, '', true, null, undefined, new Date(), () => {}].map((value) => [
|
const testData = [1, '', true, null, undefined, new Date(), () => {}].map((value) => [
|
||||||
value,
|
value,
|
||||||
|
@ -95,4 +95,63 @@ describe('objectUtils', () => {
|
||||||
assert(searchInObject({ a: ['b', { c: 'd' }] }, 'd'));
|
assert(searchInObject({ a: ['b', { c: 'd' }] }, 'd'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getObjectSizeInKB', () => {
|
||||||
|
// Test null/undefined cases
|
||||||
|
it('returns 0 for null', () => {
|
||||||
|
expect(getObjectSizeInKB(null)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for undefined', () => {
|
||||||
|
expect(getObjectSizeInKB(undefined)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test empty objects/arrays
|
||||||
|
it('returns correct size for empty object', () => {
|
||||||
|
expect(getObjectSizeInKB({})).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct size for empty array', () => {
|
||||||
|
expect(getObjectSizeInKB([])).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test regular cases
|
||||||
|
it('calculates size for simple object correctly', () => {
|
||||||
|
const obj = { name: 'test' };
|
||||||
|
expect(getObjectSizeInKB(obj)).toBe(0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates size for array correctly', () => {
|
||||||
|
const arr = [1, 2, 3];
|
||||||
|
expect(getObjectSizeInKB(arr)).toBe(0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates size for nested object correctly', () => {
|
||||||
|
const obj = {
|
||||||
|
name: 'test',
|
||||||
|
nested: {
|
||||||
|
value: 123,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(getObjectSizeInKB(obj)).toBe(0.04);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test error cases
|
||||||
|
it('throws error for circular reference', () => {
|
||||||
|
type CircularObj = {
|
||||||
|
name: string;
|
||||||
|
self?: CircularObj;
|
||||||
|
};
|
||||||
|
|
||||||
|
const obj: CircularObj = { name: 'test' };
|
||||||
|
obj.self = obj;
|
||||||
|
|
||||||
|
expect(() => getObjectSizeInKB(obj)).toThrow('Failed to calculate object size');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles special characters correctly', () => {
|
||||||
|
const obj = { name: '测试' };
|
||||||
|
expect(getObjectSizeInKB(obj)).toBe(0.02);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -18,3 +18,35 @@ export const searchInObject = (obj: ObjectOrArray, searchString: string): boolea
|
||||||
? searchInObject(entry, searchString)
|
? searchInObject(entry, searchString)
|
||||||
: entry?.toString().toLowerCase().includes(searchString.toLowerCase()),
|
: entry?.toString().toLowerCase().includes(searchString.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the size of a stringified object in KB.
|
||||||
|
* @param {unknown} obj - The object to calculate the size of
|
||||||
|
* @returns {number} The size of the object in KB
|
||||||
|
* @throws {Error} If the object is not serializable
|
||||||
|
*/
|
||||||
|
export const getObjectSizeInKB = (obj: unknown): number => {
|
||||||
|
if (obj === null || obj === undefined) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(typeof obj === 'object' && Object.keys(obj).length === 0) ||
|
||||||
|
(Array.isArray(obj) && obj.length === 0)
|
||||||
|
) {
|
||||||
|
// "{}" and "[]" both take 2 bytes in UTF-8
|
||||||
|
return Number((2 / 1024).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const str = JSON.stringify(obj);
|
||||||
|
// Using TextEncoder to get actual UTF-8 byte length (what we see in chrome dev tools)
|
||||||
|
const bytes = new TextEncoder().encode(str).length;
|
||||||
|
const kb = bytes / 1024;
|
||||||
|
return Number(kb.toFixed(2));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to calculate object size: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in a new issue