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:
92
packages/@n8n/chat/src/components/ChatFile.vue
Normal file
92
packages/@n8n/chat/src/components/ChatFile.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || '<Empty response>';
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user