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

This commit is contained in:
Milorad FIlipović 2024-10-10 13:13:30 +02:00 committed by GitHub
parent 8e6ddfe028
commit fade9e43c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 237 additions and 55 deletions

View file

@ -59,7 +59,7 @@ export function setCredentialByName(name: string) {
export function clickCreateNewCredential() {
openCredentialSelect();
getCreateNewCredentialOption().click();
getCreateNewCredentialOption().click({ force: true });
}
export function clickGetBackToCanvas() {

View file

@ -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();
@ -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();
@ -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();
@ -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();
@ -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 });
@ -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 });
@ -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();
@ -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');
@ -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();
@ -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);
@ -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);
@ -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');
@ -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;
});
});
});

View file

@ -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
}
]
]
}
}
}

View file

@ -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();

View file

@ -1,7 +0,0 @@
{
"folders": [
{
"path": "."
}
]
}

View file

@ -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';
@ -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,
@ -212,5 +251,6 @@ export const useAIAssistantHelpers = () => {
getNodesSchemas,
getCurrentViewDescription,
getReferencedNodes,
simplifyResultData,
};
};

View file

@ -83,6 +83,9 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
// We use streaming for assistants that support it, and this for agents
const assistantThinkingMessage = ref<string | undefined>();
const chatSessionTask = ref<'error' | 'support' | 'credentials' | undefined>();
// Indicate if last sent workflow and execution data is stale
const workflowDataStale = ref<boolean>(true);
const workflowExecutionDataStale = ref<boolean>(true);
const isExperimentEnabled = computed(
() => getVariant(AI_ASSISTANT_EXPERIMENT.name) === AI_ASSISTANT_EXPERIMENT.variant,
@ -124,18 +127,6 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
).length,
);
watch(route, () => {
const activeWorkflowId = workflowsStore.workflowId;
if (
!currentSessionId.value ||
currentSessionWorkflowId.value === PLACEHOLDER_EMPTY_WORKFLOW_ID ||
currentSessionWorkflowId.value === activeWorkflowId
) {
return;
}
resetAssistantChat();
});
function resetAssistantChat() {
clearMessages();
currentSessionId.value = undefined;
@ -305,11 +296,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
{ withPostHog: true },
);
// Track first user message in support chat now that we have a session id
if (
usersMessages.value.length === 1 &&
!currentSessionId.value &&
chatSessionTask.value === 'support'
) {
if (usersMessages.value.length === 1 && chatSessionTask.value === 'support') {
const firstUserMessage = usersMessages.value[0] as ChatUI.TextMessage;
trackUserMessage(firstUserMessage.content, false);
}
@ -325,6 +312,8 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
function onDoneStreaming(id: string) {
stopStreaming();
workflowDataStale.value = false;
workflowExecutionDataStale.value = false;
lastUnread.value = chatMessages.value.find(
(msg) =>
msg.id === id && !msg.read && msg.role === 'assistant' && READABLE_TYPES.includes(msg.type),
@ -353,14 +342,16 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
/**
* Gets information about the current view and active node to provide context to the assistant
*/
function getVisualContext(nodeInfo?: ChatRequest.NodeInfo): ChatRequest.UserContext | undefined {
function getVisualContext(
nodeInfo?: ChatRequest.NodeInfo,
): ChatRequest.AssistantContext | undefined {
if (chatSessionTask.value === 'error') {
return undefined;
}
const currentView = route.name as VIEWS;
const activeNode = workflowsStore.activeNode();
const activeNodeForLLM = activeNode
? assistantHelpers.processNodeForAssistant(activeNode, ['position'])
? assistantHelpers.processNodeForAssistant(activeNode, ['position', 'parameters.notice'])
: null;
const activeModals = uiStore.activeModals;
const isCredentialModalActive = activeModals.includes(CREDENTIAL_EDIT_MODAL_KEY);
@ -401,6 +392,11 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
authType: nodeInfo?.authType?.name,
}
: undefined,
currentWorkflow: workflowDataStale.value ? workflowsStore.workflow : undefined,
executionData:
workflowExecutionDataStale.value && executionResult
? assistantHelpers.simplifyResultData(executionResult)
: undefined,
};
}
@ -491,7 +487,10 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
firstName: usersStore.currentUser?.firstName ?? '',
},
error: context.error,
node: assistantHelpers.processNodeForAssistant(context.node, ['position']),
node: assistantHelpers.processNodeForAssistant(context.node, [
'position',
'parameters.notice',
]),
nodeInputData,
executionSchema: schemas,
authType,
@ -577,7 +576,10 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
) {
nodeExecutionStatus.value = 'not_executed';
}
const userContext = getVisualContext();
const activeNode = workflowsStore.activeNode() as INode;
const nodeInfo = assistantHelpers.getNodeInfoForAssistant(activeNode);
const userContext = getVisualContext(nodeInfo);
chatWithAssistant(
rootStore.restApiContext,
{
@ -764,6 +766,33 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
}
}
watch(route, () => {
const activeWorkflowId = workflowsStore.workflowId;
if (
!currentSessionId.value ||
currentSessionWorkflowId.value === PLACEHOLDER_EMPTY_WORKFLOW_ID ||
currentSessionWorkflowId.value === activeWorkflowId
) {
return;
}
resetAssistantChat();
});
watch(
() => uiStore.stateIsDirty,
() => {
workflowDataStale.value = true;
},
);
watch(
() => workflowsStore.workflowExecutionData?.data?.resultData ?? {},
() => {
workflowExecutionDataStale.value = true;
},
{ deep: true, immediate: true },
);
return {
isAssistantEnabled,
canShowAssistantButtonsOnCanvas,

View file

@ -1,11 +1,14 @@
import type { VIEWS } from '@/constants';
import type { NodeAuthenticationOption, Schema } from '@/Interface';
import type { IWorkflowDb, NodeAuthenticationOption, Schema } from '@/Interface';
import type {
ExecutionError,
ICredentialType,
IDataObject,
INode,
INodeIssues,
INodeParameters,
IRunExecutionData,
ITaskData,
} from 'n8n-workflow';
export namespace ChatRequest {
@ -16,6 +19,15 @@ export namespace ChatRequest {
export interface WorkflowContext {
executionSchema?: NodeExecutionSchema[];
currentWorkflow?: IWorkflowDb;
executionData?: IRunExecutionData['resultData'];
}
export interface ExecutionResultData {
error?: ExecutionError;
runData: Record<string, Array<Omit<ITaskData, 'data'>>>;
lastNodeExecuted?: string;
metadata?: Record<string, string>;
}
export interface ErrorContext {
@ -47,6 +59,7 @@ export namespace ChatRequest {
firstName: string;
};
context?: UserContext;
workflowContext?: WorkflowContext;
question: string;
}
@ -78,6 +91,7 @@ export namespace ChatRequest {
text: string;
quickReplyType?: string;
context?: UserContext;
workflowContext?: WorkflowContext;
}
export interface UserContext {
@ -98,6 +112,8 @@ export namespace ChatRequest {
};
}
export type AssistantContext = UserContext & WorkflowContext;
export type RequestPayload =
| {
payload: InitErrorHelper | InitSupportChat | InitCredHelp;