1
1
mirror of https://github.com/n8n-io/n8n.git synced 2024-08-17 00:50:42 +03:00

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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 2309 additions and 940 deletions

View File

@ -7,15 +7,15 @@ export function getManualChatModal() {
}
export function getManualChatInput() {
return cy.getByTestId('workflow-chat-input');
return getManualChatModal().get('.chat-inputs textarea');
}
export function getManualChatSendButton() {
return getManualChatModal().getByTestId('workflow-chat-send-button');
return getManualChatModal().get('.chat-input-send-button');
}
export function getManualChatMessages() {
return getManualChatModal().get('.messages .message');
return getManualChatModal().get('.chat-messages-list .chat-message');
}
export function getManualChatModalCloseButton() {

View File

@ -36,6 +36,7 @@
}
},
"dependencies": {
"@vueuse/core": "^10.11.0",
"highlight.js": "^11.8.0",
"markdown-it-link-attributes": "^4.0.1",
"uuid": "^8.3.2",

View File

@ -41,3 +41,15 @@ export const Windowed: Story = {
mode: 'window',
} satisfies Partial<ChatOptions>,
};
export const WorkflowChat: Story = {
name: 'Workflow Chat',
args: {
webhookUrl: 'http://localhost:5678/webhook/ad324b56-3e40-4b27-874f-58d150504edc/chat',
mode: 'fullscreen',
allowedFilesMimeTypes: 'image/*,text/*,audio/*, application/pdf',
allowFileUploads: true,
showWelcomeScreen: false,
initialMessages: [],
} satisfies Partial<ChatOptions>,
};

View File

