mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge branch 'master' of https://github.com/n8n-io/n8n into node-1598-paireditem-matches-update
This commit is contained in:
commit
899afeb71e
|
@ -3,7 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function getManualChatModal() {
|
export function getManualChatModal() {
|
||||||
return cy.getByTestId('lmChat-modal');
|
return cy.getByTestId('canvas-chat');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getManualChatInput() {
|
export function getManualChatInput() {
|
||||||
|
@ -19,11 +19,11 @@ export function getManualChatMessages() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getManualChatModalCloseButton() {
|
export function getManualChatModalCloseButton() {
|
||||||
return getManualChatModal().get('.el-dialog__close');
|
return cy.getByTestId('workflow-chat-button');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getManualChatModalLogs() {
|
export function getManualChatModalLogs() {
|
||||||
return getManualChatModal().getByTestId('lm-chat-logs');
|
return cy.getByTestId('canvas-chat-logs');
|
||||||
}
|
}
|
||||||
export function getManualChatDialog() {
|
export function getManualChatDialog() {
|
||||||
return getManualChatModal().getByTestId('workflow-lm-chat-dialog');
|
return getManualChatModal().getByTestId('workflow-lm-chat-dialog');
|
||||||
|
|
|
@ -14,7 +14,6 @@ import {
|
||||||
} from './../constants';
|
} from './../constants';
|
||||||
import {
|
import {
|
||||||
closeManualChatModal,
|
closeManualChatModal,
|
||||||
getManualChatDialog,
|
|
||||||
getManualChatMessages,
|
getManualChatMessages,
|
||||||
getManualChatModal,
|
getManualChatModal,
|
||||||
getManualChatModalLogs,
|
getManualChatModalLogs,
|
||||||
|
@ -168,7 +167,7 @@ describe('Langchain Integration', () => {
|
||||||
lastNodeExecuted: BASIC_LLM_CHAIN_NODE_NAME,
|
lastNodeExecuted: BASIC_LLM_CHAIN_NODE_NAME,
|
||||||
});
|
});
|
||||||
|
|
||||||
getManualChatDialog().should('contain', outputMessage);
|
getManualChatMessages().should('contain', outputMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be able to open and execute Agent node', () => {
|
it('should be able to open and execute Agent node', () => {
|
||||||
|
@ -208,7 +207,7 @@ describe('Langchain Integration', () => {
|
||||||
lastNodeExecuted: AGENT_NODE_NAME,
|
lastNodeExecuted: AGENT_NODE_NAME,
|
||||||
});
|
});
|
||||||
|
|
||||||
getManualChatDialog().should('contain', outputMessage);
|
getManualChatMessages().should('contain', outputMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add and use Manual Chat Trigger node together with Agent node', () => {
|
it('should add and use Manual Chat Trigger node together with Agent node', () => {
|
||||||
|
@ -229,8 +228,6 @@ describe('Langchain Integration', () => {
|
||||||
|
|
||||||
clickManualChatButton();
|
clickManualChatButton();
|
||||||
|
|
||||||
getManualChatModalLogs().should('not.exist');
|
|
||||||
|
|
||||||
const inputMessage = 'Hello!';
|
const inputMessage = 'Hello!';
|
||||||
const outputMessage = 'Hi there! How can I assist you today?';
|
const outputMessage = 'Hi there! How can I assist you today?';
|
||||||
const runData = [
|
const runData = [
|
||||||
|
@ -335,6 +332,8 @@ describe('Langchain Integration', () => {
|
||||||
getManualChatModalLogsEntries().should('have.length', 1);
|
getManualChatModalLogsEntries().should('have.length', 1);
|
||||||
|
|
||||||
closeManualChatModal();
|
closeManualChatModal();
|
||||||
|
getManualChatModalLogs().should('not.exist');
|
||||||
|
getManualChatModal().should('not.exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should auto-add chat trigger and basic LLM chain when adding LLM node', () => {
|
it('should auto-add chat trigger and basic LLM chain when adding LLM node', () => {
|
||||||
|
@ -359,6 +358,14 @@ describe('Langchain Integration', () => {
|
||||||
getConnectionBySourceAndTarget(CHAT_TRIGGER_NODE_DISPLAY_NAME, AGENT_NODE_NAME).should('exist');
|
getConnectionBySourceAndTarget(CHAT_TRIGGER_NODE_DISPLAY_NAME, AGENT_NODE_NAME).should('exist');
|
||||||
getNodes().should('have.length', 3);
|
getNodes().should('have.length', 3);
|
||||||
});
|
});
|
||||||
|
it('should not auto-add nodes if ChatTrigger is already present', () => {
|
||||||
|
addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true);
|
||||||
|
addNodeToCanvas(AGENT_NODE_NAME, true);
|
||||||
|
|
||||||
|
addNodeToCanvas(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, true);
|
||||||
|
getConnectionBySourceAndTarget(CHAT_TRIGGER_NODE_DISPLAY_NAME, AGENT_NODE_NAME).should('exist');
|
||||||
|
getNodes().should('have.length', 3);
|
||||||
|
});
|
||||||
it('should render runItems for sub-nodes and allow switching between them', () => {
|
it('should render runItems for sub-nodes and allow switching between them', () => {
|
||||||
const workflowPage = new WorkflowPage();
|
const workflowPage = new WorkflowPage();
|
||||||
const ndv = new NDV();
|
const ndv = new NDV();
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
"build:nodes": "turbo run build:nodes",
|
"build:nodes": "turbo run build:nodes",
|
||||||
"typecheck": "turbo typecheck",
|
"typecheck": "turbo typecheck",
|
||||||
"dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner",
|
"dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner",
|
||||||
|
"dev:be": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui",
|
||||||
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core",
|
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core",
|
||||||
"clean": "turbo run clean --parallel",
|
"clean": "turbo run clean --parallel",
|
||||||
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",
|
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",
|
||||||
|
|
|
@ -1 +1,13 @@
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { configure } from '@testing-library/vue';
|
||||||
|
|
||||||
|
configure({ testIdAttribute: 'data-test-id' });
|
||||||
|
|
||||||
|
window.ResizeObserver =
|
||||||
|
window.ResizeObserver ||
|
||||||
|
vi.fn().mockImplementation(() => ({
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
observe: vi.fn(),
|
||||||
|
unobserve: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
|
@ -30,22 +30,23 @@ const TypeIcon = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
function onClick() {
|
function onClick() {
|
||||||
if (props.isRemovable) {
|
|
||||||
emit('remove', props.file);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.isPreviewable) {
|
if (props.isPreviewable) {
|
||||||
window.open(URL.createObjectURL(props.file));
|
window.open(URL.createObjectURL(props.file));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function onDelete() {
|
||||||
|
emit('remove', props.file);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="chat-file" @click="onClick">
|
<div class="chat-file" @click="onClick">
|
||||||
<TypeIcon />
|
<TypeIcon />
|
||||||
<p class="chat-file-name">{{ file.name }}</p>
|
<p class="chat-file-name">{{ file.name }}</p>
|
||||||
<IconDelete v-if="isRemovable" class="chat-file-delete" />
|
<span v-if="isRemovable" class="chat-file-delete" @click.stop="onDelete">
|
||||||
<IconPreview v-if="isPreviewable" class="chat-file-preview" />
|
<IconDelete />
|
||||||
|
</span>
|
||||||
|
<IconPreview v-else-if="isPreviewable" class="chat-file-preview" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -80,12 +81,25 @@ function onClick() {
|
||||||
.chat-file-preview {
|
.chat-file-preview {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
display: none;
|
display: block;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-file:hover & {
|
.chat-file-delete {
|
||||||
display: block;
|
position: relative;
|
||||||
|
&:hover {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Increase hit area for better clickability */
|
||||||
|
&:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
right: -10px;
|
||||||
|
bottom: -10px;
|
||||||
|
left: -10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useFileDialog } from '@vueuse/core';
|
import { useFileDialog } from '@vueuse/core';
|
||||||
import IconFilePlus from 'virtual:icons/mdi/filePlus';
|
import IconPaperclip from 'virtual:icons/mdi/paperclip';
|
||||||
import IconSend from 'virtual:icons/mdi/send';
|
import IconSend from 'virtual:icons/mdi/send';
|
||||||
import { computed, onMounted, onUnmounted, ref, unref } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, unref } from 'vue';
|
||||||
|
|
||||||
|
@ -9,10 +9,20 @@ import { chatEventBus } from '@n8n/chat/event-buses';
|
||||||
|
|
||||||
import ChatFile from './ChatFile.vue';
|
import ChatFile from './ChatFile.vue';
|
||||||
|
|
||||||
|
export interface ChatInputProps {
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ChatInputProps>(), {
|
||||||
|
placeholder: 'inputPlaceholder',
|
||||||
|
});
|
||||||
|
|
||||||
export interface ArrowKeyDownPayload {
|
export interface ArrowKeyDownPayload {
|
||||||
key: 'ArrowUp' | 'ArrowDown';
|
key: 'ArrowUp' | 'ArrowDown';
|
||||||
currentInputValue: string;
|
currentInputValue: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
arrowKeyDown: [value: ArrowKeyDownPayload];
|
arrowKeyDown: [value: ArrowKeyDownPayload];
|
||||||
}>();
|
}>();
|
||||||
|
@ -20,12 +30,12 @@ const emit = defineEmits<{
|
||||||
const { options } = useOptions();
|
const { options } = useOptions();
|
||||||
const chatStore = useChat();
|
const chatStore = useChat();
|
||||||
const { waitingForResponse } = chatStore;
|
const { waitingForResponse } = chatStore;
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const files = ref<FileList | null>(null);
|
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 isSubmitting = ref(false);
|
||||||
|
const resizeObserver = ref<ResizeObserver | null>(null);
|
||||||
|
|
||||||
const isSubmitDisabled = computed(() => {
|
const isSubmitDisabled = computed(() => {
|
||||||
return input.value === '' || waitingForResponse.value || options.disabled?.value === true;
|
return input.value === '' || waitingForResponse.value || options.disabled?.value === true;
|
||||||
|
@ -74,12 +84,30 @@ onMounted(() => {
|
||||||
chatEventBus.on('focusInput', focusChatInput);
|
chatEventBus.on('focusInput', focusChatInput);
|
||||||
chatEventBus.on('blurInput', blurChatInput);
|
chatEventBus.on('blurInput', blurChatInput);
|
||||||
chatEventBus.on('setInputValue', setInputValue);
|
chatEventBus.on('setInputValue', setInputValue);
|
||||||
|
|
||||||
|
if (chatTextArea.value) {
|
||||||
|
resizeObserver.value = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.target === chatTextArea.value) {
|
||||||
|
adjustHeight({ target: chatTextArea.value } as unknown as Event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start observing the textarea
|
||||||
|
resizeObserver.value.observe(chatTextArea.value);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
chatEventBus.off('focusInput', focusChatInput);
|
chatEventBus.off('focusInput', focusChatInput);
|
||||||
chatEventBus.off('blurInput', blurChatInput);
|
chatEventBus.off('blurInput', blurChatInput);
|
||||||
chatEventBus.off('setInputValue', setInputValue);
|
chatEventBus.off('setInputValue', setInputValue);
|
||||||
|
|
||||||
|
if (resizeObserver.value) {
|
||||||
|
resizeObserver.value.disconnect();
|
||||||
|
resizeObserver.value = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function blurChatInput() {
|
function blurChatInput() {
|
||||||
|
@ -121,6 +149,7 @@ async function onSubmitKeydown(event: KeyboardEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
await onSubmit(event);
|
await onSubmit(event);
|
||||||
|
adjustHeight({ target: chatTextArea.value } as unknown as Event);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFileRemove(file: File) {
|
function onFileRemove(file: File) {
|
||||||
|
@ -151,6 +180,15 @@ function onOpenFileDialog() {
|
||||||
if (isFileUploadDisabled.value) return;
|
if (isFileUploadDisabled.value) return;
|
||||||
openFileDialog({ accept: unref(allowedFileTypes) });
|
openFileDialog({ accept: unref(allowedFileTypes) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function adjustHeight(event: Event) {
|
||||||
|
const textarea = event.target as HTMLTextAreaElement;
|
||||||
|
// Set to content minimum to get the right scrollHeight
|
||||||
|
textarea.style.height = 'var(--chat--textarea--height)';
|
||||||
|
// Get the new height, with a small buffer for padding
|
||||||
|
const newHeight = Math.min(textarea.scrollHeight, 480); // 30rem
|
||||||
|
textarea.style.height = `${newHeight}px`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -158,20 +196,25 @@ function onOpenFileDialog() {
|
||||||
<div class="chat-inputs">
|
<div class="chat-inputs">
|
||||||
<textarea
|
<textarea
|
||||||
ref="chatTextArea"
|
ref="chatTextArea"
|
||||||
|
data-test-id="chat-input"
|
||||||
v-model="input"
|
v-model="input"
|
||||||
:disabled="isInputDisabled"
|
:disabled="isInputDisabled"
|
||||||
:placeholder="t('inputPlaceholder')"
|
:placeholder="t(props.placeholder)"
|
||||||
@keydown.enter="onSubmitKeydown"
|
@keydown.enter="onSubmitKeydown"
|
||||||
|
@input="adjustHeight"
|
||||||
|
@mousedown="adjustHeight"
|
||||||
|
@focus="adjustHeight"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="chat-inputs-controls">
|
<div class="chat-inputs-controls">
|
||||||
<button
|
<button
|
||||||
v-if="isFileUploadAllowed"
|
v-if="isFileUploadAllowed"
|
||||||
:disabled="isFileUploadDisabled"
|
:disabled="isFileUploadDisabled"
|
||||||
class="chat-input-send-button"
|
class="chat-input-file-button"
|
||||||
|
data-test-id="chat-attach-file-button"
|
||||||
@click="onOpenFileDialog"
|
@click="onOpenFileDialog"
|
||||||
>
|
>
|
||||||
<IconFilePlus height="24" width="24" />
|
<IconPaperclip height="24" width="24" />
|
||||||
</button>
|
</button>
|
||||||
<button :disabled="isSubmitDisabled" class="chat-input-send-button" @click="onSubmit">
|
<button :disabled="isSubmitDisabled" class="chat-input-send-button" @click="onSubmit">
|
||||||
<IconSend height="24" width="24" />
|
<IconSend height="24" width="24" />
|
||||||
|
@ -184,6 +227,7 @@ function onOpenFileDialog() {
|
||||||
:key="file.name"
|
:key="file.name"
|
||||||
:file="file"
|
:file="file"
|
||||||
:is-removable="true"
|
:is-removable="true"
|
||||||
|
:is-previewable="true"
|
||||||
@remove="onFileRemove"
|
@remove="onFileRemove"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -217,13 +261,15 @@ function onOpenFileDialog() {
|
||||||
border-radius: var(--chat--input--border-radius, 0);
|
border-radius: var(--chat--input--border-radius, 0);
|
||||||
padding: 0.8rem;
|
padding: 0.8rem;
|
||||||
padding-right: calc(0.8rem + (var(--controls-count, 1) * var(--chat--textarea--height)));
|
padding-right: calc(0.8rem + (var(--controls-count, 1) * var(--chat--textarea--height)));
|
||||||
min-height: var(--chat--textarea--height);
|
min-height: var(--chat--textarea--height, 2.5rem); // Set a smaller initial height
|
||||||
max-height: var(--chat--textarea--max-height, var(--chat--textarea--height));
|
max-height: var(--chat--textarea--max-height, 30rem);
|
||||||
height: 100%;
|
height: var(--chat--textarea--height, 2.5rem); // Set initial height same as min-height
|
||||||
|
resize: none;
|
||||||
|
overflow-y: auto;
|
||||||
background: var(--chat--input--background, white);
|
background: var(--chat--input--background, white);
|
||||||
resize: var(--chat--textarea--resize, none);
|
|
||||||
color: var(--chat--input--text-color, initial);
|
color: var(--chat--input--text-color, initial);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
line-height: var(--chat--input--line-height, 1.5);
|
||||||
|
|
||||||
&:focus,
|
&:focus,
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -235,8 +281,10 @@ function onOpenFileDialog() {
|
||||||
display: flex;
|
display: flex;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0.5rem;
|
right: 0.5rem;
|
||||||
|
bottom: 0;
|
||||||
}
|
}
|
||||||
.chat-input-send-button {
|
.chat-input-send-button,
|
||||||
|
.chat-input-file-button {
|
||||||
height: var(--chat--textarea--height);
|
height: var(--chat--textarea--height);
|
||||||
width: var(--chat--textarea--height);
|
width: var(--chat--textarea--height);
|
||||||
background: var(--chat--input--send--button--background, white);
|
background: var(--chat--input--send--button--background, white);
|
||||||
|
@ -253,6 +301,12 @@ function onOpenFileDialog() {
|
||||||
min-width: fit-content;
|
min-width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
cursor: no-drop;
|
||||||
|
color: var(--chat--color-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input-send-button {
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus {
|
&:focus {
|
||||||
background: var(
|
background: var(
|
||||||
|
@ -261,10 +315,18 @@ function onOpenFileDialog() {
|
||||||
);
|
);
|
||||||
color: var(--chat--input--send--button--color-hover, var(--chat--color-secondary-shade-50));
|
color: var(--chat--input--send--button--color-hover, var(--chat--color-secondary-shade-50));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chat-input-file-button {
|
||||||
|
background: var(--chat--input--file--button--background, white);
|
||||||
|
color: var(--chat--input--file--button--color, var(--chat--color-secondary));
|
||||||
|
|
||||||
&[disabled] {
|
&:hover {
|
||||||
cursor: no-drop;
|
background: var(
|
||||||
color: var(--chat--color-disabled);
|
--chat--input--file--button--background-hover,
|
||||||
|
var(--chat--input--file--button--background)
|
||||||
|
);
|
||||||
|
color: var(--chat--input--file--button--color-hover, var(--chat--color-secondary-shade-50));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -275,7 +337,7 @@ function onOpenFileDialog() {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.25rem;
|
gap: 0.5rem;
|
||||||
padding: var(--chat--files-spacing, 0.25rem);
|
padding: var(--chat--files-spacing, 0.25rem);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -60,7 +60,7 @@ const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
|
||||||
const scrollToView = () => {
|
const scrollToView = () => {
|
||||||
if (messageContainer.value?.scrollIntoView) {
|
if (messageContainer.value?.scrollIntoView) {
|
||||||
messageContainer.value.scrollIntoView({
|
messageContainer.value.scrollIntoView({
|
||||||
block: 'center',
|
block: 'start',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -132,14 +132,14 @@ onMounted(async () => {
|
||||||
.chat-message {
|
.chat-message {
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
max-width: 80%;
|
max-width: fit-content;
|
||||||
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));
|
||||||
|
scroll-margin: 100px;
|
||||||
.chat-message-actions {
|
.chat-message-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 100%;
|
bottom: calc(100% - 0.5rem);
|
||||||
left: 0;
|
left: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-0.25rem);
|
transform: translateY(-0.25rem);
|
||||||
|
@ -151,6 +151,9 @@ onMounted(async () => {
|
||||||
left: auto;
|
left: auto;
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
&.chat-message-from-bot .chat-message-actions {
|
||||||
|
bottom: calc(100% - 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.chat-message-actions {
|
.chat-message-actions {
|
||||||
|
@ -159,7 +162,7 @@ onMounted(async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
line-height: var(--chat--message-line-height, 1.8);
|
line-height: var(--chat--message-line-height, 1.5);
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -34,7 +34,12 @@ onMounted(() => {
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Message ref="messageContainer" :class="classes" :message="message">
|
<Message
|
||||||
|
ref="messageContainer"
|
||||||
|
:class="classes"
|
||||||
|
:message="message"
|
||||||
|
data-test-id="chat-message-typing"
|
||||||
|
>
|
||||||
<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>
|
||||||
|
|
|
@ -37,7 +37,7 @@ body {
|
||||||
4. Prevent font size adjustment after orientation changes (IE, iOS)
|
4. Prevent font size adjustment after orientation changes (IE, iOS)
|
||||||
5. Prevent overflow from long words (all)
|
5. Prevent overflow from long words (all)
|
||||||
*/
|
*/
|
||||||
font-size: 125%; /* 2 */
|
font-size: 110%; /* 2 */
|
||||||
line-height: 1.6; /* 3 */
|
line-height: 1.6; /* 3 */
|
||||||
-webkit-text-size-adjust: 100%; /* 4 */
|
-webkit-text-size-adjust: 100%; /* 4 */
|
||||||
word-break: break-word; /* 5 */
|
word-break: break-word; /* 5 */
|
||||||
|
@ -596,7 +596,7 @@ body {
|
||||||
|
|
||||||
pre code {
|
pre code {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0.3em 0.7em;
|
padding: 0 0 0.5rem 0.5rem;
|
||||||
word-break: normal;
|
word-break: normal;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,15 +33,11 @@ const loading = ref(true);
|
||||||
const defaultLocale = computed(() => rootStore.defaultLocale);
|
const defaultLocale = computed(() => rootStore.defaultLocale);
|
||||||
const isDemoMode = computed(() => route.name === VIEWS.DEMO);
|
const isDemoMode = computed(() => route.name === VIEWS.DEMO);
|
||||||
const showAssistantButton = computed(() => assistantStore.canShowAssistantButtonsOnCanvas);
|
const showAssistantButton = computed(() => assistantStore.canShowAssistantButtonsOnCanvas);
|
||||||
|
const hasContentFooter = ref(false);
|
||||||
const appGrid = ref<Element | null>(null);
|
const appGrid = ref<Element | null>(null);
|
||||||
|
|
||||||
const assistantSidebarWidth = computed(() => assistantStore.chatWidth);
|
const assistantSidebarWidth = computed(() => assistantStore.chatWidth);
|
||||||
|
|
||||||
watch(defaultLocale, (newLocale) => {
|
|
||||||
void loadLanguage(newLocale);
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
setAppZIndexes();
|
setAppZIndexes();
|
||||||
logHiringBanner();
|
logHiringBanner();
|
||||||
|
@ -54,11 +50,6 @@ onBeforeUnmount(() => {
|
||||||
window.removeEventListener('resize', updateGridWidth);
|
window.removeEventListener('resize', updateGridWidth);
|
||||||
});
|
});
|
||||||
|
|
||||||
// As assistant sidebar width changes, recalculate the total width regularly
|
|
||||||
watch(assistantSidebarWidth, async () => {
|
|
||||||
await updateGridWidth();
|
|
||||||
});
|
|
||||||
|
|
||||||
const logHiringBanner = () => {
|
const logHiringBanner = () => {
|
||||||
if (settingsStore.isHiringBannerEnabled && !isDemoMode.value) {
|
if (settingsStore.isHiringBannerEnabled && !isDemoMode.value) {
|
||||||
console.log(HIRING_BANNER);
|
console.log(HIRING_BANNER);
|
||||||
|
@ -71,6 +62,21 @@ const updateGridWidth = async () => {
|
||||||
uiStore.appGridWidth = appGrid.value.clientWidth;
|
uiStore.appGridWidth = appGrid.value.clientWidth;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// As assistant sidebar width changes, recalculate the total width regularly
|
||||||
|
watch(assistantSidebarWidth, async () => {
|
||||||
|
await updateGridWidth();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(route, (r) => {
|
||||||
|
hasContentFooter.value = r.matched.some(
|
||||||
|
(matchedRoute) => matchedRoute.components?.footer !== undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(defaultLocale, (newLocale) => {
|
||||||
|
void loadLanguage(newLocale);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -94,6 +100,7 @@ const updateGridWidth = async () => {
|
||||||
<router-view name="sidebar"></router-view>
|
<router-view name="sidebar"></router-view>
|
||||||
</div>
|
</div>
|
||||||
<div id="content" :class="$style.content">
|
<div id="content" :class="$style.content">
|
||||||
|
<div :class="$style.contentWrapper">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<keep-alive v-if="$route.meta.keepWorkflowAlive" include="NodeViewSwitcher" :max="1">
|
<keep-alive v-if="$route.meta.keepWorkflowAlive" include="NodeViewSwitcher" :max="1">
|
||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
|
@ -101,6 +108,10 @@ const updateGridWidth = async () => {
|
||||||
<component :is="Component" v-else />
|
<component :is="Component" v-else />
|
||||||
</router-view>
|
</router-view>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="hasContentFooter" :class="$style.contentFooter">
|
||||||
|
<router-view name="footer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div :id="APP_MODALS_ELEMENT_ID" :class="$style.modals">
|
<div :id="APP_MODALS_ELEMENT_ID" :class="$style.modals">
|
||||||
<Modals />
|
<Modals />
|
||||||
</div>
|
</div>
|
||||||
|
@ -138,8 +149,26 @@ const updateGridWidth = async () => {
|
||||||
grid-area: banners;
|
grid-area: banners;
|
||||||
z-index: var(--z-index-top-banners);
|
z-index: var(--z-index-top-banners);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
overflow: auto;
|
||||||
|
grid-area: content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contentFooter {
|
||||||
|
height: auto;
|
||||||
|
z-index: 10;
|
||||||
|
width: 100%;
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
// Only show footer if there's content
|
||||||
|
&:has(*) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentWrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
grid-area: content;
|
grid-area: content;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
@ -54,6 +54,7 @@ export const mockNodeTypeDescription = ({
|
||||||
credentials = [],
|
credentials = [],
|
||||||
inputs = [NodeConnectionType.Main],
|
inputs = [NodeConnectionType.Main],
|
||||||
outputs = [NodeConnectionType.Main],
|
outputs = [NodeConnectionType.Main],
|
||||||
|
codex = {},
|
||||||
properties = [],
|
properties = [],
|
||||||
}: {
|
}: {
|
||||||
name?: INodeTypeDescription['name'];
|
name?: INodeTypeDescription['name'];
|
||||||
|
@ -61,6 +62,7 @@ export const mockNodeTypeDescription = ({
|
||||||
credentials?: INodeTypeDescription['credentials'];
|
credentials?: INodeTypeDescription['credentials'];
|
||||||
inputs?: INodeTypeDescription['inputs'];
|
inputs?: INodeTypeDescription['inputs'];
|
||||||
outputs?: INodeTypeDescription['outputs'];
|
outputs?: INodeTypeDescription['outputs'];
|
||||||
|
codex?: INodeTypeDescription['codex'];
|
||||||
properties?: INodeTypeDescription['properties'];
|
properties?: INodeTypeDescription['properties'];
|
||||||
} = {}) =>
|
} = {}) =>
|
||||||
mock<INodeTypeDescription>({
|
mock<INodeTypeDescription>({
|
||||||
|
@ -77,6 +79,7 @@ export const mockNodeTypeDescription = ({
|
||||||
group: EXECUTABLE_TRIGGER_NODE_TYPES.includes(name) ? ['trigger'] : [],
|
group: EXECUTABLE_TRIGGER_NODE_TYPES.includes(name) ? ['trigger'] : [],
|
||||||
inputs,
|
inputs,
|
||||||
outputs,
|
outputs,
|
||||||
|
codex,
|
||||||
credentials,
|
credentials,
|
||||||
documentationUrl: 'https://docs',
|
documentationUrl: 'https://docs',
|
||||||
webhooks: undefined,
|
webhooks: undefined,
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useStyles } from '@/composables/useStyles';
|
import { useStyles } from '@/composables/useStyles';
|
||||||
import { useAssistantStore } from '@/stores/assistant.store';
|
import { useAssistantStore } from '@/stores/assistant.store';
|
||||||
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
import AssistantAvatar from 'n8n-design-system/components/AskAssistantAvatar/AssistantAvatar.vue';
|
import AssistantAvatar from 'n8n-design-system/components/AskAssistantAvatar/AssistantAvatar.vue';
|
||||||
import AskAssistantButton from 'n8n-design-system/components/AskAssistantButton/AskAssistantButton.vue';
|
import AskAssistantButton from 'n8n-design-system/components/AskAssistantButton/AskAssistantButton.vue';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
@ -9,6 +10,7 @@ import { computed } from 'vue';
|
||||||
const assistantStore = useAssistantStore();
|
const assistantStore = useAssistantStore();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const { APP_Z_INDEXES } = useStyles();
|
const { APP_Z_INDEXES } = useStyles();
|
||||||
|
const canvasStore = useCanvasStore();
|
||||||
|
|
||||||
const lastUnread = computed(() => {
|
const lastUnread = computed(() => {
|
||||||
const msg = assistantStore.lastUnread;
|
const msg = assistantStore.lastUnread;
|
||||||
|
@ -39,6 +41,7 @@ const onClick = () => {
|
||||||
v-if="assistantStore.canShowAssistantButtonsOnCanvas && !assistantStore.isAssistantOpen"
|
v-if="assistantStore.canShowAssistantButtonsOnCanvas && !assistantStore.isAssistantOpen"
|
||||||
:class="$style.container"
|
:class="$style.container"
|
||||||
data-test-id="ask-assistant-floating-button"
|
data-test-id="ask-assistant-floating-button"
|
||||||
|
:style="{ '--canvas-panel-height-offset': `${canvasStore.panelHeight}px` }"
|
||||||
>
|
>
|
||||||
<n8n-tooltip
|
<n8n-tooltip
|
||||||
:z-index="APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON_TOOLTIP"
|
:z-index="APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON_TOOLTIP"
|
||||||
|
@ -61,7 +64,7 @@ const onClick = () => {
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.container {
|
.container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: var(--spacing-s);
|
bottom: calc(var(--canvas-panel-height-offset, 0px) + var(--spacing-s));
|
||||||
right: var(--spacing-s);
|
right: var(--spacing-s);
|
||||||
z-index: var(--z-index-ask-assistant-floating-button);
|
z-index: var(--z-index-ask-assistant-floating-button);
|
||||||
}
|
}
|
||||||
|
|
586
packages/editor-ui/src/components/CanvasChat/CanvasChat.test.ts
Normal file
586
packages/editor-ui/src/components/CanvasChat/CanvasChat.test.ts
Normal file
|
@ -0,0 +1,586 @@
|
||||||
|
import { setActivePinia } from 'pinia';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { waitFor } from '@testing-library/vue';
|
||||||
|
import { userEvent } from '@testing-library/user-event';
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import CanvasChat from './CanvasChat.vue';
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import { createTestWorkflowObject } from '@/__tests__/mocks';
|
||||||
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
|
import { STORES } from '@/constants';
|
||||||
|
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
||||||
|
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||||
|
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
|
import * as useChatMessaging from './composables/useChatMessaging';
|
||||||
|
import * as useChatTrigger from './composables/useChatTrigger';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
|
||||||
|
import type { IExecutionResponse, INodeUi } from '@/Interface';
|
||||||
|
import type { ChatMessage } from '@n8n/chat/types';
|
||||||
|
|
||||||
|
vi.mock('@/composables/useToast', () => {
|
||||||
|
const showMessage = vi.fn();
|
||||||
|
const showError = vi.fn();
|
||||||
|
return {
|
||||||
|
useToast: () => {
|
||||||
|
return {
|
||||||
|
showMessage,
|
||||||
|
showError,
|
||||||
|
clearAllStickyNotifications: vi.fn(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Test data
|
||||||
|
const mockNodes: INodeUi[] = [
|
||||||
|
{
|
||||||
|
parameters: {
|
||||||
|
options: {
|
||||||
|
allowFileUploads: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
id: 'chat-trigger-id',
|
||||||
|
name: 'When chat message received',
|
||||||
|
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||||
|
typeVersion: 1.1,
|
||||||
|
position: [740, 860],
|
||||||
|
webhookId: 'webhook-id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parameters: {},
|
||||||
|
id: 'agent-id',
|
||||||
|
name: 'AI Agent',
|
||||||
|
type: '@n8n/n8n-nodes-langchain.agent',
|
||||||
|
typeVersion: 1.7,
|
||||||
|
position: [960, 860],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockConnections = {
|
||||||
|
'When chat message received': {
|
||||||
|
main: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
node: 'AI Agent',
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWorkflowExecution = {
|
||||||
|
data: {
|
||||||
|
resultData: {
|
||||||
|
runData: {
|
||||||
|
'AI Agent': [
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
main: [[{ json: { output: 'AI response message' } }]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
lastNodeExecuted: 'AI Agent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CanvasChat', () => {
|
||||||
|
const renderComponent = createComponentRenderer(CanvasChat, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
[ChatSymbol as symbol]: {},
|
||||||
|
[ChatOptionsSymbol as symbol]: {},
|
||||||
|
},
|
||||||
|
plugins: [router],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||||
|
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
|
||||||
|
let canvasStore: ReturnType<typeof mockedStore<typeof useCanvasStore>>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const pinia = createTestingPinia({
|
||||||
|
initialState: {
|
||||||
|
[STORES.WORKFLOWS]: {
|
||||||
|
workflow: {
|
||||||
|
nodes: mockNodes,
|
||||||
|
connections: mockConnections,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[STORES.UI]: {
|
||||||
|
chatPanelOpen: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setActivePinia(pinia);
|
||||||
|
|
||||||
|
workflowsStore = mockedStore(useWorkflowsStore);
|
||||||
|
uiStore = mockedStore(useUIStore);
|
||||||
|
canvasStore = mockedStore(useCanvasStore);
|
||||||
|
|
||||||
|
// Setup default mocks
|
||||||
|
workflowsStore.getCurrentWorkflow.mockReturnValue(
|
||||||
|
createTestWorkflowObject({
|
||||||
|
nodes: mockNodes,
|
||||||
|
connections: mockConnections,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
workflowsStore.getNodeByName.mockImplementation(
|
||||||
|
(name) => mockNodes.find((node) => node.name === name) ?? null,
|
||||||
|
);
|
||||||
|
workflowsStore.isChatPanelOpen = true;
|
||||||
|
workflowsStore.getWorkflowExecution = mockWorkflowExecution as unknown as IExecutionResponse;
|
||||||
|
workflowsStore.getPastChatMessages = ['Previous message 1', 'Previous message 2'];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render chat when panel is open', () => {
|
||||||
|
const { getByTestId } = renderComponent();
|
||||||
|
expect(getByTestId('canvas-chat')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render chat when panel is closed', async () => {
|
||||||
|
workflowsStore.isChatPanelOpen = false;
|
||||||
|
const { queryByTestId } = renderComponent();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryByTestId('canvas-chat')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show correct input placeholder', async () => {
|
||||||
|
const { findByTestId } = renderComponent();
|
||||||
|
expect(await findByTestId('chat-input')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('message handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(chatEventBus, 'emit');
|
||||||
|
workflowsStore.runWorkflow.mockResolvedValue({ executionId: 'test-execution-id' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send message and show response', async () => {
|
||||||
|
const { findByTestId, findByText } = renderComponent();
|
||||||
|
|
||||||
|
// Send message
|
||||||
|
const input = await findByTestId('chat-input');
|
||||||
|
await userEvent.type(input, 'Hello AI!');
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
|
||||||
|
// Verify message and response
|
||||||
|
expect(await findByText('Hello AI!')).toBeInTheDocument();
|
||||||
|
await waitFor(async () => {
|
||||||
|
expect(await findByText('AI response message')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify workflow execution
|
||||||
|
expect(workflowsStore.runWorkflow).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
runData: {
|
||||||
|
'When chat message received': [
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
main: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
action: 'sendMessage',
|
||||||
|
chatInput: 'Hello AI!',
|
||||||
|
sessionId: expect.any(String),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
executionStatus: 'success',
|
||||||
|
executionTime: 0,
|
||||||
|
source: [null],
|
||||||
|
startTime: expect.any(Number),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show loading state during message processing', async () => {
|
||||||
|
const { findByTestId, queryByTestId } = renderComponent();
|
||||||
|
|
||||||
|
// Send message
|
||||||
|
const input = await findByTestId('chat-input');
|
||||||
|
await userEvent.type(input, 'Test message');
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
|
||||||
|
// Verify loading states
|
||||||
|
uiStore.isActionActive = { workflowRunning: true };
|
||||||
|
await waitFor(() => expect(queryByTestId('chat-message-typing')).toBeInTheDocument());
|
||||||
|
|
||||||
|
uiStore.isActionActive = { workflowRunning: false };
|
||||||
|
await waitFor(() => expect(queryByTestId('chat-message-typing')).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle workflow execution errors', async () => {
|
||||||
|
workflowsStore.runWorkflow.mockRejectedValueOnce(new Error());
|
||||||
|
|
||||||
|
const { findByTestId } = renderComponent();
|
||||||
|
|
||||||
|
const input = await findByTestId('chat-input');
|
||||||
|
await userEvent.type(input, 'Hello AI!');
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
expect(toast.showError).toHaveBeenCalledWith(new Error(), 'Problem running workflow');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('session management', () => {
|
||||||
|
const mockMessages: ChatMessage[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
text: 'Existing message',
|
||||||
|
sender: 'user',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
|
||||||
|
getChatMessages: vi.fn().mockReturnValue(mockMessages),
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
extractResponseMessage: vi.fn(),
|
||||||
|
previousMessageIndex: ref(0),
|
||||||
|
waitForExecution: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow copying session ID', async () => {
|
||||||
|
const clipboardSpy = vi.fn();
|
||||||
|
document.execCommand = clipboardSpy;
|
||||||
|
const { getByTestId } = renderComponent();
|
||||||
|
|
||||||
|
await userEvent.click(getByTestId('chat-session-id'));
|
||||||
|
const toast = useToast();
|
||||||
|
expect(clipboardSpy).toHaveBeenCalledWith('copy');
|
||||||
|
expect(toast.showMessage).toHaveBeenCalledWith({
|
||||||
|
message: '',
|
||||||
|
title: 'Copied to clipboard',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should refresh session with confirmation when messages exist', async () => {
|
||||||
|
const { getByTestId, getByRole } = renderComponent();
|
||||||
|
|
||||||
|
const originalSessionId = getByTestId('chat-session-id').textContent;
|
||||||
|
await userEvent.click(getByTestId('refresh-session-button'));
|
||||||
|
|
||||||
|
const confirmButton = getByRole('dialog').querySelector('button.btn--confirm');
|
||||||
|
|
||||||
|
if (!confirmButton) throw new Error('Confirm button not found');
|
||||||
|
await userEvent.click(confirmButton);
|
||||||
|
|
||||||
|
expect(getByTestId('chat-session-id').textContent).not.toEqual(originalSessionId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resize functionality', () => {
|
||||||
|
it('should handle panel resizing', async () => {
|
||||||
|
const { container } = renderComponent();
|
||||||
|
|
||||||
|
const resizeWrapper = container.querySelector('.resizeWrapper');
|
||||||
|
if (!resizeWrapper) throw new Error('Resize wrapper not found');
|
||||||
|
|
||||||
|
await userEvent.pointer([
|
||||||
|
{ target: resizeWrapper, coords: { clientX: 0, clientY: 0 } },
|
||||||
|
{ coords: { clientX: 0, clientY: 100 } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(canvasStore.setPanelHeight).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should persist resize dimensions', () => {
|
||||||
|
const mockStorage = {
|
||||||
|
getItem: vi.fn(),
|
||||||
|
setItem: vi.fn(),
|
||||||
|
};
|
||||||
|
Object.defineProperty(window, 'localStorage', { value: mockStorage });
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(mockStorage.getItem).toHaveBeenCalledWith('N8N_CANVAS_CHAT_HEIGHT');
|
||||||
|
expect(mockStorage.getItem).toHaveBeenCalledWith('N8N_CANVAS_CHAT_WIDTH');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('file handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
|
||||||
|
getChatMessages: vi.fn().mockReturnValue([]),
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
extractResponseMessage: vi.fn(),
|
||||||
|
previousMessageIndex: ref(0),
|
||||||
|
waitForExecution: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowsStore.isChatPanelOpen = true;
|
||||||
|
workflowsStore.allowFileUploads = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable file uploads when allowed by chat trigger node', async () => {
|
||||||
|
const allowFileUploads = ref(true);
|
||||||
|
const original = useChatTrigger.useChatTrigger;
|
||||||
|
vi.spyOn(useChatTrigger, 'useChatTrigger').mockImplementation((...args) => ({
|
||||||
|
...original(...args),
|
||||||
|
allowFileUploads: computed(() => allowFileUploads.value),
|
||||||
|
}));
|
||||||
|
const { getByTestId } = renderComponent();
|
||||||
|
|
||||||
|
const chatPanel = getByTestId('canvas-chat');
|
||||||
|
expect(chatPanel).toBeInTheDocument();
|
||||||
|
|
||||||
|
const fileInput = getByTestId('chat-attach-file-button');
|
||||||
|
expect(fileInput).toBeInTheDocument();
|
||||||
|
|
||||||
|
allowFileUploads.value = false;
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fileInput).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('message history handling', () => {
|
||||||
|
it('should properly navigate through message history with wrap-around', async () => {
|
||||||
|
const messages = ['Message 1', 'Message 2', 'Message 3'];
|
||||||
|
workflowsStore.getPastChatMessages = messages;
|
||||||
|
|
||||||
|
const { findByTestId } = renderComponent();
|
||||||
|
const input = await findByTestId('chat-input');
|
||||||
|
|
||||||
|
// First up should show most recent message
|
||||||
|
await userEvent.keyboard('{ArrowUp}');
|
||||||
|
expect(input).toHaveValue('Message 3');
|
||||||
|
|
||||||
|
// Second up should show second most recent
|
||||||
|
await userEvent.keyboard('{ArrowUp}');
|
||||||
|
expect(input).toHaveValue('Message 2');
|
||||||
|
|
||||||
|
// Third up should show oldest message
|
||||||
|
await userEvent.keyboard('{ArrowUp}');
|
||||||
|
expect(input).toHaveValue('Message 1');
|
||||||
|
|
||||||
|
// Fourth up should wrap around to most recent
|
||||||
|
await userEvent.keyboard('{ArrowUp}');
|
||||||
|
expect(input).toHaveValue('Message 3');
|
||||||
|
|
||||||
|
// Down arrow should go in reverse
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
expect(input).toHaveValue('Message 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset message history navigation on new input', async () => {
|
||||||
|
workflowsStore.getPastChatMessages = ['Message 1', 'Message 2'];
|
||||||
|
const { findByTestId } = renderComponent();
|
||||||
|
const input = await findByTestId('chat-input');
|
||||||
|
|
||||||
|
// Navigate to oldest message
|
||||||
|
await userEvent.keyboard('{ArrowUp}'); // Most recent
|
||||||
|
await userEvent.keyboard('{ArrowUp}'); // Oldest
|
||||||
|
expect(input).toHaveValue('Message 1');
|
||||||
|
|
||||||
|
await userEvent.type(input, 'New message');
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
|
||||||
|
await userEvent.keyboard('{ArrowUp}');
|
||||||
|
expect(input).toHaveValue('Message 2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('message reuse and repost', () => {
|
||||||
|
const sendMessageSpy = vi.fn();
|
||||||
|
beforeEach(() => {
|
||||||
|
const mockMessages: ChatMessage[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
text: 'Original message',
|
||||||
|
sender: 'user',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
text: 'AI response',
|
||||||
|
sender: 'bot',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
|
||||||
|
getChatMessages: vi.fn().mockReturnValue(mockMessages),
|
||||||
|
sendMessage: sendMessageSpy,
|
||||||
|
extractResponseMessage: vi.fn(),
|
||||||
|
previousMessageIndex: ref(0),
|
||||||
|
waitForExecution: vi.fn(),
|
||||||
|
});
|
||||||
|
workflowsStore.messages = mockMessages;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should repost user message with new execution', async () => {
|
||||||
|
const { findByTestId } = renderComponent();
|
||||||
|
const repostButton = await findByTestId('repost-message-button');
|
||||||
|
|
||||||
|
await userEvent.click(repostButton);
|
||||||
|
|
||||||
|
expect(sendMessageSpy).toHaveBeenCalledWith('Original message');
|
||||||
|
// expect.objectContaining({
|
||||||
|
// runData: expect.objectContaining({
|
||||||
|
// 'When chat message received': expect.arrayContaining([
|
||||||
|
// expect.objectContaining({
|
||||||
|
// data: expect.objectContaining({
|
||||||
|
// main: expect.arrayContaining([
|
||||||
|
// expect.arrayContaining([
|
||||||
|
// expect.objectContaining({
|
||||||
|
// json: expect.objectContaining({
|
||||||
|
// chatInput: 'Original message',
|
||||||
|
// }),
|
||||||
|
// }),
|
||||||
|
// ]),
|
||||||
|
// ]),
|
||||||
|
// }),
|
||||||
|
// }),
|
||||||
|
// ]),
|
||||||
|
// }),
|
||||||
|
// }),
|
||||||
|
// );
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show message options only for appropriate messages', async () => {
|
||||||
|
const { findByText, container } = renderComponent();
|
||||||
|
|
||||||
|
await findByText('Original message');
|
||||||
|
const userMessage = container.querySelector('.chat-message-from-user');
|
||||||
|
expect(
|
||||||
|
userMessage?.querySelector('[data-test-id="repost-message-button"]'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
userMessage?.querySelector('[data-test-id="reuse-message-button"]'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
await findByText('AI response');
|
||||||
|
const botMessage = container.querySelector('.chat-message-from-bot');
|
||||||
|
expect(
|
||||||
|
botMessage?.querySelector('[data-test-id="repost-message-button"]'),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
botMessage?.querySelector('[data-test-id="reuse-message-button"]'),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('execution handling', () => {
|
||||||
|
it('should update UI when execution is completed', async () => {
|
||||||
|
const { findByTestId, queryByTestId } = renderComponent();
|
||||||
|
|
||||||
|
// Start execution
|
||||||
|
const input = await findByTestId('chat-input');
|
||||||
|
await userEvent.type(input, 'Test message');
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
|
||||||
|
// Simulate execution completion
|
||||||
|
uiStore.isActionActive = { workflowRunning: true };
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryByTestId('chat-message-typing')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
uiStore.isActionActive = { workflowRunning: false };
|
||||||
|
workflowsStore.setWorkflowExecutionData(
|
||||||
|
mockWorkflowExecution as unknown as IExecutionResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryByTestId('chat-message-typing')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('panel state synchronization', () => {
|
||||||
|
it('should update canvas height when chat or logs panel state changes', async () => {
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
// Toggle logs panel
|
||||||
|
workflowsStore.isLogsPanelOpen = true;
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(canvasStore.setPanelHeight).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close chat panel
|
||||||
|
workflowsStore.isChatPanelOpen = false;
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(canvasStore.setPanelHeight).toHaveBeenCalledWith(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve panel state across component remounts', async () => {
|
||||||
|
const { unmount, rerender } = renderComponent();
|
||||||
|
|
||||||
|
// Set initial state
|
||||||
|
workflowsStore.isChatPanelOpen = true;
|
||||||
|
workflowsStore.isLogsPanelOpen = true;
|
||||||
|
|
||||||
|
// Unmount and remount
|
||||||
|
unmount();
|
||||||
|
await rerender({});
|
||||||
|
|
||||||
|
expect(workflowsStore.isChatPanelOpen).toBe(true);
|
||||||
|
expect(workflowsStore.isLogsPanelOpen).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('keyboard shortcuts', () => {
|
||||||
|
it('should handle Enter key with modifier to start new line', async () => {
|
||||||
|
const { findByTestId } = renderComponent();
|
||||||
|
|
||||||
|
const input = await findByTestId('chat-input');
|
||||||
|
await userEvent.type(input, 'Line 1');
|
||||||
|
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
|
||||||
|
await userEvent.type(input, 'Line 2');
|
||||||
|
|
||||||
|
expect(input).toHaveValue('Line 1\nLine 2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chat synchronization', () => {
|
||||||
|
it('should load initial chat history when first opening panel', async () => {
|
||||||
|
const getChatMessagesSpy = vi.fn().mockReturnValue(['Previous message']);
|
||||||
|
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
|
||||||
|
...vi.fn()(),
|
||||||
|
getChatMessages: getChatMessagesSpy,
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowsStore.isChatPanelOpen = false;
|
||||||
|
const { rerender } = renderComponent();
|
||||||
|
|
||||||
|
workflowsStore.isChatPanelOpen = true;
|
||||||
|
await rerender({});
|
||||||
|
|
||||||
|
expect(getChatMessagesSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
350
packages/editor-ui/src/components/CanvasChat/CanvasChat.vue
Normal file
350
packages/editor-ui/src/components/CanvasChat/CanvasChat.vue
Normal file
|
@ -0,0 +1,350 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
import { provide, watch, computed, ref, watchEffect } from 'vue';
|
||||||
|
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
||||||
|
import type { Router } from 'vue-router';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import ChatMessagesPanel from './components/ChatMessagesPanel.vue';
|
||||||
|
import ChatLogsPanel from './components/ChatLogsPanel.vue';
|
||||||
|
|
||||||
|
// Composables
|
||||||
|
import { useChatTrigger } from './composables/useChatTrigger';
|
||||||
|
import { useChatMessaging } from './composables/useChatMessaging';
|
||||||
|
import { useResize } from './composables/useResize';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
|
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { Chat, ChatMessage, ChatOptions } from '@n8n/chat/types';
|
||||||
|
import type { RunWorkflowChatPayload } from './composables/useChatMessaging';
|
||||||
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
const canvasStore = useCanvasStore();
|
||||||
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
|
const nodeHelpers = useNodeHelpers();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Component state
|
||||||
|
const messages = ref<ChatMessage[]>([]);
|
||||||
|
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
|
||||||
|
const isDisabled = ref(false);
|
||||||
|
const container = ref<HTMLElement>();
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
||||||
|
const isLoading = computed(() => {
|
||||||
|
const result = uiStore.isActionActive.workflowRunning;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
const allConnections = computed(() => workflowsStore.allConnections);
|
||||||
|
const isChatOpen = computed(() => {
|
||||||
|
const result = workflowsStore.isChatPanelOpen;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
const isLogsOpen = computed(() => workflowsStore.isLogsPanelOpen);
|
||||||
|
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
|
||||||
|
|
||||||
|
// Expose internal state for testing
|
||||||
|
defineExpose({
|
||||||
|
messages,
|
||||||
|
currentSessionId,
|
||||||
|
isDisabled,
|
||||||
|
workflow,
|
||||||
|
isLoading,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { runWorkflow } = useRunWorkflow({ router });
|
||||||
|
|
||||||
|
// Initialize features with injected dependencies
|
||||||
|
const { chatTriggerNode, connectedNode, allowFileUploads, setChatTriggerNode, setConnectedNode } =
|
||||||
|
useChatTrigger({
|
||||||
|
workflow,
|
||||||
|
canvasNodes: workflowsStore.allNodes,
|
||||||
|
getNodeByName: workflowsStore.getNodeByName,
|
||||||
|
getNodeType: nodeTypesStore.getNodeType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { sendMessage, getChatMessages } = useChatMessaging({
|
||||||
|
chatTrigger: chatTriggerNode,
|
||||||
|
connectedNode,
|
||||||
|
messages,
|
||||||
|
sessionId: currentSessionId,
|
||||||
|
workflow,
|
||||||
|
isLoading,
|
||||||
|
executionResultData: computed(() => workflowsStore.getWorkflowExecution?.data?.resultData),
|
||||||
|
getWorkflowResultDataByNodeName: workflowsStore.getWorkflowResultDataByNodeName,
|
||||||
|
onRunChatWorkflow,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
height,
|
||||||
|
chatWidth,
|
||||||
|
rootStyles,
|
||||||
|
logsWidth,
|
||||||
|
onResizeDebounced,
|
||||||
|
onResizeChatDebounced,
|
||||||
|
onWindowResize,
|
||||||
|
} = useResize(container);
|
||||||
|
|
||||||
|
// Extracted pure functions for better testability
|
||||||
|
function createChatConfig(params: {
|
||||||
|
messages: Chat['messages'];
|
||||||
|
sendMessage: Chat['sendMessage'];
|
||||||
|
currentSessionId: Chat['currentSessionId'];
|
||||||
|
isLoading: Ref<boolean>;
|
||||||
|
isDisabled: Ref<boolean>;
|
||||||
|
allowFileUploads: Ref<boolean>;
|
||||||
|
locale: ReturnType<typeof useI18n>;
|
||||||
|
}): { chatConfig: Chat; chatOptions: ChatOptions } {
|
||||||
|
const chatConfig: Chat = {
|
||||||
|
messages: params.messages,
|
||||||
|
sendMessage: params.sendMessage,
|
||||||
|
initialMessages: ref([]),
|
||||||
|
currentSessionId: params.currentSessionId,
|
||||||
|
waitingForResponse: params.isLoading,
|
||||||
|
};
|
||||||
|
|
||||||
|
const chatOptions: ChatOptions = {
|
||||||
|
i18n: {
|
||||||
|
en: {
|
||||||
|
title: '',
|
||||||
|
footer: '',
|
||||||
|
subtitle: '',
|
||||||
|
inputPlaceholder: params.locale.baseText('chat.window.chat.placeholder'),
|
||||||
|
getStarted: '',
|
||||||
|
closeButtonTooltip: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
webhookUrl: '',
|
||||||
|
mode: 'window',
|
||||||
|
showWindowCloseButton: true,
|
||||||
|
disabled: params.isDisabled,
|
||||||
|
allowFileUploads: params.allowFileUploads,
|
||||||
|
allowedFilesMimeTypes: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return { chatConfig, chatOptions };
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayExecution(params: { router: Router; workflowId: string; executionId: string }) {
|
||||||
|
const route = params.router.resolve({
|
||||||
|
name: VIEWS.EXECUTION_PREVIEW,
|
||||||
|
params: { name: params.workflowId, executionId: params.executionId },
|
||||||
|
});
|
||||||
|
window.open(route.href, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSession(params: { messages: Ref<ChatMessage[]>; currentSessionId: Ref<string> }) {
|
||||||
|
workflowsStore.setWorkflowExecutionData(null);
|
||||||
|
nodeHelpers.updateNodesExecutionIssues();
|
||||||
|
params.messages.value = [];
|
||||||
|
params.currentSessionId.value = uuid().replace(/-/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
const handleDisplayExecution = (executionId: string) => {
|
||||||
|
displayExecution({
|
||||||
|
router,
|
||||||
|
workflowId: workflow.value.id,
|
||||||
|
executionId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefreshSession = () => {
|
||||||
|
refreshSession({
|
||||||
|
messages,
|
||||||
|
currentSessionId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePanel = () => {
|
||||||
|
workflowsStore.setPanelOpen('chat', false);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function onRunChatWorkflow(payload: RunWorkflowChatPayload) {
|
||||||
|
const response = await runWorkflow({
|
||||||
|
triggerNode: payload.triggerNode,
|
||||||
|
nodeData: payload.nodeData,
|
||||||
|
source: payload.source,
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowsStore.appendChatMessage(payload.message);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize chat config
|
||||||
|
const { chatConfig, chatOptions } = createChatConfig({
|
||||||
|
messages,
|
||||||
|
sendMessage,
|
||||||
|
currentSessionId,
|
||||||
|
isLoading,
|
||||||
|
isDisabled,
|
||||||
|
allowFileUploads,
|
||||||
|
locale: useI18n(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provide chat context
|
||||||
|
provide(ChatSymbol, chatConfig);
|
||||||
|
provide(ChatOptionsSymbol, chatOptions);
|
||||||
|
|
||||||
|
// Watchers
|
||||||
|
watch(
|
||||||
|
() => isChatOpen.value,
|
||||||
|
(isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
setChatTriggerNode();
|
||||||
|
setConnectedNode();
|
||||||
|
|
||||||
|
if (messages.value.length === 0) {
|
||||||
|
messages.value = getChatMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
onWindowResize();
|
||||||
|
chatEventBus.emit('focusInput');
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => allConnections.value,
|
||||||
|
() => {
|
||||||
|
if (canvasStore.isLoading) return;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!chatTriggerNode.value) {
|
||||||
|
setChatTriggerNode();
|
||||||
|
}
|
||||||
|
setConnectedNode();
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
canvasStore.setPanelHeight(isChatOpen.value || isLogsOpen.value ? height.value : 0);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n8n-resize-wrapper
|
||||||
|
v-if="chatTriggerNode"
|
||||||
|
:is-resizing-enabled="isChatOpen || isLogsOpen"
|
||||||
|
:supported-directions="['top']"
|
||||||
|
:class="[$style.resizeWrapper, !isChatOpen && !isLogsOpen && $style.empty]"
|
||||||
|
:height="height"
|
||||||
|
:style="rootStyles"
|
||||||
|
@resize="onResizeDebounced"
|
||||||
|
>
|
||||||
|
<div ref="container" :class="$style.container">
|
||||||
|
<div v-if="isChatOpen || isLogsOpen" :class="$style.chatResizer">
|
||||||
|
<n8n-resize-wrapper
|
||||||
|
v-if="isChatOpen"
|
||||||
|
:supported-directions="['right']"
|
||||||
|
:width="chatWidth"
|
||||||
|
:class="$style.chat"
|
||||||
|
@resize="onResizeChatDebounced"
|
||||||
|
>
|
||||||
|
<div :class="$style.inner">
|
||||||
|
<ChatMessagesPanel
|
||||||
|
data-test-id="canvas-chat"
|
||||||
|
:messages="messages"
|
||||||
|
:session-id="currentSessionId"
|
||||||
|
:past-chat-messages="previousChatMessages"
|
||||||
|
@refresh-session="handleRefreshSession"
|
||||||
|
@display-execution="handleDisplayExecution"
|
||||||
|
@send-message="sendMessage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</n8n-resize-wrapper>
|
||||||
|
<div v-if="isLogsOpen && connectedNode" :class="$style.logs">
|
||||||
|
<ChatLogsPanel
|
||||||
|
:key="messages.length"
|
||||||
|
:workflow="workflow"
|
||||||
|
data-test-id="canvas-chat-logs"
|
||||||
|
:node="connectedNode"
|
||||||
|
:slim="logsWidth < 700"
|
||||||
|
@close="closePanel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n8n-resize-wrapper>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.resizeWrapper {
|
||||||
|
height: var(--panel-height);
|
||||||
|
min-height: 4rem;
|
||||||
|
max-height: 90vh;
|
||||||
|
flex-basis: content;
|
||||||
|
border-top: 1px solid var(--color-foreground-base);
|
||||||
|
|
||||||
|
&.empty {
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
flex-basis: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatResizer {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
border-top: 1px solid var(--color-foreground-base);
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--color-background-light);
|
||||||
|
display: flex;
|
||||||
|
padding: var(--spacing-2xs);
|
||||||
|
gap: var(--spacing-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat {
|
||||||
|
width: var(--chat-width);
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--color-foreground-base);
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
&:only-child {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
background-color: var(--color-background-light);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,91 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { INode, Workflow } from 'n8n-workflow';
|
||||||
|
import RunDataAi from '@/components/RunDataAi/RunDataAi.vue';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
node: INode | null;
|
||||||
|
slim?: boolean;
|
||||||
|
workflow: Workflow;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const locale = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="$style.logsWrapper" data-test-id="lm-chat-logs">
|
||||||
|
<header :class="$style.logsHeader">
|
||||||
|
<div class="meta">
|
||||||
|
{{ locale.baseText('chat.window.logs') }}
|
||||||
|
<span v-if="node">
|
||||||
|
{{
|
||||||
|
locale.baseText('chat.window.logsFromNode', { interpolate: { nodeName: node.name } })
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<n8n-icon-button
|
||||||
|
:class="$style.close"
|
||||||
|
outline
|
||||||
|
icon="times"
|
||||||
|
type="secondary"
|
||||||
|
size="mini"
|
||||||
|
@click="emit('close')"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
<div :class="$style.logs">
|
||||||
|
<RunDataAi
|
||||||
|
v-if="node"
|
||||||
|
:class="$style.runData"
|
||||||
|
:node="node"
|
||||||
|
:workflow="workflow"
|
||||||
|
:slim="slim"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.logsHeader {
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
height: 2.6875rem;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-foreground-base);
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
background-color: var(--color-foreground-xlight);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.close {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-weight: 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.logsWrapper {
|
||||||
|
--node-icon-color: var(--color-text-base);
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logsTitle {
|
||||||
|
margin: 0 var(--spacing-s) var(--spacing-s);
|
||||||
|
}
|
||||||
|
.logs {
|
||||||
|
padding: var(--spacing-s) 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,348 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import MessagesList from '@n8n/chat/components/MessagesList.vue';
|
||||||
|
import MessageOptionTooltip from './MessageOptionTooltip.vue';
|
||||||
|
import MessageOptionAction from './MessageOptionAction.vue';
|
||||||
|
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||||
|
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
|
||||||
|
import ChatInput from '@n8n/chat/components/Input.vue';
|
||||||
|
import { useMessage } from '@/composables/useMessage';
|
||||||
|
import { MODAL_CONFIRM } from '@/constants';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useClipboard } from '@/composables/useClipboard';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pastChatMessages: string[];
|
||||||
|
messages: ChatMessage[];
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
displayExecution: [id: string];
|
||||||
|
sendMessage: [message: string];
|
||||||
|
refreshSession: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const messageComposable = useMessage();
|
||||||
|
const clipboard = useClipboard();
|
||||||
|
const locale = useI18n();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const previousMessageIndex = ref(0);
|
||||||
|
|
||||||
|
const inputPlaceholder = computed(() => {
|
||||||
|
if (props.messages.length > 0) {
|
||||||
|
return locale.baseText('chat.window.chat.placeholder');
|
||||||
|
}
|
||||||
|
return locale.baseText('chat.window.chat.placeholderPristine');
|
||||||
|
});
|
||||||
|
/** Checks if message is a text message */
|
||||||
|
function isTextMessage(message: ChatMessage): message is ChatMessageText {
|
||||||
|
return message.type === 'text' || !message.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reposts the message */
|
||||||
|
function repostMessage(message: ChatMessageText) {
|
||||||
|
void sendMessage(message.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the message in input for reuse */
|
||||||
|
function reuseMessage(message: ChatMessageText) {
|
||||||
|
chatEventBus.emit('setInputValue', message.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMessage(message: string) {
|
||||||
|
previousMessageIndex.value = 0;
|
||||||
|
emit('sendMessage', message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRefreshSession() {
|
||||||
|
// If there are no messages, refresh the session without asking
|
||||||
|
if (props.messages.length === 0) {
|
||||||
|
emit('refreshSession');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmResult = await messageComposable.confirm(
|
||||||
|
locale.baseText('chat.window.session.reset.warning'),
|
||||||
|
{
|
||||||
|
title: locale.baseText('chat.window.session.reset.title'),
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: locale.baseText('chat.window.session.reset.confirm'),
|
||||||
|
showClose: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (confirmResult === MODAL_CONFIRM) {
|
||||||
|
emit('refreshSession');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onArrowKeyDown({ currentInputValue, key }: ArrowKeyDownPayload) {
|
||||||
|
const pastMessages = props.pastChatMessages;
|
||||||
|
const isCurrentInputEmptyOrMatch =
|
||||||
|
currentInputValue.length === 0 || pastMessages.includes(currentInputValue);
|
||||||
|
|
||||||
|
if (isCurrentInputEmptyOrMatch && (key === 'ArrowUp' || key === 'ArrowDown')) {
|
||||||
|
// Exit if no messages
|
||||||
|
if (pastMessages.length === 0) return;
|
||||||
|
|
||||||
|
// Temporarily blur to avoid cursor position issues
|
||||||
|
chatEventBus.emit('blurInput');
|
||||||
|
|
||||||
|
if (pastMessages.length === 1) {
|
||||||
|
previousMessageIndex.value = 0;
|
||||||
|
} else {
|
||||||
|
if (key === 'ArrowUp') {
|
||||||
|
if (currentInputValue.length === 0 && previousMessageIndex.value === 0) {
|
||||||
|
// Start with most recent message
|
||||||
|
previousMessageIndex.value = pastMessages.length - 1;
|
||||||
|
} else {
|
||||||
|
// Move backwards through history
|
||||||
|
previousMessageIndex.value =
|
||||||
|
previousMessageIndex.value === 0
|
||||||
|
? pastMessages.length - 1
|
||||||
|
: previousMessageIndex.value - 1;
|
||||||
|
}
|
||||||
|
} else if (key === 'ArrowDown') {
|
||||||
|
// Move forwards through history
|
||||||
|
previousMessageIndex.value =
|
||||||
|
previousMessageIndex.value === pastMessages.length - 1
|
||||||
|
? 0
|
||||||
|
: previousMessageIndex.value + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get message at current index
|
||||||
|
const selectedMessage = pastMessages[previousMessageIndex.value];
|
||||||
|
chatEventBus.emit('setInputValue', selectedMessage);
|
||||||
|
|
||||||
|
// Refocus and move cursor to end
|
||||||
|
chatEventBus.emit('focusInput');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset history navigation when typing new content that doesn't match history
|
||||||
|
if (!isCurrentInputEmptyOrMatch) {
|
||||||
|
previousMessageIndex.value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function copySessionId() {
|
||||||
|
void clipboard.copy(props.sessionId);
|
||||||
|
toast.showMessage({
|
||||||
|
title: locale.baseText('generic.copiedToClipboard'),
|
||||||
|
message: '',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="$style.chat" data-test-id="workflow-lm-chat-dialog">
|
||||||
|
<header :class="$style.chatHeader">
|
||||||
|
<span>{{ locale.baseText('chat.window.title') }}</span>
|
||||||
|
<div :class="$style.session">
|
||||||
|
<span>{{ locale.baseText('chat.window.session.title') }}</span>
|
||||||
|
<n8n-tooltip placement="left">
|
||||||
|
<template #content>
|
||||||
|
{{ sessionId }}
|
||||||
|
</template>
|
||||||
|
<span :class="$style.sessionId" data-test-id="chat-session-id" @click="copySessionId">{{
|
||||||
|
sessionId
|
||||||
|
}}</span>
|
||||||
|
</n8n-tooltip>
|
||||||
|
<n8n-icon-button
|
||||||
|
:class="$style.refreshSession"
|
||||||
|
data-test-id="refresh-session-button"
|
||||||
|
type="tertiary"
|
||||||
|
text
|
||||||
|
size="mini"
|
||||||
|
icon="undo"
|
||||||
|
:title="locale.baseText('chat.window.session.reset.confirm')"
|
||||||
|
@click="onRefreshSession"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main :class="$style.chatBody">
|
||||||
|
<MessagesList :messages="messages" :class="[$style.messages, 'ignore-key-press-canvas']">
|
||||||
|
<template #beforeMessage="{ message }">
|
||||||
|
<MessageOptionTooltip
|
||||||
|
v-if="message.sender === 'bot' && !message.id.includes('preload')"
|
||||||
|
placement="right"
|
||||||
|
data-test-id="execution-id-tooltip"
|
||||||
|
>
|
||||||
|
{{ locale.baseText('chat.window.chat.chatMessageOptions.executionId') }}:
|
||||||
|
<a href="#" @click="emit('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.once="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>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div :class="$style.messagesInput">
|
||||||
|
<div v-if="pastChatMessages.length > 0" :class="$style.messagesHistory">
|
||||||
|
<n8n-button
|
||||||
|
title="Navigate to previous message"
|
||||||
|
icon="chevron-up"
|
||||||
|
type="tertiary"
|
||||||
|
text
|
||||||
|
size="mini"
|
||||||
|
@click="onArrowKeyDown({ currentInputValue: '', key: 'ArrowUp' })"
|
||||||
|
/>
|
||||||
|
<n8n-button
|
||||||
|
title="Navigate to next message"
|
||||||
|
icon="chevron-down"
|
||||||
|
type="tertiary"
|
||||||
|
text
|
||||||
|
size="mini"
|
||||||
|
@click="onArrowKeyDown({ currentInputValue: '', key: 'ArrowDown' })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ChatInput
|
||||||
|
data-test-id="lm-chat-inputs"
|
||||||
|
:placeholder="inputPlaceholder"
|
||||||
|
@arrow-key-down="onArrowKeyDown"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.chat {
|
||||||
|
--chat--spacing: var(--spacing-xs);
|
||||||
|
--chat--message--padding: var(--spacing-xs);
|
||||||
|
--chat--message--font-size: var(--font-size-s);
|
||||||
|
--chat--input--font-size: var(--font-size-s);
|
||||||
|
--chat--message--bot--background: transparent;
|
||||||
|
--chat--message--user--background: var(--color-text-lighter);
|
||||||
|
--chat--message--bot--color: var(--color-text-dark);
|
||||||
|
--chat--message--user--color: var(--color-text-dark);
|
||||||
|
--chat--message--bot--border: none;
|
||||||
|
--chat--message--user--border: none;
|
||||||
|
--chat--color-typing: var(--color-text-light);
|
||||||
|
--chat--textarea--max-height: calc(var(--panel-height) * 0.5);
|
||||||
|
--chat--message--pre--background: var(--color-foreground-light);
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--color-background-light);
|
||||||
|
}
|
||||||
|
.chatHeader {
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-foreground-base);
|
||||||
|
padding: var(--chat--spacing);
|
||||||
|
background-color: var(--color-foreground-xlight);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.session {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-2xs);
|
||||||
|
color: var(--color-text-base);
|
||||||
|
max-width: 70%;
|
||||||
|
}
|
||||||
|
.sessionId {
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.refreshSession {
|
||||||
|
max-height: 1.1rem;
|
||||||
|
}
|
||||||
|
.chatBody {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
padding-top: 1.5em;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagesInput {
|
||||||
|
--input-border-color: var(--border-color-base);
|
||||||
|
--chat--input--border: none;
|
||||||
|
|
||||||
|
--chat--input--border-radius: 0.5rem;
|
||||||
|
--chat--input--send--button--background: transparent;
|
||||||
|
--chat--input--send--button--color: var(--color-primary);
|
||||||
|
--chat--input--file--button--background: transparent;
|
||||||
|
--chat--input--file--button--color: 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: transparent;
|
||||||
|
--chat--input--file--button--color: var(--color-button-secondary-font);
|
||||||
|
--chat--input--file--button--color-hover: var(--color-primary);
|
||||||
|
|
||||||
|
[data-theme='dark'] & {
|
||||||
|
--chat--input--text-color: var(--input-font-color, var(--color-text-dark));
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
--chat--input--text-color: var(--input-font-color, var(--color-text-dark));
|
||||||
|
}
|
||||||
|
|
||||||
|
padding: 0 0 0 var(--spacing-xs);
|
||||||
|
margin: 0 var(--chat--spacing) var(--chat--spacing);
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
background: var(--color-lm-chat-bot-background);
|
||||||
|
border-radius: var(--chat--input--border-radius);
|
||||||
|
transition: border-color 200ms ease-in-out;
|
||||||
|
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));
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
--input-border-color: #4538a3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagesHistory {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: var(--spacing-3xs);
|
||||||
|
|
||||||
|
button:first-child {
|
||||||
|
margin-top: var(--spacing-4xs);
|
||||||
|
margin-bottom: calc(-1 * var(--spacing-4xs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,46 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PropType } from 'vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
placement: {
|
||||||
|
type: String as PropType<'left' | 'right' | 'top' | 'bottom'>,
|
||||||
|
default: 'top',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="$style.container">
|
||||||
|
<n8n-tooltip :placement="placement">
|
||||||
|
<template #content>
|
||||||
|
{{ label }}
|
||||||
|
</template>
|
||||||
|
<n8n-icon :class="$style.icon" :icon="icon" size="xsmall" @click="$attrs.onClick" />
|
||||||
|
</n8n-tooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.container {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 var(--spacing-4xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--color-foreground-dark);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,40 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PropType } from 'vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
placement: {
|
||||||
|
type: String as PropType<'left' | 'right' | 'top' | 'bottom'>,
|
||||||
|
default: 'top',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="$style.container">
|
||||||
|
<n8n-tooltip :placement="placement">
|
||||||
|
<template #content>
|
||||||
|
<slot />
|
||||||
|
</template>
|
||||||
|
<span :class="$style.icon">
|
||||||
|
<n8n-icon icon="info" size="xsmall" />
|
||||||
|
</span>
|
||||||
|
</n8n-tooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.container {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 var(--spacing-4xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--color-foreground-dark);
|
||||||
|
cursor: help;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,299 @@
|
||||||
|
import type { ComputedRef, Ref } from 'vue';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
|
||||||
|
import { NodeConnectionType, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
||||||
|
import type {
|
||||||
|
ITaskData,
|
||||||
|
INodeExecutionData,
|
||||||
|
IBinaryKeyData,
|
||||||
|
IDataObject,
|
||||||
|
IBinaryData,
|
||||||
|
BinaryFileType,
|
||||||
|
Workflow,
|
||||||
|
IRunExecutionData,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { useMessage } from '@/composables/useMessage';
|
||||||
|
import { usePinnedData } from '@/composables/usePinnedData';
|
||||||
|
import { get, isEmpty, last } from 'lodash-es';
|
||||||
|
import { MANUAL_CHAT_TRIGGER_NODE_TYPE, MODAL_CONFIRM } from '@/constants';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import type { MemoryOutput } from '../types/chat';
|
||||||
|
import type { IExecutionPushResponse, INodeUi } from '@/Interface';
|
||||||
|
|
||||||
|
export type RunWorkflowChatPayload = {
|
||||||
|
triggerNode: string;
|
||||||
|
nodeData: ITaskData;
|
||||||
|
source: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
export interface ChatMessagingDependencies {
|
||||||
|
chatTrigger: Ref<INodeUi | null>;
|
||||||
|
connectedNode: Ref<INodeUi | null>;
|
||||||
|
messages: Ref<ChatMessage[]>;
|
||||||
|
sessionId: Ref<string>;
|
||||||
|
workflow: ComputedRef<Workflow>;
|
||||||
|
isLoading: ComputedRef<boolean>;
|
||||||
|
executionResultData: ComputedRef<IRunExecutionData['resultData'] | undefined>;
|
||||||
|
getWorkflowResultDataByNodeName: (nodeName: string) => ITaskData[] | null;
|
||||||
|
onRunChatWorkflow: (
|
||||||
|
payload: RunWorkflowChatPayload,
|
||||||
|
) => Promise<IExecutionPushResponse | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatMessaging({
|
||||||
|
chatTrigger,
|
||||||
|
connectedNode,
|
||||||
|
messages,
|
||||||
|
sessionId,
|
||||||
|
workflow,
|
||||||
|
isLoading,
|
||||||
|
executionResultData,
|
||||||
|
getWorkflowResultDataByNodeName,
|
||||||
|
onRunChatWorkflow,
|
||||||
|
}: ChatMessagingDependencies) {
|
||||||
|
const locale = useI18n();
|
||||||
|
const { showError } = useToast();
|
||||||
|
const previousMessageIndex = ref(0);
|
||||||
|
|
||||||
|
/** Converts a file to binary data */
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets keyed files for the workflow input */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts file metadata */
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts workflow execution with the message */
|
||||||
|
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 inputPayload: INodeExecutionData = {
|
||||||
|
json: {
|
||||||
|
sessionId: sessionId.value,
|
||||||
|
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 onRunChatWorkflow({
|
||||||
|
triggerNode: triggerNode.name,
|
||||||
|
nodeData,
|
||||||
|
source: 'RunData.ManualChatMessage',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response?.executionId) {
|
||||||
|
showError(
|
||||||
|
new Error('It was not possible to start workflow!'),
|
||||||
|
'Workflow could not be started',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForExecution(response.executionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Waits for workflow execution to complete */
|
||||||
|
function waitForExecution(executionId: string) {
|
||||||
|
const waitInterval = setInterval(() => {
|
||||||
|
if (!isLoading.value) {
|
||||||
|
clearInterval(waitInterval);
|
||||||
|
|
||||||
|
const lastNodeExecuted = executionResultData.value?.lastNodeExecuted;
|
||||||
|
|
||||||
|
if (!lastNodeExecuted) return;
|
||||||
|
|
||||||
|
const nodeResponseDataArray =
|
||||||
|
get(executionResultData.value.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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts response message from workflow output */
|
||||||
|
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);
|
||||||
|
|
||||||
|
const matchedOutput = get(responseData, matchedPath);
|
||||||
|
if (typeof matchedOutput === 'object') {
|
||||||
|
return '```json\n' + JSON.stringify(matchedOutput, null, 2) + '\n```';
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchedOutput?.toString() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends a message to the chat */
|
||||||
|
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 & { sessionId: string } = {
|
||||||
|
text: message,
|
||||||
|
sender: 'user',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
sessionId: sessionId.value,
|
||||||
|
id: uuid(),
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
messages.value.push(newMessage);
|
||||||
|
|
||||||
|
await startWorkflowWithMessage(newMessage.text, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChatMessages(): ChatMessageText[] {
|
||||||
|
if (!connectedNode.value) return [];
|
||||||
|
|
||||||
|
const connectedMemoryInputs =
|
||||||
|
workflow.value.connectionsByDestinationNode?.[connectedNode.value.name]?.[
|
||||||
|
NodeConnectionType.AiMemory
|
||||||
|
];
|
||||||
|
if (!connectedMemoryInputs) return [];
|
||||||
|
|
||||||
|
const memoryConnection = (connectedMemoryInputs ?? []).find((i) => i.length > 0)?.[0];
|
||||||
|
|
||||||
|
if (!memoryConnection) return [];
|
||||||
|
|
||||||
|
const nodeResultData = getWorkflowResultDataByNodeName(memoryConnection.node);
|
||||||
|
|
||||||
|
const memoryOutputData = (nodeResultData ?? [])
|
||||||
|
.map((data) => get(data, ['data', NodeConnectionType.AiMemory, 0, 0, 'json']) as MemoryOutput)
|
||||||
|
.find((data) => 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',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
previousMessageIndex,
|
||||||
|
sendMessage,
|
||||||
|
extractResponseMessage,
|
||||||
|
waitForExecution,
|
||||||
|
getChatMessages,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,138 @@
|
||||||
|
import type { ComputedRef } from 'vue';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import {
|
||||||
|
CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE,
|
||||||
|
NodeConnectionType,
|
||||||
|
NodeHelpers,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import type { INodeTypeDescription, Workflow, INode, INodeParameters } from 'n8n-workflow';
|
||||||
|
import {
|
||||||
|
AI_CATEGORY_AGENTS,
|
||||||
|
AI_CATEGORY_CHAINS,
|
||||||
|
AI_CODE_NODE_TYPE,
|
||||||
|
AI_SUBCATEGORY,
|
||||||
|
CHAT_TRIGGER_NODE_TYPE,
|
||||||
|
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
||||||
|
} from '@/constants';
|
||||||
|
import type { INodeUi } from '@/Interface';
|
||||||
|
|
||||||
|
export interface ChatTriggerDependencies {
|
||||||
|
getNodeByName: (name: string) => INodeUi | null;
|
||||||
|
getNodeType: (type: string, version: number) => INodeTypeDescription | null;
|
||||||
|
canvasNodes: INodeUi[];
|
||||||
|
workflow: ComputedRef<Workflow>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatTrigger({
|
||||||
|
getNodeByName,
|
||||||
|
getNodeType,
|
||||||
|
canvasNodes,
|
||||||
|
workflow,
|
||||||
|
}: ChatTriggerDependencies) {
|
||||||
|
const chatTriggerName = ref<string | null>(null);
|
||||||
|
const connectedNode = ref<INode | null>(null);
|
||||||
|
|
||||||
|
const chatTriggerNode = computed(() =>
|
||||||
|
chatTriggerName.value ? getNodeByName(chatTriggerName.value) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const allowFileUploads = computed(() => {
|
||||||
|
return (
|
||||||
|
(chatTriggerNode.value?.parameters?.options as INodeParameters)?.allowFileUploads === true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const allowedFilesMimeTypes = computed(() => {
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
chatTriggerNode.value?.parameters?.options as INodeParameters
|
||||||
|
)?.allowedFilesMimeTypes?.toString() ?? ''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Gets the chat trigger node from the workflow */
|
||||||
|
function setChatTriggerNode() {
|
||||||
|
const triggerNode = canvasNodes.find((node) =>
|
||||||
|
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(node.type),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!triggerNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chatTriggerName.value = triggerNode.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the connected node after finding the trigger */
|
||||||
|
function setConnectedNode() {
|
||||||
|
const triggerNode = chatTriggerNode.value;
|
||||||
|
|
||||||
|
if (!triggerNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatChildren = workflow.value.getChildNodes(triggerNode.name);
|
||||||
|
|
||||||
|
const chatRootNode = chatChildren
|
||||||
|
.reverse()
|
||||||
|
.map((nodeName: string) => getNodeByName(nodeName))
|
||||||
|
.filter((n): n is INodeUi => n !== null)
|
||||||
|
// Reverse the nodes to match the last node logs first
|
||||||
|
.reverse()
|
||||||
|
.find((storeNode: INodeUi): boolean => {
|
||||||
|
// Skip summarization nodes
|
||||||
|
if (storeNode.type === CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE) return false;
|
||||||
|
const nodeType = getNodeType(storeNode.type, storeNode.typeVersion);
|
||||||
|
|
||||||
|
if (!nodeType) return false;
|
||||||
|
|
||||||
|
// Check if node is an AI agent or chain based on its metadata
|
||||||
|
const isAgent =
|
||||||
|
nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_AGENTS);
|
||||||
|
const isChain =
|
||||||
|
nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_CHAINS);
|
||||||
|
|
||||||
|
// Handle custom AI Langchain Code nodes that could act as chains or agents
|
||||||
|
let isCustomChainOrAgent = false;
|
||||||
|
if (nodeType.name === AI_CODE_NODE_TYPE) {
|
||||||
|
// Get node connection types for inputs and outputs
|
||||||
|
const inputs = NodeHelpers.getNodeInputs(workflow.value, storeNode, nodeType);
|
||||||
|
const inputTypes = NodeHelpers.getConnectionTypes(inputs);
|
||||||
|
|
||||||
|
const outputs = NodeHelpers.getNodeOutputs(workflow.value, storeNode, nodeType);
|
||||||
|
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
|
||||||
|
|
||||||
|
// Validate if node has required AI connection types
|
||||||
|
if (
|
||||||
|
inputTypes.includes(NodeConnectionType.AiLanguageModel) &&
|
||||||
|
inputTypes.includes(NodeConnectionType.Main) &&
|
||||||
|
outputTypes.includes(NodeConnectionType.Main)
|
||||||
|
) {
|
||||||
|
isCustomChainOrAgent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if node is not an AI component
|
||||||
|
if (!isAgent && !isChain && !isCustomChainOrAgent) return false;
|
||||||
|
|
||||||
|
// Check if this node is connected to the trigger node
|
||||||
|
const parentNodes = workflow.value.getParentNodes(storeNode.name);
|
||||||
|
const isChatChild = parentNodes.some(
|
||||||
|
(parentNodeName) => parentNodeName === triggerNode.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||||
|
const result = Boolean(isChatChild && (isAgent || isChain || isCustomChainOrAgent));
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
connectedNode.value = chatRootNode ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowFileUploads,
|
||||||
|
allowedFilesMimeTypes,
|
||||||
|
chatTriggerNode,
|
||||||
|
connectedNode: computed(() => connectedNode.value),
|
||||||
|
setChatTriggerNode,
|
||||||
|
setConnectedNode,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,137 @@
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount, watchEffect } from 'vue';
|
||||||
|
import type { ResizeData } from 'n8n-design-system/components/N8nResizeWrapper/ResizeWrapper.vue';
|
||||||
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
|
import type { IChatResizeStyles } from '../types/chat';
|
||||||
|
import { useStorage } from '@/composables/useStorage';
|
||||||
|
|
||||||
|
const LOCAL_STORAGE_PANEL_HEIGHT = 'N8N_CANVAS_CHAT_HEIGHT';
|
||||||
|
const LOCAL_STORAGE_PANEL_WIDTH = 'N8N_CANVAS_CHAT_WIDTH';
|
||||||
|
|
||||||
|
// Percentage of container width for chat panel constraints
|
||||||
|
const MAX_WIDTH_PERCENTAGE = 0.8;
|
||||||
|
const MIN_WIDTH_PERCENTAGE = 0.3;
|
||||||
|
|
||||||
|
// Percentage of window height for panel constraints
|
||||||
|
const MIN_HEIGHT_PERCENTAGE = 0.3;
|
||||||
|
const MAX_HEIGHT_PERCENTAGE = 0.75;
|
||||||
|
|
||||||
|
export function useResize(container: Ref<HTMLElement | undefined>) {
|
||||||
|
const storage = {
|
||||||
|
height: useStorage(LOCAL_STORAGE_PANEL_HEIGHT),
|
||||||
|
width: useStorage(LOCAL_STORAGE_PANEL_WIDTH),
|
||||||
|
};
|
||||||
|
|
||||||
|
const dimensions = {
|
||||||
|
container: ref(0), // Container width
|
||||||
|
minHeight: ref(0),
|
||||||
|
maxHeight: ref(0),
|
||||||
|
chat: ref(0), // Chat panel width
|
||||||
|
logs: ref(0),
|
||||||
|
height: ref(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Computed styles for root element based on current dimensions */
|
||||||
|
const rootStyles = computed<IChatResizeStyles>(() => ({
|
||||||
|
'--panel-height': `${dimensions.height.value}px`,
|
||||||
|
'--chat-width': `${dimensions.chat.value}px`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const panelToContainerRatio = computed(() => {
|
||||||
|
const chatRatio = dimensions.chat.value / dimensions.container.value;
|
||||||
|
const containerRatio = dimensions.container.value / window.screen.width;
|
||||||
|
return {
|
||||||
|
chat: chatRatio.toFixed(2),
|
||||||
|
logs: (1 - chatRatio).toFixed(2),
|
||||||
|
container: containerRatio.toFixed(2),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constrains height to min/max bounds and updates panel height
|
||||||
|
*/
|
||||||
|
function onResize(newHeight: number) {
|
||||||
|
const { minHeight, maxHeight } = dimensions;
|
||||||
|
dimensions.height.value = Math.min(Math.max(newHeight, minHeight.value), maxHeight.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResizeDebounced(data: ResizeData) {
|
||||||
|
void useDebounce().callDebounced(onResize, { debounceTime: 10, trailing: true }, data.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constrains chat width to min/max percentage of container width
|
||||||
|
*/
|
||||||
|
function onResizeChat(width: number) {
|
||||||
|
const containerWidth = dimensions.container.value;
|
||||||
|
const maxWidth = containerWidth * MAX_WIDTH_PERCENTAGE;
|
||||||
|
const minWidth = containerWidth * MIN_WIDTH_PERCENTAGE;
|
||||||
|
|
||||||
|
dimensions.chat.value = Math.min(Math.max(width, minWidth), maxWidth);
|
||||||
|
dimensions.logs.value = dimensions.container.value - dimensions.chat.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResizeChatDebounced(data: ResizeData) {
|
||||||
|
void useDebounce().callDebounced(
|
||||||
|
onResizeChat,
|
||||||
|
{ debounceTime: 10, trailing: true },
|
||||||
|
data.width,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Initializes dimensions from localStorage if available
|
||||||
|
*/
|
||||||
|
function restorePersistedDimensions() {
|
||||||
|
const persistedHeight = parseInt(storage.height.value ?? '0', 10);
|
||||||
|
const persistedWidth = parseInt(storage.width.value ?? '0', 10);
|
||||||
|
|
||||||
|
if (persistedHeight) onResize(persistedHeight);
|
||||||
|
if (persistedWidth) onResizeChat(persistedWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates container width and height constraints on window resize
|
||||||
|
*/
|
||||||
|
function onWindowResize() {
|
||||||
|
if (!container.value) return;
|
||||||
|
|
||||||
|
// Update container width and adjust chat panel if needed
|
||||||
|
dimensions.container.value = container.value.getBoundingClientRect().width;
|
||||||
|
onResizeChat(dimensions.chat.value);
|
||||||
|
|
||||||
|
// Update height constraints and adjust panel height if needed
|
||||||
|
dimensions.minHeight.value = window.innerHeight * MIN_HEIGHT_PERCENTAGE;
|
||||||
|
dimensions.maxHeight.value = window.innerHeight * MAX_HEIGHT_PERCENTAGE;
|
||||||
|
onResize(dimensions.height.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist dimensions to localStorage when they change
|
||||||
|
watchEffect(() => {
|
||||||
|
const { chat, height } = dimensions;
|
||||||
|
if (chat.value > 0) storage.width.value = chat.value.toString();
|
||||||
|
if (height.value > 0) storage.height.value = height.value.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize dimensions when container is available
|
||||||
|
watchEffect(() => {
|
||||||
|
if (container.value) {
|
||||||
|
onWindowResize();
|
||||||
|
restorePersistedDimensions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Window resize handling
|
||||||
|
onMounted(() => window.addEventListener('resize', onWindowResize));
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('resize', onWindowResize));
|
||||||
|
|
||||||
|
return {
|
||||||
|
height: dimensions.height,
|
||||||
|
chatWidth: dimensions.chat,
|
||||||
|
logsWidth: dimensions.logs,
|
||||||
|
rootStyles,
|
||||||
|
onWindowResize,
|
||||||
|
onResizeDebounced,
|
||||||
|
onResizeChatDebounced,
|
||||||
|
panelToContainerRatio,
|
||||||
|
};
|
||||||
|
}
|
22
packages/editor-ui/src/components/CanvasChat/types/chat.ts
Normal file
22
packages/editor-ui/src/components/CanvasChat/types/chat.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
export interface LangChainMessage {
|
||||||
|
id: string[];
|
||||||
|
kwargs: {
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryOutput {
|
||||||
|
action: string;
|
||||||
|
chatHistory?: LangChainMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IChatMessageResponse {
|
||||||
|
executionId?: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IChatResizeStyles {
|
||||||
|
'--panel-height': string;
|
||||||
|
'--chat-width': string;
|
||||||
|
}
|
|
@ -18,7 +18,6 @@ import {
|
||||||
NEW_ASSISTANT_SESSION_MODAL,
|
NEW_ASSISTANT_SESSION_MODAL,
|
||||||
VERSIONS_MODAL_KEY,
|
VERSIONS_MODAL_KEY,
|
||||||
WORKFLOW_ACTIVE_MODAL_KEY,
|
WORKFLOW_ACTIVE_MODAL_KEY,
|
||||||
WORKFLOW_LM_CHAT_MODAL_KEY,
|
|
||||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||||
WORKFLOW_SHARE_MODAL_KEY,
|
WORKFLOW_SHARE_MODAL_KEY,
|
||||||
IMPORT_CURL_MODAL_KEY,
|
IMPORT_CURL_MODAL_KEY,
|
||||||
|
@ -51,7 +50,6 @@ import WorkflowTagsManager from '@/components/TagsManager/WorkflowTagsManager.vu
|
||||||
import AnnotationTagsManager from '@/components/TagsManager/AnnotationTagsManager.ee.vue';
|
import AnnotationTagsManager from '@/components/TagsManager/AnnotationTagsManager.ee.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/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';
|
||||||
|
@ -125,10 +123,6 @@ import type { EventBus } from 'n8n-design-system';
|
||||||
</template>
|
</template>
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
||||||
<ModalRoot :name="WORKFLOW_LM_CHAT_MODAL_KEY">
|
|
||||||
<WorkflowLMChat />
|
|
||||||
</ModalRoot>
|
|
||||||
|
|
||||||
<ModalRoot :name="WORKFLOW_SETTINGS_MODAL_KEY">
|
<ModalRoot :name="WORKFLOW_SETTINGS_MODAL_KEY">
|
||||||
<WorkflowSettings />
|
<WorkflowSettings />
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
|
@ -19,7 +19,6 @@ import {
|
||||||
AI_CATEGORY_LANGUAGE_MODELS,
|
AI_CATEGORY_LANGUAGE_MODELS,
|
||||||
BASIC_CHAIN_NODE_TYPE,
|
BASIC_CHAIN_NODE_TYPE,
|
||||||
CHAT_TRIGGER_NODE_TYPE,
|
CHAT_TRIGGER_NODE_TYPE,
|
||||||
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
|
||||||
MANUAL_TRIGGER_NODE_TYPE,
|
MANUAL_TRIGGER_NODE_TYPE,
|
||||||
NODE_CREATOR_OPEN_SOURCES,
|
NODE_CREATOR_OPEN_SOURCES,
|
||||||
NO_OP_NODE_TYPE,
|
NO_OP_NODE_TYPE,
|
||||||
|
@ -204,8 +203,6 @@ export const useActions = () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function shouldPrependChatTrigger(addedNodes: AddedNode[]): boolean {
|
function shouldPrependChatTrigger(addedNodes: AddedNode[]): boolean {
|
||||||
const { allNodes } = useWorkflowsStore();
|
|
||||||
|
|
||||||
const COMPATIBLE_CHAT_NODES = [
|
const COMPATIBLE_CHAT_NODES = [
|
||||||
QA_CHAIN_NODE_TYPE,
|
QA_CHAIN_NODE_TYPE,
|
||||||
AGENT_NODE_TYPE,
|
AGENT_NODE_TYPE,
|
||||||
|
@ -214,13 +211,25 @@ export const useActions = () => {
|
||||||
OPEN_AI_NODE_MESSAGE_ASSISTANT_TYPE,
|
OPEN_AI_NODE_MESSAGE_ASSISTANT_TYPE,
|
||||||
];
|
];
|
||||||
|
|
||||||
const isChatTriggerMissing =
|
|
||||||
allNodes.find((node) =>
|
|
||||||
[MANUAL_CHAT_TRIGGER_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE].includes(node.type),
|
|
||||||
) === undefined;
|
|
||||||
const isCompatibleNode = addedNodes.some((node) => COMPATIBLE_CHAT_NODES.includes(node.type));
|
const isCompatibleNode = addedNodes.some((node) => COMPATIBLE_CHAT_NODES.includes(node.type));
|
||||||
|
|
||||||
return isCompatibleNode && isChatTriggerMissing;
|
if (!isCompatibleNode) return false;
|
||||||
|
|
||||||
|
const { allNodes, getNodeTypes } = useWorkflowsStore();
|
||||||
|
const { getByNameAndVersion } = getNodeTypes();
|
||||||
|
|
||||||
|
// We want to add a trigger if there are no triggers other than Manual Triggers
|
||||||
|
// Performance here should be fine as `getByNameAndVersion` fetches nodeTypes once in bulk
|
||||||
|
// and `every` aborts on first `false`
|
||||||
|
const shouldAddChatTrigger = allNodes.every((node) => {
|
||||||
|
const nodeType = getByNameAndVersion(node.type, node.typeVersion);
|
||||||
|
|
||||||
|
return (
|
||||||
|
!nodeType.description.group.includes('trigger') || node.type === MANUAL_TRIGGER_NODE_TYPE
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return shouldAddChatTrigger;
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI-226: Prepend LLM Chain node when adding a language model
|
// AI-226: Prepend LLM Chain node when adding a language model
|
||||||
|
|
|
@ -5,6 +5,8 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useActions } from './composables/useActions';
|
import { useActions } from './composables/useActions';
|
||||||
import {
|
import {
|
||||||
|
AGENT_NODE_TYPE,
|
||||||
|
GITHUB_TRIGGER_NODE_TYPE,
|
||||||
HTTP_REQUEST_NODE_TYPE,
|
HTTP_REQUEST_NODE_TYPE,
|
||||||
MANUAL_TRIGGER_NODE_TYPE,
|
MANUAL_TRIGGER_NODE_TYPE,
|
||||||
NODE_CREATOR_OPEN_SOURCES,
|
NODE_CREATOR_OPEN_SOURCES,
|
||||||
|
@ -15,6 +17,7 @@ import {
|
||||||
TRIGGER_NODE_CREATOR_VIEW,
|
TRIGGER_NODE_CREATOR_VIEW,
|
||||||
WEBHOOK_NODE_TYPE,
|
WEBHOOK_NODE_TYPE,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
|
import { CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
||||||
|
|
||||||
describe('useActions', () => {
|
describe('useActions', () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
|
@ -54,6 +57,9 @@ describe('useActions', () => {
|
||||||
vi.spyOn(workflowsStore, 'workflowTriggerNodes', 'get').mockReturnValue([
|
vi.spyOn(workflowsStore, 'workflowTriggerNodes', 'get').mockReturnValue([
|
||||||
{ type: SCHEDULE_TRIGGER_NODE_TYPE } as never,
|
{ type: SCHEDULE_TRIGGER_NODE_TYPE } as never,
|
||||||
]);
|
]);
|
||||||
|
vi.spyOn(workflowsStore, 'getNodeTypes').mockReturnValue({
|
||||||
|
getByNameAndVersion: () => ({ description: { group: ['trigger'] } }),
|
||||||
|
} as never);
|
||||||
vi.spyOn(nodeCreatorStore, 'openSource', 'get').mockReturnValue(
|
vi.spyOn(nodeCreatorStore, 'openSource', 'get').mockReturnValue(
|
||||||
NODE_CREATOR_OPEN_SOURCES.ADD_NODE_BUTTON,
|
NODE_CREATOR_OPEN_SOURCES.ADD_NODE_BUTTON,
|
||||||
);
|
);
|
||||||
|
@ -67,6 +73,100 @@ describe('useActions', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should insert a ChatTrigger node when an AI Agent is added without trigger', () => {
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
|
||||||
|
vi.spyOn(workflowsStore, 'workflowTriggerNodes', 'get').mockReturnValue([]);
|
||||||
|
|
||||||
|
const { getAddedNodesAndConnections } = useActions();
|
||||||
|
|
||||||
|
expect(getAddedNodesAndConnections([{ type: AGENT_NODE_TYPE }])).toEqual({
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
from: {
|
||||||
|
nodeIndex: 0,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
nodeIndex: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nodes: [
|
||||||
|
{ type: CHAT_TRIGGER_NODE_TYPE, isAutoAdd: true },
|
||||||
|
{ type: AGENT_NODE_TYPE, openDetail: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should insert a ChatTrigger node when an AI Agent is added with only a Manual Trigger', () => {
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
|
||||||
|
vi.spyOn(workflowsStore, 'workflowTriggerNodes', 'get').mockReturnValue([
|
||||||
|
{ type: MANUAL_TRIGGER_NODE_TYPE } as never,
|
||||||
|
]);
|
||||||
|
vi.spyOn(workflowsStore, 'getNodeTypes').mockReturnValue({
|
||||||
|
getByNameAndVersion: () => ({ description: { group: ['trigger'] } }),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const { getAddedNodesAndConnections } = useActions();
|
||||||
|
|
||||||
|
expect(getAddedNodesAndConnections([{ type: AGENT_NODE_TYPE }])).toEqual({
|
||||||
|
connections: [
|
||||||
|
{
|
||||||
|
from: {
|
||||||
|
nodeIndex: 0,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
nodeIndex: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
nodes: [
|
||||||
|
{ type: CHAT_TRIGGER_NODE_TYPE, isAutoAdd: true },
|
||||||
|
{ type: AGENT_NODE_TYPE, openDetail: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not insert a ChatTrigger node when an AI Agent is added with a trigger already present', () => {
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
|
||||||
|
vi.spyOn(workflowsStore, 'allNodes', 'get').mockReturnValue([
|
||||||
|
{ type: GITHUB_TRIGGER_NODE_TYPE } as never,
|
||||||
|
]);
|
||||||
|
vi.spyOn(workflowsStore, 'getNodeTypes').mockReturnValue({
|
||||||
|
getByNameAndVersion: () => ({ description: { group: ['trigger'] } }),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const { getAddedNodesAndConnections } = useActions();
|
||||||
|
|
||||||
|
expect(getAddedNodesAndConnections([{ type: AGENT_NODE_TYPE }])).toEqual({
|
||||||
|
connections: [],
|
||||||
|
nodes: [{ type: AGENT_NODE_TYPE, openDetail: true }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not insert a ChatTrigger node when an AI Agent is added with a Chat Trigger already present', () => {
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
|
||||||
|
vi.spyOn(workflowsStore, 'workflowTriggerNodes', 'get').mockReturnValue([
|
||||||
|
{ type: CHAT_TRIGGER_NODE_TYPE } as never,
|
||||||
|
]);
|
||||||
|
vi.spyOn(workflowsStore, 'allNodes', 'get').mockReturnValue([
|
||||||
|
{ type: CHAT_TRIGGER_NODE_TYPE } as never,
|
||||||
|
]);
|
||||||
|
vi.spyOn(workflowsStore, 'getNodeTypes').mockReturnValue({
|
||||||
|
getByNameAndVersion: () => ({ description: { group: ['trigger'] } }),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const { getAddedNodesAndConnections } = useActions();
|
||||||
|
|
||||||
|
expect(getAddedNodesAndConnections([{ type: AGENT_NODE_TYPE }])).toEqual({
|
||||||
|
connections: [],
|
||||||
|
nodes: [{ type: AGENT_NODE_TYPE, openDetail: true }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('should insert a No Op node when a Loop Over Items Node is added', () => {
|
test('should insert a No Op node when a Loop Over Items Node is added', () => {
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const nodeCreatorStore = useNodeCreatorStore();
|
const nodeCreatorStore = useNodeCreatorStore();
|
||||||
|
|
|
@ -24,7 +24,7 @@ const contentParsers = useAiContentParsers();
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||||
const isExpanded = ref(getInitialExpandedState());
|
const isExpanded = ref(getInitialExpandedState());
|
||||||
const isShowRaw = ref(false);
|
const renderType = ref<'rendered' | 'json'>('rendered');
|
||||||
const contentParsed = ref(false);
|
const contentParsed = ref(false);
|
||||||
const parsedRun = ref(undefined as ParsedAiContent | undefined);
|
const parsedRun = ref(undefined as ParsedAiContent | undefined);
|
||||||
function getInitialExpandedState() {
|
function getInitialExpandedState() {
|
||||||
|
@ -134,6 +134,10 @@ function onCopyToClipboard(content: IDataObject | IDataObject[]) {
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onRenderTypeChange(value: 'rendered' | 'json') {
|
||||||
|
renderType.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
parsedRun.value = parseAiRunData(props.runData);
|
parsedRun.value = parseAiRunData(props.runData);
|
||||||
if (parsedRun.value) {
|
if (parsedRun.value) {
|
||||||
|
@ -146,16 +150,19 @@ onMounted(() => {
|
||||||
<div :class="$style.block">
|
<div :class="$style.block">
|
||||||
<header :class="$style.blockHeader" @click="onBlockHeaderClick">
|
<header :class="$style.blockHeader" @click="onBlockHeaderClick">
|
||||||
<button :class="$style.blockToggle">
|
<button :class="$style.blockToggle">
|
||||||
<font-awesome-icon :icon="isExpanded ? 'angle-down' : 'angle-up'" size="lg" />
|
<font-awesome-icon :icon="isExpanded ? 'angle-down' : 'angle-right'" size="lg" />
|
||||||
</button>
|
</button>
|
||||||
<p :class="$style.blockTitle">{{ capitalize(runData.inOut) }}</p>
|
<p :class="$style.blockTitle">{{ capitalize(runData.inOut) }}</p>
|
||||||
<!-- @click.stop to prevent event from bubbling to blockHeader and toggling expanded state when clicking on rawSwitch -->
|
<n8n-radio-buttons
|
||||||
<el-switch
|
v-if="contentParsed && !error && isExpanded"
|
||||||
v-if="contentParsed && !error"
|
size="small"
|
||||||
v-model="isShowRaw"
|
:model-value="renderType"
|
||||||
:class="$style.rawSwitch"
|
:class="$style.rawSwitch"
|
||||||
active-text="RAW JSON"
|
:options="[
|
||||||
@click.stop
|
{ label: 'Rendered', value: 'rendered' },
|
||||||
|
{ label: 'JSON', value: 'json' },
|
||||||
|
]"
|
||||||
|
@update:model-value="onRenderTypeChange"
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
<main
|
<main
|
||||||
|
@ -172,7 +179,7 @@ onMounted(() => {
|
||||||
:class="$style.contentText"
|
:class="$style.contentText"
|
||||||
:data-content-type="parsedContent?.type"
|
:data-content-type="parsedContent?.type"
|
||||||
>
|
>
|
||||||
<template v-if="parsedContent && !isShowRaw">
|
<template v-if="parsedContent && renderType === 'rendered'">
|
||||||
<template v-if="parsedContent.type === 'json'">
|
<template v-if="parsedContent.type === 'json'">
|
||||||
<VueMarkdown
|
<VueMarkdown
|
||||||
:source="jsonToMarkdown(parsedContent.data as JsonMarkdown)"
|
:source="jsonToMarkdown(parsedContent.data as JsonMarkdown)"
|
||||||
|
@ -226,17 +233,17 @@ onMounted(() => {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: var(--font-size-xl);
|
font-size: var(--font-size-l);
|
||||||
line-height: var(--font-line-height-xloose);
|
line-height: var(--font-line-height-xloose);
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: var(--font-size-l);
|
font-size: var(--font-size-m);
|
||||||
line-height: var(--font-line-height-loose);
|
line-height: var(--font-line-height-loose);
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: var(--font-size-m);
|
font-size: var(--font-size-s);
|
||||||
line-height: var(--font-line-height-regular);
|
line-height: var(--font-line-height-regular);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -252,17 +259,16 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
.contentText {
|
.contentText {
|
||||||
padding-top: var(--spacing-s);
|
padding-top: var(--spacing-s);
|
||||||
font-size: var(--font-size-xs);
|
padding-left: var(--spacing-m);
|
||||||
// max-height: 100%;
|
font-size: var(--font-size-s);
|
||||||
}
|
}
|
||||||
.block {
|
.block {
|
||||||
border: 1px solid var(--color-foreground-base);
|
padding: 0 0 var(--spacing-2xs) var(--spacing-2xs);
|
||||||
background: var(--color-background-xlight);
|
background: var(--color-foreground-light);
|
||||||
padding: var(--spacing-xs);
|
margin-top: var(--spacing-xl);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius-base);
|
||||||
margin-bottom: var(--spacing-2xs);
|
|
||||||
}
|
}
|
||||||
.blockContent {
|
:root .blockContent {
|
||||||
height: 0;
|
height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
@ -271,14 +277,17 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.runText {
|
.runText {
|
||||||
line-height: var(--font-line-height-regular);
|
line-height: var(--font-line-height-xloose);
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
.rawSwitch {
|
.rawSwitch {
|
||||||
|
opacity: 0;
|
||||||
|
height: fit-content;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
margin-right: var(--spacing-2xs);
|
||||||
|
|
||||||
& * {
|
.block:hover & {
|
||||||
font-size: var(--font-size-2xs);
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.blockHeader {
|
.blockHeader {
|
||||||
|
@ -287,21 +296,25 @@ onMounted(() => {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
/* This hack is needed to make the whole surface of header clickable */
|
/* This hack is needed to make the whole surface of header clickable */
|
||||||
margin: calc(-1 * var(--spacing-xs));
|
margin: calc(-1 * var(--spacing-xs));
|
||||||
padding: var(--spacing-xs);
|
padding: var(--spacing-2xs) var(--spacing-xs);
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
& * {
|
& * {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.blockTitle {
|
.blockTitle {
|
||||||
font-size: var(--font-size-2xs);
|
font-size: var(--font-size-s);
|
||||||
color: var(--color-text-dark);
|
color: var(--color-text-dark);
|
||||||
|
margin: 0;
|
||||||
|
padding-bottom: var(--spacing-4xs);
|
||||||
}
|
}
|
||||||
.blockToggle {
|
.blockToggle {
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
color: var(--color-text-base);
|
color: var(--color-text-base);
|
||||||
|
margin-top: calc(-1 * var(--spacing-3xs));
|
||||||
}
|
}
|
||||||
.error {
|
.error {
|
||||||
padding: var(--spacing-s) 0;
|
padding: var(--spacing-s) 0;
|
||||||
|
|
|
@ -25,7 +25,6 @@ interface TreeNode {
|
||||||
export interface Props {
|
export interface Props {
|
||||||
node: INodeUi;
|
node: INodeUi;
|
||||||
runIndex?: number;
|
runIndex?: number;
|
||||||
hideTitle?: boolean;
|
|
||||||
slim?: boolean;
|
slim?: boolean;
|
||||||
workflow: Workflow;
|
workflow: Workflow;
|
||||||
}
|
}
|
||||||
|
@ -203,7 +202,7 @@ const aiData = computed<AIResult[]>(() => {
|
||||||
const executionTree = computed<TreeNode[]>(() => {
|
const executionTree = computed<TreeNode[]>(() => {
|
||||||
const rootNode = props.node;
|
const rootNode = props.node;
|
||||||
|
|
||||||
const tree = getTreeNodeData(rootNode.name, 1);
|
const tree = getTreeNodeData(rootNode.name, 0);
|
||||||
return tree || [];
|
return tree || [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -211,7 +210,8 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="aiData.length > 0" :class="$style.container">
|
<div :class="$style.container">
|
||||||
|
<template v-if="aiData.length > 0">
|
||||||
<div :class="{ [$style.tree]: true, [$style.slim]: slim }">
|
<div :class="{ [$style.tree]: true, [$style.slim]: slim }">
|
||||||
<ElTree
|
<ElTree
|
||||||
:data="executionTree"
|
:data="executionTree"
|
||||||
|
@ -236,14 +236,18 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
|
||||||
:class="$style.treeToggle"
|
:class="$style.treeToggle"
|
||||||
@click="toggleTreeItem(node)"
|
@click="toggleTreeItem(node)"
|
||||||
>
|
>
|
||||||
<font-awesome-icon :icon="node.expanded ? 'angle-down' : 'angle-up'" />
|
<font-awesome-icon :icon="node.expanded ? 'angle-down' : 'angle-right'" />
|
||||||
</button>
|
</button>
|
||||||
<n8n-tooltip :disabled="!slim" placement="right">
|
<n8n-tooltip :disabled="!slim" placement="right">
|
||||||
<template #content>
|
<template #content>
|
||||||
{{ node.label }}
|
{{ node.label }}
|
||||||
</template>
|
</template>
|
||||||
<span :class="$style.leafLabel">
|
<span :class="$style.leafLabel">
|
||||||
<NodeIcon :node-type="getNodeType(data.node)!" :size="17" />
|
<NodeIcon
|
||||||
|
:node-type="getNodeType(data.node)!"
|
||||||
|
:size="17"
|
||||||
|
:class="$style.nodeIcon"
|
||||||
|
/>
|
||||||
<span v-if="!slim" v-text="node.label" />
|
<span v-if="!slim" v-text="node.label" />
|
||||||
</span>
|
</span>
|
||||||
</n8n-tooltip>
|
</n8n-tooltip>
|
||||||
|
@ -271,6 +275,8 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
|
||||||
<RunDataAiContent :input-data="data" :content-index="index" />
|
<RunDataAiContent :input-data="data" :content-index="index" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else :class="$style.noData">{{ $locale.baseText('ndv.output.ai.waiting') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -287,6 +293,13 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-3xs);
|
gap: var(--spacing-3xs);
|
||||||
}
|
}
|
||||||
|
.noData {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
.empty {
|
.empty {
|
||||||
padding: var(--spacing-l);
|
padding: var(--spacing-l);
|
||||||
}
|
}
|
||||||
|
@ -296,9 +309,9 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
|
||||||
}
|
}
|
||||||
.tree {
|
.tree {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
min-width: 12.8rem;
|
min-width: 8rem;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-right: 1px solid var(--color-foreground-base);
|
|
||||||
padding-right: var(--spacing-xs);
|
padding-right: var(--spacing-xs);
|
||||||
padding-left: var(--spacing-2xs);
|
padding-left: var(--spacing-2xs);
|
||||||
&.slim {
|
&.slim {
|
||||||
|
@ -337,20 +350,30 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
|
||||||
margin-left: var(--spacing-xs);
|
margin-left: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.nodeIcon {
|
||||||
|
padding: var(--spacing-3xs) var(--spacing-3xs);
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
margin-right: var(--spacing-4xs);
|
||||||
|
}
|
||||||
.isSelected {
|
.isSelected {
|
||||||
|
.nodeIcon {
|
||||||
background-color: var(--color-foreground-base);
|
background-color: var(--color-foreground-base);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.treeNode {
|
.treeNode {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-3xs);
|
padding-right: var(--spacing-3xs);
|
||||||
padding: var(--spacing-4xs) var(--spacing-3xs);
|
margin: var(--spacing-4xs) 0;
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-2xs);
|
||||||
color: var(--color-text-dark);
|
color: var(--color-text-dark);
|
||||||
margin-bottom: var(--spacing-3xs);
|
margin-bottom: var(--spacing-3xs);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.isSelected {
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--color-foreground-base);
|
background-color: var(--color-foreground-base);
|
||||||
}
|
}
|
||||||
|
@ -366,6 +389,7 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
|
||||||
height: 0.125rem;
|
height: 0.125rem;
|
||||||
left: 0.75rem;
|
left: 0.75rem;
|
||||||
width: calc(var(--item-depth) * 0.625rem);
|
width: calc(var(--item-depth) * 0.625rem);
|
||||||
|
margin-top: var(--spacing-3xs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -134,13 +134,6 @@ const outputTypeParsers: {
|
||||||
} else if (content.id.includes('SystemMessage')) {
|
} else if (content.id.includes('SystemMessage')) {
|
||||||
message = `**System Message:** ${message}`;
|
message = `**System Message:** ${message}`;
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
execData.action &&
|
|
||||||
typeof execData.action !== 'object' &&
|
|
||||||
execData.action !== 'getMessages'
|
|
||||||
) {
|
|
||||||
message = `## Action: ${execData.action}\n\n${message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
@ -148,6 +141,9 @@ const outputTypeParsers: {
|
||||||
})
|
})
|
||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
|
|
||||||
|
if (responseText.length === 0) {
|
||||||
|
return fallbackParser(execData);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: 'markdown',
|
type: 'markdown',
|
||||||
data: responseText,
|
data: responseText,
|
||||||
|
|
|
@ -1,31 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
const emit = defineEmits<{
|
|
||||||
click: [];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
label: string;
|
|
||||||
icon: string;
|
|
||||||
placement: 'left' | 'right' | 'top' | 'bottom';
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<style module>
|
|
||||||
.button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,15 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
defineProps<{
|
|
||||||
placement: 'left' | 'right';
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<style module lang="scss"></style>
|
|
|
@ -1,699 +0,0 @@
|
||||||
<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 { 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 LazyRunDataAi = 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 { 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)',
|
|
||||||
};
|
|
||||||
|
|
||||||
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
|
||||||
function getTriggerNode() {
|
|
||||||
const triggerNode = workflow.value.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 childNodes = workflow.value.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 connectedSubNodes = workflow.value.getParentNodes(childNode, 'ALL_NON_MAIN');
|
|
||||||
const resultData = connectedSubNodes.map(workflowsStore.getWorkflowResultDataByNodeName);
|
|
||||||
|
|
||||||
if (!resultData && !Array.isArray(resultData)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resultData.some((data) => data?.[0].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 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.value, storeNode, nodeType);
|
|
||||||
const inputTypes = NodeHelpers.getConnectionTypes(inputs);
|
|
||||||
|
|
||||||
const outputs = NodeHelpers.getNodeOutputs(workflow.value, 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.value.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);
|
|
||||||
|
|
||||||
const matchedOutput = get(responseData, matchedPath);
|
|
||||||
if (typeof matchedOutput === 'object') {
|
|
||||||
return '```json\n' + JSON.stringify(matchedOutput, null, 2) + '\n```';
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchedOutput?.toString() ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
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 route = router.resolve({
|
|
||||||
name: VIEWS.EXECUTION_PREVIEW,
|
|
||||||
params: { name: workflow.value.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 connectedMemoryInputs =
|
|
||||||
workflow.value.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>
|
|
||||||
|
|
||||||
<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-canvas']">
|
|
||||||
<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">
|
|
||||||
<LazyRunDataAi
|
|
||||||
:key="messages.length"
|
|
||||||
:node="node"
|
|
||||||
hide-title
|
|
||||||
slim
|
|
||||||
:workflow="workflow"
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<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));
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: 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>
|
|
|
@ -1,127 +0,0 @@
|
||||||
import { createPinia, setActivePinia } from 'pinia';
|
|
||||||
import { fireEvent, waitFor } from '@testing-library/vue';
|
|
||||||
import { mock } from 'vitest-mock-extended';
|
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
|
||||||
import type { IConnections, INode } from 'n8n-workflow';
|
|
||||||
|
|
||||||
import WorkflowLMChatModal from '@/components/WorkflowLMChat/WorkflowLMChat.vue';
|
|
||||||
import { WORKFLOW_LM_CHAT_MODAL_KEY } from '@/constants';
|
|
||||||
import type { IWorkflowDb } from '@/Interface';
|
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
||||||
|
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
|
||||||
import { setupServer } from '@/__tests__/server';
|
|
||||||
import { defaultNodeDescriptions, mockNodes } from '@/__tests__/mocks';
|
|
||||||
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
|
|
||||||
|
|
||||||
const connections: IConnections = {
|
|
||||||
'Chat Trigger': {
|
|
||||||
main: [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
node: 'Agent',
|
|
||||||
type: NodeConnectionType.Main,
|
|
||||||
index: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(WorkflowLMChatModal, {
|
|
||||||
props: {
|
|
||||||
teleported: false,
|
|
||||||
appendToBody: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function createPiniaWithAINodes(options = { withConnections: true, withAgentNode: true }) {
|
|
||||||
const { withConnections, withAgentNode } = options;
|
|
||||||
|
|
||||||
const chatTriggerNode = mockNodes[4];
|
|
||||||
const agentNode = mockNodes[5];
|
|
||||||
const nodes: INode[] = [chatTriggerNode];
|
|
||||||
if (withAgentNode) nodes.push(agentNode);
|
|
||||||
const workflow = mock<IWorkflowDb>({
|
|
||||||
nodes,
|
|
||||||
...(withConnections ? { connections } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const pinia = createPinia();
|
|
||||||
setActivePinia(pinia);
|
|
||||||
|
|
||||||
const workflowsStore = useWorkflowsStore();
|
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
|
||||||
const uiStore = useUIStore();
|
|
||||||
|
|
||||||
nodeTypesStore.setNodeTypes(defaultNodeDescriptions);
|
|
||||||
workflowsStore.workflow = workflow;
|
|
||||||
|
|
||||||
await useSettingsStore().getSettings();
|
|
||||||
await useUsersStore().loginWithCookie();
|
|
||||||
uiStore.openModal(WORKFLOW_LM_CHAT_MODAL_KEY);
|
|
||||||
|
|
||||||
return pinia;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('WorkflowLMChatModal', () => {
|
|
||||||
let server: ReturnType<typeof setupServer>;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
server = setupServer();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
createAppModals();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanupAppModals();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
server.shutdown();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render correctly', async () => {
|
|
||||||
const { getByTestId } = renderComponent({
|
|
||||||
pinia: await createPiniaWithAINodes(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(getByTestId('lmChat-modal')).toBeInTheDocument());
|
|
||||||
|
|
||||||
expect(getByTestId('workflow-lm-chat-dialog')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should send and display chat message', async () => {
|
|
||||||
const { getByTestId } = renderComponent({
|
|
||||||
pinia: await createPiniaWithAINodes({
|
|
||||||
withConnections: true,
|
|
||||||
withAgentNode: true,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(getByTestId('lmChat-modal')).toBeInTheDocument());
|
|
||||||
|
|
||||||
const chatDialog = getByTestId('workflow-lm-chat-dialog');
|
|
||||||
const chatInputsContainer = getByTestId('lm-chat-inputs');
|
|
||||||
const chatSendButton = chatInputsContainer.querySelector('.chat-input-send-button');
|
|
||||||
const chatInput = chatInputsContainer.querySelector('textarea');
|
|
||||||
|
|
||||||
if (chatInput && chatSendButton) {
|
|
||||||
await fireEvent.update(chatInput, 'Hello!');
|
|
||||||
await fireEvent.click(chatSendButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(chatDialog.querySelectorAll('.chat-message-from-user')).toHaveLength(1),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(chatDialog.querySelector('.chat-message-from-user')).toHaveTextContent('Hello!');
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,9 +1,17 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
export interface Props {
|
||||||
|
outline?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<N8nButton
|
<N8nButton
|
||||||
label="Chat"
|
label="Chat"
|
||||||
size="large"
|
size="large"
|
||||||
icon="comment"
|
icon="comment"
|
||||||
type="primary"
|
type="primary"
|
||||||
|
:outline="outline"
|
||||||
data-test-id="workflow-chat-button"
|
data-test-id="workflow-chat-button"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -22,12 +22,7 @@ import { FORM_NODE_TYPE, NodeConnectionType } from 'n8n-workflow';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
|
|
||||||
import {
|
import { CHAT_TRIGGER_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, WAIT_NODE_TYPE } from '@/constants';
|
||||||
CHAT_TRIGGER_NODE_TYPE,
|
|
||||||
FORM_TRIGGER_NODE_TYPE,
|
|
||||||
WAIT_NODE_TYPE,
|
|
||||||
WORKFLOW_LM_CHAT_MODAL_KEY,
|
|
||||||
} from '@/constants';
|
|
||||||
|
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
@ -55,7 +50,6 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const executionsStore = useExecutionsStore();
|
const executionsStore = useExecutionsStore();
|
||||||
|
|
||||||
// Starts to execute a workflow on server
|
// Starts to execute a workflow on server
|
||||||
async function runWorkflowApi(runData: IStartRunData): Promise<IExecutionPushResponse> {
|
async function runWorkflowApi(runData: IStartRunData): Promise<IExecutionPushResponse> {
|
||||||
if (!rootStore.pushConnectionActive) {
|
if (!rootStore.pushConnectionActive) {
|
||||||
|
@ -175,7 +169,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
||||||
// If the chat node has no input data or pin data, open the chat modal
|
// If the chat node has no input data or pin data, open the chat modal
|
||||||
// and halt the execution
|
// and halt the execution
|
||||||
if (!chatHasInputData && !chatHasPinData) {
|
if (!chatHasInputData && !chatHasPinData) {
|
||||||
uiStore.openModal(WORKFLOW_LM_CHAT_MODAL_KEY);
|
workflowsStore.setPanelOpen('chat', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { useExternalHooks } from './useExternalHooks';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import type { ApplicationError } from 'n8n-workflow';
|
import type { ApplicationError } from 'n8n-workflow';
|
||||||
import { useStyles } from './useStyles';
|
import { useStyles } from './useStyles';
|
||||||
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
|
||||||
export interface NotificationErrorWithNodeAndDescription extends ApplicationError {
|
export interface NotificationErrorWithNodeAndDescription extends ApplicationError {
|
||||||
|
@ -29,12 +30,13 @@ export function useToast() {
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const { APP_Z_INDEXES } = useStyles();
|
const { APP_Z_INDEXES } = useStyles();
|
||||||
|
const canvasStore = useCanvasStore();
|
||||||
|
|
||||||
const messageDefaults: Partial<Omit<NotificationOptions, 'message'>> = {
|
const messageDefaults: Partial<Omit<NotificationOptions, 'message'>> = {
|
||||||
dangerouslyUseHTMLString: false,
|
dangerouslyUseHTMLString: false,
|
||||||
position: 'bottom-right',
|
position: 'bottom-right',
|
||||||
zIndex: APP_Z_INDEXES.TOASTS, // above NDV and modal overlays
|
zIndex: APP_Z_INDEXES.TOASTS, // above NDV and modal overlays
|
||||||
offset: settingsStore.isAiAssistantEnabled ? 64 : 0,
|
offset: settingsStore.isAiAssistantEnabled || workflowsStore.isChatPanelOpen ? 64 : 0,
|
||||||
appendTo: '#app-grid',
|
appendTo: '#app-grid',
|
||||||
customClass: 'content-toast',
|
customClass: 'content-toast',
|
||||||
};
|
};
|
||||||
|
@ -43,6 +45,8 @@ export function useToast() {
|
||||||
const { message, title } = messageData;
|
const { message, title } = messageData;
|
||||||
const params = { ...messageDefaults, ...messageData };
|
const params = { ...messageDefaults, ...messageData };
|
||||||
|
|
||||||
|
params.offset = +canvasStore.panelHeight;
|
||||||
|
|
||||||
if (typeof message === 'string') {
|
if (typeof message === 'string') {
|
||||||
params.message = sanitizeHtml(message);
|
params.message = sanitizeHtml(message);
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,7 +51,6 @@ export const TAGS_MANAGER_MODAL_KEY = 'tagsManager';
|
||||||
export const ANNOTATION_TAGS_MANAGER_MODAL_KEY = 'annotationTagsManager';
|
export const ANNOTATION_TAGS_MANAGER_MODAL_KEY = 'annotationTagsManager';
|
||||||
export const VERSIONS_MODAL_KEY = 'versions';
|
export const VERSIONS_MODAL_KEY = 'versions';
|
||||||
export const WORKFLOW_SETTINGS_MODAL_KEY = 'settings';
|
export const WORKFLOW_SETTINGS_MODAL_KEY = 'settings';
|
||||||
export const WORKFLOW_LM_CHAT_MODAL_KEY = 'lmChat';
|
|
||||||
export const WORKFLOW_SHARE_MODAL_KEY = 'workflowShare';
|
export const WORKFLOW_SHARE_MODAL_KEY = 'workflowShare';
|
||||||
export const PERSONALIZATION_MODAL_KEY = 'personalization';
|
export const PERSONALIZATION_MODAL_KEY = 'personalization';
|
||||||
export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt';
|
export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt';
|
||||||
|
|
|
@ -169,11 +169,13 @@
|
||||||
"binaryDataDisplay.backToOverviewPage": "Back to overview page",
|
"binaryDataDisplay.backToOverviewPage": "Back to overview page",
|
||||||
"binaryDataDisplay.noDataFoundToDisplay": "No data found to display",
|
"binaryDataDisplay.noDataFoundToDisplay": "No data found to display",
|
||||||
"binaryDataDisplay.yourBrowserDoesNotSupport": "Your browser does not support the video element. Kindly update it to latest version.",
|
"binaryDataDisplay.yourBrowserDoesNotSupport": "Your browser does not support the video element. Kindly update it to latest version.",
|
||||||
"chat.window.title": "Chat Window ({nodeName})",
|
"chat.window.title": "Chat",
|
||||||
"chat.window.logs": "Log (for last message)",
|
"chat.window.logs": "Latest Logs",
|
||||||
|
"chat.window.logsFromNode": "from {nodeName} node",
|
||||||
"chat.window.noChatNode": "No Chat Node",
|
"chat.window.noChatNode": "No Chat Node",
|
||||||
"chat.window.noExecution": "Nothing got executed yet",
|
"chat.window.noExecution": "Nothing got executed yet",
|
||||||
"chat.window.chat.placeholder": "Type a message, or press ‘up’ arrow for previous one",
|
"chat.window.chat.placeholder": "Type a message, or press ‘up’ arrow for previous one",
|
||||||
|
"chat.window.chat.placeholderPristine": "Type a message",
|
||||||
"chat.window.chat.sendButtonText": "Send",
|
"chat.window.chat.sendButtonText": "Send",
|
||||||
"chat.window.chat.provideMessage": "Please provide a message",
|
"chat.window.chat.provideMessage": "Please provide a message",
|
||||||
"chat.window.chat.emptyChatMessage": "Empty chat message",
|
"chat.window.chat.emptyChatMessage": "Empty chat message",
|
||||||
|
@ -185,6 +187,10 @@
|
||||||
"chat.window.chat.unpinAndExecute.confirm": "Unpin and send",
|
"chat.window.chat.unpinAndExecute.confirm": "Unpin and send",
|
||||||
"chat.window.chat.unpinAndExecute.cancel": "Cancel",
|
"chat.window.chat.unpinAndExecute.cancel": "Cancel",
|
||||||
"chat.window.chat.response.empty": "[No response. Make sure the last executed node outputs the content to display here]",
|
"chat.window.chat.response.empty": "[No response. Make sure the last executed node outputs the content to display here]",
|
||||||
|
"chat.window.session.title": "Session",
|
||||||
|
"chat.window.session.reset.title": "Reset session?",
|
||||||
|
"chat.window.session.reset.warning": "This will clear all chat messages and the current execution data",
|
||||||
|
"chat.window.session.reset.confirm": "Reset",
|
||||||
"chatEmbed.infoTip.description": "Add chat to external applications using the n8n chat package.",
|
"chatEmbed.infoTip.description": "Add chat to external applications using the n8n chat package.",
|
||||||
"chatEmbed.infoTip.link": "More info",
|
"chatEmbed.infoTip.link": "More info",
|
||||||
"chatEmbed.title": "Embed Chat in your website",
|
"chatEmbed.title": "Embed Chat in your website",
|
||||||
|
@ -951,7 +957,8 @@
|
||||||
"ndv.input.disabled": "The '{nodeName}' node is disabled and won’t execute.",
|
"ndv.input.disabled": "The '{nodeName}' node is disabled and won’t execute.",
|
||||||
"ndv.input.disabled.cta": "Enable it",
|
"ndv.input.disabled.cta": "Enable it",
|
||||||
"ndv.output": "Output",
|
"ndv.output": "Output",
|
||||||
"ndv.output.ai.empty": "👈 This is {node}’s AI Logs. Click on a node to see the input it received and data it outputted.",
|
"ndv.output.ai.empty": "👈 Use these logs to see information on how the {node} node completed processing. You can click on a node to see the input it received and data it output.",
|
||||||
|
"ndv.output.ai.waiting": "Waiting for message",
|
||||||
"ndv.output.outType.logs": "Logs",
|
"ndv.output.outType.logs": "Logs",
|
||||||
"ndv.output.outType.regular": "Output",
|
"ndv.output.outType.regular": "Output",
|
||||||
"ndv.output.edit": "Edit Output",
|
"ndv.output.edit": "Edit Output",
|
||||||
|
|
|
@ -24,6 +24,7 @@ const ErrorView = async () => await import('./views/ErrorView.vue');
|
||||||
const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordView.vue');
|
const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordView.vue');
|
||||||
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
|
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
|
||||||
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
||||||
|
const CanvasChat = async () => await import('@/components/CanvasChat/CanvasChat.vue');
|
||||||
const NodeView = async () => await import('@/views/NodeViewSwitcher.vue');
|
const NodeView = async () => await import('@/views/NodeViewSwitcher.vue');
|
||||||
const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
|
const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
|
||||||
const WorkflowExecutionsLandingPage = async () =>
|
const WorkflowExecutionsLandingPage = async () =>
|
||||||
|
@ -301,6 +302,7 @@ export const routes: RouteRecordRaw[] = [
|
||||||
default: NodeView,
|
default: NodeView,
|
||||||
header: MainHeader,
|
header: MainHeader,
|
||||||
sidebar: MainSidebar,
|
sidebar: MainSidebar,
|
||||||
|
footer: CanvasChat,
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
nodeView: true,
|
nodeView: true,
|
||||||
|
@ -333,6 +335,7 @@ export const routes: RouteRecordRaw[] = [
|
||||||
default: NodeView,
|
default: NodeView,
|
||||||
header: MainHeader,
|
header: MainHeader,
|
||||||
sidebar: MainSidebar,
|
sidebar: MainSidebar,
|
||||||
|
footer: CanvasChat,
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
nodeView: true,
|
nodeView: true,
|
||||||
|
|
|
@ -54,8 +54,8 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||||
const jsPlumbInstanceRef = ref<BrowserJsPlumbInstance>();
|
const jsPlumbInstanceRef = ref<BrowserJsPlumbInstance>();
|
||||||
const isDragging = ref<boolean>(false);
|
const isDragging = ref<boolean>(false);
|
||||||
const lastSelectedConnection = ref<Connection>();
|
const lastSelectedConnection = ref<Connection>();
|
||||||
|
|
||||||
const newNodeInsertPosition = ref<XYPosition | null>(null);
|
const newNodeInsertPosition = ref<XYPosition | null>(null);
|
||||||
|
const panelHeight = ref(0);
|
||||||
|
|
||||||
const nodes = computed<INodeUi[]>(() => workflowStore.allNodes);
|
const nodes = computed<INodeUi[]>(() => workflowStore.allNodes);
|
||||||
const triggerNodes = computed<INodeUi[]>(() =>
|
const triggerNodes = computed<INodeUi[]>(() =>
|
||||||
|
@ -109,9 +109,9 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||||
const manualTriggerNode = nodeTypesStore.getNodeType(MANUAL_TRIGGER_NODE_TYPE);
|
const manualTriggerNode = nodeTypesStore.getNodeType(MANUAL_TRIGGER_NODE_TYPE);
|
||||||
|
|
||||||
if (!manualTriggerNode) {
|
if (!manualTriggerNode) {
|
||||||
console.error('Could not find the manual trigger node');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
name: manualTriggerNode.defaults.name?.toString() ?? manualTriggerNode.displayName,
|
name: manualTriggerNode.defaults.name?.toString() ?? manualTriggerNode.displayName,
|
||||||
|
@ -324,6 +324,10 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||||
|
|
||||||
watch(readOnlyEnv, setReadOnly);
|
watch(readOnlyEnv, setReadOnly);
|
||||||
|
|
||||||
|
function setPanelHeight(height: number) {
|
||||||
|
panelHeight.value = height;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDemo,
|
isDemo,
|
||||||
nodeViewScale,
|
nodeViewScale,
|
||||||
|
@ -333,6 +337,8 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||||
isLoading: loadingService.isLoading,
|
isLoading: loadingService.isLoading,
|
||||||
aiNodes,
|
aiNodes,
|
||||||
lastSelectedConnection: lastSelectedConnectionComputed,
|
lastSelectedConnection: lastSelectedConnectionComputed,
|
||||||
|
panelHeight: computed(() => panelHeight.value),
|
||||||
|
setPanelHeight,
|
||||||
setReadOnly,
|
setReadOnly,
|
||||||
setLastSelectedConnection,
|
setLastSelectedConnection,
|
||||||
startLoading: loadingService.startLoading,
|
startLoading: loadingService.startLoading,
|
||||||
|
|
|
@ -23,7 +23,6 @@ import {
|
||||||
VERSIONS_MODAL_KEY,
|
VERSIONS_MODAL_KEY,
|
||||||
VIEWS,
|
VIEWS,
|
||||||
WORKFLOW_ACTIVE_MODAL_KEY,
|
WORKFLOW_ACTIVE_MODAL_KEY,
|
||||||
WORKFLOW_LM_CHAT_MODAL_KEY,
|
|
||||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||||
WORKFLOW_SHARE_MODAL_KEY,
|
WORKFLOW_SHARE_MODAL_KEY,
|
||||||
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
|
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
|
||||||
|
@ -104,7 +103,6 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
||||||
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
|
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
|
||||||
NPS_SURVEY_MODAL_KEY,
|
NPS_SURVEY_MODAL_KEY,
|
||||||
VERSIONS_MODAL_KEY,
|
VERSIONS_MODAL_KEY,
|
||||||
WORKFLOW_LM_CHAT_MODAL_KEY,
|
|
||||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||||
WORKFLOW_SHARE_MODAL_KEY,
|
WORKFLOW_SHARE_MODAL_KEY,
|
||||||
WORKFLOW_ACTIVE_MODAL_KEY,
|
WORKFLOW_ACTIVE_MODAL_KEY,
|
||||||
|
|
|
@ -139,6 +139,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
const nodeMetadata = ref<NodeMetadataMap>({});
|
const nodeMetadata = ref<NodeMetadataMap>({});
|
||||||
const isInDebugMode = ref(false);
|
const isInDebugMode = ref(false);
|
||||||
const chatMessages = ref<string[]>([]);
|
const chatMessages = ref<string[]>([]);
|
||||||
|
const isChatPanelOpen = ref(false);
|
||||||
|
const isLogsPanelOpen = ref(false);
|
||||||
|
|
||||||
const workflowName = computed(() => workflow.value.name);
|
const workflowName = computed(() => workflow.value.name);
|
||||||
|
|
||||||
|
@ -1123,6 +1125,11 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
const { [node.name]: removedNodeMetadata, ...remainingNodeMetadata } = nodeMetadata.value;
|
const { [node.name]: removedNodeMetadata, ...remainingNodeMetadata } = nodeMetadata.value;
|
||||||
nodeMetadata.value = remainingNodeMetadata;
|
nodeMetadata.value = remainingNodeMetadata;
|
||||||
|
|
||||||
|
// If chat trigger node is removed, close chat
|
||||||
|
if (node.type === CHAT_TRIGGER_NODE_TYPE) {
|
||||||
|
setPanelOpen('chat', false);
|
||||||
|
}
|
||||||
|
|
||||||
if (workflow.value.pinData && workflow.value.pinData.hasOwnProperty(node.name)) {
|
if (workflow.value.pinData && workflow.value.pinData.hasOwnProperty(node.name)) {
|
||||||
const { [node.name]: removedPinData, ...remainingPinData } = workflow.value.pinData;
|
const { [node.name]: removedPinData, ...remainingPinData } = workflow.value.pinData;
|
||||||
workflow.value = {
|
workflow.value = {
|
||||||
|
@ -1621,6 +1628,14 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
// End Canvas V2 Functions
|
// End Canvas V2 Functions
|
||||||
//
|
//
|
||||||
|
|
||||||
|
function setPanelOpen(panel: 'chat' | 'logs', isOpen: boolean) {
|
||||||
|
if (panel === 'chat') {
|
||||||
|
isChatPanelOpen.value = isOpen;
|
||||||
|
}
|
||||||
|
// Logs panel open/close is tied to the chat panel open/close
|
||||||
|
isLogsPanelOpen.value = isOpen;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workflow,
|
workflow,
|
||||||
usedCredentials,
|
usedCredentials,
|
||||||
|
@ -1665,6 +1680,9 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
getWorkflowExecution,
|
getWorkflowExecution,
|
||||||
getTotalFinishedExecutionsCount,
|
getTotalFinishedExecutionsCount,
|
||||||
getPastChatMessages,
|
getPastChatMessages,
|
||||||
|
isChatPanelOpen: computed(() => isChatPanelOpen.value),
|
||||||
|
isLogsPanelOpen: computed(() => isLogsPanelOpen.value),
|
||||||
|
setPanelOpen,
|
||||||
outgoingConnectionsByNodeName,
|
outgoingConnectionsByNodeName,
|
||||||
incomingConnectionsByNodeName,
|
incomingConnectionsByNodeName,
|
||||||
nodeHasOutputConnection,
|
nodeHasOutputConnection,
|
||||||
|
|
|
@ -60,7 +60,6 @@ import {
|
||||||
STICKY_NODE_TYPE,
|
STICKY_NODE_TYPE,
|
||||||
VALID_WORKFLOW_IMPORT_URL_REGEX,
|
VALID_WORKFLOW_IMPORT_URL_REGEX,
|
||||||
VIEWS,
|
VIEWS,
|
||||||
WORKFLOW_LM_CHAT_MODAL_KEY,
|
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
|
||||||
|
@ -249,6 +248,8 @@ const keyBindingsEnabled = computed(() => {
|
||||||
return !ndvStore.activeNode && uiStore.activeModals.length === 0;
|
return !ndvStore.activeNode && uiStore.activeModals.length === 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isChatOpen = computed(() => workflowsStore.isChatPanelOpen);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialization
|
* Initialization
|
||||||
*/
|
*/
|
||||||
|
@ -1207,7 +1208,7 @@ const chatTriggerNodePinnedData = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onOpenChat() {
|
async function onOpenChat() {
|
||||||
uiStore.openModal(WORKFLOW_LM_CHAT_MODAL_KEY);
|
workflowsStore.setPanelOpen('chat', !workflowsStore.isChatPanelOpen);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
workflow_id: workflowId.value,
|
workflow_id: workflowId.value,
|
||||||
|
@ -1633,7 +1634,11 @@ onBeforeUnmount(() => {
|
||||||
@mouseleave="onRunWorkflowButtonMouseLeave"
|
@mouseleave="onRunWorkflowButtonMouseLeave"
|
||||||
@click="onRunWorkflow"
|
@click="onRunWorkflow"
|
||||||
/>
|
/>
|
||||||
<CanvasChatButton v-if="containsChatTriggerNodes" @click="onOpenChat" />
|
<CanvasChatButton
|
||||||
|
v-if="containsChatTriggerNodes"
|
||||||
|
:outline="isChatOpen === false"
|
||||||
|
@click="onOpenChat"
|
||||||
|
/>
|
||||||
<CanvasStopCurrentExecutionButton
|
<CanvasStopCurrentExecutionButton
|
||||||
v-if="isStopExecutionButtonVisible"
|
v-if="isStopExecutionButtonVisible"
|
||||||
:stopping="isStoppingExecution"
|
:stopping="isStoppingExecution"
|
||||||
|
|
|
@ -34,7 +34,6 @@ import {
|
||||||
NODE_CREATOR_OPEN_SOURCES,
|
NODE_CREATOR_OPEN_SOURCES,
|
||||||
CHAT_TRIGGER_NODE_TYPE,
|
CHAT_TRIGGER_NODE_TYPE,
|
||||||
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
||||||
WORKFLOW_LM_CHAT_MODAL_KEY,
|
|
||||||
AI_NODE_CREATOR_VIEW,
|
AI_NODE_CREATOR_VIEW,
|
||||||
DRAG_EVENT_DATA_KEY,
|
DRAG_EVENT_DATA_KEY,
|
||||||
UPDATE_WEBHOOK_ID_NODE_TYPES,
|
UPDATE_WEBHOOK_ID_NODE_TYPES,
|
||||||
|
@ -455,14 +454,14 @@ export default defineComponent({
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
canvasChatNode() {
|
||||||
|
return this.nodes.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE);
|
||||||
|
},
|
||||||
isManualChatOnly(): boolean {
|
isManualChatOnly(): boolean {
|
||||||
if (!this.canvasChatNode) return false;
|
if (!this.canvasChatNode) return false;
|
||||||
|
|
||||||
return this.containsChatNodes && this.triggerNodes.length === 1 && !this.pinnedChatNodeData;
|
return this.containsChatNodes && this.triggerNodes.length === 1 && !this.pinnedChatNodeData;
|
||||||
},
|
},
|
||||||
canvasChatNode() {
|
|
||||||
return this.nodes.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE);
|
|
||||||
},
|
|
||||||
pinnedChatNodeData() {
|
pinnedChatNodeData() {
|
||||||
if (!this.canvasChatNode) return null;
|
if (!this.canvasChatNode) return null;
|
||||||
|
|
||||||
|
@ -513,6 +512,9 @@ export default defineComponent({
|
||||||
: (this.projectsStore.currentProject ?? this.projectsStore.personalProject);
|
: (this.projectsStore.currentProject ?? this.projectsStore.personalProject);
|
||||||
return getResourcePermissions(project?.scopes);
|
return getResourcePermissions(project?.scopes);
|
||||||
},
|
},
|
||||||
|
isChatOpen() {
|
||||||
|
return this.workflowsStore.isChatPanelOpen;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
// Listen to route changes and load the workflow accordingly
|
// Listen to route changes and load the workflow accordingly
|
||||||
|
@ -863,7 +865,7 @@ export default defineComponent({
|
||||||
};
|
};
|
||||||
this.$telemetry.track('User clicked chat open button', telemetryPayload);
|
this.$telemetry.track('User clicked chat open button', telemetryPayload);
|
||||||
void this.externalHooks.run('nodeView.onOpenChat', telemetryPayload);
|
void this.externalHooks.run('nodeView.onOpenChat', telemetryPayload);
|
||||||
this.uiStore.openModal(WORKFLOW_LM_CHAT_MODAL_KEY);
|
this.workflowsStore.setPanelOpen('chat', !this.workflowsStore.isChatPanelOpen);
|
||||||
},
|
},
|
||||||
|
|
||||||
async onRunWorkflow() {
|
async onRunWorkflow() {
|
||||||
|
@ -4651,6 +4653,7 @@ export default defineComponent({
|
||||||
size="large"
|
size="large"
|
||||||
icon="comment"
|
icon="comment"
|
||||||
type="primary"
|
type="primary"
|
||||||
|
:outline="isChatOpen === false"
|
||||||
data-test-id="workflow-chat-button"
|
data-test-id="workflow-chat-button"
|
||||||
@click.stop="onOpenChat"
|
@click.stop="onOpenChat"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -6,7 +6,7 @@ import * as upload from '../../../../v2/actions/file/upload.operation';
|
||||||
import * as transport from '../../../../v2/transport';
|
import * as transport from '../../../../v2/transport';
|
||||||
import * as utils from '../../../../v2/helpers/utils';
|
import * as utils from '../../../../v2/helpers/utils';
|
||||||
|
|
||||||
import { createMockExecuteFunction, driveNode } from '../helpers';
|
import { createMockExecuteFunction, createTestStream, driveNode } from '../helpers';
|
||||||
|
|
||||||
jest.mock('../../../../v2/transport', () => {
|
jest.mock('../../../../v2/transport', () => {
|
||||||
const originalModule = jest.requireActual('../../../../v2/transport');
|
const originalModule = jest.requireActual('../../../../v2/transport');
|
||||||
|
@ -30,7 +30,7 @@ jest.mock('../../../../v2/helpers/utils', () => {
|
||||||
getItemBinaryData: jest.fn(async function () {
|
getItemBinaryData: jest.fn(async function () {
|
||||||
return {
|
return {
|
||||||
contentLength: '123',
|
contentLength: '123',
|
||||||
fileContent: 'Hello Drive!',
|
fileContent: Buffer.from('Hello Drive!'),
|
||||||
originalFilename: 'original.txt',
|
originalFilename: 'original.txt',
|
||||||
mimeType: 'text/plain',
|
mimeType: 'text/plain',
|
||||||
};
|
};
|
||||||
|
@ -43,13 +43,17 @@ describe('test GoogleDriveV2: file upload', () => {
|
||||||
nock.disableNetConnect();
|
nock.disableNetConnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
nock.restore();
|
nock.restore();
|
||||||
jest.unmock('../../../../v2/transport');
|
jest.unmock('../../../../v2/transport');
|
||||||
jest.unmock('../../../../v2/helpers/utils');
|
jest.unmock('../../../../v2/helpers/utils');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be called with', async () => {
|
it('should upload buffers', async () => {
|
||||||
const nodeParameters = {
|
const nodeParameters = {
|
||||||
name: 'newFile.txt',
|
name: 'newFile.txt',
|
||||||
folderId: {
|
folderId: {
|
||||||
|
@ -73,10 +77,10 @@ describe('test GoogleDriveV2: file upload', () => {
|
||||||
expect(transport.googleApiRequest).toHaveBeenCalledWith(
|
expect(transport.googleApiRequest).toHaveBeenCalledWith(
|
||||||
'POST',
|
'POST',
|
||||||
'/upload/drive/v3/files',
|
'/upload/drive/v3/files',
|
||||||
|
expect.any(Buffer),
|
||||||
|
{ uploadType: 'media' },
|
||||||
undefined,
|
undefined,
|
||||||
{ uploadType: 'resumable' },
|
{ headers: { 'Content-Length': '123', 'Content-Type': 'text/plain' } },
|
||||||
undefined,
|
|
||||||
{ returnFullResponse: true },
|
|
||||||
);
|
);
|
||||||
expect(transport.googleApiRequest).toHaveBeenCalledWith(
|
expect(transport.googleApiRequest).toHaveBeenCalledWith(
|
||||||
'PATCH',
|
'PATCH',
|
||||||
|
@ -94,4 +98,60 @@ describe('test GoogleDriveV2: file upload', () => {
|
||||||
expect(utils.getItemBinaryData).toBeCalledTimes(1);
|
expect(utils.getItemBinaryData).toBeCalledTimes(1);
|
||||||
expect(utils.getItemBinaryData).toHaveBeenCalled();
|
expect(utils.getItemBinaryData).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should stream large files in 2MB chunks', async () => {
|
||||||
|
const nodeParameters = {
|
||||||
|
name: 'newFile.jpg',
|
||||||
|
folderId: {
|
||||||
|
__rl: true,
|
||||||
|
value: 'folderIDxxxxxx',
|
||||||
|
mode: 'list',
|
||||||
|
cachedResultName: 'testFolder 3',
|
||||||
|
cachedResultUrl: 'https://drive.google.com/drive/folders/folderIDxxxxxx',
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
simplifyOutput: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const fakeExecuteFunction = createMockExecuteFunction(nodeParameters, driveNode);
|
||||||
|
const httpRequestSpy = jest.spyOn(fakeExecuteFunction.helpers, 'httpRequest');
|
||||||
|
|
||||||
|
const fileSize = 7 * 1024 * 1024; // 7MB
|
||||||
|
jest.mocked(utils.getItemBinaryData).mockResolvedValue({
|
||||||
|
mimeType: 'image/jpg',
|
||||||
|
originalFilename: 'test.jpg',
|
||||||
|
contentLength: fileSize,
|
||||||
|
fileContent: createTestStream(fileSize),
|
||||||
|
});
|
||||||
|
|
||||||
|
await upload.execute.call(fakeExecuteFunction, 0);
|
||||||
|
|
||||||
|
// 4 chunks: 7MB = 3x2MB + 1x1MB
|
||||||
|
expect(httpRequestSpy).toHaveBeenCalledTimes(4);
|
||||||
|
expect(httpRequestSpy).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ body: expect.any(Buffer) }),
|
||||||
|
);
|
||||||
|
expect(transport.googleApiRequest).toBeCalledTimes(2);
|
||||||
|
expect(transport.googleApiRequest).toHaveBeenCalledWith(
|
||||||
|
'POST',
|
||||||
|
'/upload/drive/v3/files',
|
||||||
|
undefined,
|
||||||
|
{ uploadType: 'resumable' },
|
||||||
|
undefined,
|
||||||
|
{ returnFullResponse: true },
|
||||||
|
);
|
||||||
|
expect(transport.googleApiRequest).toHaveBeenCalledWith(
|
||||||
|
'PATCH',
|
||||||
|
'/drive/v3/files/undefined',
|
||||||
|
{ mimeType: 'image/jpg', name: 'newFile.jpg', originalFilename: 'test.jpg' },
|
||||||
|
{
|
||||||
|
addParents: 'folderIDxxxxxx',
|
||||||
|
supportsAllDrives: true,
|
||||||
|
corpora: 'allDrives',
|
||||||
|
includeItemsFromAllDrives: true,
|
||||||
|
spaces: 'appDataFolder, drive',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -2,6 +2,7 @@ import type { IDataObject, IExecuteFunctions, IGetNodeParameterOptions, INode }
|
||||||
|
|
||||||
import { get } from 'lodash';
|
import { get } from 'lodash';
|
||||||
import { constructExecutionMetaData, returnJsonArray } from 'n8n-core';
|
import { constructExecutionMetaData, returnJsonArray } from 'n8n-core';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
export const driveNode: INode = {
|
export const driveNode: INode = {
|
||||||
id: '11',
|
id: '11',
|
||||||
|
@ -40,3 +41,25 @@ export const createMockExecuteFunction = (
|
||||||
} as unknown as IExecuteFunctions;
|
} as unknown as IExecuteFunctions;
|
||||||
return fakeExecuteFunction;
|
return fakeExecuteFunction;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function createTestStream(byteSize: number) {
|
||||||
|
let bytesSent = 0;
|
||||||
|
const CHUNK_SIZE = 64 * 1024; // 64kB chunks (default NodeJS highWaterMark)
|
||||||
|
|
||||||
|
return new Readable({
|
||||||
|
read() {
|
||||||
|
const remainingBytes = byteSize - bytesSent;
|
||||||
|
|
||||||
|
if (remainingBytes <= 0) {
|
||||||
|
this.push(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkSize = Math.min(CHUNK_SIZE, remainingBytes);
|
||||||
|
const chunk = Buffer.alloc(chunkSize, 'A'); // Test data just a string of "A"
|
||||||
|
|
||||||
|
bytesSent += chunkSize;
|
||||||
|
this.push(chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
setFileProperties,
|
setFileProperties,
|
||||||
setUpdateCommonParams,
|
setUpdateCommonParams,
|
||||||
setParentFolder,
|
setParentFolder,
|
||||||
|
processInChunks,
|
||||||
} from '../../helpers/utils';
|
} from '../../helpers/utils';
|
||||||
import { updateDisplayOptions } from '@utils/utilities';
|
import { updateDisplayOptions } from '@utils/utilities';
|
||||||
|
|
||||||
|
@ -129,16 +130,17 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
|
||||||
|
|
||||||
const uploadUrl = resumableUpload.headers.location;
|
const uploadUrl = resumableUpload.headers.location;
|
||||||
|
|
||||||
let offset = 0;
|
// 2MB chunks, needs to be a multiple of 256kB for Google Drive API
|
||||||
for await (const chunk of fileContent) {
|
const chunkSizeBytes = 2048 * 1024;
|
||||||
const nextOffset = offset + Number(chunk.length);
|
|
||||||
|
await processInChunks(fileContent, chunkSizeBytes, async (chunk, offset) => {
|
||||||
try {
|
try {
|
||||||
const response = await this.helpers.httpRequest({
|
const response = await this.helpers.httpRequest({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
url: uploadUrl,
|
url: uploadUrl,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Length': chunk.length,
|
'Content-Length': chunk.length,
|
||||||
'Content-Range': `bytes ${offset}-${nextOffset - 1}/${contentLength}`,
|
'Content-Range': `bytes ${offset}-${offset + chunk.byteLength - 1}/${contentLength}`,
|
||||||
},
|
},
|
||||||
body: chunk,
|
body: chunk,
|
||||||
});
|
});
|
||||||
|
@ -146,8 +148,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.response?.status !== 308) throw error;
|
if (error.response?.status !== 308) throw error;
|
||||||
}
|
}
|
||||||
offset = nextOffset;
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = this.getNodeParameter('options', i, {});
|
const options = this.getNodeParameter('options', i, {});
|
||||||
|
|
|
@ -131,3 +131,29 @@ export function setParentFolder(
|
||||||
return 'root';
|
return 'root';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function processInChunks(
|
||||||
|
stream: Readable,
|
||||||
|
chunkSize: number,
|
||||||
|
process: (chunk: Buffer, offset: number) => void | Promise<void>,
|
||||||
|
) {
|
||||||
|
let buffer = Buffer.alloc(0);
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
buffer = Buffer.concat([buffer, chunk]);
|
||||||
|
|
||||||
|
while (buffer.length >= chunkSize) {
|
||||||
|
const chunkToProcess = buffer.subarray(0, chunkSize);
|
||||||
|
await process(chunkToProcess, offset);
|
||||||
|
|
||||||
|
buffer = buffer.subarray(chunkSize);
|
||||||
|
offset += chunkSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process last chunk, could be smaller than chunkSize
|
||||||
|
if (buffer.length > 0) {
|
||||||
|
await process(buffer, offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue