feat(editor): Implement AI Assistant chat UI (#9300)

This commit is contained in:
Milorad FIlipović
2024-05-07 15:43:19 +02:00
committed by GitHub
parent 23b676d7cb
commit 491c6ec546
28 changed files with 948 additions and 193 deletions

View File

@@ -0,0 +1,227 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { useUsersStore } from '@/stores/users.store';
import ChatComponent from '@n8n/chat/components/Chat.vue';
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
import type { Chat, ChatMessage, ChatOptions } from '@n8n/chat/types';
import type { Ref } from 'vue';
import { computed, provide, ref } from 'vue';
import QuickReplies from './QuickReplies.vue';
import { DateTime } from 'luxon';
import { useAIStore } from '@/stores/ai.store';
import { chatEventBus } from '@n8n/chat/event-buses';
import { onMounted } from 'vue';
import {
AI_ASSISTANT_EXPERIMENT_URLS,
AI_ASSISTANT_LOCAL_STORAGE_KEY,
MODAL_CONFIRM,
} from '@/constants';
import { useStorage } from '@/composables/useStorage';
import { useMessage } from '@/composables/useMessage';
import { useTelemetry } from '@/composables/useTelemetry';
import { onBeforeUnmount } from 'vue';
const locale = useI18n();
const telemetry = useTelemetry();
const { confirm } = useMessage();
const usersStore = useUsersStore();
const aiStore = useAIStore();
const messages: Ref<ChatMessage[]> = ref([]);
const waitingForResponse = ref(false);
const currentSessionId = ref<string>(String(Date.now()));
const disableChat = ref(false);
const userName = computed(() => usersStore.currentUser?.firstName ?? 'there');
const latestConnectionInfo = computed(() => aiStore.latestConnectionInfo);
const chatTitle = locale.baseText('aiAssistantChat.title');
const nowMilliseconds = () => String(DateTime.now().toMillis());
const nowIsoString = () => new Date().toISOString();
const thanksResponses: ChatMessage[] = [
{
id: nowMilliseconds(),
sender: 'bot',
text: locale.baseText('aiAssistantChat.response.message1'),
createdAt: nowIsoString(),
},
{
id: nowMilliseconds(),
sender: 'bot',
text: locale.baseText('aiAssistantChat.response.message2'),
createdAt: nowIsoString(),
},
{
id: nowMilliseconds(),
sender: 'bot',
text: '🙏',
createdAt: new Date().toISOString(),
},
{
id: nowMilliseconds(),
type: 'component',
key: 'QuickReplies',
sender: 'user',
createdAt: nowIsoString(),
transparent: true,
arguments: {
suggestions: [
{ label: locale.baseText('aiAssistantChat.response.quickReply.close'), key: 'close' },
{
label: locale.baseText('aiAssistantChat.response.quickReply.giveFeedback'),
key: 'give_feedback',
},
{
label: locale.baseText('aiAssistantChat.response.quickReply.signUp'),
key: 'sign_up',
},
],
onReplySelected: ({ key }: { key: string; label: string }) => {
switch (key) {
case 'give_feedback':
window.open(AI_ASSISTANT_EXPERIMENT_URLS.FEEDBACK_FORM, '_blank');
break;
case 'sign_up':
window.open(AI_ASSISTANT_EXPERIMENT_URLS.SIGN_UP, '_blank');
break;
}
aiStore.assistantChatOpen = false;
},
},
},
];
const initialMessageText = computed(() => {
if (latestConnectionInfo.value) {
return locale.baseText('aiAssistantChat.initialMessage.nextStep', {
interpolate: { currentAction: latestConnectionInfo.value.stepName },
});
}
return locale.baseText('aiAssistantChat.initialMessage.firstStep');
});
const initialMessages: Ref<ChatMessage[]> = ref([
{
id: '1',
type: 'text',
sender: 'bot',
createdAt: new Date().toISOString(),
text: `${locale.baseText('aiAssistantChat.greeting', { interpolate: { username: userName.value ?? 'there' } })} ${initialMessageText.value}`,
},
]);
const sendMessage = async (message: string) => {
disableChat.value = true;
waitingForResponse.value = true;
messages.value.push({
id: String(messages.value.length + 1),
sender: 'user',
text: message,
createdAt: new Date().toISOString(),
});
trackUserMessage(message);
thanksResponses.forEach((response, index) => {
// Push each response with a delay of 1500ms
setTimeout(
() => {
messages.value.push(response);
chatEventBus.emit('scrollToBottom');
if (index === thanksResponses.length - 1) {
waitingForResponse.value = false;
// Once last message is sent, disable the experiment
useStorage(AI_ASSISTANT_LOCAL_STORAGE_KEY).value = 'true';
}
},
1500 * (index + 1),
);
});
chatEventBus.emit('scrollToBottom');
};
const trackUserMessage = (message: string) => {
telemetry.track('User responded in AI chat', {
prompt: message,
chatMode: 'nextStepAssistant',
initialMessage: initialMessageText.value,
});
};
const chatOptions: ChatOptions = {
i18n: {
en: {
title: chatTitle,
footer: '',
subtitle: '',
inputPlaceholder: locale.baseText('aiAssistantChat.chatPlaceholder'),
getStarted: locale.baseText('aiAssistantChat.getStarted'),
closeButtonTooltip: locale.baseText('aiAssistantChat.closeButtonTooltip'),
},
},
webhookUrl: 'https://webhook.url',
mode: 'window',
showWindowCloseButton: true,
messageComponents: {
QuickReplies,
},
disabled: disableChat,
};
const chatConfig: Chat = {
messages,
sendMessage,
initialMessages,
currentSessionId,
waitingForResponse,
};
provide(ChatSymbol, chatConfig);
provide(ChatOptionsSymbol, chatOptions);
onMounted(() => {
chatEventBus.emit('focusInput');
chatEventBus.on('close', onBeforeClose);
});
onBeforeUnmount(() => {
chatEventBus.off('close', onBeforeClose);
});
async function onBeforeClose() {
const confirmModal = await confirm(locale.baseText('aiAssistantChat.closeChatConfirmation'), {
confirmButtonText: locale.baseText('aiAssistantChat.closeChatConfirmation.confirm'),
cancelButtonText: locale.baseText('aiAssistantChat.closeChatConfirmation.cancel'),
});
if (confirmModal === MODAL_CONFIRM) {
aiStore.assistantChatOpen = false;
}
}
</script>
<template>
<div :class="[$style.container, 'ignore-key-press']">
<ChatComponent />
</div>
</template>
<style module lang="scss">
.container {
height: 100%;
background-color: var(--color-background-light);
filter: drop-shadow(0px 8px 24px #41424412);
border-left: 1px solid var(--color-foreground-dark);
overflow: hidden;
}
.header {
font-size: var(--font-size-l);
background-color: #fff;
padding: var(--spacing-xs);
}
.content {
padding: var(--spacing-xs);
}
</style>

View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
import { useAIStore } from '@/stores/ai.store';
import { useI18n } from '@/composables/useI18n';
import { computed } from 'vue';
import { useTelemetry } from '@/composables/useTelemetry';
const aiStore = useAIStore();
const locale = useI18n();
const telemetry = useTelemetry();
const emit = defineEmits<{ (event: 'optionSelected', option: string): void }>();
const aiAssistantChatOpen = computed(() => aiStore.assistantChatOpen);
const title = computed(() => {
return aiStore.nextStepPopupConfig.title;
});
const options = computed(() => [
{
label: locale.baseText('nextStepPopup.option.choose'),
icon: '',
key: 'choose',
disabled: false,
},
{
label: locale.baseText('nextStepPopup.option.generate'),
icon: '✨',
key: 'generate',
disabled: aiAssistantChatOpen.value,
},
]);
const position = computed(() => {
return [aiStore.nextStepPopupConfig.position[0], aiStore.nextStepPopupConfig.position[1]];
});
const style = computed(() => {
return {
left: `${position.value[0]}px`,
top: `${position.value[1]}px`,
};
});
const close = () => {
aiStore.closeNextStepPopup();
};
const onOptionSelected = (option: string) => {
if (option === 'choose') {
emit('optionSelected', option);
} else if (option === 'generate') {
telemetry.track('User clicked generate AI button', {}, { withPostHog: true });
aiStore.assistantChatOpen = true;
}
close();
};
</script>
<template>
<div v-on-click-outside="close" :class="$style.container" :style="style">
<div :class="$style.title">{{ title }}</div>
<ul :class="$style.options">
<li
v-for="option in options"
:key="option.key"
:class="{ [$style.option]: true, [$style.disabled]: option.disabled }"
@click="onOptionSelected(option.key)"
>
<div :class="$style.icon">
{{ option.icon }}
</div>
<div :class="$style.label">
{{ option.label }}
</div>
</li>
</ul>
</div>
</template>
<style module lang="scss">
.container {
position: fixed;
display: flex;
flex-direction: column;
min-width: 190px;
font-size: var(--font-size-2xs);
background: var(--color-background-xlight);
filter: drop-shadow(0px 6px 16px #441c170f);
border: var(--border-width-base) var(--border-style-base) var(--color-foreground-light);
border-radius: var(--border-radius-base);
// Arrow border is created as the outer triange
&:before {
content: '';
position: relative;
left: -11px;
top: calc(50% - 8px);
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
border-right: 10px solid var(--color-foreground-light);
position: absolute;
}
// Arrow background is created as the inner triangle
&:after {
content: '';
position: relative;
left: -10px;
top: calc(50% - 8px);
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
border-right: 10px solid var(--color-background-xlight);
position: absolute;
}
}
.title {
padding: var(--spacing-xs);
color: var(--color-text-base);
font-weight: var(--font-weight-bold);
}
.options {
list-style: none;
display: flex;
flex-direction: column;
padding-bottom: var(--spacing-2xs);
}
.option {
display: flex;
padding: var(--spacing-3xs) var(--spacing-xs);
gap: var(--spacing-xs);
cursor: pointer;
color: var(--color-text-dark);
&:hover {
background: var(--color-background-base);
font-weight: var(--font-weight-bold);
}
&.disabled {
pointer-events: none;
color: var(--color-text-light);
}
}
</style>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import Button from 'n8n-design-system/components/N8nButton/Button.vue';
type QuickReply = {
label: string;
key: string;
};
const locale = useI18n();
const emit = defineEmits<{
(event: 'replySelected', value: QuickReply): void;
}>();
defineProps<{
suggestions: QuickReply[];
}>();
function onButtonClick(action: QuickReply) {
emit('replySelected', action);
}
</script>
<template>
<div :class="$style.container">
<p :class="$style.hint">{{ locale.baseText('aiAssistantChat.quickReply.title') }}</p>
<div :class="$style.suggestions">
<Button
v-for="action in suggestions"
:key="action.key"
:class="$style.replyButton"
outline
type="secondary"
@click="onButtonClick(action)"
>
{{ action.label }}
</Button>
</div>
</div>
</template>
<style module lang="scss">
.container {
display: flex;
flex-direction: column;
gap: var(--spacing-2xs);
width: auto;
justify-content: flex-end;
align-items: flex-end;
}
.suggestions {
display: flex;
flex-direction: column;
width: fit-content;
gap: var(--spacing-4xs);
}
.hint {
color: var(--color-text-base);
font-size: var(--font-size-2xs);
}
.replyButton {
display: flex;
background: var(--chat--color-white);
}
</style>

View File

@@ -94,7 +94,6 @@ onBeforeUnmount(() => {
<style lang="scss" module>
.zoomMenu {
position: absolute;
width: 210px;
bottom: var(--spacing-l);
left: var(--spacing-l);
line-height: 25px;

View File

@@ -1,11 +1,16 @@
<template>
<div>
<aside :class="{ [$style.nodeCreatorScrim]: true, [$style.active]: showScrim }" />
<aside
:class="{
[$style.nodeCreatorScrim]: true,
[$style.active]: showScrim,
}"
/>
<SlideTransition>
<div
v-if="active"
ref="nodeCreator"
:class="$style.nodeCreator"
:class="{ [$style.nodeCreator]: true, [$style.chatOpened]: chatSidebarOpen }"
:style="nodeCreatorInlineStyle"
data-test-id="node-creator"
@dragover="onDragOver"
@@ -32,6 +37,7 @@ import { useActionsGenerator } from './composables/useActionsGeneration';
import NodesListPanel from './Panel/NodesListPanel.vue';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useUIStore } from '@/stores/ui.store';
import { useAIStore } from '@/stores/ai.store';
import { DRAG_EVENT_DATA_KEY } from '@/constants';
export interface Props {
@@ -47,6 +53,7 @@ const emit = defineEmits<{
(event: 'nodeTypeSelected', value: string[]): void;
}>();
const uiStore = useUIStore();
const aiStore = useAIStore();
const { setShowScrim, setActions, setMergeNodes } = useNodeCreatorStore();
const { generateMergedNodesAndActions } = useActionsGenerator();
@@ -60,6 +67,8 @@ const showScrim = computed(() => useNodeCreatorStore().showScrim);
const viewStacksLength = computed(() => useViewStacks().viewStacks.length);
const chatSidebarOpen = computed(() => aiStore.assistantChatOpen);
const nodeCreatorInlineStyle = computed(() => {
return { top: `${uiStore.bannersHeight + uiStore.headerHeight}px` };
});
@@ -168,6 +177,10 @@ onBeforeUnmount(() => {
z-index: 200;
width: $node-creator-width;
color: $node-creator-text-color;
&.chatOpened {
right: $chat-width;
}
}
.nodeCreatorScrim {