@@ -217,13 +261,15 @@ function onOpenFileDialog() {
border-radius: var(--chat--input--border-radius, 0);
padding: 0.8rem;
padding-right: calc(0.8rem + (var(--controls-count, 1) * var(--chat--textarea--height)));
- min-height: var(--chat--textarea--height);
- max-height: var(--chat--textarea--max-height, var(--chat--textarea--height));
- height: 100%;
+ min-height: var(--chat--textarea--height, 2.5rem); // Set a smaller initial height
+ max-height: var(--chat--textarea--max-height, 30rem);
+ 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);
- resize: var(--chat--textarea--resize, none);
color: var(--chat--input--text-color, initial);
outline: none;
+ line-height: var(--chat--input--line-height, 1.5);
&:focus,
&:hover {
@@ -235,8 +281,10 @@ function onOpenFileDialog() {
display: flex;
position: absolute;
right: 0.5rem;
+ bottom: 0;
}
-.chat-input-send-button {
+.chat-input-send-button,
+.chat-input-file-button {
height: var(--chat--textarea--height);
width: var(--chat--textarea--height);
background: var(--chat--input--send--button--background, white);
@@ -253,19 +301,33 @@ function onOpenFileDialog() {
min-width: fit-content;
}
- &:hover,
- &:focus {
- background: var(
- --chat--input--send--button--background-hover,
- var(--chat--input--send--button--background)
- );
- color: var(--chat--input--send--button--color-hover, var(--chat--color-secondary-shade-50));
- }
-
&[disabled] {
cursor: no-drop;
color: var(--chat--color-disabled);
}
+
+ .chat-input-send-button {
+ &:hover,
+ &:focus {
+ background: var(
+ --chat--input--send--button--background-hover,
+ var(--chat--input--send--button--background)
+ );
+ color: var(--chat--input--send--button--color-hover, var(--chat--color-secondary-shade-50));
+ }
+ }
+}
+.chat-input-file-button {
+ background: var(--chat--input--file--button--background, white);
+ color: var(--chat--input--file--button--color, var(--chat--color-secondary));
+
+ &:hover {
+ background: var(
+ --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));
+ }
}
.chat-files {
@@ -275,7 +337,7 @@ function onOpenFileDialog() {
width: 100%;
flex-direction: row;
flex-wrap: wrap;
- gap: 0.25rem;
+ gap: 0.5rem;
padding: var(--chat--files-spacing, 0.25rem);
}
diff --git a/packages/@n8n/chat/src/components/Message.vue b/packages/@n8n/chat/src/components/Message.vue
index f584516332..123bcb6040 100644
--- a/packages/@n8n/chat/src/components/Message.vue
+++ b/packages/@n8n/chat/src/components/Message.vue
@@ -60,7 +60,7 @@ const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
const scrollToView = () => {
if (messageContainer.value?.scrollIntoView) {
messageContainer.value.scrollIntoView({
- block: 'center',
+ block: 'start',
});
}
};
@@ -132,14 +132,14 @@ onMounted(async () => {
.chat-message {
display: block;
position: relative;
- max-width: 80%;
+ max-width: fit-content;
font-size: var(--chat--message--font-size, 1rem);
padding: var(--chat--message--padding, var(--chat--spacing));
border-radius: var(--chat--message--border-radius, var(--chat--border-radius));
-
+ scroll-margin: 100px;
.chat-message-actions {
position: absolute;
- bottom: 100%;
+ bottom: calc(100% - 0.5rem);
left: 0;
opacity: 0;
transform: translateY(-0.25rem);
@@ -151,6 +151,9 @@ onMounted(async () => {
left: auto;
right: 0;
}
+ &.chat-message-from-bot .chat-message-actions {
+ bottom: calc(100% - 1rem);
+ }
&:hover {
.chat-message-actions {
@@ -159,7 +162,7 @@ onMounted(async () => {
}
p {
- line-height: var(--chat--message-line-height, 1.8);
+ line-height: var(--chat--message-line-height, 1.5);
word-wrap: break-word;
}
diff --git a/packages/@n8n/chat/src/components/MessageTyping.vue b/packages/@n8n/chat/src/components/MessageTyping.vue
index 328ba6566e..e8dd3cf508 100644
--- a/packages/@n8n/chat/src/components/MessageTyping.vue
+++ b/packages/@n8n/chat/src/components/MessageTyping.vue
@@ -34,7 +34,12 @@ onMounted(() => {
});
-
+
diff --git a/packages/@n8n/chat/src/css/markdown.scss b/packages/@n8n/chat/src/css/markdown.scss
index 070e6d6a5f..6bcf13ba74 100644
--- a/packages/@n8n/chat/src/css/markdown.scss
+++ b/packages/@n8n/chat/src/css/markdown.scss
@@ -37,7 +37,7 @@ body {
4. Prevent font size adjustment after orientation changes (IE, iOS)
5. Prevent overflow from long words (all)
*/
- font-size: 125%; /* 2 */
+ font-size: 110%; /* 2 */
line-height: 1.6; /* 3 */
-webkit-text-size-adjust: 100%; /* 4 */
word-break: break-word; /* 5 */
@@ -596,7 +596,7 @@ body {
pre code {
display: block;
- padding: 0.3em 0.7em;
+ padding: 0 0 0.5rem 0.5rem;
word-break: normal;
overflow-x: auto;
}
diff --git a/packages/editor-ui/src/App.vue b/packages/editor-ui/src/App.vue
index 47d2f1268b..c9d23632de 100644
--- a/packages/editor-ui/src/App.vue
+++ b/packages/editor-ui/src/App.vue
@@ -33,15 +33,11 @@ const loading = ref(true);
const defaultLocale = computed(() => rootStore.defaultLocale);
const isDemoMode = computed(() => route.name === VIEWS.DEMO);
const showAssistantButton = computed(() => assistantStore.canShowAssistantButtonsOnCanvas);
-
+const hasContentFooter = ref(false);
const appGrid = ref(null);
const assistantSidebarWidth = computed(() => assistantStore.chatWidth);
-watch(defaultLocale, (newLocale) => {
- void loadLanguage(newLocale);
-});
-
onMounted(async () => {
setAppZIndexes();
logHiringBanner();
@@ -54,11 +50,6 @@ onBeforeUnmount(() => {
window.removeEventListener('resize', updateGridWidth);
});
-// As assistant sidebar width changes, recalculate the total width regularly
-watch(assistantSidebarWidth, async () => {
- await updateGridWidth();
-});
-
const logHiringBanner = () => {
if (settingsStore.isHiringBannerEnabled && !isDemoMode.value) {
console.log(HIRING_BANNER);
@@ -71,6 +62,21 @@ const updateGridWidth = async () => {
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);
+});
@@ -94,12 +100,17 @@ const updateGridWidth = async () => {
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
@@ -138,8 +149,26 @@ const updateGridWidth = async () => {
grid-area: banners;
z-index: var(--z-index-top-banners);
}
-
.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;
grid-area: content;
position: relative;
diff --git a/packages/editor-ui/src/__tests__/mocks.ts b/packages/editor-ui/src/__tests__/mocks.ts
index 87b4a8bbf9..15688de9ff 100644
--- a/packages/editor-ui/src/__tests__/mocks.ts
+++ b/packages/editor-ui/src/__tests__/mocks.ts
@@ -54,6 +54,7 @@ export const mockNodeTypeDescription = ({
credentials = [],
inputs = [NodeConnectionType.Main],
outputs = [NodeConnectionType.Main],
+ codex = {},
properties = [],
}: {
name?: INodeTypeDescription['name'];
@@ -61,6 +62,7 @@ export const mockNodeTypeDescription = ({
credentials?: INodeTypeDescription['credentials'];
inputs?: INodeTypeDescription['inputs'];
outputs?: INodeTypeDescription['outputs'];
+ codex?: INodeTypeDescription['codex'];
properties?: INodeTypeDescription['properties'];
} = {}) =>
mock
({
@@ -77,6 +79,7 @@ export const mockNodeTypeDescription = ({
group: EXECUTABLE_TRIGGER_NODE_TYPES.includes(name) ? ['trigger'] : [],
inputs,
outputs,
+ codex,
credentials,
documentationUrl: 'https://docs',
webhooks: undefined,
diff --git a/packages/editor-ui/src/components/AskAssistant/AskAssistantFloatingButton.vue b/packages/editor-ui/src/components/AskAssistant/AskAssistantFloatingButton.vue
index 70f6ed84fd..6a9746c65e 100644
--- a/packages/editor-ui/src/components/AskAssistant/AskAssistantFloatingButton.vue
+++ b/packages/editor-ui/src/components/AskAssistant/AskAssistantFloatingButton.vue
@@ -2,6 +2,7 @@
import { useI18n } from '@/composables/useI18n';
import { useStyles } from '@/composables/useStyles';
import { useAssistantStore } from '@/stores/assistant.store';
+import { useCanvasStore } from '@/stores/canvas.store';
import AssistantAvatar from 'n8n-design-system/components/AskAssistantAvatar/AssistantAvatar.vue';
import AskAssistantButton from 'n8n-design-system/components/AskAssistantButton/AskAssistantButton.vue';
import { computed } from 'vue';
@@ -9,6 +10,7 @@ import { computed } from 'vue';
const assistantStore = useAssistantStore();
const i18n = useI18n();
const { APP_Z_INDEXES } = useStyles();
+const canvasStore = useCanvasStore();
const lastUnread = computed(() => {
const msg = assistantStore.lastUnread;
@@ -39,6 +41,7 @@ const onClick = () => {
v-if="assistantStore.canShowAssistantButtonsOnCanvas && !assistantStore.isAssistantOpen"
:class="$style.container"
data-test-id="ask-assistant-floating-button"
+ :style="{ '--canvas-panel-height-offset': `${canvasStore.panelHeight}px` }"
>
{
diff --git a/packages/editor-ui/src/components/CanvasChat/components/ChatLogsPanel.vue b/packages/editor-ui/src/components/CanvasChat/components/ChatLogsPanel.vue
new file mode 100644
index 0000000000..856f06711a
--- /dev/null
+++ b/packages/editor-ui/src/components/CanvasChat/components/ChatLogsPanel.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
diff --git a/packages/editor-ui/src/components/CanvasChat/components/ChatMessagesPanel.vue b/packages/editor-ui/src/components/CanvasChat/components/ChatMessagesPanel.vue
new file mode 100644
index 0000000000..9e5fb15567
--- /dev/null
+++ b/packages/editor-ui/src/components/CanvasChat/components/ChatMessagesPanel.vue
@@ -0,0 +1,348 @@
+
+
+
+
+
+
+
+
+
+ {{ locale.baseText('chat.window.chat.chatMessageOptions.executionId') }}:
+ {{ message.id }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/editor-ui/src/components/CanvasChat/components/MessageOptionAction.vue b/packages/editor-ui/src/components/CanvasChat/components/MessageOptionAction.vue
new file mode 100644
index 0000000000..bc5a649a5f
--- /dev/null
+++ b/packages/editor-ui/src/components/CanvasChat/components/MessageOptionAction.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+ {{ label }}
+
+
+
+
+
+
+
diff --git a/packages/editor-ui/src/components/CanvasChat/components/MessageOptionTooltip.vue b/packages/editor-ui/src/components/CanvasChat/components/MessageOptionTooltip.vue
new file mode 100644
index 0000000000..5a346fd929
--- /dev/null
+++ b/packages/editor-ui/src/components/CanvasChat/components/MessageOptionTooltip.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/editor-ui/src/components/CanvasChat/composables/useChatMessaging.ts b/packages/editor-ui/src/components/CanvasChat/composables/useChatMessaging.ts
new file mode 100644
index 0000000000..2ceec6b54d
--- /dev/null
+++ b/packages/editor-ui/src/components/CanvasChat/composables/useChatMessaging.ts
@@ -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;
+ connectedNode: Ref;
+ messages: Ref;
+ sessionId: Ref;
+ workflow: ComputedRef;
+ isLoading: ComputedRef;
+ executionResultData: ComputedRef;
+ getWorkflowResultDataByNodeName: (nodeName: string) => ITaskData[] | null;
+ onRunChatWorkflow: (
+ payload: RunWorkflowChatPayload,
+ ) => Promise;
+}
+
+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 {
+ 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 {
+ 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 {
+ 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,
+ };
+}
diff --git a/packages/editor-ui/src/components/CanvasChat/composables/useChatTrigger.ts b/packages/editor-ui/src/components/CanvasChat/composables/useChatTrigger.ts
new file mode 100644
index 0000000000..9ec2e346a6
--- /dev/null
+++ b/packages/editor-ui/src/components/CanvasChat/composables/useChatTrigger.ts
@@ -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;
+}
+
+export function useChatTrigger({
+ getNodeByName,
+ getNodeType,
+ canvasNodes,
+ workflow,
+}: ChatTriggerDependencies) {
+ const chatTriggerName = ref(null);
+ const connectedNode = ref(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,
+ };
+}
diff --git a/packages/editor-ui/src/components/CanvasChat/composables/useResize.ts b/packages/editor-ui/src/components/CanvasChat/composables/useResize.ts
new file mode 100644
index 0000000000..cdbfcb82c2
--- /dev/null
+++ b/packages/editor-ui/src/components/CanvasChat/composables/useResize.ts
@@ -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) {
+ 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(() => ({
+ '--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,
+ };
+}
diff --git a/packages/editor-ui/src/components/CanvasChat/types/chat.ts b/packages/editor-ui/src/components/CanvasChat/types/chat.ts
new file mode 100644
index 0000000000..6eb6e2c2ad
--- /dev/null
+++ b/packages/editor-ui/src/components/CanvasChat/types/chat.ts
@@ -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;
+}
diff --git a/packages/editor-ui/src/components/Modals.vue b/packages/editor-ui/src/components/Modals.vue
index 5ec528b629..fc7b2d67f1 100644
--- a/packages/editor-ui/src/components/Modals.vue
+++ b/packages/editor-ui/src/components/Modals.vue
@@ -18,7 +18,6 @@ import {
NEW_ASSISTANT_SESSION_MODAL,
VERSIONS_MODAL_KEY,
WORKFLOW_ACTIVE_MODAL_KEY,
- WORKFLOW_LM_CHAT_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_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 UpdatesPanel from '@/components/UpdatesPanel.vue';
import NpsSurvey from '@/components/NpsSurvey.vue';
-import WorkflowLMChat from '@/components/WorkflowLMChat/WorkflowLMChat.vue';
import WorkflowSettings from '@/components/WorkflowSettings.vue';
import DeleteUserModal from '@/components/DeleteUserModal.vue';
import ActivationModal from '@/components/ActivationModal.vue';
@@ -125,10 +123,6 @@ import type { EventBus } from 'n8n-design-system';
-
-
-
-
diff --git a/packages/editor-ui/src/components/Node/NodeCreator/composables/useActions.ts b/packages/editor-ui/src/components/Node/NodeCreator/composables/useActions.ts
index 87be1105b7..92a5563d80 100644
--- a/packages/editor-ui/src/components/Node/NodeCreator/composables/useActions.ts
+++ b/packages/editor-ui/src/components/Node/NodeCreator/composables/useActions.ts
@@ -19,7 +19,6 @@ import {
AI_CATEGORY_LANGUAGE_MODELS,
BASIC_CHAIN_NODE_TYPE,
CHAT_TRIGGER_NODE_TYPE,
- MANUAL_CHAT_TRIGGER_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
NODE_CREATOR_OPEN_SOURCES,
NO_OP_NODE_TYPE,
@@ -204,8 +203,6 @@ export const useActions = () => {
);
}
function shouldPrependChatTrigger(addedNodes: AddedNode[]): boolean {
- const { allNodes } = useWorkflowsStore();
-
const COMPATIBLE_CHAT_NODES = [
QA_CHAIN_NODE_TYPE,
AGENT_NODE_TYPE,
@@ -214,13 +211,25 @@ export const useActions = () => {
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));
- 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
diff --git a/packages/editor-ui/src/components/Node/NodeCreator/useActions.test.ts b/packages/editor-ui/src/components/Node/NodeCreator/useActions.test.ts
index 4b5682cbbd..cf8fa94014 100644
--- a/packages/editor-ui/src/components/Node/NodeCreator/useActions.test.ts
+++ b/packages/editor-ui/src/components/Node/NodeCreator/useActions.test.ts
@@ -5,6 +5,8 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useActions } from './composables/useActions';
import {
+ AGENT_NODE_TYPE,
+ GITHUB_TRIGGER_NODE_TYPE,
HTTP_REQUEST_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
NODE_CREATOR_OPEN_SOURCES,
@@ -15,6 +17,7 @@ import {
TRIGGER_NODE_CREATOR_VIEW,
WEBHOOK_NODE_TYPE,
} from '@/constants';
+import { CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
describe('useActions', () => {
beforeAll(() => {
@@ -54,6 +57,9 @@ describe('useActions', () => {
vi.spyOn(workflowsStore, 'workflowTriggerNodes', 'get').mockReturnValue([
{ type: SCHEDULE_TRIGGER_NODE_TYPE } as never,
]);
+ vi.spyOn(workflowsStore, 'getNodeTypes').mockReturnValue({
+ getByNameAndVersion: () => ({ description: { group: ['trigger'] } }),
+ } as never);
vi.spyOn(nodeCreatorStore, 'openSource', 'get').mockReturnValue(
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', () => {
const workflowsStore = useWorkflowsStore();
const nodeCreatorStore = useNodeCreatorStore();
diff --git a/packages/editor-ui/src/components/RunDataAi/AiRunContentBlock.vue b/packages/editor-ui/src/components/RunDataAi/AiRunContentBlock.vue
index 9458f75380..7c11707de9 100644
--- a/packages/editor-ui/src/components/RunDataAi/AiRunContentBlock.vue
+++ b/packages/editor-ui/src/components/RunDataAi/AiRunContentBlock.vue
@@ -24,7 +24,7 @@ const contentParsers = useAiContentParsers();
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const isExpanded = ref(getInitialExpandedState());
-const isShowRaw = ref(false);
+const renderType = ref<'rendered' | 'json'>('rendered');
const contentParsed = ref(false);
const parsedRun = ref(undefined as ParsedAiContent | undefined);
function getInitialExpandedState() {
@@ -134,6 +134,10 @@ function onCopyToClipboard(content: IDataObject | IDataObject[]) {
} catch (err) {}
}
+function onRenderTypeChange(value: 'rendered' | 'json') {
+ renderType.value = value;
+}
+
onMounted(() => {
parsedRun.value = parseAiRunData(props.runData);
if (parsedRun.value) {
@@ -146,16 +150,19 @@ onMounted(() => {
{
:class="$style.contentText"
:data-content-type="parsedContent?.type"
>
-
+
{
white-space: pre-wrap;
h1 {
- font-size: var(--font-size-xl);
+ font-size: var(--font-size-l);
line-height: var(--font-line-height-xloose);
}
h2 {
- font-size: var(--font-size-l);
+ font-size: var(--font-size-m);
line-height: var(--font-line-height-loose);
}
h3 {
- font-size: var(--font-size-m);
+ font-size: var(--font-size-s);
line-height: var(--font-line-height-regular);
}
@@ -252,17 +259,16 @@ onMounted(() => {
}
.contentText {
padding-top: var(--spacing-s);
- font-size: var(--font-size-xs);
- // max-height: 100%;
+ padding-left: var(--spacing-m);
+ font-size: var(--font-size-s);
}
.block {
- border: 1px solid var(--color-foreground-base);
- background: var(--color-background-xlight);
- padding: var(--spacing-xs);
- border-radius: 4px;
- margin-bottom: var(--spacing-2xs);
+ padding: 0 0 var(--spacing-2xs) var(--spacing-2xs);
+ background: var(--color-foreground-light);
+ margin-top: var(--spacing-xl);
+ border-radius: var(--border-radius-base);
}
-.blockContent {
+:root .blockContent {
height: 0;
overflow: hidden;
@@ -271,14 +277,17 @@ onMounted(() => {
}
}
.runText {
- line-height: var(--font-line-height-regular);
+ line-height: var(--font-line-height-xloose);
white-space: pre-line;
}
.rawSwitch {
+ opacity: 0;
+ height: fit-content;
margin-left: auto;
+ margin-right: var(--spacing-2xs);
- & * {
- font-size: var(--font-size-2xs);
+ .block:hover & {
+ opacity: 1;
}
}
.blockHeader {
@@ -287,21 +296,25 @@ onMounted(() => {
cursor: pointer;
/* This hack is needed to make the whole surface of header clickable */
margin: calc(-1 * var(--spacing-xs));
- padding: var(--spacing-xs);
+ padding: var(--spacing-2xs) var(--spacing-xs);
+ align-items: center;
& * {
user-select: none;
}
}
.blockTitle {
- font-size: var(--font-size-2xs);
+ font-size: var(--font-size-s);
color: var(--color-text-dark);
+ margin: 0;
+ padding-bottom: var(--spacing-4xs);
}
.blockToggle {
border: none;
background: none;
padding: 0;
color: var(--color-text-base);
+ margin-top: calc(-1 * var(--spacing-3xs));
}
.error {
padding: var(--spacing-s) 0;
diff --git a/packages/editor-ui/src/components/RunDataAi/RunDataAi.vue b/packages/editor-ui/src/components/RunDataAi/RunDataAi.vue
index 4092f09884..1d78d957cb 100644
--- a/packages/editor-ui/src/components/RunDataAi/RunDataAi.vue
+++ b/packages/editor-ui/src/components/RunDataAi/RunDataAi.vue
@@ -25,7 +25,6 @@ interface TreeNode {
export interface Props {
node: INodeUi;
runIndex?: number;
- hideTitle?: boolean;
slim?: boolean;
workflow: Workflow;
}
@@ -203,7 +202,7 @@ const aiData = computed(() => {
const executionTree = computed(() => {
const rootNode = props.node;
- const tree = getTreeNodeData(rootNode.name, 1);
+ const tree = getTreeNodeData(rootNode.name, 0);
return tree || [];
});
@@ -211,66 +210,73 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
-
-
-
-
-
-
@@ -287,6 +293,13 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
align-items: center;
gap: var(--spacing-3xs);
}
+.noData {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ color: var(--color-text-light);
+}
.empty {
padding: var(--spacing-l);
}
@@ -296,9 +309,9 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
}
.tree {
flex-shrink: 0;
- min-width: 12.8rem;
+ min-width: 8rem;
height: 100%;
- border-right: 1px solid var(--color-foreground-base);
+
padding-right: var(--spacing-xs);
padding-left: var(--spacing-2xs);
&.slim {
@@ -337,20 +350,30 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
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 {
- background-color: var(--color-foreground-base);
+ .nodeIcon {
+ background-color: var(--color-foreground-base);
+ }
}
.treeNode {
display: inline-flex;
border-radius: var(--border-radius-base);
align-items: center;
- gap: var(--spacing-3xs);
- padding: var(--spacing-4xs) var(--spacing-3xs);
- font-size: var(--font-size-xs);
+ padding-right: var(--spacing-3xs);
+ margin: var(--spacing-4xs) 0;
+ font-size: var(--font-size-2xs);
color: var(--color-text-dark);
margin-bottom: var(--spacing-3xs);
cursor: pointer;
+ &.isSelected {
+ font-weight: var(--font-weight-bold);
+ }
&:hover {
background-color: var(--color-foreground-base);
}
@@ -366,6 +389,7 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
height: 0.125rem;
left: 0.75rem;
width: calc(var(--item-depth) * 0.625rem);
+ margin-top: var(--spacing-3xs);
}
}
diff --git a/packages/editor-ui/src/components/RunDataAi/useAiContentParsers.ts b/packages/editor-ui/src/components/RunDataAi/useAiContentParsers.ts
index c7e204548e..2f02d6b0cc 100644
--- a/packages/editor-ui/src/components/RunDataAi/useAiContentParsers.ts
+++ b/packages/editor-ui/src/components/RunDataAi/useAiContentParsers.ts
@@ -134,13 +134,6 @@ const outputTypeParsers: {
} else if (content.id.includes('SystemMessage')) {
message = `**System Message:** ${message}`;
}
- if (
- execData.action &&
- typeof execData.action !== 'object' &&
- execData.action !== 'getMessages'
- ) {
- message = `## Action: ${execData.action}\n\n${message}`;
- }
return message;
}
@@ -148,6 +141,9 @@ const outputTypeParsers: {
})
.join('\n\n');
+ if (responseText.length === 0) {
+ return fallbackParser(execData);
+ }
return {
type: 'markdown',
data: responseText,
diff --git a/packages/editor-ui/src/components/WorkflowLMChat/MessageOptionAction.vue b/packages/editor-ui/src/components/WorkflowLMChat/MessageOptionAction.vue
deleted file mode 100644
index 69bb16737d..0000000000
--- a/packages/editor-ui/src/components/WorkflowLMChat/MessageOptionAction.vue
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
-
-
-
- {{ label }}
-
-
-
-
-
diff --git a/packages/editor-ui/src/components/WorkflowLMChat/MessageOptionTooltip.vue b/packages/editor-ui/src/components/WorkflowLMChat/MessageOptionTooltip.vue
deleted file mode 100644
index 008993fee5..0000000000
--- a/packages/editor-ui/src/components/WorkflowLMChat/MessageOptionTooltip.vue
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/editor-ui/src/components/WorkflowLMChat/WorkflowLMChat.vue b/packages/editor-ui/src/components/WorkflowLMChat/WorkflowLMChat.vue
deleted file mode 100644
index 72fcd3012d..0000000000
--- a/packages/editor-ui/src/components/WorkflowLMChat/WorkflowLMChat.vue
+++ /dev/null
@@ -1,699 +0,0 @@
-
-
-
-
-
-
-
-
-
- {{ locale.baseText('chat.window.chat.chatMessageOptions.executionId') }}:
- {{ message.id }}
-
-
-
-
-
-
-
-
-
{{
- locale.baseText('chat.window.logs')
- }}
-
-
-
-
-
-
-
-
-
- {{ locale.baseText('chatEmbed.infoTip.description') }}
-
- {{ locale.baseText('chatEmbed.infoTip.link') }}
-
-
-
-
-
-
-
-
diff --git a/packages/editor-ui/src/components/WorkflowLMChatModal.test.ts b/packages/editor-ui/src/components/WorkflowLMChatModal.test.ts
deleted file mode 100644
index 8fa20d0965..0000000000
--- a/packages/editor-ui/src/components/WorkflowLMChatModal.test.ts
+++ /dev/null
@@ -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({
- 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;
-
- 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!');
- });
-});
diff --git a/packages/editor-ui/src/components/canvas/elements/buttons/CanvasChatButton.vue b/packages/editor-ui/src/components/canvas/elements/buttons/CanvasChatButton.vue
index 877180cd4e..034b16752a 100644
--- a/packages/editor-ui/src/components/canvas/elements/buttons/CanvasChatButton.vue
+++ b/packages/editor-ui/src/components/canvas/elements/buttons/CanvasChatButton.vue
@@ -1,9 +1,17 @@
+
diff --git a/packages/editor-ui/src/composables/useRunWorkflow.ts b/packages/editor-ui/src/composables/useRunWorkflow.ts
index 658e35b3c6..a944a93348 100644
--- a/packages/editor-ui/src/composables/useRunWorkflow.ts
+++ b/packages/editor-ui/src/composables/useRunWorkflow.ts
@@ -22,12 +22,7 @@ import { FORM_NODE_TYPE, NodeConnectionType } from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
-import {
- CHAT_TRIGGER_NODE_TYPE,
- FORM_TRIGGER_NODE_TYPE,
- WAIT_NODE_TYPE,
- WORKFLOW_LM_CHAT_MODAL_KEY,
-} from '@/constants';
+import { CHAT_TRIGGER_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, WAIT_NODE_TYPE } from '@/constants';
import { useRootStore } from '@/stores/root.store';
import { useUIStore } from '@/stores/ui.store';
@@ -55,7 +50,6 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType {
if (!rootStore.pushConnectionActive) {
@@ -175,7 +169,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType> = {
dangerouslyUseHTMLString: false,
position: 'bottom-right',
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',
customClass: 'content-toast',
};
@@ -43,6 +45,8 @@ export function useToast() {
const { message, title } = messageData;
const params = { ...messageDefaults, ...messageData };
+ params.offset = +canvasStore.panelHeight;
+
if (typeof message === 'string') {
params.message = sanitizeHtml(message);
}
diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts
index 325a6737fc..0d70e156c2 100644
--- a/packages/editor-ui/src/constants.ts
+++ b/packages/editor-ui/src/constants.ts
@@ -51,7 +51,6 @@ export const TAGS_MANAGER_MODAL_KEY = 'tagsManager';
export const ANNOTATION_TAGS_MANAGER_MODAL_KEY = 'annotationTagsManager';
export const VERSIONS_MODAL_KEY = 'versions';
export const WORKFLOW_SETTINGS_MODAL_KEY = 'settings';
-export const WORKFLOW_LM_CHAT_MODAL_KEY = 'lmChat';
export const WORKFLOW_SHARE_MODAL_KEY = 'workflowShare';
export const PERSONALIZATION_MODAL_KEY = 'personalization';
export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt';
diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json
index 2b6f03cb2b..ca342147fc 100644
--- a/packages/editor-ui/src/plugins/i18n/locales/en.json
+++ b/packages/editor-ui/src/plugins/i18n/locales/en.json
@@ -169,11 +169,13 @@
"binaryDataDisplay.backToOverviewPage": "Back to overview page",
"binaryDataDisplay.noDataFoundToDisplay": "No data found to display",
"binaryDataDisplay.yourBrowserDoesNotSupport": "Your browser does not support the video element. Kindly update it to latest version.",
- "chat.window.title": "Chat Window ({nodeName})",
- "chat.window.logs": "Log (for last message)",
+ "chat.window.title": "Chat",
+ "chat.window.logs": "Latest Logs",
+ "chat.window.logsFromNode": "from {nodeName} node",
"chat.window.noChatNode": "No Chat Node",
"chat.window.noExecution": "Nothing got executed yet",
"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.provideMessage": "Please provide a message",
"chat.window.chat.emptyChatMessage": "Empty chat message",
@@ -185,6 +187,10 @@
"chat.window.chat.unpinAndExecute.confirm": "Unpin and send",
"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.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.link": "More info",
"chatEmbed.title": "Embed Chat in your website",
@@ -951,7 +957,8 @@
"ndv.input.disabled": "The '{nodeName}' node is disabled and wonβt execute.",
"ndv.input.disabled.cta": "Enable it",
"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.regular": "Output",
"ndv.output.edit": "Edit Output",
diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts
index 8a22cdf2f7..1580a3bff4 100644
--- a/packages/editor-ui/src/router.ts
+++ b/packages/editor-ui/src/router.ts
@@ -24,6 +24,7 @@ const ErrorView = async () => await import('./views/ErrorView.vue');
const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordView.vue');
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.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 WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
const WorkflowExecutionsLandingPage = async () =>
@@ -301,6 +302,7 @@ export const routes: RouteRecordRaw[] = [
default: NodeView,
header: MainHeader,
sidebar: MainSidebar,
+ footer: CanvasChat,
},
meta: {
nodeView: true,
@@ -333,6 +335,7 @@ export const routes: RouteRecordRaw[] = [
default: NodeView,
header: MainHeader,
sidebar: MainSidebar,
+ footer: CanvasChat,
},
meta: {
nodeView: true,
diff --git a/packages/editor-ui/src/stores/canvas.store.ts b/packages/editor-ui/src/stores/canvas.store.ts
index 252c06859e..b96699f297 100644
--- a/packages/editor-ui/src/stores/canvas.store.ts
+++ b/packages/editor-ui/src/stores/canvas.store.ts
@@ -54,8 +54,8 @@ export const useCanvasStore = defineStore('canvas', () => {
const jsPlumbInstanceRef = ref();
const isDragging = ref(false);
const lastSelectedConnection = ref();
-
const newNodeInsertPosition = ref(null);
+ const panelHeight = ref(0);
const nodes = computed(() => workflowStore.allNodes);
const triggerNodes = computed(() =>
@@ -109,9 +109,9 @@ export const useCanvasStore = defineStore('canvas', () => {
const manualTriggerNode = nodeTypesStore.getNodeType(MANUAL_TRIGGER_NODE_TYPE);
if (!manualTriggerNode) {
- console.error('Could not find the manual trigger node');
return null;
}
+
return {
id: uuid(),
name: manualTriggerNode.defaults.name?.toString() ?? manualTriggerNode.displayName,
@@ -324,6 +324,10 @@ export const useCanvasStore = defineStore('canvas', () => {
watch(readOnlyEnv, setReadOnly);
+ function setPanelHeight(height: number) {
+ panelHeight.value = height;
+ }
+
return {
isDemo,
nodeViewScale,
@@ -333,6 +337,8 @@ export const useCanvasStore = defineStore('canvas', () => {
isLoading: loadingService.isLoading,
aiNodes,
lastSelectedConnection: lastSelectedConnectionComputed,
+ panelHeight: computed(() => panelHeight.value),
+ setPanelHeight,
setReadOnly,
setLastSelectedConnection,
startLoading: loadingService.startLoading,
diff --git a/packages/editor-ui/src/stores/ui.store.ts b/packages/editor-ui/src/stores/ui.store.ts
index 6a12495543..abd463d57e 100644
--- a/packages/editor-ui/src/stores/ui.store.ts
+++ b/packages/editor-ui/src/stores/ui.store.ts
@@ -23,7 +23,6 @@ import {
VERSIONS_MODAL_KEY,
VIEWS,
WORKFLOW_ACTIVE_MODAL_KEY,
- WORKFLOW_LM_CHAT_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
@@ -104,7 +103,6 @@ export const useUIStore = defineStore(STORES.UI, () => {
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
NPS_SURVEY_MODAL_KEY,
VERSIONS_MODAL_KEY,
- WORKFLOW_LM_CHAT_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
WORKFLOW_ACTIVE_MODAL_KEY,
diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts
index 98a54ce1d1..c1b16f291b 100644
--- a/packages/editor-ui/src/stores/workflows.store.ts
+++ b/packages/editor-ui/src/stores/workflows.store.ts
@@ -139,6 +139,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const nodeMetadata = ref({});
const isInDebugMode = ref(false);
const chatMessages = ref([]);
+ const isChatPanelOpen = ref(false);
+ const isLogsPanelOpen = ref(false);
const workflowName = computed(() => workflow.value.name);
@@ -1123,6 +1125,11 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const { [node.name]: removedNodeMetadata, ...remainingNodeMetadata } = nodeMetadata.value;
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)) {
const { [node.name]: removedPinData, ...remainingPinData } = workflow.value.pinData;
workflow.value = {
@@ -1621,6 +1628,14 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
// 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 {
workflow,
usedCredentials,
@@ -1665,6 +1680,9 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
getWorkflowExecution,
getTotalFinishedExecutionsCount,
getPastChatMessages,
+ isChatPanelOpen: computed(() => isChatPanelOpen.value),
+ isLogsPanelOpen: computed(() => isLogsPanelOpen.value),
+ setPanelOpen,
outgoingConnectionsByNodeName,
incomingConnectionsByNodeName,
nodeHasOutputConnection,
diff --git a/packages/editor-ui/src/views/NodeView.v2.vue b/packages/editor-ui/src/views/NodeView.v2.vue
index 8ce1e97941..3c2273d42a 100644
--- a/packages/editor-ui/src/views/NodeView.v2.vue
+++ b/packages/editor-ui/src/views/NodeView.v2.vue
@@ -60,7 +60,6 @@ import {
STICKY_NODE_TYPE,
VALID_WORKFLOW_IMPORT_URL_REGEX,
VIEWS,
- WORKFLOW_LM_CHAT_MODAL_KEY,
} from '@/constants';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useNodeCreatorStore } from '@/stores/nodeCreator.store';
@@ -249,6 +248,8 @@ const keyBindingsEnabled = computed(() => {
return !ndvStore.activeNode && uiStore.activeModals.length === 0;
});
+const isChatOpen = computed(() => workflowsStore.isChatPanelOpen);
+
/**
* Initialization
*/
@@ -1207,7 +1208,7 @@ const chatTriggerNodePinnedData = computed(() => {
});
async function onOpenChat() {
- uiStore.openModal(WORKFLOW_LM_CHAT_MODAL_KEY);
+ workflowsStore.setPanelOpen('chat', !workflowsStore.isChatPanelOpen);
const payload = {
workflow_id: workflowId.value,
@@ -1633,7 +1634,11 @@ onBeforeUnmount(() => {
@mouseleave="onRunWorkflowButtonMouseLeave"
@click="onRunWorkflow"
/>
-
+
node.type === CHAT_TRIGGER_NODE_TYPE);
+ },
isManualChatOnly(): boolean {
if (!this.canvasChatNode) return false;
return this.containsChatNodes && this.triggerNodes.length === 1 && !this.pinnedChatNodeData;
},
- canvasChatNode() {
- return this.nodes.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE);
- },
pinnedChatNodeData() {
if (!this.canvasChatNode) return null;
@@ -513,6 +512,9 @@ export default defineComponent({
: (this.projectsStore.currentProject ?? this.projectsStore.personalProject);
return getResourcePermissions(project?.scopes);
},
+ isChatOpen() {
+ return this.workflowsStore.isChatPanelOpen;
+ },
},
watch: {
// 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);
void this.externalHooks.run('nodeView.onOpenChat', telemetryPayload);
- this.uiStore.openModal(WORKFLOW_LM_CHAT_MODAL_KEY);
+ this.workflowsStore.setPanelOpen('chat', !this.workflowsStore.isChatPanelOpen);
},
async onRunWorkflow() {
@@ -4651,6 +4653,7 @@ export default defineComponent({
size="large"
icon="comment"
type="primary"
+ :outline="isChatOpen === false"
data-test-id="workflow-chat-button"
@click.stop="onOpenChat"
/>
diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/upload.test.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/upload.test.ts
index 1eadf887fd..80446db6ce 100644
--- a/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/upload.test.ts
+++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/file/upload.test.ts
@@ -6,7 +6,7 @@ import * as upload from '../../../../v2/actions/file/upload.operation';
import * as transport from '../../../../v2/transport';
import * as utils from '../../../../v2/helpers/utils';
-import { createMockExecuteFunction, driveNode } from '../helpers';
+import { createMockExecuteFunction, createTestStream, driveNode } from '../helpers';
jest.mock('../../../../v2/transport', () => {
const originalModule = jest.requireActual('../../../../v2/transport');
@@ -30,7 +30,7 @@ jest.mock('../../../../v2/helpers/utils', () => {
getItemBinaryData: jest.fn(async function () {
return {
contentLength: '123',
- fileContent: 'Hello Drive!',
+ fileContent: Buffer.from('Hello Drive!'),
originalFilename: 'original.txt',
mimeType: 'text/plain',
};
@@ -43,13 +43,17 @@ describe('test GoogleDriveV2: file upload', () => {
nock.disableNetConnect();
});
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
afterAll(() => {
nock.restore();
jest.unmock('../../../../v2/transport');
jest.unmock('../../../../v2/helpers/utils');
});
- it('should be called with', async () => {
+ it('should upload buffers', async () => {
const nodeParameters = {
name: 'newFile.txt',
folderId: {
@@ -73,10 +77,10 @@ describe('test GoogleDriveV2: file upload', () => {
expect(transport.googleApiRequest).toHaveBeenCalledWith(
'POST',
'/upload/drive/v3/files',
+ expect.any(Buffer),
+ { uploadType: 'media' },
undefined,
- { uploadType: 'resumable' },
- undefined,
- { returnFullResponse: true },
+ { headers: { 'Content-Length': '123', 'Content-Type': 'text/plain' } },
);
expect(transport.googleApiRequest).toHaveBeenCalledWith(
'PATCH',
@@ -94,4 +98,60 @@ describe('test GoogleDriveV2: file upload', () => {
expect(utils.getItemBinaryData).toBeCalledTimes(1);
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',
+ },
+ );
+ });
});
diff --git a/packages/nodes-base/nodes/Google/Drive/test/v2/node/helpers.ts b/packages/nodes-base/nodes/Google/Drive/test/v2/node/helpers.ts
index b04a33e2e2..522f28afdb 100644
--- a/packages/nodes-base/nodes/Google/Drive/test/v2/node/helpers.ts
+++ b/packages/nodes-base/nodes/Google/Drive/test/v2/node/helpers.ts
@@ -2,6 +2,7 @@ import type { IDataObject, IExecuteFunctions, IGetNodeParameterOptions, INode }
import { get } from 'lodash';
import { constructExecutionMetaData, returnJsonArray } from 'n8n-core';
+import { Readable } from 'stream';
export const driveNode: INode = {
id: '11',
@@ -40,3 +41,25 @@ export const createMockExecuteFunction = (
} as unknown as IExecuteFunctions;
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);
+ },
+ });
+}
diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/file/upload.operation.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/upload.operation.ts
index da4ca39016..f81dc3478e 100644
--- a/packages/nodes-base/nodes/Google/Drive/v2/actions/file/upload.operation.ts
+++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/file/upload.operation.ts
@@ -12,6 +12,7 @@ import {
setFileProperties,
setUpdateCommonParams,
setParentFolder,
+ processInChunks,
} from '../../helpers/utils';
import { updateDisplayOptions } from '@utils/utilities';
@@ -129,16 +130,17 @@ export async function execute(this: IExecuteFunctions, i: number): Promise {
try {
const response = await this.helpers.httpRequest({
method: 'PUT',
url: uploadUrl,
headers: {
'Content-Length': chunk.length,
- 'Content-Range': `bytes ${offset}-${nextOffset - 1}/${contentLength}`,
+ 'Content-Range': `bytes ${offset}-${offset + chunk.byteLength - 1}/${contentLength}`,
},
body: chunk,
});
@@ -146,8 +148,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise void | Promise,
+) {
+ 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);
+ }
+}