mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08: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:
parent
501bcd80ff
commit
df783151b8
|
@ -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() {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>,
|
||||
};
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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}`,
|
||||
|
|
92
packages/@n8n/chat/src/components/ChatFile.vue
Normal file
92
packages/@n8n/chat/src/components/ChatFile.vue
Normal file
|
@ -0,0 +1,92 @@
|
|||
<script setup lang="ts">
|
||||
import IconFileText from 'virtual:icons/mdi/fileText';
|
||||
import IconFileMusic from 'virtual:icons/mdi/fileMusic';
|
||||
import IconFileImage from 'virtual:icons/mdi/fileImage';
|
||||
import IconFileVideo from 'virtual:icons/mdi/fileVideo';
|
||||
import IconDelete from 'virtual:icons/mdi/closeThick';
|
||||
import IconPreview from 'virtual:icons/mdi/openInNew';
|
||||
|
||||
import { computed, type FunctionalComponent } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
file: File;
|
||||
isRemovable: boolean;
|
||||
isPreviewable?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: [value: File];
|
||||
}>();
|
||||
|
||||
const iconMapper: Record<string, FunctionalComponent> = {
|
||||
document: IconFileText,
|
||||
audio: IconFileMusic,
|
||||
image: IconFileImage,
|
||||
video: IconFileVideo,
|
||||
};
|
||||
|
||||
const TypeIcon = computed(() => {
|
||||
const type = props.file?.type.split('/')[0];
|
||||
return iconMapper[type] || IconFileText;
|
||||
});
|
||||
|
||||
function onClick() {
|
||||
if (props.isRemovable) {
|
||||
emit('remove', props.file);
|
||||
}
|
||||
|
||||
if (props.isPreviewable) {
|
||||
window.open(URL.createObjectURL(props.file));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-file" @click="onClick">
|
||||
<TypeIcon />
|
||||
<p class="chat-file-name">{{ file.name }}</p>
|
||||
<IconDelete v-if="isRemovable" class="chat-file-delete" />
|
||||
<IconPreview v-if="isPreviewable" class="chat-file-preview" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
width: fit-content;
|
||||
max-width: 15rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
background: white;
|
||||
color: var(--chat--color-dark);
|
||||
border: 1px solid var(--chat--color-dark);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-file-name-tooltip {
|
||||
overflow: hidden;
|
||||
}
|
||||
.chat-file-name {
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
.chat-file-delete,
|
||||
.chat-file-preview {
|
||||
background: none;
|
||||
border: none;
|
||||
display: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
.chat-file:hover & {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,31 +1,102 @@
|
|||
<script setup lang="ts">
|
||||
import IconSend from 'virtual:icons/mdi/send';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import IconFilePlus from 'virtual:icons/mdi/filePlus';
|
||||
import { computed, onMounted, onUnmounted, ref, unref } from 'vue';
|
||||
import { useFileDialog } from '@vueuse/core';
|
||||
import ChatFile from './ChatFile.vue';
|
||||
import { useI18n, useChat, useOptions } from '@n8n/chat/composables';
|
||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||
|
||||
export interface ArrowKeyDownPayload {
|
||||
key: 'ArrowUp' | 'ArrowDown';
|
||||
currentInputValue: string;
|
||||
}
|
||||
const emit = defineEmits<{
|
||||
arrowKeyDown: [value: ArrowKeyDownPayload];
|
||||
}>();
|
||||
|
||||
const { options } = useOptions();
|
||||
const chatStore = useChat();
|
||||
const { waitingForResponse } = chatStore;
|
||||
const { t } = useI18n();
|
||||
|
||||
const files = ref<FileList | null>(null);
|
||||
const chatTextArea = ref<HTMLTextAreaElement | null>(null);
|
||||
const input = ref('');
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const isSubmitDisabled = computed(() => {
|
||||
return input.value === '' || waitingForResponse.value || options.disabled?.value === true;
|
||||
});
|
||||
|
||||
const isInputDisabled = computed(() => options.disabled?.value === true);
|
||||
const isFileUploadDisabled = computed(
|
||||
() => isFileUploadAllowed.value && waitingForResponse.value && !options.disabled?.value,
|
||||
);
|
||||
const isFileUploadAllowed = computed(() => unref(options.allowFileUploads) === true);
|
||||
const allowedFileTypes = computed(() => unref(options.allowedFilesMimeTypes));
|
||||
|
||||
const styleVars = computed(() => {
|
||||
const controlsCount = isFileUploadAllowed.value ? 2 : 1;
|
||||
return {
|
||||
'--controls-count': controlsCount,
|
||||
};
|
||||
});
|
||||
|
||||
const {
|
||||
open: openFileDialog,
|
||||
reset: resetFileDialog,
|
||||
onChange,
|
||||
} = useFileDialog({
|
||||
multiple: true,
|
||||
reset: false,
|
||||
});
|
||||
|
||||
onChange((newFiles) => {
|
||||
if (!newFiles) return;
|
||||
const newFilesDT = new DataTransfer();
|
||||
// Add current files
|
||||
if (files.value) {
|
||||
for (let i = 0; i < files.value.length; i++) {
|
||||
newFilesDT.items.add(files.value[i]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < newFiles.length; i++) {
|
||||
newFilesDT.items.add(newFiles[i]);
|
||||
}
|
||||
files.value = newFilesDT.files;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
chatEventBus.on('focusInput', () => {
|
||||
if (chatTextArea.value) {
|
||||
chatTextArea.value.focus();
|
||||
}
|
||||
});
|
||||
chatEventBus.on('focusInput', focusChatInput);
|
||||
chatEventBus.on('blurInput', blurChatInput);
|
||||
chatEventBus.on('setInputValue', setInputValue);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
chatEventBus.off('focusInput', focusChatInput);
|
||||
chatEventBus.off('blurInput', blurChatInput);
|
||||
chatEventBus.off('setInputValue', setInputValue);
|
||||
});
|
||||
|
||||
function blurChatInput() {
|
||||
if (chatTextArea.value) {
|
||||
chatTextArea.value.blur();
|
||||
}
|
||||
}
|
||||
|
||||
function focusChatInput() {
|
||||
if (chatTextArea.value) {
|
||||
chatTextArea.value.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function setInputValue(value: string) {
|
||||
input.value = value;
|
||||
focusChatInput();
|
||||
}
|
||||
|
||||
async function onSubmit(event: MouseEvent | KeyboardEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
|
@ -35,7 +106,11 @@ async function onSubmit(event: MouseEvent | KeyboardEvent) {
|
|||
|
||||
const messageText = input.value;
|
||||
input.value = '';
|
||||
await chatStore.sendMessage(messageText);
|
||||
isSubmitting.value = true;
|
||||
await chatStore.sendMessage(messageText, Array.from(files.value ?? []));
|
||||
isSubmitting.value = false;
|
||||
resetFileDialog();
|
||||
files.value = null;
|
||||
}
|
||||
|
||||
async function onSubmitKeydown(event: KeyboardEvent) {
|
||||
|
@ -45,64 +120,156 @@ async function onSubmitKeydown(event: KeyboardEvent) {
|
|||
|
||||
await onSubmit(event);
|
||||
}
|
||||
|
||||
function onFileRemove(file: File) {
|
||||
if (!files.value) return;
|
||||
|
||||
const dt = new DataTransfer();
|
||||
for (let i = 0; i < files.value.length; i++) {
|
||||
const currentFile = files.value[i];
|
||||
if (file.name !== currentFile.name) dt.items.add(currentFile);
|
||||
}
|
||||
|
||||
resetFileDialog();
|
||||
files.value = dt.files;
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
|
||||
emit('arrowKeyDown', {
|
||||
key: event.key,
|
||||
currentInputValue: input.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onOpenFileDialog() {
|
||||
if (isFileUploadDisabled.value) return;
|
||||
openFileDialog({ accept: unref(allowedFileTypes) });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-input">
|
||||
<textarea
|
||||
ref="chatTextArea"
|
||||
v-model="input"
|
||||
rows="1"
|
||||
:disabled="isInputDisabled"
|
||||
:placeholder="t('inputPlaceholder')"
|
||||
@keydown.enter="onSubmitKeydown"
|
||||
/>
|
||||
<button :disabled="isSubmitDisabled" class="chat-input-send-button" @click="onSubmit">
|
||||
<IconSend height="32" width="32" />
|
||||
</button>
|
||||
<div class="chat-input" :style="styleVars" @keydown.stop="onKeyDown">
|
||||
<div class="chat-inputs">
|
||||
<textarea
|
||||
ref="chatTextArea"
|
||||
v-model="input"
|
||||
:disabled="isInputDisabled"
|
||||
:placeholder="t('inputPlaceholder')"
|
||||
@keydown.enter="onSubmitKeydown"
|
||||
/>
|
||||
|
||||
<div class="chat-inputs-controls">
|
||||
<button
|
||||
v-if="isFileUploadAllowed"
|
||||
:disabled="isFileUploadDisabled"
|
||||
class="chat-input-send-button"
|
||||
@click="onOpenFileDialog"
|
||||
>
|
||||
<IconFilePlus height="24" width="24" />
|
||||
</button>
|
||||
<button :disabled="isSubmitDisabled" class="chat-input-send-button" @click="onSubmit">
|
||||
<IconSend height="24" width="24" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="files?.length && !isSubmitting" class="chat-files">
|
||||
<ChatFile
|
||||
v-for="file in files"
|
||||
:key="file.name"
|
||||
:file="file"
|
||||
:is-removable="true"
|
||||
@remove="onFileRemove"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
.chat-input {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
background: white;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
.chat-inputs {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: var(--chat--input--font-size, inherit);
|
||||
width: 100%;
|
||||
border: 0;
|
||||
padding: var(--chat--spacing);
|
||||
max-height: var(--chat--textarea--height);
|
||||
resize: none;
|
||||
}
|
||||
border: var(--chat--input--border, 0);
|
||||
border-radius: var(--chat--input--border-radius, 0);
|
||||
padding: 0.8rem;
|
||||
padding-right: calc(0.8rem + (var(--controls-count, 1) * var(--chat--textarea--height)));
|
||||
min-height: var(--chat--textarea--height);
|
||||
max-height: var(--chat--textarea--max-height, var(--chat--textarea--height));
|
||||
height: 100%;
|
||||
background: var(--chat--input--background, white);
|
||||
resize: var(--chat--textarea--resize, none);
|
||||
color: var(--chat--input--text-color, initial);
|
||||
outline: none;
|
||||
|
||||
.chat-input-send-button {
|
||||
height: var(--chat--textarea--height);
|
||||
width: var(--chat--textarea--height);
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
color: var(--chat--input--send--button--color, var(--chat--color-secondary));
|
||||
border: 0;
|
||||
font-size: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color var(--chat--transition-duration) ease;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: var(--chat--color-secondary-shade-50);
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
cursor: default;
|
||||
color: var(--chat--color-disabled);
|
||||
&:focus,
|
||||
&:hover {
|
||||
border-color: var(--chat--input--border-active, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
.chat-inputs-controls {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
}
|
||||
.chat-input-send-button {
|
||||
height: var(--chat--textarea--height);
|
||||
width: var(--chat--textarea--height);
|
||||
background: var(--chat--input--send--button--background, white);
|
||||
cursor: pointer;
|
||||
color: var(--chat--input--send--button--color, var(--chat--color-secondary));
|
||||
border: 0;
|
||||
font-size: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color var(--chat--transition-duration) ease;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: var(
|
||||
--chat--input--send--button--background-hover,
|
||||
var(--chat--input--send--button--background)
|
||||
);
|
||||
color: var(--chat--input--send--button--color-hover, var(--chat--color-secondary-shade-50));
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
cursor: no-drop;
|
||||
color: var(--chat--color-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-files {
|
||||
display: flex;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding: var(--chat--files-spacing, 0.25rem);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,10 +1,16 @@
|
|||
<script lang="ts" setup>
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import { computed, toRefs } from 'vue';
|
||||
import { computed, ref, toRefs, onMounted } from 'vue';
|
||||
import VueMarkdown from 'vue-markdown-render';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import javascript from 'highlight.js/lib/languages/javascript';
|
||||
import typescript from 'highlight.js/lib/languages/typescript';
|
||||
import python from 'highlight.js/lib/languages/python';
|
||||
import xml from 'highlight.js/lib/languages/xml';
|
||||
import bash from 'highlight.js/lib/languages/bash';
|
||||
import markdownLink from 'markdown-it-link-attributes';
|
||||
import type MarkdownIt from 'markdown-it';
|
||||
import ChatFile from './ChatFile.vue';
|
||||
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
|
||||
import { useOptions } from '@n8n/chat/composables';
|
||||
|
||||
|
@ -12,8 +18,21 @@ const props = defineProps<{
|
|||
message: ChatMessage;
|
||||
}>();
|
||||
|
||||
hljs.registerLanguage('javascript', javascript);
|
||||
hljs.registerLanguage('typescript', typescript);
|
||||
hljs.registerLanguage('python', python);
|
||||
hljs.registerLanguage('xml', xml);
|
||||
hljs.registerLanguage('bash', bash);
|
||||
|
||||
defineSlots<{
|
||||
beforeMessage(props: { message: ChatMessage }): ChatMessage;
|
||||
default: { message: ChatMessage };
|
||||
}>();
|
||||
|
||||
const { message } = toRefs(props);
|
||||
const { options } = useOptions();
|
||||
const messageContainer = ref<HTMLElement | null>(null);
|
||||
const fileSources = ref<Record<string, string>>({});
|
||||
|
||||
const messageText = computed(() => {
|
||||
return (message.value as ChatMessageText).text || '<Empty response>';
|
||||
|
@ -36,6 +55,14 @@ const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
|
|||
});
|
||||
};
|
||||
|
||||
const scrollToView = () => {
|
||||
if (messageContainer.value?.scrollIntoView) {
|
||||
messageContainer.value.scrollIntoView({
|
||||
block: 'center',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const markdownOptions = {
|
||||
highlight(str: string, lang: string) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
|
@ -48,10 +75,37 @@ const markdownOptions = {
|
|||
},
|
||||
};
|
||||
|
||||
const messageComponents = options?.messageComponents ?? {};
|
||||
const messageComponents = { ...(options?.messageComponents ?? {}) };
|
||||
|
||||
defineExpose({ scrollToView });
|
||||
|
||||
const readFileAsDataURL = async (file: File): Promise<string> =>
|
||||
await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
if (message.value.files) {
|
||||
for (const file of message.value.files) {
|
||||
try {
|
||||
const dataURL = await readFileAsDataURL(file);
|
||||
fileSources.value[file.name] = dataURL;
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-message" :class="classes">
|
||||
<div ref="messageContainer" class="chat-message" :class="classes">
|
||||
<div v-if="$slots.beforeMessage" class="chat-message-actions">
|
||||
<slot name="beforeMessage" v-bind="{ message }" />
|
||||
</div>
|
||||
<slot>
|
||||
<template v-if="message.type === 'component' && messageComponents[message.key]">
|
||||
<component :is="messageComponents[message.key]" v-bind="message.arguments" />
|
||||
|
@ -63,6 +117,11 @@ const messageComponents = options?.messageComponents ?? {};
|
|||
:options="markdownOptions"
|
||||
:plugins="[linksNewTabPlugin]"
|
||||
/>
|
||||
<div v-if="(message.files ?? []).length > 0" class="chat-message-files">
|
||||
<div v-for="file in message.files ?? []" :key="file.name" class="chat-message-file">
|
||||
<ChatFile :file="file" :is-removable="false" :is-previewable="true" />
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -70,11 +129,33 @@ const messageComponents = options?.messageComponents ?? {};
|
|||
<style lang="scss">
|
||||
.chat-message {
|
||||
display: block;
|
||||
position: relative;
|
||||
max-width: 80%;
|
||||
font-size: var(--chat--message--font-size, 1rem);
|
||||
padding: var(--chat--message--padding, var(--chat--spacing));
|
||||
border-radius: var(--chat--message--border-radius, var(--chat--border-radius));
|
||||
|
||||
.chat-message-actions {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
transform: translateY(-0.25rem);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&.chat-message-from-user .chat-message-actions {
|
||||
left: auto;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.chat-message-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: var(--chat--message-line-height, 1.8);
|
||||
word-wrap: break-word;
|
||||
|
@ -82,7 +163,7 @@ const messageComponents = options?.messageComponents ?? {};
|
|||
|
||||
// Default message gap is half of the spacing
|
||||
+ .chat-message {
|
||||
margin-top: var(--chat--message--margin-bottom, calc(var(--chat--spacing) * 0.5));
|
||||
margin-top: var(--chat--message--margin-bottom, calc(var(--chat--spacing) * 1));
|
||||
}
|
||||
|
||||
// Spacing between messages from different senders is double the individual message gap
|
||||
|
@ -133,5 +214,11 @@ const messageComponents = options?.messageComponents ?? {};
|
|||
border-radius: var(--chat--border-radius);
|
||||
}
|
||||
}
|
||||
.chat-message-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { Message } from './index';
|
||||
import type { ChatMessage } from '@n8n/chat/types';
|
||||
|
||||
|
@ -18,7 +18,7 @@ const message: ChatMessage = {
|
|||
sender: 'bot',
|
||||
createdAt: '',
|
||||
};
|
||||
|
||||
const messageContainer = ref<InstanceType<typeof Message>>();
|
||||
const classes = computed(() => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
@ -26,9 +26,13 @@ const classes = computed(() => {
|
|||
[`chat-message-typing-animation-${props.animation}`]: true,
|
||||
};
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
messageContainer.value?.scrollToView();
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Message :class="classes" :message="message">
|
||||
<Message ref="messageContainer" :class="classes" :message="message">
|
||||
<div class="chat-message-typing-body">
|
||||
<span class="chat-message-typing-circle"></span>
|
||||
<span class="chat-message-typing-circle"></span>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import Message from '@n8n/chat/components/Message.vue';
|
||||
import type { ChatMessage } from '@n8n/chat/types';
|
||||
import MessageTyping from '@n8n/chat/components/MessageTyping.vue';
|
||||
|
@ -8,9 +9,23 @@ defineProps<{
|
|||
messages: ChatMessage[];
|
||||
}>();
|
||||
|
||||
const chatStore = useChat();
|
||||
defineSlots<{
|
||||
beforeMessage(props: { message: ChatMessage }): ChatMessage;
|
||||
}>();
|
||||
|
||||
const chatStore = useChat();
|
||||
const messageComponents = ref<Array<InstanceType<typeof Message>>>([]);
|
||||
const { initialMessages, waitingForResponse } = chatStore;
|
||||
|
||||
watch(
|
||||
() => messageComponents.value.length,
|
||||
() => {
|
||||
const lastMessageComponent = messageComponents.value[messageComponents.value.length - 1];
|
||||
if (lastMessageComponent) {
|
||||
lastMessageComponent.scrollToView();
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<div class="chat-messages-list">
|
||||
|
@ -19,7 +34,14 @@ const { initialMessages, waitingForResponse } = chatStore;
|
|||
:key="initialMessage.id"
|
||||
:message="initialMessage"
|
||||
/>
|
||||
<Message v-for="message in messages" :key="message.id" :message="message" />
|
||||
|
||||
<template v-for="message in messages" :key="message.id">
|
||||
<Message ref="messageComponents" :message="message">
|
||||
<template #beforeMessage="{ message }">
|
||||
<slot name="beforeMessage" v-bind="{ message }" />
|
||||
</template>
|
||||
</Message>
|
||||
</template>
|
||||
<MessageTyping v-if="waitingForResponse" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
@import 'tokens';
|
||||
@import 'markdown';
|
||||
|
|
627
packages/@n8n/chat/src/css/markdown.scss
Normal file
627
packages/@n8n/chat/src/css/markdown.scss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -16,4 +16,5 @@ interface ChatMessageBase {
|
|||
createdAt: string;
|
||||
transparent?: boolean;
|
||||
sender: 'user' | 'bot';
|
||||
files?: 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;
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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)],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)},` : ''}
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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!');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
102
pnpm-lock.yaml
102
pnpm-lock.yaml
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue