Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(editor): Send workflow context to assistant store #11135

Merged
merged 14 commits into from
Oct 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cypress/composables/ndv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export function setCredentialByName(name: string) {

export function clickCreateNewCredential() {
openCredentialSelect();
getCreateNewCredentialOption().click();
getCreateNewCredentialOption().click({ force: true });
}

export function clickGetBackToCanvas() {
Expand Down
115 changes: 92 additions & 23 deletions cypress/e2e/45-ai-assistant.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@ describe('AI Assistant::enabled', () => {
it('should start chat session from node error view', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Stop and Error');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
Expand All @@ -98,9 +98,9 @@ describe('AI Assistant::enabled', () => {
it('should render chat input correctly', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Stop and Error');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
Expand Down Expand Up @@ -131,9 +131,9 @@ describe('AI Assistant::enabled', () => {
it('should render and handle quick replies', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/quick_reply_message_response.json',
fixture: 'aiAssistant/responses/quick_reply_message_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Stop and Error');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
Expand All @@ -149,15 +149,21 @@ describe('AI Assistant::enabled', () => {
cy.intercept('POST', '/rest/ai/chat', (req) => {
req.reply((res) => {
if (['init-error-helper', 'message'].includes(req.body.payload.type)) {
res.send({ statusCode: 200, fixture: 'aiAssistant/simple_message_response.json' });
res.send({
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
});
} else if (req.body.payload.type === 'event') {
res.send({ statusCode: 200, fixture: 'aiAssistant/node_execution_error_response.json' });
res.send({
statusCode: 200,
fixture: 'aiAssistant/responses/node_execution_error_response.json',
});
} else {
res.send({ statusCode: 500 });
}
});
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Edit Fields');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
Expand All @@ -172,16 +178,15 @@ describe('AI Assistant::enabled', () => {
aiAssistant.getters.quickReplies().should('not.exist');
ndv.getters.nodeExecuteButton().click();
// But after executing the node again, quick replies should be shown
aiAssistant.getters.chatMessagesAssistant().should('have.length', 4);
aiAssistant.getters.quickReplies().should('have.length', 2);
});

it('should warn before starting a new session', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Edit Fields');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click({ force: true });
Expand All @@ -206,13 +211,13 @@ describe('AI Assistant::enabled', () => {
it('should apply code diff to code node', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/code_diff_suggestion_response.json',
fixture: 'aiAssistant/responses/code_diff_suggestion_response.json',
}).as('chatRequest');
cy.intercept('POST', '/rest/ai/chat/apply-suggestion', {
statusCode: 200,
fixture: 'aiAssistant/apply_code_diff_response.json',
fixture: 'aiAssistant/responses/apply_code_diff_response.json',
}).as('applySuggestion');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Code');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click({ force: true });
Expand Down Expand Up @@ -256,9 +261,9 @@ describe('AI Assistant::enabled', () => {
it('should end chat session when `end_session` event is received', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/end_session_response.json',
fixture: 'aiAssistant/responses/end_session_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Stop and Error');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
Expand All @@ -271,9 +276,12 @@ describe('AI Assistant::enabled', () => {
cy.intercept('POST', '/rest/ai/chat', (req) => {
req.reply((res) => {
if (['init-support-chat'].includes(req.body.payload.type)) {
res.send({ statusCode: 200, fixture: 'aiAssistant/simple_message_response.json' });
res.send({
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
});
} else {
res.send({ statusCode: 200, fixture: 'aiAssistant/end_session_response.json' });
res.send({ statusCode: 200, fixture: 'aiAssistant/responses/end_session_response.json' });
}
});
}).as('chatRequest');
Expand All @@ -298,7 +306,7 @@ describe('AI Assistant::enabled', () => {
it('Should not reset assistant session when workflow is saved', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
wf.actions.addInitialNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
aiAssistant.actions.openChat();
Expand All @@ -323,7 +331,7 @@ describe('AI Assistant Credential Help', () => {
it('should start credential help from node credential', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
wf.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
wf.actions.addNodeToCanvas(GMAIL_NODE_NAME);
Expand All @@ -349,7 +357,7 @@ describe('AI Assistant Credential Help', () => {
it('should start credential help from credential list', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');

cy.visit(credentialsPage.url);
Expand Down Expand Up @@ -448,7 +456,7 @@ describe('General help', () => {
it('assistant returns code snippet', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/code_snippet_response.json',
fixture: 'aiAssistant/responses/code_snippet_response.json',
}).as('chatRequest');

aiAssistant.getters.askAssistantFloatingButton().should('be.visible');
Expand Down Expand Up @@ -492,4 +500,65 @@ describe('General help', () => {
);
aiAssistant.getters.codeSnippet().should('have.text', '{{$json.body.city}}');
});

it('should send current context to support chat', () => {
cy.createFixtureWorkflow('aiAssistant/workflows/simple_http_request_workflow.json');
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');

aiAssistant.getters.askAssistantFloatingButton().click();
aiAssistant.actions.sendMessage('What is wrong with this workflow?');

cy.wait('@chatRequest').then((interception) => {
const { body } = interception.request;
// Body should contain the current workflow context
expect(body.payload).to.have.property('context');
expect(body.payload.context).to.have.property('currentView');
expect(body.payload.context.currentView.name).to.equal('NodeViewExisting');
expect(body.payload.context).to.have.property('currentWorkflow');
});
});

it('should not send workflow context if nothing changed', () => {
cy.createFixtureWorkflow('aiAssistant/workflows/simple_http_request_workflow.json');
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');

aiAssistant.getters.askAssistantFloatingButton().click();
aiAssistant.actions.sendMessage('What is wrong with this workflow?');
cy.wait('@chatRequest');

// Send another message without changing workflow or executing any node
aiAssistant.actions.sendMessage('And now?');

cy.wait('@chatRequest').then((interception) => {
const { body } = interception.request;
// Workflow context should be empty
expect(body.payload).to.have.property('context');
expect(body.payload.context).not.to.have.property('currentWorkflow');
});

// Update http request node url
wf.actions.openNode('HTTP Request');
ndv.actions.typeIntoParameterInput('url', 'https://example.com');
ndv.actions.close();
// Also execute the workflow
wf.actions.executeWorkflow();

// Send another message
aiAssistant.actions.sendMessage('What about now?');
cy.wait('@chatRequest').then((interception) => {
const { body } = interception.request;
// Both workflow and execution context should be sent
expect(body.payload).to.have.property('context');
expect(body.payload.context).to.have.property('currentWorkflow');
expect(body.payload.context.currentWorkflow).not.to.be.empty;
expect(body.payload.context).to.have.property('executionData');
expect(body.payload.context.executionData).not.to.be.empty;
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"nodes": [
{
"parameters": {},
"id": "298d3dc9-5e99-4b3f-919e-05fdcdfbe2d0",
"name": "When clicking ‘Test workflow’",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [360, 220]
},
{
"parameters": {
"options": {}
},
"id": "65c32346-e939-4ec7-88a9-1f9184e2258d",
"name": "HTTP Request",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [580, 220]
}
],
"connections": {
"When clicking ‘Test workflow’": {
"main": [
[
{
"node": "HTTP Request",
"type": "main",
"index": 0
}
]
]
}
}
}
2 changes: 1 addition & 1 deletion cypress/pages/ndv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export class NDV extends BasePage {
this.getters.nodeExecuteButton().first().click();
},
close: () => {
this.getters.backToCanvas().click();
this.getters.backToCanvas().click({ force: true });
},
openInlineExpressionEditor: () => {
cy.contains('Expression').invoke('show').click();
Expand Down
7 changes: 0 additions & 7 deletions n8n.code-workspace

This file was deleted.

42 changes: 41 additions & 1 deletion packages/editor-ui/src/composables/useAIAssistantHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { IDataObject, NodeApiError, NodeError, NodeOperationError } from 'n8n-workflow';
import type {
IDataObject,
IRunExecutionData,
NodeApiError,
NodeError,
NodeOperationError,
} from 'n8n-workflow';
import { deepCopy, type INode } from 'n8n-workflow';
import { useWorkflowHelpers } from './useWorkflowHelpers';
import { useRouter } from 'vue-router';
Expand Down Expand Up @@ -203,6 +209,39 @@ export const useAIAssistantHelpers = () => {
return undefined;
}
}
/**
* Prepare workflow execution result data for the AI assistant
* by removing data from nodes
**/
function simplifyResultData(
data: IRunExecutionData['resultData'],
): ChatRequest.ExecutionResultData {
const simplifiedResultData: ChatRequest.ExecutionResultData = {
runData: {},
};

// Handle optional error
if (data.error) {
simplifiedResultData.error = data.error;
}
// Map runData, excluding the `data` field from ITaskData
Object.keys(data.runData).forEach((key) => {
const taskDataArray = data.runData[key];
simplifiedResultData.runData[key] = taskDataArray.map((taskData) => {
const { data: taskDataContent, ...taskDataWithoutData } = taskData;
return taskDataWithoutData;
});
});
// Handle lastNodeExecuted if it exists
if (data.lastNodeExecuted) {
simplifiedResultData.lastNodeExecuted = data.lastNodeExecuted;
}
// Handle metadata if it exists
if (data.metadata) {
simplifiedResultData.metadata = data.metadata;
}
return simplifiedResultData;
}

return {
processNodeForAssistant,
Expand All @@ -212,5 +251,6 @@ export const useAIAssistantHelpers = () => {
getNodesSchemas,
getCurrentViewDescription,
getReferencedNodes,
simplifyResultData,
};
};
Loading
Loading