feat(editor): Implement AI Assistant chat UI (#9300)
This commit is contained in:
committed by
GitHub
parent
23b676d7cb
commit
491c6ec546
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user