feat(editor): Add AI Assistant support chat (#10656)

This commit is contained in:
Milorad FIlipović 2024-09-05 10:54:35 +02:00 committed by GitHub
parent 899b0a19ef
commit 3a8078068e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 223 additions and 79 deletions

View file

@ -31,7 +31,8 @@ describe('AI Assistant::enabled', () => {
aiAssistant.getters.askAssistantFloatingButton().click();
aiAssistant.getters.askAssistantChat().should('be.visible');
aiAssistant.getters.placeholderMessage().should('be.visible');
aiAssistant.getters.chatInputWrapper().should('not.exist');
aiAssistant.getters.chatInput().should('be.visible');
aiAssistant.getters.sendMessageButton().should('be.disabled');
aiAssistant.getters.closeChatButton().should('be.visible');
aiAssistant.getters.closeChatButton().click();
aiAssistant.getters.askAssistantChat().should('not.be.visible');
@ -137,29 +138,6 @@ describe('AI Assistant::enabled', () => {
aiAssistant.getters.chatMessagesUser().eq(0).should('contain.text', "Sure, let's do it");
});
it('should send message to assistant when node is executed only once', () => {
const TOTAL_REQUEST_COUNT = 1;
cy.intercept('POST', '/rest/ai-assistant/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
wf.actions.openNode('Edit Fields');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
cy.wait('@chatRequest');
aiAssistant.getters.chatMessagesAssistant().should('have.length', 1);
cy.get('@chatRequest.all').then((interceptions) => {
expect(interceptions).to.have.length(TOTAL_REQUEST_COUNT);
});
// Executing the same node should not send a new message if users haven't responded to quick replies
ndv.getters.nodeExecuteButton().click();
cy.get('@chatRequest.all').then((interceptions) => {
expect(interceptions).to.have.length(TOTAL_REQUEST_COUNT);
});
aiAssistant.getters.chatMessagesAssistant().should('have.length', 2);
});
it('should show quick replies when node is executed after new suggestion', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', (req) => {
req.reply((res) => {
@ -281,4 +259,32 @@ describe('AI Assistant::enabled', () => {
aiAssistant.getters.chatMessagesSystem().should('have.length', 1);
aiAssistant.getters.chatMessagesSystem().first().should('contain.text', 'session has ended');
});
it('should reset session after it ended and sidebar is closed', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', (req) => {
req.reply((res) => {
if (['init-support-chat'].includes(req.body.payload.type)) {
res.send({ statusCode: 200, fixture: 'aiAssistant/simple_message_response.json' });
} else {
res.send({ statusCode: 200, fixture: 'aiAssistant/end_session_response.json' });
}
});
}).as('chatRequest');
aiAssistant.actions.openChat();
aiAssistant.actions.sendMessage('Hello');
cy.wait('@chatRequest');
aiAssistant.actions.closeChat();
aiAssistant.actions.openChat();
// After closing and reopening the chat, all messages should be still there
aiAssistant.getters.chatMessagesAll().should('have.length', 2);
// End the session
aiAssistant.actions.sendMessage('Thanks, bye');
cy.wait('@chatRequest');
aiAssistant.getters.chatMessagesSystem().should('have.length', 1);
aiAssistant.getters.chatMessagesSystem().first().should('contain.text', 'session has ended');
aiAssistant.actions.closeChat();
aiAssistant.actions.openChat();
// Now, session should be reset
aiAssistant.getters.placeholderMessage().should('be.visible');
});
});

View file

@ -1,9 +1,9 @@
{
"sessionId": "f9130bd7-c078-4862-a38a-369b27b0ff20-e96eb9f7-d581-4684-b6a9-fd3dfe9fe1fb-XCldJLlusGrEVku5I9cYT",
"sessionId": "1",
"messages": [
{
"role": "assistant",
"type": "agent-suggestion",
"type": "message",
"title": "Glad to Help",
"text": "I'm glad I could help. If you have any more questions or need further assistance with your n8n workflows, feel free to ask!"
},

View file

@ -38,13 +38,24 @@ export class AIAssistant extends BasePage {
};
actions = {
enableAssistant(): void {
enableAssistant: () => {
overrideFeatureFlag(AI_ASSISTANT_FEATURE.experimentName, AI_ASSISTANT_FEATURE.enabledFor);
cy.enableFeature(AI_ASSISTANT_FEATURE.name);
},
disableAssistant(): void {
disableAssistant: () => {
overrideFeatureFlag(AI_ASSISTANT_FEATURE.experimentName, AI_ASSISTANT_FEATURE.disabledFor);
cy.disableFeature(AI_ASSISTANT_FEATURE.name);
},
sendMessage: (message: string) => {
this.getters.chatInput().type(message).type('{enter}');
},
closeChat: () => {
this.getters.closeChatButton().click();
this.getters.askAssistantChat().should('not.be.visible');
},
openChat: () => {
this.getters.askAssistantFloatingButton().click();
this.getters.askAssistantChat().should('be.visible');
},
};
}

View file

@ -247,3 +247,23 @@ AssistantThinkingChat.args = {
},
loadingMessage: 'Thinking...',
};
export const WithCodeSnippet = Template.bind({});
WithCodeSnippet.args = {
user: {
firstName: 'Max',
lastName: 'Test',
},
messages: getMessages([
{
id: '58575953',
type: 'text',
role: 'assistant',
content:
'To filter every other item in the Code node, you can use the following JavaScript code snippet. This code will iterate through the incoming items and only pass through every other item.',
codeSnippet:
"node.on('input', function(msg) {\n if (msg.seed) { dummyjson.seed = msg.seed; }\n try {\n var value = dummyjson.parse(node.template, {mockdata: msg});\n if (node.syntax === 'json') {\n try { value = JSON.parse(value); }\n catch(e) { node.error(RED._('datagen.errors.json-error')); }\n }\n if (node.fieldType === 'msg') {\n RED.util.setMessageProperty(msg,node.field,value);\n }\n else if (node.fieldType === 'flow') {\n node.context().flow.set(node.field,value);\n }\n else if (node.fieldType === 'global') {\n node.context().global.set(node.field,value);\n }\n node.send(msg);\n }\n catch(e) {",
read: true,
},
]),
};

View file

@ -163,11 +163,16 @@ function growInput() {
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-if="message.role === 'user'" v-html="renderMarkdown(message.content)"></span>
<!-- eslint-disable-next-line vue/no-v-html -->
<span
<div
v-else
:class="$style.assistantText"
v-html="renderMarkdown(message.content)"
></span>
></div>
<div
v-if="message?.codeSnippet"
:class="$style['code-snippet']"
v-html="renderMarkdown(message.codeSnippet).trim()"
></div>
<BlinkingCursor
v-if="streaming && i === messages?.length - 1 && message.role === 'assistant'"
/>
@ -243,20 +248,16 @@ function growInput() {
</p>
<p>
{{ t('assistantChat.placeholder.2') }}
</p>
<p>
{{ t('assistantChat.placeholder.3') }}
<InlineAskAssistantButton size="small" :static="true" />
{{ t('assistantChat.placeholder.4') }}
{{ t('assistantChat.placeholder.3') }}
</p>
<p>
{{ t('assistantChat.placeholder.5') }}
{{ t('assistantChat.placeholder.4') }}
</p>
</div>
</div>
</div>
<div
v-if="messages?.length"
:class="{ [$style.inputWrapper]: true, [$style.disabledInput]: sessionEnded }"
data-test-id="chat-input-wrapper"
>
@ -407,8 +408,29 @@ p {
.textMessage {
display: flex;
align-items: center;
flex-direction: column;
gap: var(--spacing-xs);
font-size: var(--font-size-2xs);
word-break: break-word;
}
.code-snippet {
border: var(--border-base);
background-color: var(--color-foreground-xlight);
border-radius: var(--border-radius-base);
padding: var(--spacing-2xs);
font-family: var(--font-family-monospace);
max-height: 218px; // 12 lines
overflow: auto;
pre {
white-space-collapse: collapse;
}
code {
background-color: transparent;
font-size: var(--font-size-3xs);
}
}
.block {

View file

@ -132,7 +132,7 @@ exports[`AskAssistantChat > renders chat with messages correctly 1`] = `
<!-- eslint-disable-next-line vue/no-v-html -->
<!-- eslint-disable-next-line vue/no-v-html -->
<span
<div
class="assistantText"
>
<p>
@ -144,9 +144,10 @@ exports[`AskAssistantChat > renders chat with messages correctly 1`] = `
</p>
</span>
</div>
<!--v-if-->
<!--v-if-->
</div>
<!--v-if-->
</div>
@ -449,6 +450,7 @@ exports[`AskAssistantChat > renders chat with messages correctly 1`] = `
</span>
<!--v-if-->
<!--v-if-->
</div>
<!--v-if-->
</div>
@ -842,13 +844,10 @@ exports[`AskAssistantChat > renders default placeholder chat correctly 1`] = `
class="info"
>
<p>
I'm your Assistant, here to guide you through your journey with n8n.
I can answer most questions about building workflows in n8n.
</p>
<p>
While I'm still learning, I'm already equipped to help you debug any errors you might encounter.
</p>
<p>
If you run into an issue with a node, you'll see the
For specific tasks, youll see the
<button
class="button"
style="height: 18px;"
@ -901,15 +900,33 @@ exports[`AskAssistantChat > renders default placeholder chat correctly 1`] = `
</div>
</div>
</button>
button
button in the UI.
</p>
<p>
Clicking it will start a chat with me, and I'll do my best to assist you!
How can I help?
</p>
</div>
</div>
</div>
<!--v-if-->
<div
class="inputWrapper"
data-test-id="chat-input-wrapper"
>
<textarea
data-test-id="chat-input"
placeholder="Enter your response..."
rows="1"
wrap="hard"
/>
<n8n-icon-button
class="sendButton"
data-test-id="send-message-button"
disabled="true"
icon="paper-plane"
size="large"
type="text"
/>
</div>
</div>
</div>
`;
@ -1046,7 +1063,7 @@ exports[`AskAssistantChat > renders end of session chat correctly 1`] = `
<!-- eslint-disable-next-line vue/no-v-html -->
<!-- eslint-disable-next-line vue/no-v-html -->
<span
<div
class="assistantText"
>
<p>
@ -1058,9 +1075,10 @@ exports[`AskAssistantChat > renders end of session chat correctly 1`] = `
</p>
</span>
</div>
<!--v-if-->
<!--v-if-->
</div>
<!--v-if-->
</div>
@ -1295,7 +1313,7 @@ exports[`AskAssistantChat > renders streaming chat correctly 1`] = `
<!-- eslint-disable-next-line vue/no-v-html -->
<!-- eslint-disable-next-line vue/no-v-html -->
<span
<div
class="assistantText"
>
<p>
@ -1307,9 +1325,10 @@ exports[`AskAssistantChat > renders streaming chat correctly 1`] = `
</p>
</span>
</div>
<!--v-if-->
<!--v-if-->
</div>
<!--v-if-->
</div>

View file

@ -39,13 +39,10 @@ export default {
'assistantChat.you': 'You',
'assistantChat.quickRepliesTitle': 'Quick reply 👇',
'assistantChat.placeholder.1': () =>
"I'm your Assistant, here to guide you through your journey with n8n.",
'assistantChat.placeholder.2':
"While I'm still learning, I'm already equipped to help you debug any errors you might encounter.",
'assistantChat.placeholder.3': "If you run into an issue with a node, you'll see the",
'assistantChat.placeholder.4': 'button',
'assistantChat.placeholder.5':
"Clicking it will start a chat with me, and I'll do my best to assist you!",
'I can answer most questions about building workflows in n8n.',
'assistantChat.placeholder.2': 'For specific tasks, youll see the',
'assistantChat.placeholder.3': 'button in the UI.',
'assistantChat.placeholder.4': 'How can I help?',
'assistantChat.inputPlaceholder': 'Enter your response...',
'inlineAskAssistantButton.asked': 'Asked',
} as N8nLocale;

View file

@ -3,6 +3,7 @@ export namespace ChatUI {
role: 'assistant' | 'user';
type: 'text';
content: string;
codeSnippet?: string;
}
export interface SummaryBlock {

View file

@ -27,14 +27,16 @@ function onResizeDebounced(data: { direction: string; x: number; width: number }
}
async function onUserMessage(content: string, quickReplyType?: string, isFeedback = false) {
await assistantStore.sendMessage({ text: content, quickReplyType });
const task = 'error';
const solutionCount =
task === 'error'
? assistantStore.chatMessages.filter(
(msg) => msg.role === 'assistant' && !['text', 'event'].includes(msg.type),
).length
: null;
// If there is no current session running, initialize the support chat session
if (!assistantStore.currentSessionId) {
await assistantStore.initSupportChat(content);
} else {
await assistantStore.sendMessage({ text: content, quickReplyType });
}
const task = assistantStore.isSupportChatSessionInProgress ? 'support' : 'error';
const solutionCount = assistantStore.chatMessages.filter(
(msg) => msg.role === 'assistant' && !['text', 'event'].includes(msg.type),
).length;
if (isFeedback) {
telemetry.track('User gave feedback', {
task,

View file

@ -143,7 +143,7 @@ defineExpose({
<style lang="scss" module>
.floatingNodes {
position: fixed;
position: absolute;
bottom: 0;
top: 0;
right: 0;

View file

@ -79,9 +79,13 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
ENABLED_VIEWS.includes(route.name as VIEWS),
);
const assistantMessages = computed(() =>
chatMessages.value.filter((msg) => msg.role === 'assistant'),
);
const usersMessages = computed(() => chatMessages.value.filter((msg) => msg.role === 'user'));
const isSessionEnded = computed(() => {
const assistantMessages = chatMessages.value.filter((msg) => msg.role === 'assistant');
const lastAssistantMessage = assistantMessages[assistantMessages.length - 1];
const lastAssistantMessage = assistantMessages.value[assistantMessages.value.length - 1];
const sessionExplicitlyEnded =
lastAssistantMessage?.type === 'event' && lastAssistantMessage?.eventName === 'end-session';
@ -106,6 +110,10 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
).length,
);
const isSupportChatSessionInProgress = computed(() => {
return currentSessionId.value !== undefined && chatSessionError.value === undefined;
});
watch(route, () => {
const activeWorkflowId = workflowsStore.workflowId;
if (!currentSessionId.value || currentSessionWorkflowId.value === activeWorkflowId) {
@ -137,17 +145,21 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
// Looks smoother if we wait for slide animation to finish before updating the grid width
setTimeout(() => {
uiStore.appGridWidth = window.innerWidth;
// If session has ended, reset the chat
if (isSessionEnded.value) {
resetAssistantChat();
}
}, 200);
}
function addAssistantMessages(assistantMessages: ChatRequest.MessageResponse[], id: string) {
function addAssistantMessages(newMessages: ChatRequest.MessageResponse[], id: string) {
const read = chatWindowOpen.value;
const messages = [...chatMessages.value].filter(
(msg) => !(msg.id === id && msg.role === 'assistant'),
);
assistantThinkingMessage.value = undefined;
// TODO: simplify
assistantMessages.forEach((msg) => {
newMessages.forEach((msg) => {
if (msg.type === 'message') {
messages.push({
id,
@ -155,6 +167,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
role: 'assistant',
content: msg.text,
quickReplies: msg.quickReplies,
codeSnippet: msg.codeSnippet,
read,
});
} else if (msg.type === 'code-diff') {
@ -262,10 +275,15 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
'Assistant session started',
{
chat_session_id: currentSessionId.value,
task: 'error',
task: isSupportChatSessionInProgress.value ? 'support' : 'error',
},
{ withPostHog: true },
);
// Track first user message in support chat now that we have a session id
if (usersMessages.value.length === 1 && isSupportChatSessionInProgress.value) {
const firstUserMessage = usersMessages.value[0] as ChatUI.TextMessage;
trackUserMessage(firstUserMessage.content, false);
}
} else if (currentSessionId.value !== response.sessionId) {
return;
}
@ -289,6 +307,33 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
}, 4000);
}
async function initSupportChat(userMessage: string) {
const id = getRandomId();
resetAssistantChat();
chatSessionError.value = undefined;
currentSessionActiveExecutionId.value = undefined;
currentSessionWorkflowId.value = workflowsStore.workflowId;
addUserMessage(userMessage, id);
addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.thinking'));
streaming.value = true;
chatWithAssistant(
rootStore.restApiContext,
{
payload: {
role: 'user',
type: 'init-support-chat',
user: {
firstName: usersStore.currentUser?.firstName ?? '',
},
question: userMessage,
},
},
(msg) => onEachStreamingMessage(msg, id),
() => onDoneStreaming(id),
(e) => handleServiceError(e, id),
);
}
async function initErrorHelper(context: ChatRequest.ErrorContext) {
const id = getRandomId();
if (chatSessionError.value) {
@ -433,18 +478,26 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
() => onDoneStreaming(id),
(e) => handleServiceError(e, id),
);
telemetry.track('User sent message in Assistant', {
message: chatMessage.text,
is_quick_reply: !!chatMessage.quickReplyType,
chat_session_id: currentSessionId.value,
message_number: chatMessages.value.filter((msg) => msg.role === 'user').length,
});
trackUserMessage(chatMessage.text, !!chatMessage.quickReplyType);
} catch (e: unknown) {
// in case of assert
handleServiceError(e, id);
}
}
function trackUserMessage(message: string, isQuickReply: boolean) {
if (!currentSessionId.value) {
return;
}
telemetry.track('User sent message in Assistant', {
message,
is_quick_reply: isQuickReply,
chat_session_id: currentSessionId.value,
message_number: usersMessages.value.length,
task: isSupportChatSessionInProgress.value ? 'support' : 'error',
});
}
function updateParameters(nodeName: string, parameters: INodeParameters) {
if (ndvStore.activeNodeName === nodeName) {
Object.keys(parameters).forEach((key) => {
@ -584,6 +637,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
updateWindowWidth,
isNodeErrorActive,
initErrorHelper,
initSupportChat,
sendMessage,
applyCodeDiff,
undoCodeDiff,
@ -591,5 +645,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
chatWindowOpen,
addAssistantMessages,
assistantThinkingMessage,
chatSessionError,
isSupportChatSessionInProgress,
};
});

View file

@ -32,6 +32,15 @@ export namespace ChatRequest {
authType?: { name: string; value: string };
}
export interface InitSupportChat {
role: 'user';
type: 'init-support-chat';
user: {
firstName: string;
};
question: string;
}
export type InteractionEventName = 'node-execution-succeeded' | 'node-execution-errored';
interface EventRequestPayload {
@ -50,7 +59,7 @@ export namespace ChatRequest {
export type RequestPayload =
| {
payload: InitErrorHelper;
payload: InitErrorHelper | InitSupportChat;
}
| {
payload: EventRequestPayload | UserChatMessage;
@ -77,6 +86,7 @@ export namespace ChatRequest {
type: 'message';
text: string;
step?: 'n8n_documentation' | 'n8n_forum';
codeSnippet?: string;
}
interface AssistantSummaryMessage {