Skip to content

Commit 491c6ec

Browse files
feat(editor): Implement AI Assistant chat UI (#9300)
1 parent 23b676d commit 491c6ec

File tree

28 files changed

+948
-193
lines changed

28 files changed

+948
-193
lines changed

packages/@n8n/chat/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,12 +210,31 @@ The Chat window is entirely customizable using CSS variables.
210210
--chat--window--width: 400px;
211211
--chat--window--height: 600px;
212212

213+
--chat--header-height: auto;
214+
--chat--header--padding: var(--chat--spacing);
215+
--chat--header--background: var(--chat--color-dark);
216+
--chat--header--color: var(--chat--color-light);
217+
--chat--header--border-top: none;
218+
--chat--header--border-bottom: none;
219+
--chat--header--border-bottom: none;
220+
--chat--header--border-bottom: none;
221+
--chat--heading--font-size: 2em;
222+
--chat--header--color: var(--chat--color-light);
223+
--chat--subtitle--font-size: inherit;
224+
--chat--subtitle--line-height: 1.8;
225+
213226
--chat--textarea--height: 50px;
214227

228+
--chat--message--font-size: 1rem;
229+
--chat--message--padding: var(--chat--spacing);
230+
--chat--message--border-radius: var(--chat--border-radius);
231+
--chat--message-line-height: 1.8;
215232
--chat--message--bot--background: var(--chat--color-white);
216233
--chat--message--bot--color: var(--chat--color-dark);
234+
--chat--message--bot--border: none;
217235
--chat--message--user--background: var(--chat--color-secondary);
218236
--chat--message--user--color: var(--chat--color-white);
237+
--chat--message--user--border: none;
219238
--chat--message--pre--background: rgba(0, 0, 0, 0.05);
220239

221240
--chat--toggle--background: var(--chat--color-primary);

packages/@n8n/chat/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
"highlight.js": "^11.8.0",
4343
"markdown-it-link-attributes": "^4.0.1",
4444
"uuid": "^8.3.2",
45-
"vue": "^3.3.4",
45+
"vue": "^3.4.21",
4646
"vue-markdown-render": "^2.1.1"
4747
},
4848
"devDependencies": {

packages/@n8n/chat/src/components/Chat.vue

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
<script setup lang="ts">
2-
import { nextTick, onMounted } from 'vue';
2+
// eslint-disable-next-line import/no-unresolved
3+
import Close from 'virtual:icons/mdi/close';
4+
import { computed, nextTick, onMounted } from 'vue';
35
import Layout from '@n8n/chat/components/Layout.vue';
46
import GetStarted from '@n8n/chat/components/GetStarted.vue';
57
import GetStartedFooter from '@n8n/chat/components/GetStartedFooter.vue';
@@ -14,20 +16,32 @@ const chatStore = useChat();
1416
const { messages, currentSessionId } = chatStore;
1517
const { options } = useOptions();
1618
19+
const showCloseButton = computed(() => options.mode === 'window' && options.showWindowCloseButton);
20+
1721
async function getStarted() {
22+
if (!chatStore.startNewSession) {
23+
return;
24+
}
1825
void chatStore.startNewSession();
1926
void nextTick(() => {
2027
chatEventBus.emit('scrollToBottom');
2128
});
2229
}
2330
2431
async function initialize() {
32+
if (!chatStore.loadPreviousSession) {
33+
return;
34+
}
2535
await chatStore.loadPreviousSession();
2636
void nextTick(() => {
2737
chatEventBus.emit('scrollToBottom');
2838
});
2939
}
3040
41+
function closeChat() {
42+
chatEventBus.emit('close');
43+
}
44+
3145
onMounted(async () => {
3246
await initialize();
3347
if (!options.showWelcomeScreen && !currentSessionId.value) {
@@ -39,8 +53,20 @@ onMounted(async () => {
3953
<template>
4054
<Layout class="chat-wrapper">
4155
<template #header>
42-
<h1>{{ t('title') }}</h1>
43-
<p>{{ t('subtitle') }}</p>
56+
<div class="chat-heading">
57+
<h1>
58+
{{ t('title') }}
59+
</h1>
60+
<button
61+
v-if="showCloseButton"
62+
class="chat-close-button"
63+
:title="t('closeButtonTooltip')"
64+
@click="closeChat"
65+
>
66+
<Close height="18" width="18" />
67+
</button>
68+
</div>
69+
<p v-if="t('subtitle')">{{ t('subtitle') }}</p>
4470
</template>
4571
<GetStarted v-if="!currentSessionId && options.showWelcomeScreen" @click:button="getStarted" />
4672
<MessagesList v-else :messages="messages" />
@@ -50,3 +76,22 @@ onMounted(async () => {
5076
</template>
5177
</Layout>
5278
</template>
79+
80+
<style lang="scss">
81+
.chat-heading {
82+
display: flex;
83+
justify-content: space-between;
84+
align-items: center;
85+
}
86+
87+
.chat-close-button {
88+
display: flex;
89+
border: none;
90+
background: none;
91+
cursor: pointer;
92+
93+
&:hover {
94+
color: var(--chat--close--button--color-hover, var(--chat--color-primary));
95+
}
96+
}
97+
</style>

packages/@n8n/chat/src/components/Input.vue

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,30 @@
11
<script setup lang="ts">
22
// eslint-disable-next-line import/no-unresolved
33
import IconSend from 'virtual:icons/mdi/send';
4-
import { computed, ref } from 'vue';
5-
import { useI18n, useChat } from '@n8n/chat/composables';
4+
import { computed, onMounted, ref } from 'vue';
5+
import { useI18n, useChat, useOptions } from '@n8n/chat/composables';
6+
import { chatEventBus } from '@n8n/chat/event-buses';
67
8+
const { options } = useOptions();
79
const chatStore = useChat();
810
const { waitingForResponse } = chatStore;
911
const { t } = useI18n();
1012
13+
const chatTextArea = ref<HTMLTextAreaElement | null>(null);
1114
const input = ref('');
1215
1316
const isSubmitDisabled = computed(() => {
14-
return input.value === '' || waitingForResponse.value;
17+
return input.value === '' || waitingForResponse.value || options.disabled?.value === true;
18+
});
19+
20+
const isInputDisabled = computed(() => options.disabled?.value === true);
21+
22+
onMounted(() => {
23+
chatEventBus.on('focusInput', () => {
24+
if (chatTextArea.value) {
25+
chatTextArea.value.focus();
26+
}
27+
});
1528
});
1629
1730
async function onSubmit(event: MouseEvent | KeyboardEvent) {
@@ -38,8 +51,10 @@ async function onSubmitKeydown(event: KeyboardEvent) {
3851
<template>
3952
<div class="chat-input">
4053
<textarea
54+
ref="chatTextArea"
4155
v-model="input"
4256
rows="1"
57+
:disabled="isInputDisabled"
4358
:placeholder="t('inputPlaceholder')"
4459
@keydown.enter="onSubmitKeydown"
4560
/>
@@ -55,10 +70,11 @@ async function onSubmitKeydown(event: KeyboardEvent) {
5570
justify-content: center;
5671
align-items: center;
5772
width: 100%;
73+
background: white;
5874
5975
textarea {
6076
font-family: inherit;
61-
font-size: inherit;
77+
font-size: var(--chat--input--font-size, inherit);
6278
width: 100%;
6379
border: 0;
6480
padding: var(--chat--spacing);
@@ -71,7 +87,7 @@ async function onSubmitKeydown(event: KeyboardEvent) {
7187
width: var(--chat--textarea--height);
7288
background: white;
7389
cursor: pointer;
74-
color: var(--chat--color-secondary);
90+
color: var(--chat--input--send--button--color, var(--chat--color-secondary));
7591
border: 0;
7692
font-size: 24px;
7793
display: inline-flex;

packages/@n8n/chat/src/components/Layout.vue

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,26 @@ onBeforeUnmount(() => {
5858
);
5959
6060
.chat-header {
61+
display: flex;
62+
flex-direction: column;
63+
justify-content: center;
64+
gap: 1em;
65+
height: var(--chat--header-height, auto);
6166
padding: var(--chat--header--padding, var(--chat--spacing));
6267
background: var(--chat--header--background, var(--chat--color-dark));
6368
color: var(--chat--header--color, var(--chat--color-light));
69+
border-top: var(--chat--header--border-top, none);
70+
border-bottom: var(--chat--header--border-bottom, none);
71+
border-left: var(--chat--header--border-left, none);
72+
border-right: var(--chat--header--border-right, none);
73+
h1 {
74+
font-size: var(--chat--heading--font-size);
75+
color: var(--chat--header--color, var(--chat--color-light));
76+
}
77+
p {
78+
font-size: var(--chat--subtitle--font-size, inherit);
79+
line-height: var(--chat--subtitle--line-height, 1.8);
80+
}
6481
}
6582
6683
.chat-body {

packages/@n8n/chat/src/components/Message.vue

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import VueMarkdown from 'vue-markdown-render';
66
import hljs from 'highlight.js/lib/core';
77
import markdownLink from 'markdown-it-link-attributes';
88
import type MarkdownIt from 'markdown-it';
9-
import type { ChatMessage } from '@n8n/chat/types';
9+
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
10+
import { useOptions } from '@n8n/chat/composables';
1011
1112
const props = defineProps({
1213
message: {
@@ -16,15 +17,17 @@ const props = defineProps({
1617
});
1718
1819
const { message } = toRefs(props);
20+
const { options } = useOptions();
1921
2022
const messageText = computed(() => {
21-
return message.value.text || '&lt;Empty response&gt;';
23+
return (message.value as ChatMessageText).text || '&lt;Empty response&gt;';
2224
});
2325
2426
const classes = computed(() => {
2527
return {
2628
'chat-message-from-user': message.value.sender === 'user',
2729
'chat-message-from-bot': message.value.sender === 'bot',
30+
'chat-message-transparent': message.value.transparent === true,
2831
};
2932
});
3033
@@ -48,11 +51,17 @@ const markdownOptions = {
4851
return ''; // use external default escaping
4952
},
5053
};
54+
55+
const messageComponents = options.messageComponents ?? {};
5156
</script>
5257
<template>
5358
<div class="chat-message" :class="classes">
5459
<slot>
60+
<template v-if="message.type === 'component' && messageComponents[message.key]">
61+
<component :is="messageComponents[message.key]" v-bind="message.arguments" />
62+
</template>
5563
<VueMarkdown
64+
v-else
5665
class="chat-message-markdown"
5766
:source="messageText"
5867
:options="markdownOptions"
@@ -66,21 +75,40 @@ const markdownOptions = {
6675
.chat-message {
6776
display: block;
6877
max-width: 80%;
78+
font-size: var(--chat--message--font-size, 1rem);
6979
padding: var(--chat--message--padding, var(--chat--spacing));
7080
border-radius: var(--chat--message--border-radius, var(--chat--border-radius));
7181
82+
p {
83+
line-height: var(--chat--message-line-height, 1.8);
84+
word-wrap: break-word;
85+
}
86+
87+
// Default message gap is half of the spacing
7288
+ .chat-message {
7389
margin-top: var(--chat--message--margin-bottom, calc(var(--chat--spacing) * 0.5));
7490
}
7591
92+
// Spacing between messages from different senders is double the individual message gap
93+
&.chat-message-from-user + &.chat-message-from-bot,
94+
&.chat-message-from-bot + &.chat-message-from-user {
95+
margin-top: var(--chat--spacing);
96+
}
97+
7698
&.chat-message-from-bot {
77-
background-color: var(--chat--message--bot--background);
99+
&:not(.chat-message-transparent) {
100+
background-color: var(--chat--message--bot--background);
101+
border: var(--chat--message--bot--border, none);
102+
}
78103
color: var(--chat--message--bot--color);
79104
border-bottom-left-radius: 0;
80105
}
81106
82107
&.chat-message-from-user {
83-
background-color: var(--chat--message--user--background);
108+
&:not(.chat-message-transparent) {
109+
background-color: var(--chat--message--user--background);
110+
border: var(--chat--message--user--border, none);
111+
}
84112
color: var(--chat--message--user--color);
85113
margin-left: auto;
86114
border-bottom-right-radius: 0;

packages/@n8n/chat/src/composables/useI18n.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
import { isRef } from 'vue';
12
import { useOptions } from '@n8n/chat/composables/useOptions';
23

34
export function useI18n() {
45
const { options } = useOptions();
56
const language = options?.defaultLanguage ?? 'en';
67

78
function t(key: string): string {
8-
return options?.i18n?.[language]?.[key] ?? key;
9+
const val = options?.i18n?.[language]?.[key];
10+
if (isRef(val)) {
11+
return val.value as string;
12+
}
13+
return val ?? key;
914
}
1015

1116
function te(key: string): boolean {

packages/@n8n/chat/src/constants/defaults.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const defaultOptions: ChatOptions = {
2121
footer: '',
2222
getStarted: 'New Conversation',
2323
inputPlaceholder: 'Type your question..',
24+
closeButtonTooltip: 'Close chat',
2425
},
2526
},
2627
theme: {},

packages/@n8n/chat/src/css/_tokens.scss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,6 @@
3333
--chat--toggle--active--background: var(--chat--color-primary-shade-100);
3434
--chat--toggle--color: var(--chat--color-white);
3535
--chat--toggle--size: 64px;
36+
37+
--chat--heading--font-size: 2em;
3638
}

packages/@n8n/chat/src/types/chat.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export interface Chat {
66
messages: Ref<ChatMessage[]>;
77
currentSessionId: Ref<string | null>;
88
waitingForResponse: Ref<boolean>;
9-
loadPreviousSession: () => Promise<string | undefined>;
10-
startNewSession: () => Promise<void>;
9+
loadPreviousSession?: () => Promise<string | undefined>;
10+
startNewSession?: () => Promise<void>;
1111
sendMessage: (text: string) => Promise<void>;
1212
}
Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
1-
export interface ChatMessage {
2-
id: string;
1+
export type ChatMessage<T = Record<string, unknown>> = ChatMessageComponent<T> | ChatMessageText;
2+
3+
export interface ChatMessageComponent<T = Record<string, unknown>> extends ChatMessageBase {
4+
type: 'component';
5+
key: string;
6+
arguments: T;
7+
}
8+
9+
export interface ChatMessageText extends ChatMessageBase {
10+
type?: 'text';
311
text: string;
12+
}
13+
14+
interface ChatMessageBase {
15+
id: string;
416
createdAt: string;
17+
transparent?: boolean;
518
sender: 'user' | 'bot';
619
}

0 commit comments

Comments
 (0)