mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 04:34: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() {
|
export function getManualChatInput() {
|
||||||
return cy.getByTestId('workflow-chat-input');
|
return getManualChatModal().get('.chat-inputs textarea');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getManualChatSendButton() {
|
export function getManualChatSendButton() {
|
||||||
return getManualChatModal().getByTestId('workflow-chat-send-button');
|
return getManualChatModal().get('.chat-input-send-button');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getManualChatMessages() {
|
export function getManualChatMessages() {
|
||||||
return getManualChatModal().get('.messages .message');
|
return getManualChatModal().get('.chat-messages-list .chat-message');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getManualChatModalCloseButton() {
|
export function getManualChatModalCloseButton() {
|
||||||
|
|
|
@ -36,6 +36,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@vueuse/core": "^10.11.0",
|
||||||
"highlight.js": "^11.8.0",
|
"highlight.js": "^11.8.0",
|
||||||
"markdown-it-link-attributes": "^4.0.1",
|
"markdown-it-link-attributes": "^4.0.1",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
|
|
|
@ -41,3 +41,15 @@ export const Windowed: Story = {
|
||||||
mode: 'window',
|
mode: 'window',
|
||||||
} satisfies Partial<ChatOptions>,
|
} 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> {
|
export async function authenticatedFetch<T>(...args: Parameters<typeof fetch>): Promise<T> {
|
||||||
const accessToken = await getAccessToken();
|
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], {
|
const response = await fetch(args[0], {
|
||||||
...args[1],
|
...args[1],
|
||||||
mode: 'cors',
|
mode: 'cors',
|
||||||
cache: 'no-cache',
|
cache: 'no-cache',
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(accessToken ? { authorization: `Bearer ${accessToken}` } : {}),
|
|
||||||
...args[1]?.headers,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (await response.json()) as T;
|
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),
|
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 = {}) {
|
export async function put<T>(url: string, body: object = {}, options: RequestInit = {}) {
|
||||||
return await authenticatedFetch<T>(url, {
|
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 {
|
import type {
|
||||||
ChatOptions,
|
ChatOptions,
|
||||||
LoadPreviousSessionResponse,
|
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;
|
const method = options.webhookConfig?.method === 'POST' ? post : get;
|
||||||
return await method<SendMessageResponse>(
|
return await method<SendMessageResponse>(
|
||||||
`${options.webhookUrl}`,
|
`${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,30 +1,101 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import IconSend from 'virtual:icons/mdi/send';
|
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 { useI18n, useChat, useOptions } from '@n8n/chat/composables';
|
||||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
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 { options } = useOptions();
|
||||||
const chatStore = useChat();
|
const chatStore = useChat();
|
||||||
const { waitingForResponse } = chatStore;
|
const { waitingForResponse } = chatStore;
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const files = ref<FileList | null>(null);
|
||||||
const chatTextArea = ref<HTMLTextAreaElement | null>(null);
|
const chatTextArea = ref<HTMLTextAreaElement | null>(null);
|
||||||
const input = ref('');
|
const input = ref('');
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
|
||||||
const isSubmitDisabled = computed(() => {
|
const isSubmitDisabled = computed(() => {
|
||||||
return input.value === '' || waitingForResponse.value || options.disabled?.value === true;
|
return input.value === '' || waitingForResponse.value || options.disabled?.value === true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isInputDisabled = computed(() => 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(() => {
|
onMounted(() => {
|
||||||
chatEventBus.on('focusInput', () => {
|
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) {
|
if (chatTextArea.value) {
|
||||||
chatTextArea.value.focus();
|
chatTextArea.value.focus();
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
});
|
|
||||||
|
function setInputValue(value: string) {
|
||||||
|
input.value = value;
|
||||||
|
focusChatInput();
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmit(event: MouseEvent | KeyboardEvent) {
|
async function onSubmit(event: MouseEvent | KeyboardEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
@ -35,7 +106,11 @@ async function onSubmit(event: MouseEvent | KeyboardEvent) {
|
||||||
|
|
||||||
const messageText = input.value;
|
const messageText = input.value;
|
||||||
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) {
|
async function onSubmitKeydown(event: KeyboardEvent) {
|
||||||
|
@ -45,46 +120,124 @@ async function onSubmitKeydown(event: KeyboardEvent) {
|
||||||
|
|
||||||
await onSubmit(event);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="chat-input">
|
<div class="chat-input" :style="styleVars" @keydown.stop="onKeyDown">
|
||||||
|
<div class="chat-inputs">
|
||||||
<textarea
|
<textarea
|
||||||
ref="chatTextArea"
|
ref="chatTextArea"
|
||||||
v-model="input"
|
v-model="input"
|
||||||
rows="1"
|
|
||||||
:disabled="isInputDisabled"
|
:disabled="isInputDisabled"
|
||||||
:placeholder="t('inputPlaceholder')"
|
:placeholder="t('inputPlaceholder')"
|
||||||
@keydown.enter="onSubmitKeydown"
|
@keydown.enter="onSubmitKeydown"
|
||||||
/>
|
/>
|
||||||
<button :disabled="isSubmitDisabled" class="chat-input-send-button" @click="onSubmit">
|
|
||||||
<IconSend height="32" width="32" />
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss" scoped>
|
||||||
.chat-input {
|
.chat-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
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 {
|
textarea {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: var(--chat--input--font-size, inherit);
|
font-size: var(--chat--input--font-size, inherit);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 0;
|
border: var(--chat--input--border, 0);
|
||||||
padding: var(--chat--spacing);
|
border-radius: var(--chat--input--border-radius, 0);
|
||||||
max-height: var(--chat--textarea--height);
|
padding: 0.8rem;
|
||||||
resize: none;
|
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 {
|
&: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);
|
height: var(--chat--textarea--height);
|
||||||
width: var(--chat--textarea--height);
|
width: var(--chat--textarea--height);
|
||||||
background: white;
|
background: var(--chat--input--send--button--background, white);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--chat--input--send--button--color, var(--chat--color-secondary));
|
color: var(--chat--input--send--button--color, var(--chat--color-secondary));
|
||||||
border: 0;
|
border: 0;
|
||||||
|
@ -96,13 +249,27 @@ async function onSubmitKeydown(event: KeyboardEvent) {
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus {
|
&:focus {
|
||||||
color: var(--chat--color-secondary-shade-50);
|
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] {
|
&[disabled] {
|
||||||
cursor: default;
|
cursor: no-drop;
|
||||||
color: var(--chat--color-disabled);
|
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>
|
</style>
|
||||||
|
|
|
@ -1,10 +1,16 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
/* eslint-disable @typescript-eslint/naming-convention */
|
/* 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 VueMarkdown from 'vue-markdown-render';
|
||||||
import hljs from 'highlight.js/lib/core';
|
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 markdownLink from 'markdown-it-link-attributes';
|
||||||
import type MarkdownIt from 'markdown-it';
|
import type MarkdownIt from 'markdown-it';
|
||||||
|
import ChatFile from './ChatFile.vue';
|
||||||
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
|
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
|
||||||
import { useOptions } from '@n8n/chat/composables';
|
import { useOptions } from '@n8n/chat/composables';
|
||||||
|
|
||||||
|
@ -12,8 +18,21 @@ const props = defineProps<{
|
||||||
message: ChatMessage;
|
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 { message } = toRefs(props);
|
||||||
const { options } = useOptions();
|
const { options } = useOptions();
|
||||||
|
const messageContainer = ref<HTMLElement | null>(null);
|
||||||
|
const fileSources = ref<Record<string, string>>({});
|
||||||
|
|
||||||
const messageText = computed(() => {
|
const messageText = computed(() => {
|
||||||
return (message.value as ChatMessageText).text || '<Empty response>';
|
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 = {
|
const markdownOptions = {
|
||||||
highlight(str: string, lang: string) {
|
highlight(str: string, lang: string) {
|
||||||
if (lang && hljs.getLanguage(lang)) {
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
<slot>
|
||||||
<template v-if="message.type === 'component' && messageComponents[message.key]">
|
<template v-if="message.type === 'component' && messageComponents[message.key]">
|
||||||
<component :is="messageComponents[message.key]" v-bind="message.arguments" />
|
<component :is="messageComponents[message.key]" v-bind="message.arguments" />
|
||||||
|
@ -63,6 +117,11 @@ const messageComponents = options?.messageComponents ?? {};
|
||||||
:options="markdownOptions"
|
:options="markdownOptions"
|
||||||
:plugins="[linksNewTabPlugin]"
|
: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>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -70,11 +129,33 @@ const messageComponents = options?.messageComponents ?? {};
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.chat-message {
|
.chat-message {
|
||||||
display: block;
|
display: block;
|
||||||
|
position: relative;
|
||||||
max-width: 80%;
|
max-width: 80%;
|
||||||
font-size: var(--chat--message--font-size, 1rem);
|
font-size: var(--chat--message--font-size, 1rem);
|
||||||
padding: var(--chat--message--padding, var(--chat--spacing));
|
padding: var(--chat--message--padding, var(--chat--spacing));
|
||||||
border-radius: var(--chat--message--border-radius, var(--chat--border-radius));
|
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 {
|
p {
|
||||||
line-height: var(--chat--message-line-height, 1.8);
|
line-height: var(--chat--message-line-height, 1.8);
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
@ -82,7 +163,7 @@ const messageComponents = options?.messageComponents ?? {};
|
||||||
|
|
||||||
// Default message gap is half of the spacing
|
// Default message gap is half of the spacing
|
||||||
+ .chat-message {
|
+ .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
|
// 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);
|
border-radius: var(--chat--border-radius);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.chat-message-files {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { Message } from './index';
|
import { Message } from './index';
|
||||||
import type { ChatMessage } from '@n8n/chat/types';
|
import type { ChatMessage } from '@n8n/chat/types';
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@ const message: ChatMessage = {
|
||||||
sender: 'bot',
|
sender: 'bot',
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
};
|
};
|
||||||
|
const messageContainer = ref<InstanceType<typeof Message>>();
|
||||||
const classes = computed(() => {
|
const classes = computed(() => {
|
||||||
return {
|
return {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
@ -26,9 +26,13 @@ const classes = computed(() => {
|
||||||
[`chat-message-typing-animation-${props.animation}`]: true,
|
[`chat-message-typing-animation-${props.animation}`]: true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
messageContainer.value?.scrollToView();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Message :class="classes" :message="message">
|
<Message ref="messageContainer" :class="classes" :message="message">
|
||||||
<div class="chat-message-typing-body">
|
<div class="chat-message-typing-body">
|
||||||
<span class="chat-message-typing-circle"></span>
|
<span class="chat-message-typing-circle"></span>
|
||||||
<span class="chat-message-typing-circle"></span>
|
<span class="chat-message-typing-circle"></span>
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
import Message from '@n8n/chat/components/Message.vue';
|
import Message from '@n8n/chat/components/Message.vue';
|
||||||
import type { ChatMessage } from '@n8n/chat/types';
|
import type { ChatMessage } from '@n8n/chat/types';
|
||||||
import MessageTyping from '@n8n/chat/components/MessageTyping.vue';
|
import MessageTyping from '@n8n/chat/components/MessageTyping.vue';
|
||||||
|
@ -8,9 +9,23 @@ defineProps<{
|
||||||
messages: ChatMessage[];
|
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;
|
const { initialMessages, waitingForResponse } = chatStore;
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => messageComponents.value.length,
|
||||||
|
() => {
|
||||||
|
const lastMessageComponent = messageComponents.value[messageComponents.value.length - 1];
|
||||||
|
if (lastMessageComponent) {
|
||||||
|
lastMessageComponent.scrollToView();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="chat-messages-list">
|
<div class="chat-messages-list">
|
||||||
|
@ -19,7 +34,14 @@ const { initialMessages, waitingForResponse } = chatStore;
|
||||||
:key="initialMessage.id"
|
:key="initialMessage.id"
|
||||||
:message="initialMessage"
|
: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" />
|
<MessageTyping v-if="waitingForResponse" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1 +1,2 @@
|
||||||
@import 'tokens';
|
@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 = {
|
const sentMessage: ChatMessage = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
text,
|
text,
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
|
files,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -41,6 +42,7 @@ export const ChatPlugin: Plugin<ChatOptions> = {
|
||||||
|
|
||||||
const sendMessageResponse = await api.sendMessage(
|
const sendMessageResponse = await api.sendMessage(
|
||||||
text,
|
text,
|
||||||
|
files,
|
||||||
currentSessionId.value as string,
|
currentSessionId.value as string,
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
|
|
@ -8,5 +8,5 @@ export interface Chat {
|
||||||
waitingForResponse: Ref<boolean>;
|
waitingForResponse: Ref<boolean>;
|
||||||
loadPreviousSession?: () => Promise<string | undefined>;
|
loadPreviousSession?: () => Promise<string | undefined>;
|
||||||
startNewSession?: () => Promise<void>;
|
startNewSession?: () => Promise<void>;
|
||||||
sendMessage: (text: string) => Promise<void>;
|
sendMessage: (text: string, files: File[]) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,4 +16,5 @@ interface ChatMessageBase {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
transparent?: boolean;
|
transparent?: boolean;
|
||||||
sender: 'user' | 'bot';
|
sender: 'user' | 'bot';
|
||||||
|
files?: File[];
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Component, Ref } from 'vue';
|
import type { Component, Ref } from 'vue';
|
||||||
|
|
||||||
export interface ChatOptions {
|
export interface ChatOptions {
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
webhookConfig?: {
|
webhookConfig?: {
|
||||||
|
@ -30,4 +31,6 @@ export interface ChatOptions {
|
||||||
theme?: {};
|
theme?: {};
|
||||||
messageComponents?: Record<string, Component>;
|
messageComponents?: Record<string, Component>;
|
||||||
disabled?: Ref<boolean>;
|
disabled?: Ref<boolean>;
|
||||||
|
allowFileUploads?: Ref<boolean> | boolean;
|
||||||
|
allowedFilesMimeTypes?: Ref<string> | string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,6 +38,14 @@ export const toolsAgentProperties: INodeProperties[] = [
|
||||||
default: false,
|
default: false,
|
||||||
description: 'Whether or not the output should include intermediate steps the agent took',
|
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 { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||||
|
|
||||||
import type { AgentAction, AgentFinish, AgentStep } from 'langchain/agents';
|
import type { AgentAction, AgentFinish, AgentStep } from 'langchain/agents';
|
||||||
import { AgentExecutor, createToolCallingAgent } from 'langchain/agents';
|
import { AgentExecutor, createToolCallingAgent } from 'langchain/agents';
|
||||||
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
|
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
|
||||||
|
import type { BaseMessagePromptTemplateLike } from '@langchain/core/prompts';
|
||||||
import { ChatPromptTemplate } from '@langchain/core/prompts';
|
import { ChatPromptTemplate } from '@langchain/core/prompts';
|
||||||
import { omit } from 'lodash';
|
import { omit } from 'lodash';
|
||||||
import type { Tool } from '@langchain/core/tools';
|
import type { Tool } from '@langchain/core/tools';
|
||||||
|
@ -13,6 +14,7 @@ import type { ZodObject } from 'zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { BaseOutputParser, StructuredOutputParser } from '@langchain/core/output_parsers';
|
import type { BaseOutputParser, StructuredOutputParser } from '@langchain/core/output_parsers';
|
||||||
import { OutputFixingParser } from 'langchain/output_parsers';
|
import { OutputFixingParser } from 'langchain/output_parsers';
|
||||||
|
import { HumanMessage } from '@langchain/core/messages';
|
||||||
import {
|
import {
|
||||||
isChatInstance,
|
isChatInstance,
|
||||||
getPromptInputByType,
|
getPromptInputByType,
|
||||||
|
@ -39,6 +41,40 @@ function getOutputParserSchema(outputParser: BaseOutputParser): ZodObject<any, a
|
||||||
return schema;
|
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[][]> {
|
export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||||
this.logger.verbose('Executing Tools Agent');
|
this.logger.verbose('Executing Tools Agent');
|
||||||
const model = await this.getInputConnectionData(NodeConnectionType.AiLanguageModel, 0);
|
const model = await this.getInputConnectionData(NodeConnectionType.AiLanguageModel, 0);
|
||||||
|
@ -113,12 +149,20 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeE
|
||||||
returnIntermediateSteps?: boolean;
|
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}' : ''}`],
|
['system', `{system_message}${outputParser ? '\n\n{formatting_instructions}' : ''}`],
|
||||||
['placeholder', '{chat_history}'],
|
['placeholder', '{chat_history}'],
|
||||||
['human', '{input}'],
|
['human', '{input}'],
|
||||||
['placeholder', '{agent_scratchpad}'],
|
['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({
|
const agent = createToolCallingAgent({
|
||||||
llm: model,
|
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',
|
displayName: 'Data Format',
|
||||||
name: 'loader',
|
name: 'loader',
|
||||||
|
@ -187,6 +211,9 @@ export class DocumentDefaultDataLoader implements INodeType {
|
||||||
show: {
|
show: {
|
||||||
dataType: ['binary'],
|
dataType: ['binary'],
|
||||||
},
|
},
|
||||||
|
hide: {
|
||||||
|
binaryMode: ['allInputData'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
import {
|
import { Node, NodeConnectionType } from 'n8n-workflow';
|
||||||
type IDataObject,
|
import type {
|
||||||
type IWebhookFunctions,
|
IDataObject,
|
||||||
type IWebhookResponseData,
|
IWebhookFunctions,
|
||||||
type INodeType,
|
IWebhookResponseData,
|
||||||
type INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
NodeConnectionType,
|
MultiPartFormData,
|
||||||
|
INodeExecutionData,
|
||||||
|
IBinaryData,
|
||||||
|
INodeProperties,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { pick } from 'lodash';
|
import { pick } from 'lodash';
|
||||||
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
|
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
|
||||||
import { createPage } from './templates';
|
import { createPage } from './templates';
|
||||||
|
@ -13,15 +17,31 @@ import { validateAuth } from './GenericFunctions';
|
||||||
import type { LoadPreviousSessionChatOption } from './types';
|
import type { LoadPreviousSessionChatOption } from './types';
|
||||||
|
|
||||||
const CHAT_TRIGGER_PATH_IDENTIFIER = 'chat';
|
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 = {
|
description: INodeTypeDescription = {
|
||||||
displayName: 'Chat Trigger',
|
displayName: 'Chat Trigger',
|
||||||
name: 'chatTrigger',
|
name: 'chatTrigger',
|
||||||
icon: 'fa:comments',
|
icon: 'fa:comments',
|
||||||
iconColor: 'black',
|
iconColor: 'black',
|
||||||
group: ['trigger'],
|
group: ['trigger'],
|
||||||
version: 1,
|
version: [1, 1.1],
|
||||||
description: 'Runs the workflow when an n8n generated webchat is submitted',
|
description: 'Runs the workflow when an n8n generated webchat is submitted',
|
||||||
defaults: {
|
defaults: {
|
||||||
name: 'When chat message received',
|
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?',
|
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',
|
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',
|
displayName: 'Options',
|
||||||
name: 'options',
|
name: 'options',
|
||||||
|
@ -207,6 +241,22 @@ export class ChatTrigger implements INodeType {
|
||||||
placeholder: 'Add Field',
|
placeholder: 'Add Field',
|
||||||
default: {},
|
default: {},
|
||||||
options: [
|
options: [
|
||||||
|
{
|
||||||
|
...allowFileUploadsOption,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
'/mode': ['hostedChat'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...allowedFileMimeTypeOption,
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
'/mode': ['hostedChat'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Input Placeholder',
|
displayName: 'Input Placeholder',
|
||||||
name: 'inputPlaceholder',
|
name: 'inputPlaceholder',
|
||||||
|
@ -320,11 +370,73 @@ export class ChatTrigger implements INodeType {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
|
private async handleFormData(context: IWebhookFunctions) {
|
||||||
const res = this.getResponseObject();
|
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 returnItem: INodeExecutionData = {
|
||||||
const nodeMode = this.getNodeParameter('mode', 'hostedChat') as string;
|
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) {
|
if (!isPublic) {
|
||||||
res.status(404).end();
|
res.status(404).end();
|
||||||
return {
|
return {
|
||||||
|
@ -332,22 +444,25 @@ export class ChatTrigger implements INodeType {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const webhookName = this.getWebhookName();
|
const options = ctx.getNodeParameter('options', {}) as {
|
||||||
const mode = this.getMode() === 'manual' ? 'test' : 'production';
|
|
||||||
const bodyData = this.getBodyData() ?? {};
|
|
||||||
|
|
||||||
const options = this.getNodeParameter('options', {}) as {
|
|
||||||
getStarted?: string;
|
getStarted?: string;
|
||||||
inputPlaceholder?: string;
|
inputPlaceholder?: string;
|
||||||
loadPreviousSession?: LoadPreviousSessionChatOption;
|
loadPreviousSession?: LoadPreviousSessionChatOption;
|
||||||
showWelcomeScreen?: boolean;
|
showWelcomeScreen?: boolean;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
title?: 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') {
|
if (nodeMode === 'hostedChat') {
|
||||||
try {
|
try {
|
||||||
await validateAuth(this);
|
await validateAuth(ctx);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error) {
|
if (error) {
|
||||||
res.writeHead((error as IDataObject).responseCode as number, {
|
res.writeHead((error as IDataObject).responseCode as number, {
|
||||||
|
@ -361,19 +476,19 @@ export class ChatTrigger implements INodeType {
|
||||||
|
|
||||||
// Show the chat on GET request
|
// Show the chat on GET request
|
||||||
if (webhookName === 'setup') {
|
if (webhookName === 'setup') {
|
||||||
const webhookUrlRaw = this.getNodeWebhookUrl('default') as string;
|
const webhookUrlRaw = ctx.getNodeWebhookUrl('default') as string;
|
||||||
const webhookUrl =
|
const webhookUrl =
|
||||||
mode === 'test' ? webhookUrlRaw.replace('/webhook', '/webhook-test') : webhookUrlRaw;
|
mode === 'test' ? webhookUrlRaw.replace('/webhook', '/webhook-test') : webhookUrlRaw;
|
||||||
const authentication = this.getNodeParameter('authentication') as
|
const authentication = ctx.getNodeParameter('authentication') as
|
||||||
| 'none'
|
| 'none'
|
||||||
| 'basicAuth'
|
| 'basicAuth'
|
||||||
| 'n8nUserAuth';
|
| 'n8nUserAuth';
|
||||||
const initialMessagesRaw = this.getNodeParameter('initialMessages', '') as string;
|
const initialMessagesRaw = ctx.getNodeParameter('initialMessages', '') as string;
|
||||||
const initialMessages = initialMessagesRaw
|
const initialMessages = initialMessagesRaw
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.filter((line) => line)
|
.filter((line) => line)
|
||||||
.map((line) => line.trim());
|
.map((line) => line.trim());
|
||||||
const instanceId = this.getInstanceId();
|
const instanceId = ctx.getInstanceId();
|
||||||
|
|
||||||
const i18nConfig = pick(options, ['getStarted', 'inputPlaceholder', 'subtitle', 'title']);
|
const i18nConfig = pick(options, ['getStarted', 'inputPlaceholder', 'subtitle', 'title']);
|
||||||
|
|
||||||
|
@ -388,6 +503,8 @@ export class ChatTrigger implements INodeType {
|
||||||
mode,
|
mode,
|
||||||
instanceId,
|
instanceId,
|
||||||
authentication,
|
authentication,
|
||||||
|
allowFileUploads: options.allowFileUploads,
|
||||||
|
allowedFilesMimeTypes: options.allowedFilesMimeTypes,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(200).send(page).end();
|
res.status(200).send(page).end();
|
||||||
|
@ -399,7 +516,7 @@ export class ChatTrigger implements INodeType {
|
||||||
|
|
||||||
if (bodyData.action === 'loadPreviousSession') {
|
if (bodyData.action === 'loadPreviousSession') {
|
||||||
if (options?.loadPreviousSession === 'memory') {
|
if (options?.loadPreviousSession === 'memory') {
|
||||||
const memory = (await this.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as
|
const memory = (await ctx.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as
|
||||||
| BaseChatMemory
|
| BaseChatMemory
|
||||||
| undefined;
|
| undefined;
|
||||||
const messages = ((await memory?.chatHistory.getMessages()) ?? [])
|
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 };
|
const webhookResponse: IDataObject = { status: 200 };
|
||||||
|
if (req.contentType === 'multipart/form-data') {
|
||||||
|
returnData = [await this.handleFormData(ctx)];
|
||||||
return {
|
return {
|
||||||
webhookResponse,
|
webhookResponse,
|
||||||
workflowData: [this.helpers.returnJsonArray(returnData)],
|
workflowData: [returnData],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
returnData = [{ json: bodyData }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
webhookResponse,
|
||||||
|
workflowData: [ctx.helpers.returnJsonArray(returnData)],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,8 @@ export function createPage({
|
||||||
i18n: { en },
|
i18n: { en },
|
||||||
initialMessages,
|
initialMessages,
|
||||||
authentication,
|
authentication,
|
||||||
|
allowFileUploads,
|
||||||
|
allowedFilesMimeTypes,
|
||||||
}: {
|
}: {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
webhookUrl?: string;
|
webhookUrl?: string;
|
||||||
|
@ -19,6 +21,8 @@ export function createPage({
|
||||||
initialMessages: string[];
|
initialMessages: string[];
|
||||||
mode: 'test' | 'production';
|
mode: 'test' | 'production';
|
||||||
authentication: AuthenticationChatOption;
|
authentication: AuthenticationChatOption;
|
||||||
|
allowFileUploads?: boolean;
|
||||||
|
allowedFilesMimeTypes?: string;
|
||||||
}) {
|
}) {
|
||||||
const validAuthenticationOptions: AuthenticationChatOption[] = [
|
const validAuthenticationOptions: AuthenticationChatOption[] = [
|
||||||
'none',
|
'none',
|
||||||
|
@ -35,6 +39,8 @@ export function createPage({
|
||||||
? authentication
|
? authentication
|
||||||
: 'none';
|
: 'none';
|
||||||
const sanitizedShowWelcomeScreen = !!showWelcomeScreen;
|
const sanitizedShowWelcomeScreen = !!showWelcomeScreen;
|
||||||
|
const sanitizedAllowFileUploads = !!allowFileUploads;
|
||||||
|
const sanitizedAllowedFilesMimeTypes = allowedFilesMimeTypes?.toString() ?? '';
|
||||||
const sanitizedLoadPreviousSession = validLoadPreviousSessionOptions.includes(
|
const sanitizedLoadPreviousSession = validLoadPreviousSessionOptions.includes(
|
||||||
loadPreviousSession as LoadPreviousSessionChatOption,
|
loadPreviousSession as LoadPreviousSessionChatOption,
|
||||||
)
|
)
|
||||||
|
@ -103,6 +109,8 @@ export function createPage({
|
||||||
'X-Instance-Id': '${instanceId}',
|
'X-Instance-Id': '${instanceId}',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
allowFileUploads: ${sanitizedAllowFileUploads},
|
||||||
|
allowedFilesMimeTypes: '${sanitizedAllowedFilesMimeTypes}',
|
||||||
i18n: {
|
i18n: {
|
||||||
${en ? `en: ${JSON.stringify(en)},` : ''}
|
${en ? `en: ${JSON.stringify(en)},` : ''}
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { pipeline } from 'stream/promises';
|
import { pipeline } from 'stream/promises';
|
||||||
import { createWriteStream } from 'fs';
|
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 { NodeOperationError, BINARY_ENCODING } from 'n8n-workflow';
|
||||||
|
|
||||||
import type { TextSplitter } from '@langchain/textsplitters';
|
import type { TextSplitter } from '@langchain/textsplitters';
|
||||||
|
@ -60,21 +60,10 @@ export class N8nBinaryLoader {
|
||||||
return docs;
|
return docs;
|
||||||
}
|
}
|
||||||
|
|
||||||
async processItem(item: INodeExecutionData, itemIndex: number): Promise<Document[]> {
|
private async validateMimeType(
|
||||||
const selectedLoader: keyof typeof SUPPORTED_MIME_TYPES = this.context.getNodeParameter(
|
mimeType: string,
|
||||||
'loader',
|
selectedLoader: keyof typeof SUPPORTED_MIME_TYPES,
|
||||||
itemIndex,
|
): Promise<void> {
|
||||||
'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;
|
|
||||||
|
|
||||||
// Check if loader matches the mime-type of the data
|
// Check if loader matches the mime-type of the data
|
||||||
if (selectedLoader !== 'auto' && !SUPPORTED_MIME_TYPES[selectedLoader].includes(mimeType)) {
|
if (selectedLoader !== 'auto' && !SUPPORTED_MIME_TYPES[selectedLoader].includes(mimeType)) {
|
||||||
const neededLoader = Object.keys(SUPPORTED_MIME_TYPES).find((loader) =>
|
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)) {
|
if (!Object.values(SUPPORTED_MIME_TYPES).flat().includes(mimeType)) {
|
||||||
throw new NodeOperationError(this.context.getNode(), `Unsupported mime type: ${mimeType}`);
|
throw new NodeOperationError(this.context.getNode(), `Unsupported mime type: ${mimeType}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!SUPPORTED_MIME_TYPES[selectedLoader].includes(mimeType) &&
|
!SUPPORTED_MIME_TYPES[selectedLoader].includes(mimeType) &&
|
||||||
selectedLoader !== 'textLoader' &&
|
selectedLoader !== 'textLoader' &&
|
||||||
|
@ -100,24 +90,31 @@ export class N8nBinaryLoader {
|
||||||
`Unsupported mime type: ${mimeType} for selected loader: ${selectedLoader}`,
|
`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) {
|
if (binaryData.id) {
|
||||||
const binaryBuffer = await this.context.helpers.binaryToBuffer(
|
const binaryBuffer = await this.context.helpers.binaryToBuffer(
|
||||||
await this.context.helpers.getBinaryStream(binaryData.id),
|
await this.context.helpers.getBinaryStream(binaryData.id),
|
||||||
);
|
);
|
||||||
filePathOrBlob = new Blob([binaryBuffer], {
|
return new Blob([binaryBuffer], {
|
||||||
type: mimeType,
|
type: mimeType,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
filePathOrBlob = new Blob([Buffer.from(binaryData.data, BINARY_ENCODING)], {
|
return new Blob([Buffer.from(binaryData.data, BINARY_ENCODING)], {
|
||||||
type: mimeType,
|
type: mimeType,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let loader: PDFLoader | CSVLoader | EPubLoader | DocxLoader | TextLoader | JSONLoader;
|
private async getLoader(
|
||||||
let cleanupTmpFile: DirectoryResult['cleanup'] | undefined = undefined;
|
mimeType: string,
|
||||||
|
filePathOrBlob: string | Blob,
|
||||||
|
itemIndex: number,
|
||||||
|
): Promise<PDFLoader | CSVLoader | EPubLoader | DocxLoader | TextLoader | JSONLoader> {
|
||||||
switch (mimeType) {
|
switch (mimeType) {
|
||||||
case 'application/pdf':
|
case 'application/pdf':
|
||||||
const splitPages = this.context.getNodeParameter(
|
const splitPages = this.context.getNodeParameter(
|
||||||
|
@ -125,10 +122,7 @@ export class N8nBinaryLoader {
|
||||||
itemIndex,
|
itemIndex,
|
||||||
false,
|
false,
|
||||||
) as boolean;
|
) as boolean;
|
||||||
loader = new PDFLoader(filePathOrBlob, {
|
return new PDFLoader(filePathOrBlob, { splitPages });
|
||||||
splitPages,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'text/csv':
|
case 'text/csv':
|
||||||
const column = this.context.getNodeParameter(
|
const column = this.context.getNodeParameter(
|
||||||
`${this.optionsPrefix}column`,
|
`${this.optionsPrefix}column`,
|
||||||
|
@ -140,38 +134,23 @@ export class N8nBinaryLoader {
|
||||||
itemIndex,
|
itemIndex,
|
||||||
',',
|
',',
|
||||||
) as string;
|
) as string;
|
||||||
|
return new CSVLoader(filePathOrBlob, { column: column ?? undefined, separator });
|
||||||
loader = new CSVLoader(filePathOrBlob, {
|
|
||||||
column: column ?? undefined,
|
|
||||||
separator,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case 'application/epub+zip':
|
case 'application/epub+zip':
|
||||||
// EPubLoader currently does not accept Blobs https://github.com/langchain-ai/langchainjs/issues/1623
|
// EPubLoader currently does not accept Blobs https://github.com/langchain-ai/langchainjs/issues/1623
|
||||||
let filePath: string;
|
let filePath: string;
|
||||||
if (filePathOrBlob instanceof Blob) {
|
if (filePathOrBlob instanceof Blob) {
|
||||||
const tmpFileData = await tmpFile({ prefix: 'epub-loader-' });
|
const tmpFileData = await tmpFile({ prefix: 'epub-loader-' });
|
||||||
cleanupTmpFile = tmpFileData.cleanup;
|
|
||||||
try {
|
|
||||||
const bufferData = await filePathOrBlob.arrayBuffer();
|
const bufferData = await filePathOrBlob.arrayBuffer();
|
||||||
await pipeline([new Uint8Array(bufferData)], createWriteStream(tmpFileData.path));
|
await pipeline([new Uint8Array(bufferData)], createWriteStream(tmpFileData.path));
|
||||||
loader = new EPubLoader(tmpFileData.path);
|
return new EPubLoader(tmpFileData.path);
|
||||||
break;
|
|
||||||
} catch (error) {
|
|
||||||
await cleanupTmpFile();
|
|
||||||
throw new NodeOperationError(this.context.getNode(), error as Error);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
filePath = filePathOrBlob;
|
filePath = filePathOrBlob;
|
||||||
}
|
}
|
||||||
loader = new EPubLoader(filePath);
|
return new EPubLoader(filePath);
|
||||||
break;
|
|
||||||
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
|
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
|
||||||
loader = new DocxLoader(filePathOrBlob);
|
return new DocxLoader(filePathOrBlob);
|
||||||
break;
|
|
||||||
case 'text/plain':
|
case 'text/plain':
|
||||||
loader = new TextLoader(filePathOrBlob);
|
return new TextLoader(filePathOrBlob);
|
||||||
break;
|
|
||||||
case 'application/json':
|
case 'application/json':
|
||||||
const pointers = this.context.getNodeParameter(
|
const pointers = this.context.getNodeParameter(
|
||||||
`${this.optionsPrefix}pointers`,
|
`${this.optionsPrefix}pointers`,
|
||||||
|
@ -179,15 +158,77 @@ export class N8nBinaryLoader {
|
||||||
'',
|
'',
|
||||||
) as string;
|
) as string;
|
||||||
const pointersArray = pointers.split(',').map((pointer) => pointer.trim());
|
const pointersArray = pointers.split(',').map((pointer) => pointer.trim());
|
||||||
loader = new JSONLoader(filePathOrBlob, pointersArray);
|
return new JSONLoader(filePathOrBlob, pointersArray);
|
||||||
break;
|
|
||||||
default:
|
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 this.textSplitter.splitDocuments(await loader.load())
|
||||||
: 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);
|
docs.push(...loadedDoc);
|
||||||
|
|
||||||
|
@ -200,9 +241,8 @@ export class N8nBinaryLoader {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cleanupTmpFile) {
|
await this.cleanupTmpFileIfNeeded(cleanupTmpFile);
|
||||||
await cleanupTmpFile();
|
|
||||||
}
|
|
||||||
return docs;
|
return docs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,10 @@
|
||||||
import { NodeConnectionType, NodeOperationError, jsonStringify } from 'n8n-workflow';
|
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 { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||||
import type { BaseOutputParser } from '@langchain/core/output_parsers';
|
import type { BaseOutputParser } from '@langchain/core/output_parsers';
|
||||||
import type { BaseMessage } from '@langchain/core/messages';
|
import type { BaseMessage } from '@langchain/core/messages';
|
||||||
|
@ -81,7 +86,7 @@ export function getPromptInputByType(options: {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionId(
|
export function getSessionId(
|
||||||
ctx: IExecuteFunctions,
|
ctx: IExecuteFunctions | IWebhookFunctions,
|
||||||
itemIndex: number,
|
itemIndex: number,
|
||||||
selectorKey = 'sessionIdType',
|
selectorKey = 'sessionIdType',
|
||||||
autoSelect = 'fromInput',
|
autoSelect = 'fromInput',
|
||||||
|
@ -91,7 +96,15 @@ export function getSessionId(
|
||||||
const selectorType = ctx.getNodeParameter(selectorKey, itemIndex) as string;
|
const selectorType = ctx.getNodeParameter(selectorKey, itemIndex) as string;
|
||||||
|
|
||||||
if (selectorType === autoSelect) {
|
if (selectorType === autoSelect) {
|
||||||
|
// 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;
|
sessionId = ctx.evaluateExpression('{{ $json.sessionId }}', itemIndex) as string;
|
||||||
|
}
|
||||||
|
|
||||||
if (sessionId === '' || sessionId === undefined) {
|
if (sessionId === '' || sessionId === undefined) {
|
||||||
throw new NodeOperationError(ctx.getNode(), 'No session ID found', {
|
throw new NodeOperationError(ctx.getNode(), 'No session ID found', {
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -51,8 +51,8 @@
|
||||||
"@vue-flow/core": "^1.33.5",
|
"@vue-flow/core": "^1.33.5",
|
||||||
"@vue-flow/minimap": "^1.4.0",
|
"@vue-flow/minimap": "^1.4.0",
|
||||||
"@vue-flow/node-toolbar": "^1.1.0",
|
"@vue-flow/node-toolbar": "^1.1.0",
|
||||||
"@vueuse/components": "^10.5.0",
|
"@vueuse/components": "^10.11.0",
|
||||||
"@vueuse/core": "^10.5.0",
|
"@vueuse/core": "^10.11.0",
|
||||||
"axios": "1.6.7",
|
"axios": "1.6.7",
|
||||||
"chart.js": "^4.4.0",
|
"chart.js": "^4.4.0",
|
||||||
"codemirror-lang-html-n8n": "^1.0.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 TagsManager from '@/components/TagsManager/TagsManager.vue';
|
||||||
import UpdatesPanel from '@/components/UpdatesPanel.vue';
|
import UpdatesPanel from '@/components/UpdatesPanel.vue';
|
||||||
import NpsSurvey from '@/components/NpsSurvey.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 WorkflowSettings from '@/components/WorkflowSettings.vue';
|
||||||
import DeleteUserModal from '@/components/DeleteUserModal.vue';
|
import DeleteUserModal from '@/components/DeleteUserModal.vue';
|
||||||
import ActivationModal from '@/components/ActivationModal.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 { NodeConnectionType } from 'n8n-workflow';
|
||||||
import type { IConnections, INode } 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 { WORKFLOW_LM_CHAT_MODAL_KEY } from '@/constants';
|
||||||
import type { IWorkflowDb } from '@/Interface';
|
import type { IWorkflowDb } from '@/Interface';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
@ -82,36 +82,6 @@ describe('WorkflowLMChatModal', () => {
|
||||||
server.shutdown();
|
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 () => {
|
it('should render correctly', async () => {
|
||||||
const wrapper = renderComponent({
|
const wrapper = renderComponent({
|
||||||
pinia: await createPiniaWithAINodes(),
|
pinia: await createPiniaWithAINodes(),
|
||||||
|
@ -137,14 +107,17 @@ describe('WorkflowLMChatModal', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const chatDialog = wrapper.getByTestId('workflow-lm-chat-dialog');
|
const chatDialog = wrapper.getByTestId('workflow-lm-chat-dialog');
|
||||||
const chatSendButton = wrapper.getByTestId('workflow-chat-send-button');
|
const chatInputsContainer = wrapper.getByTestId('lm-chat-inputs');
|
||||||
const chatInput = wrapper.getByTestId('workflow-chat-input');
|
const chatSendButton = chatInputsContainer.querySelector('.chat-input-send-button');
|
||||||
|
const chatInput = chatInputsContainer.querySelector('textarea');
|
||||||
|
|
||||||
|
if (chatInput && chatSendButton) {
|
||||||
await fireEvent.update(chatInput, 'Hello!');
|
await fireEvent.update(chatInput, 'Hello!');
|
||||||
await fireEvent.click(chatSendButton);
|
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();
|
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()', () => {
|
describe('copy()', () => {
|
||||||
it('should copy text value', async () => {
|
it('should copy text value', async () => {
|
||||||
const { getByTestId } = render(TestComponent);
|
const { getByTestId } = render(TestComponent);
|
||||||
|
|
||||||
const copyButton = getByTestId('copy');
|
const copyButton = getByTestId('copy');
|
||||||
copyButton.click();
|
await userEvent.click(copyButton);
|
||||||
expect((window.navigator.clipboard as unknown as { items: string[] }).items).toHaveLength(1);
|
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:
|
packages/@n8n/chat:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@vueuse/core':
|
||||||
|
specifier: ^10.11.0
|
||||||
|
version: 10.11.0(vue@3.4.21(typescript@5.5.2))
|
||||||
highlight.js:
|
highlight.js:
|
||||||
specifier: ^11.8.0
|
specifier: ^11.8.0
|
||||||
version: 11.9.0
|
version: 11.9.0
|
||||||
|
@ -1178,14 +1181,14 @@ importers:
|
||||||
specifier: ^1.1.0
|
specifier: ^1.1.0
|
||||||
version: 1.1.0(@vue-flow/core@1.33.5(vue@3.4.21(typescript@5.5.2)))
|
version: 1.1.0(@vue-flow/core@1.33.5(vue@3.4.21(typescript@5.5.2)))
|
||||||
'@vueuse/components':
|
'@vueuse/components':
|
||||||
specifier: ^10.5.0
|
specifier: ^10.11.0
|
||||||
version: 10.5.0(vue@3.4.21(typescript@5.5.2))
|
version: 10.11.0(vue@3.4.21(typescript@5.5.2))
|
||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^10.5.0
|
specifier: ^10.11.0
|
||||||
version: 10.5.0(vue@3.4.21(typescript@5.5.2))
|
version: 10.11.0(vue@3.4.21(typescript@5.5.2))
|
||||||
axios:
|
axios:
|
||||||
specifier: 1.6.7
|
specifier: 1.6.7
|
||||||
version: 1.6.7
|
version: 1.6.7(debug@3.2.7)
|
||||||
chart.js:
|
chart.js:
|
||||||
specifier: ^4.4.0
|
specifier: ^4.4.0
|
||||||
version: 4.4.0
|
version: 4.4.0
|
||||||
|
@ -5705,8 +5708,8 @@ packages:
|
||||||
'@types/web-bluetooth@0.0.16':
|
'@types/web-bluetooth@0.0.16':
|
||||||
resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==}
|
resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==}
|
||||||
|
|
||||||
'@types/web-bluetooth@0.0.18':
|
'@types/web-bluetooth@0.0.20':
|
||||||
resolution: {integrity: sha512-v/ZHEj9xh82usl8LMR3GarzFY1IrbXJw5L4QfQhokjRV91q+SelFqxQWSep1ucXEZ22+dSTwLFkXeur25sPIbw==}
|
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
|
||||||
|
|
||||||
'@types/webidl-conversions@7.0.0':
|
'@types/webidl-conversions@7.0.0':
|
||||||
resolution: {integrity: sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog==}
|
resolution: {integrity: sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog==}
|
||||||
|
@ -5985,23 +5988,23 @@ packages:
|
||||||
'@vue/tsconfig@0.5.1':
|
'@vue/tsconfig@0.5.1':
|
||||||
resolution: {integrity: sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==}
|
resolution: {integrity: sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==}
|
||||||
|
|
||||||
'@vueuse/components@10.5.0':
|
'@vueuse/components@10.11.0':
|
||||||
resolution: {integrity: sha512-zWQZ8zkNBvX++VHfyiUaQ4otb+4PWI8679GR8FvdrNnj+01LXnqvrkyKd8yTCMJ9nHqwRRTJikS5fu4Zspn9DQ==}
|
resolution: {integrity: sha512-ZvLZI23d5ZAtva5fGyYh/jQtZO8l+zJ5tAXyYNqHJZkq1o5yWyqZhENvSv5mfDmN5IuAOp4tq02mRmX/ipFGcg==}
|
||||||
|
|
||||||
'@vueuse/core@10.5.0':
|
'@vueuse/core@10.11.0':
|
||||||
resolution: {integrity: sha512-z/tI2eSvxwLRjOhDm0h/SXAjNm8N5ld6/SC/JQs6o6kpJ6Ya50LnEL8g5hoYu005i28L0zqB5L5yAl8Jl26K3A==}
|
resolution: {integrity: sha512-x3sD4Mkm7PJ+pcq3HX8PLPBadXCAlSDR/waK87dz0gQE+qJnaaFhc/dZVfJz+IUYzTMVGum2QlR7ImiJQN4s6g==}
|
||||||
|
|
||||||
'@vueuse/core@9.13.0':
|
'@vueuse/core@9.13.0':
|
||||||
resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==}
|
resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==}
|
||||||
|
|
||||||
'@vueuse/metadata@10.5.0':
|
'@vueuse/metadata@10.11.0':
|
||||||
resolution: {integrity: sha512-fEbElR+MaIYyCkeM0SzWkdoMtOpIwO72x8WsZHRE7IggiOlILttqttM69AS13nrDxosnDBYdyy3C5mR1LCxHsw==}
|
resolution: {integrity: sha512-kQX7l6l8dVWNqlqyN3ePW3KmjCQO3ZMgXuBMddIu83CmucrsBfXlH+JoviYyRBws/yLTQO8g3Pbw+bdIoVm4oQ==}
|
||||||
|
|
||||||
'@vueuse/metadata@9.13.0':
|
'@vueuse/metadata@9.13.0':
|
||||||
resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==}
|
resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==}
|
||||||
|
|
||||||
'@vueuse/shared@10.5.0':
|
'@vueuse/shared@10.11.0':
|
||||||
resolution: {integrity: sha512-18iyxbbHYLst9MqU1X1QNdMHIjks6wC7XTVf0KNOv5es/Ms6gjVFCAAWTVP2JStuGqydg3DT+ExpFORUEi9yhg==}
|
resolution: {integrity: sha512-fyNoIXEq3PfX1L3NkNhtVQUSRtqYwJtJg+Bp9rIzculIZWHTkKSysujrOk2J+NrRulLTQH9+3gGSfYLWSEWU1A==}
|
||||||
|
|
||||||
'@vueuse/shared@9.13.0':
|
'@vueuse/shared@9.13.0':
|
||||||
resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==}
|
resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==}
|
||||||
|
@ -13152,6 +13155,17 @@ packages:
|
||||||
'@vue/composition-api':
|
'@vue/composition-api':
|
||||||
optional: true
|
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:
|
vue-docgen-api@4.76.0:
|
||||||
resolution: {integrity: sha512-Nykmg/Net1BhoS1tENGqcevDdgha4us0x2Xnin7n5SxxAH6+s10FXTWtg7E9+jhC3GWEE83lcFHMS/Ml4C1dog==}
|
resolution: {integrity: sha512-Nykmg/Net1BhoS1tENGqcevDdgha4us0x2Xnin7n5SxxAH6+s10FXTWtg7E9+jhC3GWEE83lcFHMS/Ml4C1dog==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -16118,7 +16132,7 @@ snapshots:
|
||||||
'@antfu/install-pkg': 0.1.1
|
'@antfu/install-pkg': 0.1.1
|
||||||
'@antfu/utils': 0.7.6
|
'@antfu/utils': 0.7.6
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
debug: 4.3.4
|
debug: 4.3.4(supports-color@8.1.1)
|
||||||
kolorist: 1.8.0
|
kolorist: 1.8.0
|
||||||
local-pkg: 0.4.3
|
local-pkg: 0.4.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
|
@ -19370,7 +19384,7 @@ snapshots:
|
||||||
|
|
||||||
'@types/web-bluetooth@0.0.16': {}
|
'@types/web-bluetooth@0.0.16': {}
|
||||||
|
|
||||||
'@types/web-bluetooth@0.0.18': {}
|
'@types/web-bluetooth@0.0.20': {}
|
||||||
|
|
||||||
'@types/webidl-conversions@7.0.0': {}
|
'@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))':
|
'@vue-flow/core@1.33.5(vue@3.4.21(typescript@5.5.2))':
|
||||||
dependencies:
|
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-drag: 3.0.0
|
||||||
d3-selection: 3.0.0
|
d3-selection: 3.0.0
|
||||||
d3-zoom: 3.0.0
|
d3-zoom: 3.0.0
|
||||||
|
@ -19772,21 +19786,21 @@ snapshots:
|
||||||
|
|
||||||
'@vue/tsconfig@0.5.1': {}
|
'@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:
|
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))
|
||||||
'@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))
|
||||||
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:
|
transitivePeerDependencies:
|
||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
- vue
|
- 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:
|
dependencies:
|
||||||
'@types/web-bluetooth': 0.0.18
|
'@types/web-bluetooth': 0.0.20
|
||||||
'@vueuse/metadata': 10.5.0
|
'@vueuse/metadata': 10.11.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))
|
||||||
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:
|
transitivePeerDependencies:
|
||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
- vue
|
- vue
|
||||||
|
@ -19796,25 +19810,25 @@ snapshots:
|
||||||
'@types/web-bluetooth': 0.0.16
|
'@types/web-bluetooth': 0.0.16
|
||||||
'@vueuse/metadata': 9.13.0
|
'@vueuse/metadata': 9.13.0
|
||||||
'@vueuse/shared': 9.13.0(vue@3.4.21(typescript@5.5.2))
|
'@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:
|
transitivePeerDependencies:
|
||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
'@vueuse/metadata@10.5.0': {}
|
'@vueuse/metadata@10.11.0': {}
|
||||||
|
|
||||||
'@vueuse/metadata@9.13.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:
|
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:
|
transitivePeerDependencies:
|
||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
'@vueuse/shared@9.13.0(vue@3.4.21(typescript@5.5.2))':
|
'@vueuse/shared@9.13.0(vue@3.4.21(typescript@5.5.2))':
|
||||||
dependencies:
|
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:
|
transitivePeerDependencies:
|
||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
- vue
|
- vue
|
||||||
|
@ -19962,7 +19976,7 @@ snapshots:
|
||||||
|
|
||||||
agent-base@6.0.2:
|
agent-base@6.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.3.4
|
debug: 4.3.4(supports-color@8.1.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
@ -20266,14 +20280,6 @@ snapshots:
|
||||||
'@babel/runtime': 7.23.6
|
'@babel/runtime': 7.23.6
|
||||||
is-retry-allowed: 2.2.0
|
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):
|
axios@1.6.7(debug@3.2.7):
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects: 1.15.6(debug@3.2.7)
|
follow-redirects: 1.15.6(debug@3.2.7)
|
||||||
|
@ -21300,10 +21306,6 @@ snapshots:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
supports-color: 8.1.1
|
supports-color: 8.1.1
|
||||||
|
|
||||||
debug@4.3.4:
|
|
||||||
dependencies:
|
|
||||||
ms: 2.1.2
|
|
||||||
|
|
||||||
debug@4.3.4(supports-color@8.1.1):
|
debug@4.3.4(supports-color@8.1.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.2
|
ms: 2.1.2
|
||||||
|
@ -22478,8 +22480,6 @@ snapshots:
|
||||||
|
|
||||||
fn.name@1.1.0: {}
|
fn.name@1.1.0: {}
|
||||||
|
|
||||||
follow-redirects@1.15.6: {}
|
|
||||||
|
|
||||||
follow-redirects@1.15.6(debug@3.2.7):
|
follow-redirects@1.15.6(debug@3.2.7):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
debug: 3.2.7(supports-color@5.5.0)
|
debug: 3.2.7(supports-color@5.5.0)
|
||||||
|
@ -23092,7 +23092,7 @@ snapshots:
|
||||||
https-proxy-agent@5.0.1:
|
https-proxy-agent@5.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 6.0.2
|
agent-base: 6.0.2
|
||||||
debug: 4.3.4
|
debug: 4.3.4(supports-color@8.1.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
@ -27828,7 +27828,7 @@ snapshots:
|
||||||
'@antfu/install-pkg': 0.1.1
|
'@antfu/install-pkg': 0.1.1
|
||||||
'@antfu/utils': 0.7.6
|
'@antfu/utils': 0.7.6
|
||||||
'@iconify/utils': 2.1.11
|
'@iconify/utils': 2.1.11
|
||||||
debug: 4.3.4
|
debug: 4.3.4(supports-color@8.1.1)
|
||||||
kolorist: 1.8.0
|
kolorist: 1.8.0
|
||||||
local-pkg: 0.5.0
|
local-pkg: 0.5.0
|
||||||
unplugin: 1.5.1
|
unplugin: 1.5.1
|
||||||
|
@ -28071,6 +28071,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.4.21(typescript@5.5.2)
|
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)):
|
vue-docgen-api@4.76.0(vue@3.4.21(typescript@5.5.2)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/parser': 7.24.0
|
'@babel/parser': 7.24.0
|
||||||
|
|
Loading…
Reference in a new issue