@ -5,15 +5,23 @@ async function getAccessToken() {
export async function authenticatedFetch<T>(...args: Parameters<typeof fetch>): Promise<T> {
const accessToken = await getAccessToken();
const body = args[1]?.body;
const headers: RequestInit['headers'] & { 'Content-Type'?: string } = {
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
...args[1]?.headers,
};
// Automatically set content type to application/json if body is FormData
if (body instanceof FormData) {
delete headers['Content-Type'];
} else {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(args[0], {
...args[1],
mode: 'cors',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
...args[1]?.headers,
},
headers,
});
return (await response.json()) as T;
@ -37,6 +45,28 @@ export async function post<T>(url: string, body: object = {}, options: RequestIn
body: JSON.stringify(body),
});
}
export async function postWithFiles<T>(
url: string,
body: Record<string, unknown> = {},
files: File[] = [],
options: RequestInit = {},
) {
const formData = new FormData();
for (const key in body) {
formData.append(key, body[key] as string);
}
for (const file of files) {
formData.append('files', file);
}
return await authenticatedFetch<T>(url, {
...options,
method: 'POST',
body: formData,
});
}
export async function put<T>(url: string, body: object = {}, options: RequestInit = {}) {
return await authenticatedFetch<T>(url, {

View File

@ -1,4 +1,4 @@
import { get, post } from '@n8n/chat/api/generic';
import { get, post, postWithFiles } from '@n8n/chat/api/generic';
import type {
ChatOptions,
LoadPreviousSessionResponse,
@ -20,7 +20,27 @@ export async function loadPreviousSession(sessionId: string, options: ChatOption
);
}
export async function sendMessage(message: string, sessionId: string, options: ChatOptions) {
export async function sendMessage(
message: string,
files: File[],
sessionId: string,
options: ChatOptions,
) {
if (files.length > 0) {
return await postWithFiles<SendMessageResponse>(
`${options.webhookUrl}`,
{
action: 'sendMessage',
[options.chatSessionKey as string]: sessionId,
[options.chatInputKey as string]: message,
...(options.metadata ? { metadata: options.metadata } : {}),
},
files,
{
headers: options.webhookConfig?.headers,
},
);
}
const method = options.webhookConfig?.method === 'POST' ? post : get;
return await method<SendMessageResponse>(
`${options.webhookUrl}`,

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>

View File

@ -1 +1,2 @@
@import 'tokens';
@import 'markdown';

View File

@ -0,0 +1,627 @@
@import 'highlight.js/styles/github.css';
// https://github.com/pxlrbt/markdown-css
.chat-message-markdown {
/*
universalize.css (v1.0.2) by Alexander Sandberg (https://alexandersandberg.com)
------------------------------------------------------------------------------
Based on Sanitize.css (https://github.com/csstools/sanitize.css).
(all) = Used for all browsers.
x lines = Applies to x lines down, including current line.
------------------------------------------------------------------------------
*/
/*
1. Use default UI font (all)
2. Make font size more accessible to everyone (all)
3. Make line height consistent (all)
4. Prevent font size adjustment after orientation changes (IE, iOS)
5. Prevent overflow from long words (all)
*/
font-size: 125%; /* 2 */
line-height: 1.6; /* 3 */
-webkit-text-size-adjust: 100%; /* 4 */
word-break: break-word; /* 5 */
/*
Prevent padding and border from affecting width (all)
*/
*,
::before,
::after {
box-sizing: border-box;
}
/*
1. Inherit text decoration (all)
2. Inherit vertical alignment (all)
*/
::before,
::after {
text-decoration: inherit; /* 1 */
vertical-align: inherit; /* 2 */
}
/*
Remove inconsistent and unnecessary margins
*/
body, /* (all) */
dl dl, /* (Chrome, Edge, IE, Safari) 5 lines */
dl ol,
dl ul,
ol dl,
ul dl,
ol ol, /* (Edge 18-, IE) 4 lines */
ol ul,
ul ol,
ul ul,
button, /* (Safari) 3 lines */
input,
select,
textarea { /* (Firefox, Safari) */
margin: 0;
}
/*
1. Show overflow (IE18-, IE)
2. Correct sizing (Firefox)
*/
hr {
overflow: visible;
height: 0;
}
/*
Add correct display
*/
main, /* (IE11) */
details { /* (Edge 18-, IE) */
display: block;
}
summary { /* (all) */
display: list-item;
}
/*
Remove style on navigation lists (all)
*/
nav ol,
nav ul {
list-style: none;
padding: 0;
}
/*
1. Use default monospace UI font (all)
2. Correct font sizing (all)
*/
pre,
code,
kbd,
samp {
font-family:
/* macOS 10.10+ */ "Menlo",
/* Windows 6+ */ "Consolas",
/* Android 4+ */ "Roboto Mono",
/* Ubuntu 10.10+ */ "Ubuntu Monospace",
/* KDE Plasma 5+ */ "Noto Mono",
/* KDE Plasma 4+ */ "Oxygen Mono",
/* Linux/OpenOffice fallback */ "Liberation Mono",
/* fallback */ monospace,
/* macOS emoji */ "Apple Color Emoji",
/* Windows emoji */ "Segoe UI Emoji",
/* Windows emoji */ "Segoe UI Symbol",
/* Linux emoji */ "Noto Color Emoji"; /* 1 */
font-size: 1em; /* 2 */
}
/*
1. Change cursor for <abbr> elements (all)
2. Add correct text decoration (Edge 18-, IE, Safari)
*/
abbr[title] {
cursor: help; /* 1 */
text-decoration: underline; /* 2 */
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted; /* 2 */
}
/*
Add correct font weight (Chrome, Edge, Safari)
*/
b,
strong {
font-weight: bolder;
}
/*
Add correct font size (all)
*/
small {
font-size: 80%;
}
/*
Change alignment on media elements (all)
*/
audio,
canvas,
iframe,
img,
svg,
video {
vertical-align: middle;
}
/*
Remove border on iframes (all)
*/
iframe {
border-style: none;
}
/*
Change fill color to match text (all)
*/
svg:not([fill]) {
fill: currentColor;
}
/*
Hide overflow (IE11)
*/
svg:not(:root) {
overflow: hidden;
}
/*
Show overflow (Edge 18-, IE)
*/
button,
input {
overflow: visible;
}
/*
Remove inheritance of text transform (Edge 18-, Firefox, IE)
*/
button,
select {
text-transform: none;
}
/*
Correct inability to style buttons (iOS, Safari)
*/
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
/*
1. Fix inconsistent appearance (all)
2. Correct padding (Firefox)
*/
fieldset {
border: 1px solid #666; /* 1 */
padding: 0.35em 0.75em 0.625em; /* 2 */
}
/*
1. Correct color inheritance from <fieldset> (IE)
2. Correct text wrapping (Edge 18-, IE)
*/
legend {
color: inherit; /* 1 */
display: table; /* 2 */
max-width: 100%; /* 2 */
white-space: normal; /* 2 */
}
/*
1. Add correct display (Edge 18-, IE)
2. Add correct vertical alignment (Chrome, Edge, Firefox)
*/
progress {
display: inline-block; /* 1 */
vertical-align: baseline; /* 2 */
}
/*
1. Remove default vertical scrollbar (IE)
2. Change resize direction (all)
*/
textarea {
overflow: auto; /* 1 */
resize: vertical; /* 2 */
}
/*
1. Correct outline style (Safari)
2. Correct odd appearance (Chrome, Edge, Safari)
*/
[type="search"] {
outline-offset: -2px; /* 1 */
-webkit-appearance: textfield; /* 2 */
}
/*
Correct cursor style of increment and decrement buttons (Safari)
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
Correct text style (Chrome, Edge, Safari)
*/
::-webkit-input-placeholder {
color: inherit;
opacity: 0.54;
}
/*
Remove inner padding (Chrome, Edge, Safari on macOS)
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Inherit font properties (Safari)
2. Correct inability to style upload buttons (iOS, Safari)
*/
::-webkit-file-upload-button {
font: inherit; /* 1 */
-webkit-appearance: button; /* 2 */
}
/*
Remove inner border and padding of focus outlines (Firefox)
*/
::-moz-focus-inner {
border-style: none;
padding: 0;
}
/*
Restore focus outline style (Firefox)
*/
:-moz-focusring {
outline: 1px dotted ButtonText;
}
/*
Remove :invalid styles (Firefox)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Change cursor on busy elements (all)
*/
[aria-busy="true"] {
cursor: progress;
}
/*
Change cursor on control elements (all)
*/
[aria-controls] {
cursor: pointer;
}
/*
Change cursor on disabled, non-editable, or inoperable elements (all)
*/
[aria-disabled="true"],
[disabled] {
cursor: not-allowed;
}
/*
Change display on visually hidden accessible elements (all)
*/
[aria-hidden="false"][hidden] {
display: inline;
display: initial;
}
[aria-hidden="false"][hidden]:not(:focus) {
clip: rect(0, 0, 0, 0);
position: absolute;
}
/*
Print out URLs after links (all)
*/
@media print {
a[href^="http"]::after {
content: " (" attr(href) ")";
}
}
/* ----- Variables ----- */
/* Light mode default, dark mode if recognized as preferred */
:root {
--background-main: #fefefe;
--background-element: #eee;
--background-inverted: #282a36;
--text-main: #1f1f1f;
--text-alt: #333;
--text-inverted: #fefefe;
--border-element: #282a36;
--theme: #7a283a;
--theme-light: hsl(0, 25%, 65%);
--theme-dark: hsl(0, 25%, 45%);
}
/* @media (prefers-color-scheme: dark) {
:root {
--background-main: #282a36;
--text-main: #fefefe;
}
} */
/* ----- Base ----- */
body {
margin: auto;
max-width: 36rem;
min-height: 100%;
overflow-x: hidden;
}
/* ----- Typography ----- */
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 3.2rem 0 0.8em;
}
/*
Heading sizes based on a modular scale of 1.25 (all)
*/
h1 {
font-size: 2.441rem;
line-height: 1.1;
}
h2 {
font-size: 1.953rem;
line-height: 1.15;
}
h3 {
font-size: 1.563rem;
line-height: 1.2;
}
h4 {
font-size: 1.25rem;
line-height: 1.3;
}
h5 {
font-size: 1rem;
line-height: 1.4;
}
h6 {
font-size: 1rem;
line-height: 1.4;
/* differentiate from h5, somehow. color or style? */
}
p,
ul,
ol,
figure {
margin: 0.6rem 0 1.2rem;
}
/*
Subtitles
- Change to header h* + span instead?
- Add support for taglines (small title above main) as well? Needs <header>:
header > span:first-child
*/
h1 span,
h2 span,
h3 span,
h4 span,
h5 span,
h6 span {
display: block;
font-size: 1em;
font-style: italic;
font-weight: normal;
line-height: 1.3;
margin-top: 0.3em;
}
h1 span {
font-size: 0.6em;
}
h2 span {
font-size: 0.7em;
}
h3 span {
font-size: 0.8em;
}
h4 span {
font-size: 0.9em;
}
small {
font-size: 1em;
opacity: 0.8; /* or some other way of differentiating it from body text */
}
mark {
background: pink; /* change to proper color, based on theme */
}
/*
Define a custom tab-size in browsers that support it.
*/
pre {
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
}
/*
Long underlined text can be hard to read for dyslexics. Replace with bold.
*/
ins {
text-decoration: none;
font-weight: bolder;
}
blockquote {
border-left: 0.3rem solid #7a283a;
border-left: 0.3rem solid var(--theme);
margin: 0.6rem 0 1.2rem 0;
padding-left: 2rem;
}
blockquote p {
font-size: 1.2em;
font-style: italic;
}
figure {
margin: 0;
}
/* ----- Layout ----- */
body {
background: #fefefe;
background: var(--background-main);
color: #1f1f1f;
color: var(--text-main);
}
a {
color: #7a283a;
color: var(--theme);
text-decoration: underline;
}
a:hover {
color: hsl(0, 25%, 65%);
color: var(--theme-light);
}
a:active {
color: hsl(0, 25%, 45%);
color: var(--theme-dark);
}
:focus {
outline: 3px solid hsl(0, 25%, 65%);
outline: 3px solid var(--theme-light);
outline-offset: 3px;
}
input {
background: #eee;
background: var(--background-element);
padding: 0.5rem 0.65rem;
border-radius: 0.5rem;
border: 2px solid #282a36;
border: 2px solid var(--border-element);
font-size: 1rem;
}
mark {
background: pink; /* change to proper color, based on theme */
padding: 0.1em 0.15em;
}
kbd, /* different style for kbd? */
code {
background: #eee;
padding: 0.1em 0.25em;
border-radius: 0.2rem;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}
kbd > kbd {
padding-left: 0;
padding-right: 0;
}
pre {
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
}
pre code {
display: block;
padding: 0.3em 0.7em;
word-break: normal;
overflow-x: auto;
}
/* ----- Forms ----- */
/* ----- Misc ----- */
[tabindex="-1"]:focus {
outline: none;
}
[hidden] {
display: none;
}
[aria-disabled],
[disabled] {
cursor: not-allowed !important;
pointer-events: none !important;
}
/*
Style anchor links only
*/
a[href^='#']::after {
content: '';
}
/*
Skip link
*/
body > a:first-child {
background: #7a283a;
background: var(--theme);
border-radius: 0.2rem;
color: #fefefe;
color: var(--text-inverted);
padding: 0.3em 0.5em;
position: absolute;
top: -10rem;
}
body > a:first-child:focus {
top: 1rem;
}
}

View File

@ -24,11 +24,12 @@ export const ChatPlugin: Plugin<ChatOptions> = {
})),
);
async function sendMessage(text: string) {
async function sendMessage(text: string, files: File[] = []) {
const sentMessage: ChatMessage = {
id: uuidv4(),
text,
sender: 'user',
files,
createdAt: new Date().toISOString(),
};
@ -41,6 +42,7 @@ export const ChatPlugin: Plugin<ChatOptions> = {
const sendMessageResponse = await api.sendMessage(
text,
files,
currentSessionId.value as string,
options,
);

View File

@ -8,5 +8,5 @@ export interface Chat {
waitingForResponse: Ref<boolean>;
loadPreviousSession?: () => Promise<string | undefined>;
startNewSession?: () => Promise<void>;
sendMessage: (text: string) => Promise<void>;
sendMessage: (text: string, files: File[]) => Promise<void>;
}

View File

@ -16,4 +16,5 @@ interface ChatMessageBase {
createdAt: string;
transparent?: boolean;
sender: 'user' | 'bot';
files?: File[];
}

View File

@ -1,4 +1,5 @@
import type { Component, Ref } from 'vue';
export interface ChatOptions {
webhookUrl: string;
webhookConfig?: {
@ -30,4 +31,6 @@ export interface ChatOptions {
theme?: {};
messageComponents?: Record<string, Component>;
disabled?: Ref<boolean>;
allowFileUploads?: Ref<boolean> | boolean;
allowedFilesMimeTypes?: Ref<string> | string;
}

View File

@ -38,6 +38,14 @@ export const toolsAgentProperties: INodeProperties[] = [
default: false,
description: 'Whether or not the output should include intermediate steps the agent took',
},
{
displayName: 'Automatically Passthrough Binary Images',
name: 'passthroughBinaryImages',
type: 'boolean',
default: true,
description:
'Whether or not binary images should be automatically passed through to the agent as image type messages',
},
],
},
];

View File

@ -1,9 +1,10 @@
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
import { BINARY_ENCODING, NodeConnectionType, NodeOperationError } from 'n8n-workflow';
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import type { AgentAction, AgentFinish, AgentStep } from 'langchain/agents';
import { AgentExecutor, createToolCallingAgent } from 'langchain/agents';
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
import type { BaseMessagePromptTemplateLike } from '@langchain/core/prompts';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { omit } from 'lodash';
import type { Tool } from '@langchain/core/tools';
@ -13,6 +14,7 @@ import type { ZodObject } from 'zod';
import { z } from 'zod';
import type { BaseOutputParser, StructuredOutputParser } from '@langchain/core/output_parsers';
import { OutputFixingParser } from 'langchain/output_parsers';
import { HumanMessage } from '@langchain/core/messages';
import {
isChatInstance,
getPromptInputByType,
@ -39,6 +41,40 @@ function getOutputParserSchema(outputParser: BaseOutputParser): ZodObject<any, a
return schema;
}
async function extractBinaryMessages(ctx: IExecuteFunctions) {
const binaryData = ctx.getInputData(0, 'main')?.[0]?.binary ?? {};
const binaryMessages = await Promise.all(
Object.values(binaryData)
.filter((data) => data.mimeType.startsWith('image/'))
.map(async (data) => {
let binaryUrlString;
// In filesystem mode we need to get binary stream by id before converting it to buffer
if (data.id) {
const binaryBuffer = await ctx.helpers.binaryToBuffer(
await ctx.helpers.getBinaryStream(data.id),
);
binaryUrlString = `data:${data.mimeType};base64,${Buffer.from(binaryBuffer).toString(BINARY_ENCODING)}`;
} else {
binaryUrlString = data.data.includes('base64')
? data.data
: `data:${data.mimeType};base64,${data.data}`;
}
return {
type: 'image_url',
image_url: {
url: binaryUrlString,
},
};
}),
);
return new HumanMessage({
content: [...binaryMessages],
});
}
export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
this.logger.verbose('Executing Tools Agent');
const model = await this.getInputConnectionData(NodeConnectionType.AiLanguageModel, 0);
@ -113,12 +149,20 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeE
returnIntermediateSteps?: boolean;
};
const prompt = ChatPromptTemplate.fromMessages([
const passthroughBinaryImages = this.getNodeParameter('options.passthroughBinaryImages', 0, true);
const messages: BaseMessagePromptTemplateLike[] = [
['system', `{system_message}${outputParser ? '\n\n{formatting_instructions}' : ''}`],
['placeholder', '{chat_history}'],
['human', '{input}'],
['placeholder', '{agent_scratchpad}'],
]);
];
const hasBinaryData = this.getInputData(0, 'main')?.[0]?.binary !== undefined;
if (hasBinaryData && passthroughBinaryImages) {
const binaryMessage = await extractBinaryMessages(this);
messages.push(binaryMessage);
}
const prompt = ChatPromptTemplate.fromMessages(messages);
const agent = createToolCallingAgent({
llm: model,

View File

@ -109,6 +109,30 @@ export class DocumentDefaultDataLoader implements INodeType {
},
],
},
{
displayName: 'Mode',
name: 'binaryMode',
type: 'options',
default: 'allInputData',
required: true,
displayOptions: {
show: {
dataType: ['binary'],
},
},
options: [
{
name: 'Load All Input Data',
value: 'allInputData',
description: 'Use all Binary data that flows into the parent agent or chain',
},
{
name: 'Load Specific Data',
value: 'specificField',
description: 'Load data from a specific field in the parent agent or chain',
},
],
},
{
displayName: 'Data Format',
name: 'loader',
@ -187,6 +211,9 @@ export class DocumentDefaultDataLoader implements INodeType {
show: {
dataType: ['binary'],
},
hide: {
binaryMode: ['allInputData'],
},
},
},
{

View File

@ -1,11 +1,15 @@
import {
type IDataObject,
type IWebhookFunctions,
type IWebhookResponseData,
type INodeType,
type INodeTypeDescription,
NodeConnectionType,
import { Node, NodeConnectionType } from 'n8n-workflow';
import type {
IDataObject,
IWebhookFunctions,
IWebhookResponseData,
INodeTypeDescription,
MultiPartFormData,
INodeExecutionData,
IBinaryData,
INodeProperties,
} from 'n8n-workflow';
import { pick } from 'lodash';
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
import { createPage } from './templates';
@ -13,15 +17,31 @@ import { validateAuth } from './GenericFunctions';
import type { LoadPreviousSessionChatOption } from './types';
const CHAT_TRIGGER_PATH_IDENTIFIER = 'chat';
const allowFileUploadsOption: INodeProperties = {
displayName: 'Allow File Uploads',
name: 'allowFileUploads',
type: 'boolean',
default: false,
description: 'Whether to allow file uploads in the chat',
};
const allowedFileMimeTypeOption: INodeProperties = {
displayName: 'Allowed File Mime Types',
name: 'allowedFilesMimeTypes',
type: 'string',
default: '*',
placeholder: 'e.g. image/*, text/*, application/pdf',
description:
'Allowed file types for upload. Comma-separated list of <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types" target="_blank">MIME types</a>.',
};
export class ChatTrigger implements INodeType {
export class ChatTrigger extends Node {
description: INodeTypeDescription = {
displayName: 'Chat Trigger',
name: 'chatTrigger',
icon: 'fa:comments',
iconColor: 'black',
group: ['trigger'],
version: 1,
version: [1, 1.1],
description: 'Runs the workflow when an n8n generated webchat is submitted',
defaults: {
name: 'When chat message received',
@ -194,6 +214,20 @@ export class ChatTrigger implements INodeType {
default: 'Hi there! 👋\nMy name is Nathan. How can I assist you today?',
description: 'Default messages shown at the start of the chat, one per line',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
displayOptions: {
show: {
public: [false],
'@version': [{ _cnd: { gte: 1.1 } }],
},
},
placeholder: 'Add Field',
default: {},
options: [allowFileUploadsOption, allowedFileMimeTypeOption],
},
{
displayName: 'Options',
name: 'options',
@ -207,6 +241,22 @@ export class ChatTrigger implements INodeType {
placeholder: 'Add Field',
default: {},
options: [
{
...allowFileUploadsOption,
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
},
{
...allowedFileMimeTypeOption,
displayOptions: {
show: {
'/mode': ['hostedChat'],
},
},
},
{
displayName: 'Input Placeholder',
name: 'inputPlaceholder',
@ -320,11 +370,73 @@ export class ChatTrigger implements INodeType {
],
};
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
const res = this.getResponseObject();
private async handleFormData(context: IWebhookFunctions) {
const req = context.getRequestObject() as MultiPartFormData.Request;
const options = context.getNodeParameter('options', {}) as IDataObject;
const { data, files } = req.body;
const isPublic = this.getNodeParameter('public', false) as boolean;
const nodeMode = this.getNodeParameter('mode', 'hostedChat') as string;
const returnItem: INodeExecutionData = {
json: data,
};
if (files && Object.keys(files).length) {
returnItem.json.files = [] as Array<Omit<IBinaryData, 'data'>>;
returnItem.binary = {};
const count = 0;
for (const fileKey of Object.keys(files)) {
const processedFiles: MultiPartFormData.File[] = [];
if (Array.isArray(files[fileKey])) {
processedFiles.push(...files[fileKey]);
} else {
processedFiles.push(files[fileKey]);
}
let fileIndex = 0;
for (const file of processedFiles) {
let binaryPropertyName = 'data';
// Remove the '[]' suffix from the binaryPropertyName if it exists
if (binaryPropertyName.endsWith('[]')) {
binaryPropertyName = binaryPropertyName.slice(0, -2);
}
if (options.binaryPropertyName) {
binaryPropertyName = `${options.binaryPropertyName.toString()}${count}`;
}
const binaryFile = await context.nodeHelpers.copyBinaryFile(
file.filepath,
file.originalFilename ?? file.newFilename,
file.mimetype,
);
const binaryKey = `${binaryPropertyName}${fileIndex}`;
const binaryInfo = {
...pick(binaryFile, ['fileName', 'fileSize', 'fileType', 'mimeType', 'fileExtension']),
binaryKey,
};
returnItem.binary = Object.assign(returnItem.binary ?? {}, {
[`${binaryKey}`]: binaryFile,
});
returnItem.json.files = [
...(returnItem.json.files as Array<Omit<IBinaryData, 'data'>>),
binaryInfo,
];
fileIndex += 1;
}
}
}
return returnItem;
}
async webhook(ctx: IWebhookFunctions): Promise<IWebhookResponseData> {
const res = ctx.getResponseObject();
const isPublic = ctx.getNodeParameter('public', false) as boolean;
const nodeMode = ctx.getNodeParameter('mode', 'hostedChat') as string;
if (!isPublic) {
res.status(404).end();
return {
@ -332,22 +444,25 @@ export class ChatTrigger implements INodeType {
};
}
const webhookName = this.getWebhookName();
const mode = this.getMode() === 'manual' ? 'test' : 'production';
const bodyData = this.getBodyData() ?? {};
const options = this.getNodeParameter('options', {}) as {
const options = ctx.getNodeParameter('options', {}) as {
getStarted?: string;
inputPlaceholder?: string;
loadPreviousSession?: LoadPreviousSessionChatOption;
showWelcomeScreen?: boolean;
subtitle?: string;
title?: string;
allowFileUploads?: boolean;
allowedFilesMimeTypes?: string;
};
const req = ctx.getRequestObject();
const webhookName = ctx.getWebhookName();
const mode = ctx.getMode() === 'manual' ? 'test' : 'production';
const bodyData = ctx.getBodyData() ?? {};
if (nodeMode === 'hostedChat') {
try {
await validateAuth(this);
await validateAuth(ctx);
} catch (error) {
if (error) {
res.writeHead((error as IDataObject).responseCode as number, {
@ -361,19 +476,19 @@ export class ChatTrigger implements INodeType {
// Show the chat on GET request
if (webhookName === 'setup') {
const webhookUrlRaw = this.getNodeWebhookUrl('default') as string;
const webhookUrlRaw = ctx.getNodeWebhookUrl('default') as string;
const webhookUrl =
mode === 'test' ? webhookUrlRaw.replace('/webhook', '/webhook-test') : webhookUrlRaw;
const authentication = this.getNodeParameter('authentication') as
const authentication = ctx.getNodeParameter('authentication') as
| 'none'
| 'basicAuth'
| 'n8nUserAuth';
const initialMessagesRaw = this.getNodeParameter('initialMessages', '') as string;
const initialMessagesRaw = ctx.getNodeParameter('initialMessages', '') as string;
const initialMessages = initialMessagesRaw
.split('\n')
.filter((line) => line)
.map((line) => line.trim());
const instanceId = this.getInstanceId();
const instanceId = ctx.getInstanceId();
const i18nConfig = pick(options, ['getStarted', 'inputPlaceholder', 'subtitle', 'title']);
@ -388,6 +503,8 @@ export class ChatTrigger implements INodeType {
mode,
instanceId,
authentication,
allowFileUploads: options.allowFileUploads,
allowedFilesMimeTypes: options.allowedFilesMimeTypes,
});
res.status(200).send(page).end();
@ -399,7 +516,7 @@ export class ChatTrigger implements INodeType {
if (bodyData.action === 'loadPreviousSession') {
if (options?.loadPreviousSession === 'memory') {
const memory = (await this.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as
const memory = (await ctx.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as
| BaseChatMemory
| undefined;
const messages = ((await memory?.chatHistory.getMessages()) ?? [])
@ -416,11 +533,21 @@ export class ChatTrigger implements INodeType {
}
}
const returnData: IDataObject = { ...bodyData };
let returnData: INodeExecutionData[];
const webhookResponse: IDataObject = { status: 200 };
if (req.contentType === 'multipart/form-data') {
returnData = [await this.handleFormData(ctx)];
return {
webhookResponse,
workflowData: [returnData],
};
} else {
returnData = [{ json: bodyData }];
}
return {
webhookResponse,
workflowData: [this.helpers.returnJsonArray(returnData)],
workflowData: [ctx.helpers.returnJsonArray(returnData)],
};
}
}

View File

@ -8,6 +8,8 @@ export function createPage({
i18n: { en },
initialMessages,
authentication,
allowFileUploads,
allowedFilesMimeTypes,
}: {
instanceId: string;
webhookUrl?: string;
@ -19,6 +21,8 @@ export function createPage({
initialMessages: string[];
mode: 'test' | 'production';
authentication: AuthenticationChatOption;
allowFileUploads?: boolean;
allowedFilesMimeTypes?: string;
}) {
const validAuthenticationOptions: AuthenticationChatOption[] = [
'none',
@ -35,6 +39,8 @@ export function createPage({
? authentication
: 'none';
const sanitizedShowWelcomeScreen = !!showWelcomeScreen;
const sanitizedAllowFileUploads = !!allowFileUploads;
const sanitizedAllowedFilesMimeTypes = allowedFilesMimeTypes?.toString() ?? '';
const sanitizedLoadPreviousSession = validLoadPreviousSessionOptions.includes(
loadPreviousSession as LoadPreviousSessionChatOption,
)
@ -103,6 +109,8 @@ export function createPage({
'X-Instance-Id': '${instanceId}',
}
},
allowFileUploads: ${sanitizedAllowFileUploads},
allowedFilesMimeTypes: '${sanitizedAllowedFilesMimeTypes}',
i18n: {
${en ? `en: ${JSON.stringify(en)},` : ''}
},

View File

@ -1,6 +1,6 @@
import { pipeline } from 'stream/promises';
import { createWriteStream } from 'fs';
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import type { IBinaryData, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError, BINARY_ENCODING } from 'n8n-workflow';
import type { TextSplitter } from '@langchain/textsplitters';
@ -60,21 +60,10 @@ export class N8nBinaryLoader {
return docs;
}
async processItem(item: INodeExecutionData, itemIndex: number): Promise<Document[]> {
const selectedLoader: keyof typeof SUPPORTED_MIME_TYPES = this.context.getNodeParameter(
'loader',
itemIndex,
'auto',
) as keyof typeof SUPPORTED_MIME_TYPES;
const docs: Document[] = [];
const metadata = getMetadataFiltersValues(this.context, itemIndex);
if (!item) return [];
const binaryData = this.context.helpers.assertBinaryData(itemIndex, this.binaryDataKey);
const { mimeType } = binaryData;
private async validateMimeType(
mimeType: string,
selectedLoader: keyof typeof SUPPORTED_MIME_TYPES,
): Promise<void> {
// Check if loader matches the mime-type of the data
if (selectedLoader !== 'auto' && !SUPPORTED_MIME_TYPES[selectedLoader].includes(mimeType)) {
const neededLoader = Object.keys(SUPPORTED_MIME_TYPES).find((loader) =>
@ -90,6 +79,7 @@ export class N8nBinaryLoader {
if (!Object.values(SUPPORTED_MIME_TYPES).flat().includes(mimeType)) {
throw new NodeOperationError(this.context.getNode(), `Unsupported mime type: ${mimeType}`);
}
if (
!SUPPORTED_MIME_TYPES[selectedLoader].includes(mimeType) &&
selectedLoader !== 'textLoader' &&
@ -100,24 +90,31 @@ export class N8nBinaryLoader {
`Unsupported mime type: ${mimeType} for selected loader: ${selectedLoader}`,
);
}
}
let filePathOrBlob: string | Blob;
private async getFilePathOrBlob(
binaryData: IBinaryData,
mimeType: string,
): Promise<string | Blob> {
if (binaryData.id) {
const binaryBuffer = await this.context.helpers.binaryToBuffer(
await this.context.helpers.getBinaryStream(binaryData.id),
);
filePathOrBlob = new Blob([binaryBuffer], {
return new Blob([binaryBuffer], {
type: mimeType,
});
} else {
filePathOrBlob = new Blob([Buffer.from(binaryData.data, BINARY_ENCODING)], {
return new Blob([Buffer.from(binaryData.data, BINARY_ENCODING)], {
type: mimeType,
});
}
}
let loader: PDFLoader | CSVLoader | EPubLoader | DocxLoader | TextLoader | JSONLoader;
let cleanupTmpFile: DirectoryResult['cleanup'] | undefined = undefined;
private async getLoader(
mimeType: string,
filePathOrBlob: string | Blob,
itemIndex: number,
): Promise<PDFLoader | CSVLoader | EPubLoader | DocxLoader | TextLoader | JSONLoader> {
switch (mimeType) {
case 'application/pdf':
const splitPages = this.context.getNodeParameter(
@ -125,10 +122,7 @@ export class N8nBinaryLoader {
itemIndex,
false,
) as boolean;
loader = new PDFLoader(filePathOrBlob, {
splitPages,
});
break;
return new PDFLoader(filePathOrBlob, { splitPages });
case 'text/csv':
const column = this.context.getNodeParameter(
`${this.optionsPrefix}column`,
@ -140,38 +134,23 @@ export class N8nBinaryLoader {
itemIndex,
',',
) as string;
loader = new CSVLoader(filePathOrBlob, {
column: column ?? undefined,
separator,
});
break;
return new CSVLoader(filePathOrBlob, { column: column ?? undefined, separator });
case 'application/epub+zip':
// EPubLoader currently does not accept Blobs https://github.com/langchain-ai/langchainjs/issues/1623
let filePath: string;
if (filePathOrBlob instanceof Blob) {
const tmpFileData = await tmpFile({ prefix: 'epub-loader-' });
cleanupTmpFile = tmpFileData.cleanup;
try {
const bufferData = await filePathOrBlob.arrayBuffer();
await pipeline([new Uint8Array(bufferData)], createWriteStream(tmpFileData.path));
loader = new EPubLoader(tmpFileData.path);
break;
} catch (error) {
await cleanupTmpFile();
throw new NodeOperationError(this.context.getNode(), error as Error);
}
const bufferData = await filePathOrBlob.arrayBuffer();
await pipeline([new Uint8Array(bufferData)], createWriteStream(tmpFileData.path));
return new EPubLoader(tmpFileData.path);
} else {
filePath = filePathOrBlob;
}
loader = new EPubLoader(filePath);
break;
return new EPubLoader(filePath);
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
loader = new DocxLoader(filePathOrBlob);
break;
return new DocxLoader(filePathOrBlob);
case 'text/plain':
loader = new TextLoader(filePathOrBlob);
break;
return new TextLoader(filePathOrBlob);
case 'application/json':
const pointers = this.context.getNodeParameter(
`${this.optionsPrefix}pointers`,
@ -179,15 +158,77 @@ export class N8nBinaryLoader {
'',
) as string;
const pointersArray = pointers.split(',').map((pointer) => pointer.trim());
loader = new JSONLoader(filePathOrBlob, pointersArray);
break;
return new JSONLoader(filePathOrBlob, pointersArray);
default:
loader = new TextLoader(filePathOrBlob);
return new TextLoader(filePathOrBlob);
}
}
const loadedDoc = this.textSplitter
private async loadDocuments(
loader: PDFLoader | CSVLoader | EPubLoader | DocxLoader | TextLoader | JSONLoader,
): Promise<Document[]> {
return this.textSplitter
? await this.textSplitter.splitDocuments(await loader.load())
: await loader.load();
}
private async cleanupTmpFileIfNeeded(
cleanupTmpFile: DirectoryResult['cleanup'] | undefined,
): Promise<void> {
if (cleanupTmpFile) {
await cleanupTmpFile();
}
}
async processItem(item: INodeExecutionData, itemIndex: number): Promise<Document[]> {
const docs: Document[] = [];
const binaryMode = this.context.getNodeParameter('binaryMode', itemIndex, 'allInputData');
if (binaryMode === 'allInputData') {
const binaryData = this.context.getInputData();
for (const data of binaryData) {
if (data.binary) {
const binaryDataKeys = Object.keys(data.binary);
for (const fileKey of binaryDataKeys) {
const processedDocuments = await this.processItemByKey(item, itemIndex, fileKey);
docs.push(...processedDocuments);
}
}
}
} else {
const processedDocuments = await this.processItemByKey(item, itemIndex, this.binaryDataKey);
docs.push(...processedDocuments);
}
return docs;
}
async processItemByKey(
item: INodeExecutionData,
itemIndex: number,
binaryKey: string,
): Promise<Document[]> {
const selectedLoader: keyof typeof SUPPORTED_MIME_TYPES = this.context.getNodeParameter(
'loader',
itemIndex,
'auto',
) as keyof typeof SUPPORTED_MIME_TYPES;
const docs: Document[] = [];
const metadata = getMetadataFiltersValues(this.context, itemIndex);
if (!item) return [];
const binaryData = this.context.helpers.assertBinaryData(itemIndex, binaryKey);
const { mimeType } = binaryData;
await this.validateMimeType(mimeType, selectedLoader);
const filePathOrBlob = await this.getFilePathOrBlob(binaryData, mimeType);
const cleanupTmpFile: DirectoryResult['cleanup'] | undefined = undefined;
const loader = await this.getLoader(mimeType, filePathOrBlob, itemIndex);
const loadedDoc = await this.loadDocuments(loader);
docs.push(...loadedDoc);
@ -200,9 +241,8 @@ export class N8nBinaryLoader {
});
}
if (cleanupTmpFile) {
await cleanupTmpFile();
}
await this.cleanupTmpFileIfNeeded(cleanupTmpFile);
return docs;
}
}

View File

@ -1,5 +1,10 @@
import { NodeConnectionType, NodeOperationError, jsonStringify } from 'n8n-workflow';
import type { EventNamesAiNodesType, IDataObject, IExecuteFunctions } from 'n8n-workflow';
import type {
EventNamesAiNodesType,
IDataObject,
IExecuteFunctions,
IWebhookFunctions,
} from 'n8n-workflow';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { BaseOutputParser } from '@langchain/core/output_parsers';
import type { BaseMessage } from '@langchain/core/messages';
@ -81,7 +86,7 @@ export function getPromptInputByType(options: {
}
export function getSessionId(
ctx: IExecuteFunctions,
ctx: IExecuteFunctions | IWebhookFunctions,
itemIndex: number,
selectorKey = 'sessionIdType',
autoSelect = 'fromInput',
@ -91,7 +96,15 @@ export function getSessionId(
const selectorType = ctx.getNodeParameter(selectorKey, itemIndex) as string;
if (selectorType === autoSelect) {
sessionId = ctx.evaluateExpression('{{ $json.sessionId }}', itemIndex) as string;
// If memory node is used in webhook like node(like chat trigger node), it doesn't have access to evaluateExpression
// so we try to extract sessionId from the bodyData
if ('getBodyData' in ctx) {
const bodyData = ctx.getBodyData() ?? {};
sessionId = bodyData.sessionId as string;
} else {
sessionId = ctx.evaluateExpression('{{ $json.sessionId }}', itemIndex) as string;
}
if (sessionId === '' || sessionId === undefined) {
throw new NodeOperationError(ctx.getNode(), 'No session ID found', {
description:

View File

@ -51,8 +51,8 @@
"@vue-flow/core": "^1.33.5",
"@vue-flow/minimap": "^1.4.0",
"@vue-flow/node-toolbar": "^1.1.0",
"@vueuse/components": "^10.5.0",
"@vueuse/core": "^10.5.0",
"@vueuse/components": "^10.11.0",
"@vueuse/core": "^10.11.0",
"axios": "1.6.7",
"chart.js": "^4.4.0",
"codemirror-lang-html-n8n": "^1.0.0",

View File

@ -47,7 +47,7 @@ import PersonalizationModal from '@/components/PersonalizationModal.vue';
import TagsManager from '@/components/TagsManager/TagsManager.vue';
import UpdatesPanel from '@/components/UpdatesPanel.vue';
import NpsSurvey from '@/components/NpsSurvey.vue';
import WorkflowLMChat from '@/components/WorkflowLMChat.vue';
import WorkflowLMChat from '@/components/WorkflowLMChat/WorkflowLMChat.vue';
import WorkflowSettings from '@/components/WorkflowSettings.vue';
import DeleteUserModal from '@/components/DeleteUserModal.vue';
import ActivationModal from '@/components/ActivationModal.vue';

View File

@ -1,695 +0,0 @@
<template>
<Modal
:name="WORKFLOW_LM_CHAT_MODAL_KEY"
width="80%"
max-height="80%"
:title="
$locale.baseText('chat.window.title', {
interpolate: {
nodeName: connectedNode?.name || $locale.baseText('chat.window.noChatNode'),
},
})
"
:event-bus="modalBus"
:scrollable="false"
@keydown.stop
>
<template #content>
<div class="workflow-lm-chat" data-test-id="workflow-lm-chat-dialog">
<div class="messages ignore-key-press">
<div
v-for="message in messages"
:key="`${message.executionId}__${message.sender}`"
ref="messageContainer"
:class="['message', message.sender]"
>
<div :class="['content', message.sender]">
{{ message.text }}
<div class="message-options no-select-on-click">
<n8n-info-tip
v-if="message.sender === 'bot'"
type="tooltip"
theme="info-light"
tooltip-placement="right"
>
<div v-if="message.executionId">
<n8n-text :bold="true" size="small">
<span @click.stop="displayExecution(message.executionId)">
{{ $locale.baseText('chat.window.chat.chatMessageOptions.executionId') }}:
<a href="#" class="link">{{ message.executionId }}</a>
</span>
</n8n-text>
</div>
</n8n-info-tip>
<div
v-if="message.sender === 'user'"
class="option"
:title="$locale.baseText('chat.window.chat.chatMessageOptions.repostMessage')"
data-test-id="repost-message-button"
@click="repostMessage(message)"
>
<font-awesome-icon icon="redo" />
</div>
<div
v-if="message.sender === 'user'"
class="option"
:title="$locale.baseText('chat.window.chat.chatMessageOptions.reuseMessage')"
data-test-id="reuse-message-button"
@click="reuseMessage(message)"
>
<font-awesome-icon icon="copy" />
</div>
</div>
</div>
</div>
<MessageTyping v-if="isLoading" ref="messageContainer" />
</div>
<div v-if="node && messages.length" class="logs-wrapper" data-test-id="lm-chat-logs">
<n8n-text class="logs-title" tag="p" size="large">{{
$locale.baseText('chat.window.logs')
}}</n8n-text>
<div class="logs">
<RunDataAi :key="messages.length" :node="node" hide-title slim />
</div>
</div>
</div>
</template>
<template #footer>
<div class="workflow-lm-chat-footer">
<n8n-input
ref="inputField"
v-model="currentMessage"
class="message-input"
type="textarea"
:minlength="1"
m
:placeholder="$locale.baseText('chat.window.chat.placeholder')"
data-test-id="workflow-chat-input"
@keydown.stop="updated"
/>
<n8n-tooltip :disabled="currentMessage.length > 0">
<n8n-button
class="send-button"
:disabled="currentMessage === ''"
:loading="isLoading"
:label="$locale.baseText('chat.window.chat.sendButtonText')"
size="large"
icon="comment"
type="primary"
data-test-id="workflow-chat-send-button"
@click.stop="sendChatMessage(currentMessage)"
/>
<template #content>
{{ $locale.baseText('chat.window.chat.provideMessage') }}
</template>
</n8n-tooltip>
<n8n-info-tip class="mt-s">
{{ $locale.baseText('chatEmbed.infoTip.description') }}
<a @click="openChatEmbedModal">
{{ $locale.baseText('chatEmbed.infoTip.link') }}
</a>
</n8n-info-tip>
</div>
</template>
</Modal>
</template>
<script lang="ts">
import { defineAsyncComponent, defineComponent } from 'vue';
import { mapStores } from 'pinia';
import { useToast } from '@/composables/useToast';
import { useMessage } from '@/composables/useMessage';
import Modal from '@/components/Modal.vue';
import {
AI_CATEGORY_AGENTS,
AI_CATEGORY_CHAINS,
AI_CODE_NODE_TYPE,
AI_SUBCATEGORY,
CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE,
CHAT_EMBED_MODAL_KEY,
CHAT_TRIGGER_NODE_TYPE,
MANUAL_CHAT_TRIGGER_NODE_TYPE,
MODAL_CONFIRM,
VIEWS,
WORKFLOW_LM_CHAT_MODAL_KEY,
} from '@/constants';
import { get, last } from 'lodash-es';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createEventBus } from 'n8n-design-system/utils';
import type { IDataObject, INodeType, INode, ITaskData } from 'n8n-workflow';
import { NodeHelpers, NodeConnectionType } from 'n8n-workflow';
import type { INodeUi, IUser } from '@/Interface';
import { useExternalHooks } from '@/composables/useExternalHooks';
// eslint-disable-next-line import/no-unresolved
import MessageTyping from '@n8n/chat/components/MessageTyping.vue';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useRouter } from 'vue-router';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { usePinnedData } from '@/composables/usePinnedData';
import { isEmpty } from '@/utils/typesUtils';
const RunDataAi = defineAsyncComponent(
async () => await import('@/components/RunDataAi/RunDataAi.vue'),
);
interface ChatMessage {
text: string;
sender: 'bot' | 'user';
executionId?: string;
}
// TODO: Add proper type
interface LangChainMessage {
id: string[];
kwargs: {
content: string;
};
}
interface MemoryOutput {
action: string;
chatHistory?: LangChainMessage[];
}
// TODO:
// - display additional information like execution time, tokens used, ...
// - display errors better
export default defineComponent({
name: 'WorkflowLMChat',
components: {
Modal,
MessageTyping,
RunDataAi,
},
setup() {
const router = useRouter();
const externalHooks = useExternalHooks();
const workflowHelpers = useWorkflowHelpers({ router });
const { runWorkflow } = useRunWorkflow({ router });
return {
runWorkflow,
externalHooks,
workflowHelpers,
...useToast(),
...useMessage(),
};
},
data() {
return {
connectedNode: null as INodeUi | null,
currentMessage: '',
messages: [] as ChatMessage[],
modalBus: createEventBus(),
node: null as INodeUi | null,
WORKFLOW_LM_CHAT_MODAL_KEY,
previousMessageIndex: 0,
};
},
computed: {
...mapStores(useWorkflowsStore, useUIStore, useNodeTypesStore),
isLoading(): boolean {
return this.uiStore.isActionActive['workflowRunning'];
},
},
async mounted() {
this.setConnectedNode();
this.messages = this.getChatMessages();
this.setNode();
setTimeout(() => {
this.scrollToLatestMessage();
const inputField = this.$refs.inputField as HTMLInputElement | null;
inputField?.focus();
}, 0);
},
methods: {
displayExecution(executionId: string) {
const workflow = this.workflowHelpers.getCurrentWorkflow();
const route = this.$router.resolve({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: workflow.id, executionId },
});
window.open(route.href, '_blank');
},
repostMessage(message: ChatMessage) {
void this.sendChatMessage(message.text);
},
reuseMessage(message: ChatMessage) {
this.currentMessage = message.text;
const inputField = this.$refs.inputField as HTMLInputElement;
inputField.focus();
},
updated(event: KeyboardEvent) {
const pastMessages = this.workflowsStore.getPastChatMessages;
if (
(this.currentMessage.length === 0 || pastMessages.includes(this.currentMessage)) &&
event.key === 'ArrowUp'
) {
const inputField = this.$refs.inputField as HTMLInputElement;
inputField?.blur();
this.currentMessage =
pastMessages[pastMessages.length - 1 - this.previousMessageIndex] ?? '';
this.previousMessageIndex = (this.previousMessageIndex + 1) % pastMessages.length;
// Refocus to move the cursor to the end of the input
setTimeout(() => inputField?.focus(), 0);
}
if (event.key === 'Enter' && !event.shiftKey && this.currentMessage) {
void this.sendChatMessage(this.currentMessage);
event.stopPropagation();
event.preventDefault();
}
},
async sendChatMessage(message: string) {
if (this.currentMessage.trim() === '') {
this.showError(
new Error(this.$locale.baseText('chat.window.chat.provideMessage')),
this.$locale.baseText('chat.window.chat.emptyChatMessage'),
);
return;
}
const pinnedChatData = usePinnedData(this.getTriggerNode());
if (pinnedChatData.hasData.value) {
const confirmResult = await this.confirm(
this.$locale.baseText('chat.window.chat.unpinAndExecute.description'),
this.$locale.baseText('chat.window.chat.unpinAndExecute.title'),
{
confirmButtonText: this.$locale.baseText('chat.window.chat.unpinAndExecute.confirm'),
cancelButtonText: this.$locale.baseText('chat.window.chat.unpinAndExecute.cancel'),
},
);
if (!(confirmResult === MODAL_CONFIRM)) return;
pinnedChatData.unsetData('unpin-and-send-chat-message-modal');
}
this.messages.push({
text: message,
sender: 'user',
} as ChatMessage);
this.currentMessage = '';
this.previousMessageIndex = 0;
await this.$nextTick();
this.scrollToLatestMessage();
await this.startWorkflowWithMessage(message);
},
setConnectedNode() {
const triggerNode = this.getTriggerNode();
if (!triggerNode) {
this.showError(
new Error('Chat Trigger Node could not be found!'),
'Trigger Node not found',
);
return;
}
const workflow = this.workflowHelpers.getCurrentWorkflow();
const chatNode = this.workflowsStore.getNodes().find((node: INodeUi): boolean => {
if (node.type === CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE) return false;
const nodeType = this.nodeTypesStore.getNodeType(node.type, node.typeVersion);
if (!nodeType) return false;
const isAgent =
nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_AGENTS);
const isChain =
nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_CHAINS);
let isCustomChainOrAgent = false;
if (nodeType.name === AI_CODE_NODE_TYPE) {
const inputs = NodeHelpers.getNodeInputs(workflow, node, nodeType);
const inputTypes = NodeHelpers.getConnectionTypes(inputs);
const outputs = NodeHelpers.getNodeOutputs(workflow, node, nodeType);
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
if (
inputTypes.includes(NodeConnectionType.AiLanguageModel) &&
inputTypes.includes(NodeConnectionType.Main) &&
outputTypes.includes(NodeConnectionType.Main)
) {
isCustomChainOrAgent = true;
}
}
if (!isAgent && !isChain && !isCustomChainOrAgent) return false;
const parentNodes = workflow.getParentNodes(node.name);
const isChatChild = parentNodes.some(
(parentNodeName) => parentNodeName === triggerNode.name,
);
return Boolean(isChatChild && (isAgent || isChain || isCustomChainOrAgent));
});
if (!chatNode) {
this.showError(
new Error(
'Chat only works when an AI agent or chain(except summarization chain) is connected to the chat trigger node',
),
'Missing AI node',
);
return;
}
this.connectedNode = chatNode;
},
getChatMessages(): ChatMessage[] {
if (!this.connectedNode) return [];
const workflow = this.workflowHelpers.getCurrentWorkflow();
const connectedMemoryInputs =
workflow.connectionsByDestinationNode[this.connectedNode.name][NodeConnectionType.AiMemory];
if (!connectedMemoryInputs) return [];
const memoryConnection = (connectedMemoryInputs ?? []).find((i) => i.length > 0)?.[0];
if (!memoryConnection) return [];
const nodeResultData = this.workflowsStore.getWorkflowResultDataByNodeName(
memoryConnection.node,
);
const memoryOutputData = (nodeResultData ?? [])
.map(
(data) => get(data, ['data', NodeConnectionType.AiMemory, 0, 0, 'json']) as MemoryOutput,
)
.find((data) => data.action === 'saveContext');
return (memoryOutputData?.chatHistory ?? []).map((message) => {
return {
text: message.kwargs.content,
sender: last(message.id) === 'HumanMessage' ? 'user' : 'bot',
};
});
},
setNode(): void {
const triggerNode = this.getTriggerNode();
if (!triggerNode) {
return;
}
const workflow = this.workflowHelpers.getCurrentWorkflow();
const childNodes = workflow.getChildNodes(triggerNode.name);
for (const childNode of childNodes) {
// Look for the first connected node with metadata
// TODO: Allow later users to change that in the UI
const resultData = this.workflowsStore.getWorkflowResultDataByNodeName(childNode);
if (!resultData && !Array.isArray(resultData)) {
continue;
}
if (resultData[resultData.length - 1].metadata) {
this.node = this.workflowsStore.getNodeByName(childNode);
break;
}
}
},
getTriggerNode(): INode | null {
const workflow = this.workflowHelpers.getCurrentWorkflow();
const triggerNode = workflow.queryNodes((nodeType: INodeType) =>
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(nodeType.description.name),
);
if (!triggerNode.length) {
return null;
}
return triggerNode[0];
},
async startWorkflowWithMessage(message: string): Promise<void> {
const triggerNode = this.getTriggerNode();
if (!triggerNode) {
this.showError(
new Error('Chat Trigger Node could not be found!'),
'Trigger Node not found',
);
return;
}
let inputKey = 'chatInput';
if (triggerNode.type === MANUAL_CHAT_TRIGGER_NODE_TYPE && triggerNode.typeVersion < 1.1) {
inputKey = 'input';
}
if (triggerNode.type === CHAT_TRIGGER_NODE_TYPE) {
inputKey = 'chatInput';
}
const usersStore = useUsersStore();
const currentUser = usersStore.currentUser ?? ({} as IUser);
const nodeData: ITaskData = {
startTime: new Date().getTime(),
executionTime: 0,
executionStatus: 'success',
data: {
main: [
[
{
json: {
sessionId: `test-${currentUser.id || 'unknown'}`,
action: 'sendMessage',
[inputKey]: message,
},
},
],
],
},
source: [null],
};
const response = await this.runWorkflow({
triggerNode: triggerNode.name,
nodeData,
source: 'RunData.ManualChatMessage',
});
this.workflowsStore.appendChatMessage(message);
if (!response) {
this.showError(
new Error('It was not possible to start workflow!'),
'Workflow could not be started',
);
return;
}
this.waitForExecution(response.executionId);
},
extractResponseMessage(responseData?: IDataObject) {
if (!responseData || isEmpty(responseData)) {
return this.$locale.baseText('chat.window.chat.response.empty');
}
// Paths where the response message might be located
const paths = ['output', 'text', 'response.text'];
const matchedPath = paths.find((path) => get(responseData, path));
if (!matchedPath) return JSON.stringify(responseData, null, 2);
return get(responseData, matchedPath) as string;
},
waitForExecution(executionId?: string) {
const that = this;
const waitInterval = setInterval(() => {
if (!that.isLoading) {
clearInterval(waitInterval);
const lastNodeExecuted =
this.workflowsStore.getWorkflowExecution?.data?.resultData.lastNodeExecuted;
if (!lastNodeExecuted) return;
const nodeResponseDataArray =
get(
this.workflowsStore.getWorkflowExecution?.data?.resultData.runData,
lastNodeExecuted,
) ?? [];
const nodeResponseData = nodeResponseDataArray[nodeResponseDataArray.length - 1];
let responseMessage: string;
if (get(nodeResponseData, 'error')) {
responseMessage = '[ERROR: ' + get(nodeResponseData, 'error.message') + ']';
} else {
const responseData = get(nodeResponseData, 'data.main[0][0].json');
responseMessage = this.extractResponseMessage(responseData);
}
this.messages.push({
text: responseMessage,
sender: 'bot',
executionId,
} as ChatMessage);
void this.$nextTick(() => {
that.setNode();
this.scrollToLatestMessage();
});
}
}, 500);
},
scrollToLatestMessage() {
const containerRef = this.$refs.messageContainer as HTMLElement[] | undefined;
if (containerRef) {
containerRef[containerRef.length - 1]?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
},
closeDialog() {
this.modalBus.emit('close');
void this.externalHooks.run('workflowSettings.dialogVisibleChanged', {
dialogVisible: false,
});
},
openChatEmbedModal() {
this.uiStore.openModal(CHAT_EMBED_MODAL_KEY);
},
},
});
</script>
<style scoped lang="scss">
.no-node-connected {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.workflow-lm-chat {
color: $custom-font-black;
font-size: var(--font-size-s);
display: flex;
height: 100%;
min-height: 400px;
z-index: 9999;
.logs-wrapper {
--node-icon-color: var(--color-text-base);
border: 1px solid var(--color-foreground-base);
border-radius: 4px;
height: 100%;
overflow-y: auto;
width: 100%;
padding: var(--spacing-xs) 0;
.logs-title {
margin: 0 var(--spacing-s) var(--spacing-s);
}
}
.messages {
background-color: var(--color-lm-chat-messages-background);
border: 1px solid var(--color-foreground-base);
border-radius: 4px;
height: 100%;
width: 100%;
overflow: hidden auto;
padding-top: 1.5em;
margin-right: 1em;
.chat-message {
float: left;
margin: var(--spacing-2xs) var(--spacing-s);
}
.message {
float: left;
position: relative;
width: 100%;
.content {
border-radius: var(--border-radius-base);
line-height: 1.5;
margin: var(--spacing-2xs) var(--spacing-s);
max-width: 75%;
padding: 1em;
white-space: pre-wrap;
overflow-x: auto;
&.bot {
background-color: var(--color-lm-chat-bot-background);
float: left;
border-bottom-left-radius: 0;
.message-options {
left: 1.5em;
}
}
&.user {
background-color: var(--color-lm-chat-user-background);
color: var(--color-lm-chat-user-color);
float: right;
text-align: right;
border-bottom-right-radius: 0;
.message-options {
right: 1.5em;
text-align: right;
}
}
.message-options {
color: #aaa;
display: none;
font-size: 0.9em;
height: 26px;
position: absolute;
text-align: left;
top: -1.2em;
width: 120px;
z-index: 10;
.option {
cursor: pointer;
display: inline-block;
width: 28px;
}
.link {
text-decoration: underline;
}
}
&:hover {
.message-options {
display: initial;
}
}
}
}
}
}
.workflow-lm-chat-footer {
.message-input {
width: calc(100% - 8em);
}
.send-button {
float: right;
}
}
</style>

View File

@ -0,0 +1,31 @@
<template>
<N8nTooltip :placement="placement">
<button :class="$style.button" :style="{ color: '#aaa' }" @click="emit('click')">
<N8nIcon :icon="icon" size="small" />
</button>
<template #content>
{{ label }}
</template>
</N8nTooltip>
</template>
<script setup lang="ts">
const emit = defineEmits<{
click: [];
}>();
defineProps<{
label: string;
icon: string;
placement: 'left' | 'right' | 'top' | 'bottom';
}>();
</script>
<style module>
.button {
background: none;
border: none;
cursor: pointer;
padding: 0;
}
</style>

View File

@ -0,0 +1,15 @@
<template>
<n8n-info-tip type="tooltip" theme="info-light" :tooltip-placement="placement">
<n8n-text :bold="true" size="small">
<slot />
</n8n-text>
</n8n-info-tip>
</template>
<script setup lang="ts">
defineProps<{
placement: 'left' | 'right';
}>();
</script>
<style module lang="scss"></style>

View File

@ -0,0 +1,688 @@
<template>
<Modal
:name="WORKFLOW_LM_CHAT_MODAL_KEY"
width="80%"
max-height="80%"
:title="
locale.baseText('chat.window.title', {
interpolate: {
nodeName: connectedNode?.name || locale.baseText('chat.window.noChatNode'),
},
})
"
:event-bus="modalBus"
:scrollable="false"
@keydown.stop
>
<template #content>
<div
:class="$style.workflowLmChat"
data-test-id="workflow-lm-chat-dialog"
:style="messageVars"
>
<MessagesList :messages="messages" :class="[$style.messages, 'ignore-key-press']">
<template #beforeMessage="{ message }">
<MessageOptionTooltip
v-if="message.sender === 'bot' && !message.id.includes('preload')"
placement="right"
>
{{ locale.baseText('chat.window.chat.chatMessageOptions.executionId') }}:
<a href="#" @click="displayExecution(message.id)">{{ message.id }}</a>
</MessageOptionTooltip>
<MessageOptionAction
v-if="isTextMessage(message) && message.sender === 'user'"
data-test-id="repost-message-button"
icon="redo"
:label="locale.baseText('chat.window.chat.chatMessageOptions.repostMessage')"
placement="left"
@click="repostMessage(message)"
/>
<MessageOptionAction
v-if="isTextMessage(message) && message.sender === 'user'"
data-test-id="reuse-message-button"
icon="copy"
:label="locale.baseText('chat.window.chat.chatMessageOptions.reuseMessage')"
placement="left"
@click="reuseMessage(message)"
/>
</template>
</MessagesList>
<div v-if="node" :class="$style.logsWrapper" data-test-id="lm-chat-logs">
<n8n-text :class="$style.logsTitle" tag="p" size="large">{{
locale.baseText('chat.window.logs')
}}</n8n-text>
<div :class="$style.logs">
<RunDataAi :key="messages.length" :node="node" hide-title slim />
</div>
</div>
</div>
</template>
<template #footer>
<ChatInput
:class="$style.messagesInput"
data-test-id="lm-chat-inputs"
@arrow-key-down="onArrowKeyDown"
/>
<n8n-info-tip class="mt-s">
{{ locale.baseText('chatEmbed.infoTip.description') }}
<a @click="uiStore.openModal(CHAT_EMBED_MODAL_KEY)">
{{ locale.baseText('chatEmbed.infoTip.link') }}
</a>
</n8n-info-tip>
</template>
</Modal>
</template>
<script setup lang="ts">
import type { Ref } from 'vue';
import { defineAsyncComponent, provide, ref, computed, onMounted, nextTick } from 'vue';
import { v4 as uuid } from 'uuid';
import Modal from '@/components/Modal.vue';
import {
AI_CATEGORY_AGENTS,
AI_CATEGORY_CHAINS,
AI_CODE_NODE_TYPE,
AI_SUBCATEGORY,
CHAT_EMBED_MODAL_KEY,
CHAT_TRIGGER_NODE_TYPE,
MANUAL_CHAT_TRIGGER_NODE_TYPE,
MODAL_CONFIRM,
VIEWS,
WORKFLOW_LM_CHAT_MODAL_KEY,
} from '@/constants';
import { useUsersStore } from '@/stores/users.store';
// eslint-disable-next-line import/no-unresolved
import MessagesList from '@n8n/chat/components/MessagesList.vue';
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
import ChatInput from '@n8n/chat/components/Input.vue';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useRouter } from 'vue-router';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import type { Chat, ChatMessage, ChatMessageText, ChatOptions } from '@n8n/chat/types';
import { useI18n } from '@/composables/useI18n';
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
import MessageOptionTooltip from './MessageOptionTooltip.vue';
import MessageOptionAction from './MessageOptionAction.vue';
import type {
BinaryFileType,
IBinaryData,
IBinaryKeyData,
IDataObject,
INode,
INodeExecutionData,
INodeParameters,
INodeType,
ITaskData,
IUser,
} from 'n8n-workflow';
import {
CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE,
NodeConnectionType,
NodeHelpers,
} from 'n8n-workflow';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useToast } from '@/composables/useToast';
import type { INodeUi } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { createEventBus } from 'n8n-design-system';
import { useUIStore } from '@/stores/ui.store';
import { useMessage } from '@/composables/useMessage';
import { usePinnedData } from '@/composables/usePinnedData';
import { get, last } from 'lodash-es';
import { isEmpty } from '@/utils/typesUtils';
import { chatEventBus } from '@n8n/chat/event-buses';
const RunDataAi = defineAsyncComponent(
async () => await import('@/components/RunDataAi/RunDataAi.vue'),
);
// TODO: Add proper type
interface LangChainMessage {
id: string[];
kwargs: {
content: string;
};
}
interface MemoryOutput {
action: string;
chatHistory?: LangChainMessage[];
}
const router = useRouter();
const workflowHelpers = useWorkflowHelpers({ router });
const { runWorkflow } = useRunWorkflow({ router });
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const { showError } = useToast();
const messages: Ref<ChatMessage[]> = ref([]);
const currentSessionId = ref<string>(String(Date.now()));
const isDisabled = ref(false);
const connectedNode = ref<INode | null>(null);
const chatTrigger = ref<INode | null>(null);
const modalBus = createEventBus();
const node = ref<INode | null>(null);
const previousMessageIndex = ref(0);
const isLoading = computed(() => uiStore.isActionActive.workflowRunning);
const allowFileUploads = computed(() => {
return (chatTrigger.value?.parameters?.options as INodeParameters)?.allowFileUploads === true;
});
const allowedFilesMimeTypes = computed(() => {
return (
(
chatTrigger.value?.parameters?.options as INodeParameters
)?.allowedFilesMimeTypes?.toString() ?? ''
);
});
const locale = useI18n();
const chatOptions: ChatOptions = {
i18n: {
en: {
title: '',
footer: '',
subtitle: '',
inputPlaceholder: locale.baseText('chat.window.chat.placeholder'),
getStarted: '',
closeButtonTooltip: '',
},
},
webhookUrl: '',
mode: 'window',
showWindowCloseButton: true,
disabled: isDisabled,
allowFileUploads,
allowedFilesMimeTypes,
};
const chatConfig: Chat = {
messages,
sendMessage,
initialMessages: ref([]),
currentSessionId,
waitingForResponse: isLoading,
};
const messageVars = {
'--chat--message--bot--background': 'var(--color-lm-chat-bot-background)',
'--chat--message--user--background': 'var(--color-lm-chat-user-background)',
'--chat--message--bot--color': 'var(--color-text-dark)',
'--chat--message--user--color': 'var(--color-lm-chat-user-color)',
'--chat--message--bot--border': 'none',
'--chat--message--user--border': 'none',
'--chat--color-typing': 'var(--color-text-dark)',
};
function getTriggerNode() {
const workflow = workflowHelpers.getCurrentWorkflow();
const triggerNode = workflow.queryNodes((nodeType: INodeType) =>
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(nodeType.description.name),
);
if (!triggerNode.length) {
chatTrigger.value = null;
}
chatTrigger.value = triggerNode[0];
}
function setNode() {
const triggerNode = chatTrigger.value;
if (!triggerNode) {
return;
}
const workflow = workflowHelpers.getCurrentWorkflow();
const childNodes = workflow.getChildNodes(triggerNode.name);
for (const childNode of childNodes) {
// Look for the first connected node with metadata
// TODO: Allow later users to change that in the UI
const resultData = workflowsStore.getWorkflowResultDataByNodeName(childNode);
if (!resultData && !Array.isArray(resultData)) {
continue;
}
if (resultData[resultData.length - 1].metadata) {
node.value = workflowsStore.getNodeByName(childNode);
break;
}
}
}
function setConnectedNode() {
const triggerNode = chatTrigger.value;
if (!triggerNode) {
showError(new Error('Chat Trigger Node could not be found!'), 'Trigger Node not found');
return;
}
const workflow = workflowHelpers.getCurrentWorkflow();
const chatNode = workflowsStore.getNodes().find((storeNode: INodeUi): boolean => {
if (storeNode.type === CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE) return false;
const nodeType = nodeTypesStore.getNodeType(storeNode.type, storeNode.typeVersion);
if (!nodeType) return false;
const isAgent = nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_AGENTS);
const isChain = nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_CHAINS);
let isCustomChainOrAgent = false;
if (nodeType.name === AI_CODE_NODE_TYPE) {
const inputs = NodeHelpers.getNodeInputs(workflow, storeNode, nodeType);
const inputTypes = NodeHelpers.getConnectionTypes(inputs);
const outputs = NodeHelpers.getNodeOutputs(workflow, storeNode, nodeType);
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
if (
inputTypes.includes(NodeConnectionType.AiLanguageModel) &&
inputTypes.includes(NodeConnectionType.Main) &&
outputTypes.includes(NodeConnectionType.Main)
) {
isCustomChainOrAgent = true;
}
}
if (!isAgent && !isChain && !isCustomChainOrAgent) return false;
const parentNodes = workflow.getParentNodes(storeNode.name);
const isChatChild = parentNodes.some((parentNodeName) => parentNodeName === triggerNode.name);
return Boolean(isChatChild && (isAgent || isChain || isCustomChainOrAgent));
});
if (!chatNode) {
return;
}
connectedNode.value = chatNode;
}
async function convertFileToBinaryData(file: File): Promise<IBinaryData> {
const reader = new FileReader();
return await new Promise((resolve, reject) => {
reader.onload = () => {
const binaryData: IBinaryData = {
data: (reader.result as string).split('base64,')?.[1] ?? '',
mimeType: file.type,
fileName: file.name,
fileSize: `${file.size} bytes`,
fileExtension: file.name.split('.').pop() ?? '',
fileType: file.type.split('/')[0] as BinaryFileType,
};
resolve(binaryData);
};
reader.onerror = () => {
reject(new Error('Failed to convert file to binary data'));
};
reader.readAsDataURL(file);
});
}
async function getKeyedFiles(files: File[]): Promise<IBinaryKeyData> {
const binaryData: IBinaryKeyData = {};
await Promise.all(
files.map(async (file, index) => {
const data = await convertFileToBinaryData(file);
const key = `data${index}`;
binaryData[key] = data;
}),
);
return binaryData;
}
function extractFileMeta(file: File): IDataObject {
return {
fileName: file.name,
fileSize: `${file.size} bytes`,
fileExtension: file.name.split('.').pop() ?? '',
fileType: file.type.split('/')[0],
mimeType: file.type,
};
}
async function startWorkflowWithMessage(message: string, files?: File[]): Promise<void> {
const triggerNode = chatTrigger.value;
if (!triggerNode) {
showError(new Error('Chat Trigger Node could not be found!'), 'Trigger Node not found');
return;
}
let inputKey = 'chatInput';
if (triggerNode.type === MANUAL_CHAT_TRIGGER_NODE_TYPE && triggerNode.typeVersion < 1.1) {
inputKey = 'input';
}
if (triggerNode.type === CHAT_TRIGGER_NODE_TYPE) {
inputKey = 'chatInput';
}
const usersStore = useUsersStore();
const currentUser = usersStore.currentUser ?? ({} as IUser);
const inputPayload: INodeExecutionData = {
json: {
sessionId: `test-${currentUser.id || 'unknown'}`,
action: 'sendMessage',
[inputKey]: message,
},
};
if (files && files.length > 0) {
const filesMeta = files.map((file) => extractFileMeta(file));
const binaryData = await getKeyedFiles(files);
inputPayload.json.files = filesMeta;
inputPayload.binary = binaryData;
}
const nodeData: ITaskData = {
startTime: new Date().getTime(),
executionTime: 0,
executionStatus: 'success',
data: {
main: [[inputPayload]],
},
source: [null],
};
const response = await runWorkflow({
triggerNode: triggerNode.name,
nodeData,
source: 'RunData.ManualChatMessage',
});
workflowsStore.appendChatMessage(message);
if (!response) {
showError(new Error('It was not possible to start workflow!'), 'Workflow could not be started');
return;
}
waitForExecution(response.executionId);
}
function waitForExecution(executionId?: string) {
const waitInterval = setInterval(() => {
if (!isLoading.value) {
clearInterval(waitInterval);
const lastNodeExecuted =
workflowsStore.getWorkflowExecution?.data?.resultData.lastNodeExecuted;
if (!lastNodeExecuted) return;
const nodeResponseDataArray =
get(workflowsStore.getWorkflowExecution?.data?.resultData.runData, lastNodeExecuted) ?? [];
const nodeResponseData = nodeResponseDataArray[nodeResponseDataArray.length - 1];
let responseMessage: string;
if (get(nodeResponseData, 'error')) {
responseMessage = '[ERROR: ' + get(nodeResponseData, 'error.message') + ']';
} else {
const responseData = get(nodeResponseData, 'data.main[0][0].json');
responseMessage = extractResponseMessage(responseData);
}
messages.value.push({
text: responseMessage,
sender: 'bot',
createdAt: new Date().toISOString(),
id: executionId ?? uuid(),
});
void nextTick(setNode);
}
}, 500);
}
function extractResponseMessage(responseData?: IDataObject) {
if (!responseData || isEmpty(responseData)) {
return locale.baseText('chat.window.chat.response.empty');
}
// Paths where the response message might be located
const paths = ['output', 'text', 'response.text'];
const matchedPath = paths.find((path) => get(responseData, path));
if (!matchedPath) return JSON.stringify(responseData, null, 2);
return get(responseData, matchedPath) as string;
}
async function sendMessage(message: string, files?: File[]) {
previousMessageIndex.value = 0;
if (message.trim() === '' && (!files || files.length === 0)) {
showError(
new Error(locale.baseText('chat.window.chat.provideMessage')),
locale.baseText('chat.window.chat.emptyChatMessage'),
);
return;
}
const pinnedChatData = usePinnedData(chatTrigger.value);
if (pinnedChatData.hasData.value) {
const confirmResult = await useMessage().confirm(
locale.baseText('chat.window.chat.unpinAndExecute.description'),
locale.baseText('chat.window.chat.unpinAndExecute.title'),
{
confirmButtonText: locale.baseText('chat.window.chat.unpinAndExecute.confirm'),
cancelButtonText: locale.baseText('chat.window.chat.unpinAndExecute.cancel'),
},
);
if (!(confirmResult === MODAL_CONFIRM)) return;
pinnedChatData.unsetData('unpin-and-send-chat-message-modal');
}
const newMessage: ChatMessage = {
text: message,
sender: 'user',
createdAt: new Date().toISOString(),
id: uuid(),
files,
};
messages.value.push(newMessage);
await startWorkflowWithMessage(newMessage.text, files);
}
function displayExecution(executionId: string) {
const workflow = workflowHelpers.getCurrentWorkflow();
const route = router.resolve({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: workflow.id, executionId },
});
window.open(route.href, '_blank');
}
function isTextMessage(message: ChatMessage): message is ChatMessageText {
return message.type === 'text' || !message.type;
}
function repostMessage(message: ChatMessageText) {
void sendMessage(message.text);
}
function reuseMessage(message: ChatMessageText) {
chatEventBus.emit('setInputValue', message.text);
}
function getChatMessages(): ChatMessageText[] {
if (!connectedNode.value) return [];
const workflow = workflowHelpers.getCurrentWorkflow();
const connectedMemoryInputs =
workflow.connectionsByDestinationNode[connectedNode.value.name][NodeConnectionType.AiMemory];
if (!connectedMemoryInputs) return [];
const memoryConnection = (connectedMemoryInputs ?? []).find((i) => i.length > 0)?.[0];
if (!memoryConnection) return [];
const nodeResultData = workflowsStore.getWorkflowResultDataByNodeName(memoryConnection.node);
const memoryOutputData = (nodeResultData ?? [])
.map((data) => get(data, ['data', NodeConnectionType.AiMemory, 0, 0, 'json']) as MemoryOutput)
.find((data) => data.action === 'saveContext');
return (memoryOutputData?.chatHistory ?? []).map((message, index) => {
return {
createdAt: new Date().toISOString(),
text: message.kwargs.content,
id: `preload__${index}`,
sender: last(message.id) === 'HumanMessage' ? 'user' : 'bot',
};
});
}
function onArrowKeyDown({ currentInputValue, key }: ArrowKeyDownPayload) {
const pastMessages = workflowsStore.getPastChatMessages;
const isCurrentInputEmptyOrMatch =
currentInputValue.length === 0 || pastMessages.includes(currentInputValue);
if (isCurrentInputEmptyOrMatch && (key === 'ArrowUp' || key === 'ArrowDown')) {
// Blur the input when the user presses the up or down arrow key
chatEventBus.emit('blurInput');
if (pastMessages.length === 1) {
previousMessageIndex.value = 0;
} else if (key === 'ArrowUp') {
previousMessageIndex.value = (previousMessageIndex.value + 1) % pastMessages.length;
} else if (key === 'ArrowDown') {
previousMessageIndex.value =
(previousMessageIndex.value - 1 + pastMessages.length) % pastMessages.length;
}
chatEventBus.emit(
'setInputValue',
pastMessages[pastMessages.length - 1 - previousMessageIndex.value] ?? '',
);
// Refocus to move the cursor to the end of the input
chatEventBus.emit('focusInput');
}
}
provide(ChatSymbol, chatConfig);
provide(ChatOptionsSymbol, chatOptions);
onMounted(() => {
getTriggerNode();
setConnectedNode();
messages.value = getChatMessages();
setNode();
setTimeout(() => chatEventBus.emit('focusInput'), 0);
});
</script>
<style lang="scss">
.chat-message-markdown ul,
.chat-message-markdown ol {
padding: 0 0 0 1em;
}
</style>
<style module lang="scss">
.no-node-connected {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.workflowLmChat {
--chat--spacing: var(--spacing-m);
--chat--message--padding: var(--spacing-xs);
display: flex;
height: 100%;
z-index: 9999;
min-height: 10rem;
@media (min-height: 34rem) {
min-height: 14.5rem;
}
@media (min-height: 47rem) {
min-height: 25rem;
}
& ::-webkit-scrollbar {
width: 4px;
}
& ::-webkit-scrollbar-thumb {
border-radius: var(--border-radius-base);
background: var(--color-foreground-dark);
border: 1px solid white;
}
& ::-webkit-scrollbar-thumb:hover {
background: var(--color-foreground-xdark);
}
}
.logsWrapper {
--node-icon-color: var(--color-text-base);
border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-base);
height: 100%;
overflow: auto;
width: 100%;
padding: var(--spacing-xs) 0;
}
.logsTitle {
margin: 0 var(--spacing-s) var(--spacing-s);
}
.messages {
background-color: var(--color-lm-chat-messages-background);
border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-base);
height: 100%;
width: 100%;
overflow: auto;
padding-top: 1.5em;
&:not(:last-child) {
margin-right: 1em;
}
& * {
font-size: var(--font-size-s);
}
}
.messagesInput {
--chat--input--border: var(--input-border-color, var(--border-color-base))
var(--input-border-style, var(--border-style-base))
var(--input-border-width, var(--border-width-base));
--chat--input--border-radius: var(--border-radius-base) 0 0 var(--border-radius-base);
--chat--input--send--button--background: transparent;
--chat--input--send--button--color: var(--color-button-secondary-font);
--chat--input--send--button--color-hover: var(--color-primary);
--chat--input--border-active: var(--input-focus-border-color, var(--color-secondary));
--chat--files-spacing: var(--spacing-2xs) 0;
--chat--input--background: var(--color-lm-chat-bot-background);
[data-theme='dark'] & {
--chat--input--text-color: var(--input-font-color, var(--color-text-dark));
}
border-bottom-right-radius: var(--border-radius-base);
border-top-right-radius: var(--border-radius-base);
overflow: hidden;
}
</style>

View File

@ -4,7 +4,7 @@ import { mock } from 'vitest-mock-extended';
import { NodeConnectionType } from 'n8n-workflow';
import type { IConnections, INode } from 'n8n-workflow';
import WorkflowLMChatModal from '@/components/WorkflowLMChat.vue';
import WorkflowLMChatModal from '@/components/WorkflowLMChat/WorkflowLMChat.vue';
import { WORKFLOW_LM_CHAT_MODAL_KEY } from '@/constants';
import type { IWorkflowDb } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
@ -82,36 +82,6 @@ describe('WorkflowLMChatModal', () => {
server.shutdown();
});
it('should render correctly when Agent Node not present', async () => {
renderComponent({
pinia: await createPiniaWithAINodes({
withConnections: false,
withAgentNode: false,
}),
});
await waitFor(() =>
expect(document.querySelectorAll('.el-notification')[0]).toHaveTextContent(
'Missing AI node Chat only works when an AI agent or chain(except summarization chain) is connected to the chat trigger node',
),
);
});
it('should render correctly when Agent Node present but not connected to Manual Chat Node', async () => {
renderComponent({
pinia: await createPiniaWithAINodes({
withConnections: false,
withAgentNode: true,
}),
});
await waitFor(() =>
expect(document.querySelectorAll('.el-notification')[1]).toHaveTextContent(
'Missing AI node Chat only works when an AI agent or chain(except summarization chain) is connected to the chat trigger node',
),
);
});
it('should render correctly', async () => {
const wrapper = renderComponent({
pinia: await createPiniaWithAINodes(),
@ -137,14 +107,17 @@ describe('WorkflowLMChatModal', () => {
);
const chatDialog = wrapper.getByTestId('workflow-lm-chat-dialog');
const chatSendButton = wrapper.getByTestId('workflow-chat-send-button');
const chatInput = wrapper.getByTestId('workflow-chat-input');
const chatInputsContainer = wrapper.getByTestId('lm-chat-inputs');
const chatSendButton = chatInputsContainer.querySelector('.chat-input-send-button');
const chatInput = chatInputsContainer.querySelector('textarea');
await fireEvent.update(chatInput, 'Hello!');
await fireEvent.click(chatSendButton);
if (chatInput && chatSendButton) {
await fireEvent.update(chatInput, 'Hello!');
await fireEvent.click(chatSendButton);
}
await waitFor(() => expect(chatDialog.querySelectorAll('.message')).toHaveLength(1));
await waitFor(() => expect(chatDialog.querySelectorAll('.chat-message')).toHaveLength(1));
expect(chatDialog.querySelector('.message')).toHaveTextContent('Hello!');
expect(chatDialog.querySelector('.chat-message')).toHaveTextContent('Hello!');
});
});

View File

@ -32,12 +32,29 @@ describe('useClipboard()', () => {
userEvent.setup();
});
beforeEach(() => {
// Mock document.execCommand implementation to set clipboard items
document.execCommand = vi.fn().mockImplementation((command) => {
if (command === 'copy') {
Object.defineProperty(window.navigator, 'clipboard', {
value: { items: [testValue] },
configurable: true,
});
}
return true;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('copy()', () => {
it('should copy text value', async () => {
const { getByTestId } = render(TestComponent);
const copyButton = getByTestId('copy');
copyButton.click();
await userEvent.click(copyButton);
expect((window.navigator.clipboard as unknown as { items: string[] }).items).toHaveLength(1);
});
});

View File

@ -157,6 +157,9 @@ importers:
packages/@n8n/chat:
dependencies:
'@vueuse/core':
specifier: ^10.11.0
version: 10.11.0(vue@3.4.21(typescript@5.5.2))
highlight.js:
specifier: ^11.8.0
version: 11.9.0
@ -1178,14 +1181,14 @@ importers:
specifier: ^1.1.0
version: 1.1.0(@vue-flow/core@1.33.5(vue@3.4.21(typescript@5.5.2)))
'@vueuse/components':
specifier: ^10.5.0
version: 10.5.0(vue@3.4.21(typescript@5.5.2))
specifier: ^10.11.0
version: 10.11.0(vue@3.4.21(typescript@5.5.2))
'@vueuse/core':
specifier: ^10.5.0
version: 10.5.0(vue@3.4.21(typescript@5.5.2))
specifier: ^10.11.0
version: 10.11.0(vue@3.4.21(typescript@5.5.2))
axios:
specifier: 1.6.7
version: 1.6.7
version: 1.6.7(debug@3.2.7)
chart.js:
specifier: ^4.4.0
version: 4.4.0
@ -5705,8 +5708,8 @@ packages:
'@types/web-bluetooth@0.0.16':
resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==}
'@types/web-bluetooth@0.0.18':
resolution: {integrity: sha512-v/ZHEj9xh82usl8LMR3GarzFY1IrbXJw5L4QfQhokjRV91q+SelFqxQWSep1ucXEZ22+dSTwLFkXeur25sPIbw==}
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
'@types/webidl-conversions@7.0.0':
resolution: {integrity: sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog==}
@ -5985,23 +5988,23 @@ packages:
'@vue/tsconfig@0.5.1':
resolution: {integrity: sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==}
'@vueuse/components@10.5.0':
resolution: {integrity: sha512-zWQZ8zkNBvX++VHfyiUaQ4otb+4PWI8679GR8FvdrNnj+01LXnqvrkyKd8yTCMJ9nHqwRRTJikS5fu4Zspn9DQ==}
'@vueuse/components@10.11.0':
resolution: {integrity: sha512-ZvLZI23d5ZAtva5fGyYh/jQtZO8l+zJ5tAXyYNqHJZkq1o5yWyqZhENvSv5mfDmN5IuAOp4tq02mRmX/ipFGcg==}
'@vueuse/core@10.5.0':
resolution: {integrity: sha512-z/tI2eSvxwLRjOhDm0h/SXAjNm8N5ld6/SC/JQs6o6kpJ6Ya50LnEL8g5hoYu005i28L0zqB5L5yAl8Jl26K3A==}
'@vueuse/core@10.11.0':
resolution: {integrity: sha512-x3sD4Mkm7PJ+pcq3HX8PLPBadXCAlSDR/waK87dz0gQE+qJnaaFhc/dZVfJz+IUYzTMVGum2QlR7ImiJQN4s6g==}
'@vueuse/core@9.13.0':
resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==}
'@vueuse/metadata@10.5.0':
resolution: {integrity: sha512-fEbElR+MaIYyCkeM0SzWkdoMtOpIwO72x8WsZHRE7IggiOlILttqttM69AS13nrDxosnDBYdyy3C5mR1LCxHsw==}
'@vueuse/metadata@10.11.0':
resolution: {integrity: sha512-kQX7l6l8dVWNqlqyN3ePW3KmjCQO3ZMgXuBMddIu83CmucrsBfXlH+JoviYyRBws/yLTQO8g3Pbw+bdIoVm4oQ==}
'@vueuse/metadata@9.13.0':
resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==}
'@vueuse/shared@10.5.0':
resolution: {integrity: sha512-18iyxbbHYLst9MqU1X1QNdMHIjks6wC7XTVf0KNOv5es/Ms6gjVFCAAWTVP2JStuGqydg3DT+ExpFORUEi9yhg==}
'@vueuse/shared@10.11.0':
resolution: {integrity: sha512-fyNoIXEq3PfX1L3NkNhtVQUSRtqYwJtJg+Bp9rIzculIZWHTkKSysujrOk2J+NrRulLTQH9+3gGSfYLWSEWU1A==}
'@vueuse/shared@9.13.0':
resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==}
@ -13152,6 +13155,17 @@ packages:
'@vue/composition-api':
optional: true
vue-demi@0.14.8:
resolution: {integrity: sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-docgen-api@4.76.0:
resolution: {integrity: sha512-Nykmg/Net1BhoS1tENGqcevDdgha4us0x2Xnin7n5SxxAH6+s10FXTWtg7E9+jhC3GWEE83lcFHMS/Ml4C1dog==}
peerDependencies:
@ -16118,7 +16132,7 @@ snapshots:
'@antfu/install-pkg': 0.1.1
'@antfu/utils': 0.7.6
'@iconify/types': 2.0.0
debug: 4.3.4
debug: 4.3.4(supports-color@8.1.1)
kolorist: 1.8.0
local-pkg: 0.4.3
transitivePeerDependencies:
@ -19370,7 +19384,7 @@ snapshots:
'@types/web-bluetooth@0.0.16': {}
'@types/web-bluetooth@0.0.18': {}
'@types/web-bluetooth@0.0.20': {}
'@types/webidl-conversions@7.0.0': {}
@ -19639,7 +19653,7 @@ snapshots:
'@vue-flow/core@1.33.5(vue@3.4.21(typescript@5.5.2))':
dependencies:
'@vueuse/core': 10.5.0(vue@3.4.21(typescript@5.5.2))
'@vueuse/core': 10.11.0(vue@3.4.21(typescript@5.5.2))
d3-drag: 3.0.0
d3-selection: 3.0.0
d3-zoom: 3.0.0
@ -19772,21 +19786,21 @@ snapshots:
'@vue/tsconfig@0.5.1': {}
'@vueuse/components@10.5.0(vue@3.4.21(typescript@5.5.2))':
'@vueuse/components@10.11.0(vue@3.4.21(typescript@5.5.2))':
dependencies:
'@vueuse/core': 10.5.0(vue@3.4.21(typescript@5.5.2))
'@vueuse/shared': 10.5.0(vue@3.4.21(typescript@5.5.2))
vue-demi: 0.14.6(vue@3.4.21(typescript@5.5.2))
'@vueuse/core': 10.11.0(vue@3.4.21(typescript@5.5.2))
'@vueuse/shared': 10.11.0(vue@3.4.21(typescript@5.5.2))
vue-demi: 0.14.8(vue@3.4.21(typescript@5.5.2))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/core@10.5.0(vue@3.4.21(typescript@5.5.2))':
'@vueuse/core@10.11.0(vue@3.4.21(typescript@5.5.2))':
dependencies:
'@types/web-bluetooth': 0.0.18
'@vueuse/metadata': 10.5.0
'@vueuse/shared': 10.5.0(vue@3.4.21(typescript@5.5.2))
vue-demi: 0.14.6(vue@3.4.21(typescript@5.5.2))
'@types/web-bluetooth': 0.0.20
'@vueuse/metadata': 10.11.0
'@vueuse/shared': 10.11.0(vue@3.4.21(typescript@5.5.2))
vue-demi: 0.14.8(vue@3.4.21(typescript@5.5.2))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
@ -19796,25 +19810,25 @@ snapshots:
'@types/web-bluetooth': 0.0.16
'@vueuse/metadata': 9.13.0
'@vueuse/shared': 9.13.0(vue@3.4.21(typescript@5.5.2))
vue-demi: 0.14.5(vue@3.4.21(typescript@5.5.2))
vue-demi: 0.14.6(vue@3.4.21(typescript@5.5.2))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/metadata@10.5.0': {}
'@vueuse/metadata@10.11.0': {}
'@vueuse/metadata@9.13.0': {}
'@vueuse/shared@10.5.0(vue@3.4.21(typescript@5.5.2))':
'@vueuse/shared@10.11.0(vue@3.4.21(typescript@5.5.2))':
dependencies:
vue-demi: 0.14.6(vue@3.4.21(typescript@5.5.2))
vue-demi: 0.14.8(vue@3.4.21(typescript@5.5.2))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/shared@9.13.0(vue@3.4.21(typescript@5.5.2))':
dependencies:
vue-demi: 0.14.5(vue@3.4.21(typescript@5.5.2))
vue-demi: 0.14.6(vue@3.4.21(typescript@5.5.2))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
@ -19962,7 +19976,7 @@ snapshots:
agent-base@6.0.2:
dependencies:
debug: 4.3.4
debug: 4.3.4(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -20266,14 +20280,6 @@ snapshots:
'@babel/runtime': 7.23.6
is-retry-allowed: 2.2.0
axios@1.6.7:
dependencies:
follow-redirects: 1.15.6
form-data: 4.0.0
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
axios@1.6.7(debug@3.2.7):
dependencies:
follow-redirects: 1.15.6(debug@3.2.7)
@ -21300,10 +21306,6 @@ snapshots:
optionalDependencies:
supports-color: 8.1.1
debug@4.3.4:
dependencies:
ms: 2.1.2
debug@4.3.4(supports-color@8.1.1):
dependencies:
ms: 2.1.2
@ -22478,8 +22480,6 @@ snapshots:
fn.name@1.1.0: {}
follow-redirects@1.15.6: {}
follow-redirects@1.15.6(debug@3.2.7):
optionalDependencies:
debug: 3.2.7(supports-color@5.5.0)
@ -23092,7 +23092,7 @@ snapshots:
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.3.4
debug: 4.3.4(supports-color@8.1.1)
transitivePeerDependencies:
- supports-color
@ -27828,7 +27828,7 @@ snapshots:
'@antfu/install-pkg': 0.1.1
'@antfu/utils': 0.7.6
'@iconify/utils': 2.1.11
debug: 4.3.4
debug: 4.3.4(supports-color@8.1.1)
kolorist: 1.8.0
local-pkg: 0.5.0
unplugin: 1.5.1
@ -28071,6 +28071,10 @@ snapshots:
dependencies:
vue: 3.4.21(typescript@5.5.2)
vue-demi@0.14.8(vue@3.4.21(typescript@5.5.2)):
dependencies:
vue: 3.4.21(typescript@5.5.2)
vue-docgen-api@4.76.0(vue@3.4.21(typescript@5.5.2)):
dependencies:
'@babel/parser': 7.24.0