Merge branch 'master' of https://github.com/n8n-io/n8n into node-1598-paireditem-matches-update

This commit is contained in:
Michael Kret 2024-11-13 12:59:52 +02:00
commit 899afeb71e
47 changed files with 2698 additions and 1083 deletions

View file

@ -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');

View file

@ -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();

View file

@ -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",

View file

@ -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(),
}));

View file

@ -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>

View file

@ -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>

View file

@ -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;
} }

View file

@ -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>

View file

@ -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;
} }

View file

@ -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;

View file

@ -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,

View file

@ -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);
} }

View 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();
});
});
});

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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,
};
}

View file

@ -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,
};
}

View file

@ -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,
};
}

View 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;
}

View file

@ -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>

View file

@ -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

View file

@ -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();

View file

@ -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;

View file

@ -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>

View file

@ -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,

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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!');
});
});

View file

@ -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>

View file

@ -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;
} }
} }

View file

@ -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);
} }

View file

@ -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';

View file

@ -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 wont execute.", "ndv.input.disabled": "The '{nodeName}' node is disabled and wont 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",

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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"

View file

@ -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"
/> />

View file

@ -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',
},
);
});
}); });

View file

@ -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);
},
});
}

View file

@ -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, {});

View file

@ -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);
}
}