feat(Chat Trigger Node): Add support for file uploads & harmonize public and development chat (#9802)

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
oleg
2024-07-09 13:45:41 +02:00
committed by GitHub
parent 501bcd80ff
commit df783151b8
32 changed files with 2309 additions and 940 deletions

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import IconFileText from 'virtual:icons/mdi/fileText';
import IconFileMusic from 'virtual:icons/mdi/fileMusic';
import IconFileImage from 'virtual:icons/mdi/fileImage';
import IconFileVideo from 'virtual:icons/mdi/fileVideo';
import IconDelete from 'virtual:icons/mdi/closeThick';
import IconPreview from 'virtual:icons/mdi/openInNew';
import { computed, type FunctionalComponent } from 'vue';
const props = defineProps<{
file: File;
isRemovable: boolean;
isPreviewable?: boolean;
}>();
const emit = defineEmits<{
remove: [value: File];
}>();
const iconMapper: Record<string, FunctionalComponent> = {
document: IconFileText,
audio: IconFileMusic,
image: IconFileImage,
video: IconFileVideo,
};
const TypeIcon = computed(() => {
const type = props.file?.type.split('/')[0];
return iconMapper[type] || IconFileText;
});
function onClick() {
if (props.isRemovable) {
emit('remove', props.file);
}
if (props.isPreviewable) {
window.open(URL.createObjectURL(props.file));
}
}
</script>
<template>
<div class="chat-file" @click="onClick">
<TypeIcon />
<p class="chat-file-name">{{ file.name }}</p>
<IconDelete v-if="isRemovable" class="chat-file-delete" />
<IconPreview v-if="isPreviewable" class="chat-file-preview" />
</div>
</template>
<style scoped lang="scss">
.chat-file {
display: flex;
align-items: center;
flex-wrap: nowrap;
width: fit-content;
max-width: 15rem;
padding: 0.5rem;
border-radius: 0.25rem;
gap: 0.25rem;
font-size: 0.75rem;
background: white;
color: var(--chat--color-dark);
border: 1px solid var(--chat--color-dark);
cursor: pointer;
}
.chat-file-name-tooltip {
overflow: hidden;
}
.chat-file-name {
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
}
.chat-file-delete,
.chat-file-preview {
background: none;
border: none;
display: none;
cursor: pointer;
flex-shrink: 0;
.chat-file:hover & {
display: block;
}
}
</style>

View File

@@ -1,31 +1,102 @@
<script setup lang="ts">
import IconSend from 'virtual:icons/mdi/send';
import { computed, onMounted, ref } from 'vue';
import IconFilePlus from 'virtual:icons/mdi/filePlus';
import { computed, onMounted, onUnmounted, ref, unref } from 'vue';
import { useFileDialog } from '@vueuse/core';
import ChatFile from './ChatFile.vue';
import { useI18n, useChat, useOptions } from '@n8n/chat/composables';
import { chatEventBus } from '@n8n/chat/event-buses';
export interface ArrowKeyDownPayload {
key: 'ArrowUp' | 'ArrowDown';
currentInputValue: string;
}
const emit = defineEmits<{
arrowKeyDown: [value: ArrowKeyDownPayload];
}>();
const { options } = useOptions();
const chatStore = useChat();
const { waitingForResponse } = chatStore;
const { t } = useI18n();
const files = ref<FileList | null>(null);
const chatTextArea = ref<HTMLTextAreaElement | null>(null);
const input = ref('');
const isSubmitting = ref(false);
const isSubmitDisabled = computed(() => {
return input.value === '' || waitingForResponse.value || options.disabled?.value === true;
});
const isInputDisabled = computed(() => options.disabled?.value === true);
const isFileUploadDisabled = computed(
() => isFileUploadAllowed.value && waitingForResponse.value && !options.disabled?.value,
);
const isFileUploadAllowed = computed(() => unref(options.allowFileUploads) === true);
const allowedFileTypes = computed(() => unref(options.allowedFilesMimeTypes));
const styleVars = computed(() => {
const controlsCount = isFileUploadAllowed.value ? 2 : 1;
return {
'--controls-count': controlsCount,
};
});
const {
open: openFileDialog,
reset: resetFileDialog,
onChange,
} = useFileDialog({
multiple: true,
reset: false,
});
onChange((newFiles) => {
if (!newFiles) return;
const newFilesDT = new DataTransfer();
// Add current files
if (files.value) {
for (let i = 0; i < files.value.length; i++) {
newFilesDT.items.add(files.value[i]);
}
}
for (let i = 0; i < newFiles.length; i++) {
newFilesDT.items.add(newFiles[i]);
}
files.value = newFilesDT.files;
});
onMounted(() => {
chatEventBus.on('focusInput', () => {
if (chatTextArea.value) {
chatTextArea.value.focus();
}
});
chatEventBus.on('focusInput', focusChatInput);
chatEventBus.on('blurInput', blurChatInput);
chatEventBus.on('setInputValue', setInputValue);
});
onUnmounted(() => {
chatEventBus.off('focusInput', focusChatInput);
chatEventBus.off('blurInput', blurChatInput);
chatEventBus.off('setInputValue', setInputValue);
});
function blurChatInput() {
if (chatTextArea.value) {
chatTextArea.value.blur();
}
}
function focusChatInput() {
if (chatTextArea.value) {
chatTextArea.value.focus();
}
}
function setInputValue(value: string) {
input.value = value;
focusChatInput();
}
async function onSubmit(event: MouseEvent | KeyboardEvent) {
event.preventDefault();
@@ -35,7 +106,11 @@ async function onSubmit(event: MouseEvent | KeyboardEvent) {
const messageText = input.value;
input.value = '';
await chatStore.sendMessage(messageText);
isSubmitting.value = true;
await chatStore.sendMessage(messageText, Array.from(files.value ?? []));
isSubmitting.value = false;
resetFileDialog();
files.value = null;
}
async function onSubmitKeydown(event: KeyboardEvent) {
@@ -45,64 +120,156 @@ async function onSubmitKeydown(event: KeyboardEvent) {
await onSubmit(event);
}
function onFileRemove(file: File) {
if (!files.value) return;
const dt = new DataTransfer();
for (let i = 0; i < files.value.length; i++) {
const currentFile = files.value[i];
if (file.name !== currentFile.name) dt.items.add(currentFile);
}
resetFileDialog();
files.value = dt.files;
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
emit('arrowKeyDown', {
key: event.key,
currentInputValue: input.value,
});
}
}
function onOpenFileDialog() {
if (isFileUploadDisabled.value) return;
openFileDialog({ accept: unref(allowedFileTypes) });
}
</script>
<template>
<div class="chat-input">
<textarea
ref="chatTextArea"
v-model="input"
rows="1"
:disabled="isInputDisabled"
:placeholder="t('inputPlaceholder')"
@keydown.enter="onSubmitKeydown"
/>
<button :disabled="isSubmitDisabled" class="chat-input-send-button" @click="onSubmit">
<IconSend height="32" width="32" />
</button>
<div class="chat-input" :style="styleVars" @keydown.stop="onKeyDown">
<div class="chat-inputs">
<textarea
ref="chatTextArea"
v-model="input"
:disabled="isInputDisabled"
:placeholder="t('inputPlaceholder')"
@keydown.enter="onSubmitKeydown"
/>
<div class="chat-inputs-controls">
<button
v-if="isFileUploadAllowed"
:disabled="isFileUploadDisabled"
class="chat-input-send-button"
@click="onOpenFileDialog"
>
<IconFilePlus height="24" width="24" />
</button>
<button :disabled="isSubmitDisabled" class="chat-input-send-button" @click="onSubmit">
<IconSend height="24" width="24" />
</button>
</div>
</div>
<div v-if="files?.length && !isSubmitting" class="chat-files">
<ChatFile
v-for="file in files"
:key="file.name"
:file="file"
:is-removable="true"
@remove="onFileRemove"
/>
</div>
</div>
</template>
<style lang="scss">
<style lang="scss" scoped>
.chat-input {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
background: white;
flex-direction: column;
position: relative;
* {
box-sizing: border-box;
}
}
.chat-inputs {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
textarea {
font-family: inherit;
font-size: var(--chat--input--font-size, inherit);
width: 100%;
border: 0;
padding: var(--chat--spacing);
max-height: var(--chat--textarea--height);
resize: none;
}
border: var(--chat--input--border, 0);
border-radius: var(--chat--input--border-radius, 0);
padding: 0.8rem;
padding-right: calc(0.8rem + (var(--controls-count, 1) * var(--chat--textarea--height)));
min-height: var(--chat--textarea--height);
max-height: var(--chat--textarea--max-height, var(--chat--textarea--height));
height: 100%;
background: var(--chat--input--background, white);
resize: var(--chat--textarea--resize, none);
color: var(--chat--input--text-color, initial);
outline: none;
.chat-input-send-button {
height: var(--chat--textarea--height);
width: var(--chat--textarea--height);
background: white;
cursor: pointer;
color: var(--chat--input--send--button--color, var(--chat--color-secondary));
border: 0;
font-size: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color var(--chat--transition-duration) ease;
&:hover,
&:focus {
color: var(--chat--color-secondary-shade-50);
}
&[disabled] {
cursor: default;
color: var(--chat--color-disabled);
&:focus,
&:hover {
border-color: var(--chat--input--border-active, 0);
}
}
}
.chat-inputs-controls {
display: flex;
position: absolute;
right: 0.5rem;
}
.chat-input-send-button {
height: var(--chat--textarea--height);
width: var(--chat--textarea--height);
background: var(--chat--input--send--button--background, white);
cursor: pointer;
color: var(--chat--input--send--button--color, var(--chat--color-secondary));
border: 0;
font-size: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: color var(--chat--transition-duration) ease;
&:hover,
&:focus {
background: var(
--chat--input--send--button--background-hover,
var(--chat--input--send--button--background)
);
color: var(--chat--input--send--button--color-hover, var(--chat--color-secondary-shade-50));
}
&[disabled] {
cursor: no-drop;
color: var(--chat--color-disabled);
}
}
.chat-files {
display: flex;
overflow-x: hidden;
overflow-y: auto;
width: 100%;
flex-direction: row;
flex-wrap: wrap;
gap: 0.25rem;
padding: var(--chat--files-spacing, 0.25rem);
}
</style>

View File

@@ -1,10 +1,16 @@
<script lang="ts" setup>
/* eslint-disable @typescript-eslint/naming-convention */
import { computed, toRefs } from 'vue';
import { computed, ref, toRefs, onMounted } from 'vue';
import VueMarkdown from 'vue-markdown-render';
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import typescript from 'highlight.js/lib/languages/typescript';
import python from 'highlight.js/lib/languages/python';
import xml from 'highlight.js/lib/languages/xml';
import bash from 'highlight.js/lib/languages/bash';
import markdownLink from 'markdown-it-link-attributes';
import type MarkdownIt from 'markdown-it';
import ChatFile from './ChatFile.vue';
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
import { useOptions } from '@n8n/chat/composables';
@@ -12,8 +18,21 @@ const props = defineProps<{
message: ChatMessage;
}>();
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('python', python);
hljs.registerLanguage('xml', xml);
hljs.registerLanguage('bash', bash);
defineSlots<{
beforeMessage(props: { message: ChatMessage }): ChatMessage;
default: { message: ChatMessage };
}>();
const { message } = toRefs(props);
const { options } = useOptions();
const messageContainer = ref<HTMLElement | null>(null);
const fileSources = ref<Record<string, string>>({});
const messageText = computed(() => {
return (message.value as ChatMessageText).text || '&lt;Empty response&gt;';
@@ -36,6 +55,14 @@ const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
});
};
const scrollToView = () => {
if (messageContainer.value?.scrollIntoView) {
messageContainer.value.scrollIntoView({
block: 'center',
});
}
};
const markdownOptions = {
highlight(str: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
@@ -48,10 +75,37 @@ const markdownOptions = {
},
};
const messageComponents = options?.messageComponents ?? {};
const messageComponents = { ...(options?.messageComponents ?? {}) };
defineExpose({ scrollToView });
const readFileAsDataURL = async (file: File): Promise<string> =>
await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
onMounted(async () => {
if (message.value.files) {
for (const file of message.value.files) {
try {
const dataURL = await readFileAsDataURL(file);
fileSources.value[file.name] = dataURL;
} catch (error) {
console.error('Error reading file:', error);
}
}
}
});
</script>
<template>
<div class="chat-message" :class="classes">
<div ref="messageContainer" class="chat-message" :class="classes">
<div v-if="$slots.beforeMessage" class="chat-message-actions">
<slot name="beforeMessage" v-bind="{ message }" />
</div>
<slot>
<template v-if="message.type === 'component' && messageComponents[message.key]">
<component :is="messageComponents[message.key]" v-bind="message.arguments" />
@@ -63,6 +117,11 @@ const messageComponents = options?.messageComponents ?? {};
:options="markdownOptions"
:plugins="[linksNewTabPlugin]"
/>
<div v-if="(message.files ?? []).length > 0" class="chat-message-files">
<div v-for="file in message.files ?? []" :key="file.name" class="chat-message-file">
<ChatFile :file="file" :is-removable="false" :is-previewable="true" />
</div>
</div>
</slot>
</div>
</template>
@@ -70,11 +129,33 @@ const messageComponents = options?.messageComponents ?? {};
<style lang="scss">
.chat-message {
display: block;
position: relative;
max-width: 80%;
font-size: var(--chat--message--font-size, 1rem);
padding: var(--chat--message--padding, var(--chat--spacing));
border-radius: var(--chat--message--border-radius, var(--chat--border-radius));
.chat-message-actions {
position: absolute;
bottom: 100%;
left: 0;
opacity: 0;
transform: translateY(-0.25rem);
display: flex;
gap: 1rem;
}
&.chat-message-from-user .chat-message-actions {
left: auto;
right: 0;
}
&:hover {
.chat-message-actions {
opacity: 1;
}
}
p {
line-height: var(--chat--message-line-height, 1.8);
word-wrap: break-word;
@@ -82,7 +163,7 @@ const messageComponents = options?.messageComponents ?? {};
// Default message gap is half of the spacing
+ .chat-message {
margin-top: var(--chat--message--margin-bottom, calc(var(--chat--spacing) * 0.5));
margin-top: var(--chat--message--margin-bottom, calc(var(--chat--spacing) * 1));
}
// Spacing between messages from different senders is double the individual message gap
@@ -133,5 +214,11 @@ const messageComponents = options?.messageComponents ?? {};
border-radius: var(--chat--border-radius);
}
}
.chat-message-files {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding-top: 0.5rem;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { Message } from './index';
import type { ChatMessage } from '@n8n/chat/types';
@@ -18,7 +18,7 @@ const message: ChatMessage = {
sender: 'bot',
createdAt: '',
};
const messageContainer = ref<InstanceType<typeof Message>>();
const classes = computed(() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -26,9 +26,13 @@ const classes = computed(() => {
[`chat-message-typing-animation-${props.animation}`]: true,
};
});
onMounted(() => {
messageContainer.value?.scrollToView();
});
</script>
<template>
<Message :class="classes" :message="message">
<Message ref="messageContainer" :class="classes" :message="message">
<div class="chat-message-typing-body">
<span class="chat-message-typing-circle"></span>
<span class="chat-message-typing-circle"></span>

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import Message from '@n8n/chat/components/Message.vue';
import type { ChatMessage } from '@n8n/chat/types';
import MessageTyping from '@n8n/chat/components/MessageTyping.vue';
@@ -8,9 +9,23 @@ defineProps<{
messages: ChatMessage[];
}>();
const chatStore = useChat();
defineSlots<{
beforeMessage(props: { message: ChatMessage }): ChatMessage;
}>();
const chatStore = useChat();
const messageComponents = ref<Array<InstanceType<typeof Message>>>([]);
const { initialMessages, waitingForResponse } = chatStore;
watch(
() => messageComponents.value.length,
() => {
const lastMessageComponent = messageComponents.value[messageComponents.value.length - 1];
if (lastMessageComponent) {
lastMessageComponent.scrollToView();
}
},
);
</script>
<template>
<div class="chat-messages-list">
@@ -19,7 +34,14 @@ const { initialMessages, waitingForResponse } = chatStore;
:key="initialMessage.id"
:message="initialMessage"
/>
<Message v-for="message in messages" :key="message.id" :message="message" />
<template v-for="message in messages" :key="message.id">
<Message ref="messageComponents" :message="message">
<template #beforeMessage="{ message }">
<slot name="beforeMessage" v-bind="{ message }" />
</template>
</Message>
</template>
<MessageTyping v-if="waitingForResponse" />
</div>
</template>