feat(editor): Canvas chat UI & UX improvements (#11924)

This commit is contained in:
oleg
2024-11-29 11:24:17 +01:00
committed by GitHub
parent 5f6f8a1bdd
commit 1e25774541
17 changed files with 258 additions and 212 deletions

View File

@@ -38,12 +38,12 @@ const isSubmitting = ref(false);
const resizeObserver = ref<ResizeObserver | null>(null); const resizeObserver = ref<ResizeObserver | null>(null);
const isSubmitDisabled = computed(() => { const isSubmitDisabled = computed(() => {
return input.value === '' || waitingForResponse.value || options.disabled?.value === true; return input.value === '' || unref(waitingForResponse) || options.disabled?.value === true;
}); });
const isInputDisabled = computed(() => options.disabled?.value === true); const isInputDisabled = computed(() => options.disabled?.value === true);
const isFileUploadDisabled = computed( const isFileUploadDisabled = computed(
() => isFileUploadAllowed.value && waitingForResponse.value && !options.disabled?.value, () => isFileUploadAllowed.value && unref(waitingForResponse) && !options.disabled?.value,
); );
const isFileUploadAllowed = computed(() => unref(options.allowFileUploads) === true); const isFileUploadAllowed = computed(() => unref(options.allowFileUploads) === true);
const allowedFileTypes = computed(() => unref(options.allowedFilesMimeTypes)); const allowedFileTypes = computed(() => unref(options.allowedFilesMimeTypes));
@@ -194,10 +194,13 @@ function adjustHeight(event: Event) {
<template> <template>
<div class="chat-input" :style="styleVars" @keydown.stop="onKeyDown"> <div class="chat-input" :style="styleVars" @keydown.stop="onKeyDown">
<div class="chat-inputs"> <div class="chat-inputs">
<div v-if="$slots.leftPanel" class="chat-input-left-panel">
<slot name="leftPanel" />
</div>
<textarea <textarea
ref="chatTextArea" ref="chatTextArea"
data-test-id="chat-input"
v-model="input" v-model="input"
data-test-id="chat-input"
:disabled="isInputDisabled" :disabled="isInputDisabled"
:placeholder="t(props.placeholder)" :placeholder="t(props.placeholder)"
@keydown.enter="onSubmitKeydown" @keydown.enter="onSubmitKeydown"
@@ -251,7 +254,7 @@ function adjustHeight(event: Event) {
width: 100%; width: 100%;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: flex-end;
textarea { textarea {
font-family: inherit; font-family: inherit;
@@ -259,8 +262,7 @@ function adjustHeight(event: Event) {
width: 100%; width: 100%;
border: var(--chat--input--border, 0); border: var(--chat--input--border, 0);
border-radius: var(--chat--input--border-radius, 0); border-radius: var(--chat--input--border-radius, 0);
padding: 0.8rem; padding: var(--chat--input--padding, 0.8rem);
padding-right: calc(0.8rem + (var(--controls-count, 1) * var(--chat--textarea--height)));
min-height: var(--chat--textarea--height, 2.5rem); // Set a smaller initial height min-height: var(--chat--textarea--height, 2.5rem); // Set a smaller initial height
max-height: var(--chat--textarea--max-height, 30rem); max-height: var(--chat--textarea--max-height, 30rem);
height: var(--chat--textarea--height, 2.5rem); // Set initial height same as min-height height: var(--chat--textarea--height, 2.5rem); // Set initial height same as min-height
@@ -271,6 +273,9 @@ function adjustHeight(event: Event) {
outline: none; outline: none;
line-height: var(--chat--input--line-height, 1.5); line-height: var(--chat--input--line-height, 1.5);
&::placeholder {
font-size: var(--chat--input--placeholder--font-size, var(--chat--input--font-size, inherit));
}
&:focus, &:focus,
&:hover { &:hover {
border-color: var(--chat--input--border-active, 0); border-color: var(--chat--input--border-active, 0);
@@ -279,9 +284,6 @@ function adjustHeight(event: Event) {
} }
.chat-inputs-controls { .chat-inputs-controls {
display: flex; display: flex;
position: absolute;
right: 0.5rem;
bottom: 0;
} }
.chat-input-send-button, .chat-input-send-button,
.chat-input-file-button { .chat-input-file-button {
@@ -340,4 +342,9 @@ function adjustHeight(event: Event) {
gap: 0.5rem; gap: 0.5rem;
padding: var(--chat--files-spacing, 0.25rem); padding: var(--chat--files-spacing, 0.25rem);
} }
.chat-input-left-panel {
width: var(--chat--input--left--panel--width, 2rem);
margin-left: 0.4rem;
}
</style> </style>

View File

@@ -136,7 +136,8 @@ onMounted(async () => {
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; scroll-margin: 3rem;
.chat-message-actions { .chat-message-actions {
position: absolute; position: absolute;
bottom: calc(100% - 0.5rem); bottom: calc(100% - 0.5rem);
@@ -151,9 +152,6 @@ 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 {

View File

@@ -37,8 +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: 110%; /* 2 */ line-height: 1.4; /* 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 */
@@ -407,7 +406,7 @@ body {
h4, h4,
h5, h5,
h6 { h6 {
margin: 3.2rem 0 0.8em; margin: 2rem 0 0.8em;
} }
/* /*
@@ -641,4 +640,15 @@ body {
body > a:first-child:focus { body > a:first-child:focus {
top: 1rem; top: 1rem;
} }
// Lists
ul,
ol {
padding-left: 1.5rem;
margin-bottom: 1rem;
li {
margin-bottom: 0.5rem;
}
}
} }

View File

@@ -66,7 +66,7 @@ export const inputSchemaField: INodeProperties = {
}; };
export const promptTypeOptions: INodeProperties = { export const promptTypeOptions: INodeProperties = {
displayName: 'Prompt Source', displayName: 'Prompt Source (User Message)',
name: 'promptType', name: 'promptType',
type: 'options', type: 'options',
options: [ options: [

View File

@@ -4,6 +4,7 @@ import { waitFor } from '@testing-library/vue';
import { userEvent } from '@testing-library/user-event'; import { userEvent } from '@testing-library/user-event';
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import type { INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionType } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow';
import CanvasChat from './CanvasChat.vue'; import CanvasChat from './CanvasChat.vue';
@@ -15,7 +16,6 @@ import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
import { chatEventBus } from '@n8n/chat/event-buses'; import { chatEventBus } from '@n8n/chat/event-buses';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useUIStore } from '@/stores/ui.store';
import { useCanvasStore } from '@/stores/canvas.store'; import { useCanvasStore } from '@/stores/canvas.store';
import * as useChatMessaging from './composables/useChatMessaging'; import * as useChatMessaging from './composables/useChatMessaging';
import * as useChatTrigger from './composables/useChatTrigger'; import * as useChatTrigger from './composables/useChatTrigger';
@@ -23,6 +23,7 @@ import { useToast } from '@/composables/useToast';
import type { IExecutionResponse, INodeUi } from '@/Interface'; import type { IExecutionResponse, INodeUi } from '@/Interface';
import type { ChatMessage } from '@n8n/chat/types'; import type { ChatMessage } from '@n8n/chat/types';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
vi.mock('@/composables/useToast', () => { vi.mock('@/composables/useToast', () => {
const showMessage = vi.fn(); const showMessage = vi.fn();
@@ -61,6 +62,26 @@ const mockNodes: INodeUi[] = [
position: [960, 860], position: [960, 860],
}, },
]; ];
const mockNodeTypes: INodeTypeDescription[] = [
{
displayName: 'AI Agent',
name: '@n8n/n8n-nodes-langchain.agent',
properties: [],
defaults: {
name: 'AI Agent',
},
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
version: 0,
group: [],
description: '',
codex: {
subcategories: {
AI: ['Agents'],
},
},
},
];
const mockConnections = { const mockConnections = {
'When chat message received': { 'When chat message received': {
@@ -110,8 +131,8 @@ describe('CanvasChat', () => {
}); });
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>; let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
let canvasStore: ReturnType<typeof mockedStore<typeof useCanvasStore>>; let canvasStore: ReturnType<typeof mockedStore<typeof useCanvasStore>>;
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
beforeEach(() => { beforeEach(() => {
const pinia = createTestingPinia({ const pinia = createTestingPinia({
@@ -131,8 +152,8 @@ describe('CanvasChat', () => {
setActivePinia(pinia); setActivePinia(pinia);
workflowsStore = mockedStore(useWorkflowsStore); workflowsStore = mockedStore(useWorkflowsStore);
uiStore = mockedStore(useUIStore);
canvasStore = mockedStore(useCanvasStore); canvasStore = mockedStore(useCanvasStore);
nodeTypeStore = mockedStore(useNodeTypesStore);
// Setup default mocks // Setup default mocks
workflowsStore.getCurrentWorkflow.mockReturnValue( workflowsStore.getCurrentWorkflow.mockReturnValue(
@@ -141,12 +162,21 @@ describe('CanvasChat', () => {
connections: mockConnections, connections: mockConnections,
}), }),
); );
workflowsStore.getNodeByName.mockImplementation( workflowsStore.getNodeByName.mockImplementation((name) => {
(name) => mockNodes.find((node) => node.name === name) ?? null, const matchedNode = mockNodes.find((node) => node.name === name) ?? null;
);
return matchedNode;
});
workflowsStore.isChatPanelOpen = true; workflowsStore.isChatPanelOpen = true;
workflowsStore.isLogsPanelOpen = true;
workflowsStore.getWorkflowExecution = mockWorkflowExecution as unknown as IExecutionResponse; workflowsStore.getWorkflowExecution = mockWorkflowExecution as unknown as IExecutionResponse;
workflowsStore.getPastChatMessages = ['Previous message 1', 'Previous message 2']; workflowsStore.getPastChatMessages = ['Previous message 1', 'Previous message 2'];
nodeTypeStore.getNodeType = vi.fn().mockImplementation((nodeTypeName) => {
return mockNodeTypes.find((node) => node.name === nodeTypeName) ?? null;
});
workflowsStore.runWorkflow.mockResolvedValue({ executionId: 'test-execution-issd' });
}); });
afterEach(() => { afterEach(() => {
@@ -190,6 +220,10 @@ describe('CanvasChat', () => {
// Verify message and response // Verify message and response
expect(await findByText('Hello AI!')).toBeInTheDocument(); expect(await findByText('Hello AI!')).toBeInTheDocument();
await waitFor(async () => { await waitFor(async () => {
workflowsStore.getWorkflowExecution = {
...(mockWorkflowExecution as unknown as IExecutionResponse),
status: 'success',
};
expect(await findByText('AI response message')).toBeInTheDocument(); expect(await findByText('AI response message')).toBeInTheDocument();
}); });
@@ -231,11 +265,12 @@ describe('CanvasChat', () => {
await userEvent.type(input, 'Test message'); await userEvent.type(input, 'Test message');
await userEvent.keyboard('{Enter}'); await userEvent.keyboard('{Enter}');
// Verify loading states
uiStore.isActionActive = { workflowRunning: true };
await waitFor(() => expect(queryByTestId('chat-message-typing')).toBeInTheDocument()); await waitFor(() => expect(queryByTestId('chat-message-typing')).toBeInTheDocument());
uiStore.isActionActive = { workflowRunning: false }; workflowsStore.getWorkflowExecution = {
...(mockWorkflowExecution as unknown as IExecutionResponse),
status: 'success',
};
await waitFor(() => expect(queryByTestId('chat-message-typing')).not.toBeInTheDocument()); await waitFor(() => expect(queryByTestId('chat-message-typing')).not.toBeInTheDocument());
}); });
@@ -269,7 +304,7 @@ describe('CanvasChat', () => {
sendMessage: vi.fn(), sendMessage: vi.fn(),
extractResponseMessage: vi.fn(), extractResponseMessage: vi.fn(),
previousMessageIndex: ref(0), previousMessageIndex: ref(0),
waitForExecution: vi.fn(), isLoading: computed(() => false),
}); });
}); });
@@ -339,7 +374,7 @@ describe('CanvasChat', () => {
sendMessage: vi.fn(), sendMessage: vi.fn(),
extractResponseMessage: vi.fn(), extractResponseMessage: vi.fn(),
previousMessageIndex: ref(0), previousMessageIndex: ref(0),
waitForExecution: vi.fn(), isLoading: computed(() => false),
}); });
workflowsStore.isChatPanelOpen = true; workflowsStore.isChatPanelOpen = true;
@@ -437,7 +472,7 @@ describe('CanvasChat', () => {
sendMessage: sendMessageSpy, sendMessage: sendMessageSpy,
extractResponseMessage: vi.fn(), extractResponseMessage: vi.fn(),
previousMessageIndex: ref(0), previousMessageIndex: ref(0),
waitForExecution: vi.fn(), isLoading: computed(() => false),
}); });
workflowsStore.messages = mockMessages; workflowsStore.messages = mockMessages;
}); });
@@ -449,26 +484,25 @@ describe('CanvasChat', () => {
await userEvent.click(repostButton); await userEvent.click(repostButton);
expect(sendMessageSpy).toHaveBeenCalledWith('Original message'); expect(sendMessageSpy).toHaveBeenCalledWith('Original message');
// expect.objectContaining({ expect.objectContaining({
// runData: expect.objectContaining({ runData: expect.objectContaining({
// 'When chat message received': expect.arrayContaining([ 'When chat message received': expect.arrayContaining([
// expect.objectContaining({ expect.objectContaining({
// data: expect.objectContaining({ data: expect.objectContaining({
// main: expect.arrayContaining([ main: expect.arrayContaining([
// expect.arrayContaining([ expect.arrayContaining([
// expect.objectContaining({ expect.objectContaining({
// json: expect.objectContaining({ json: expect.objectContaining({
// chatInput: 'Original message', chatInput: 'Original message',
// }), }),
// }), }),
// ]), ]),
// ]), ]),
// }), }),
// }), }),
// ]), ]),
// }), }),
// }), });
// );
}); });
it('should show message options only for appropriate messages', async () => { it('should show message options only for appropriate messages', async () => {
@@ -494,32 +528,6 @@ describe('CanvasChat', () => {
}); });
}); });
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', () => { describe('panel state synchronization', () => {
it('should update canvas height when chat or logs panel state changes', async () => { it('should update canvas height when chat or logs panel state changes', async () => {
renderComponent(); renderComponent();

View File

@@ -25,11 +25,9 @@ import type { Chat, ChatMessage, ChatOptions } from '@n8n/chat/types';
import type { RunWorkflowChatPayload } from './composables/useChatMessaging'; import type { RunWorkflowChatPayload } from './composables/useChatMessaging';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCanvasStore } from '@/stores/canvas.store'; import { useCanvasStore } from '@/stores/canvas.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const uiStore = useUIStore();
const canvasStore = useCanvasStore(); const canvasStore = useCanvasStore();
const nodeTypesStore = useNodeTypesStore(); const nodeTypesStore = useNodeTypesStore();
const nodeHelpers = useNodeHelpers(); const nodeHelpers = useNodeHelpers();
@@ -43,10 +41,7 @@ const container = ref<HTMLElement>();
// Computed properties // Computed properties
const workflow = computed(() => workflowsStore.getCurrentWorkflow()); const workflow = computed(() => workflowsStore.getCurrentWorkflow());
const isLoading = computed(() => {
const result = uiStore.isActionActive.workflowRunning;
return result;
});
const allConnections = computed(() => workflowsStore.allConnections); const allConnections = computed(() => workflowsStore.allConnections);
const isChatOpen = computed(() => { const isChatOpen = computed(() => {
const result = workflowsStore.isChatPanelOpen; const result = workflowsStore.isChatPanelOpen;
@@ -55,34 +50,38 @@ const isChatOpen = computed(() => {
const canvasNodes = computed(() => workflowsStore.allNodes); const canvasNodes = computed(() => workflowsStore.allNodes);
const isLogsOpen = computed(() => workflowsStore.isLogsPanelOpen); const isLogsOpen = computed(() => workflowsStore.isLogsPanelOpen);
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages); const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
const resultData = computed(() => workflowsStore.getWorkflowRunData);
// Expose internal state for testing // Expose internal state for testing
defineExpose({ defineExpose({
messages, messages,
currentSessionId, currentSessionId,
isDisabled, isDisabled,
workflow, workflow,
isLoading,
}); });
const { runWorkflow } = useRunWorkflow({ router }); const { runWorkflow } = useRunWorkflow({ router });
// Initialize features with injected dependencies // Initialize features with injected dependencies
const { chatTriggerNode, connectedNode, allowFileUploads, setChatTriggerNode, setConnectedNode } = const {
useChatTrigger({ chatTriggerNode,
connectedNode,
allowFileUploads,
allowedFilesMimeTypes,
setChatTriggerNode,
setConnectedNode,
} = useChatTrigger({
workflow, workflow,
canvasNodes, canvasNodes,
getNodeByName: workflowsStore.getNodeByName, getNodeByName: workflowsStore.getNodeByName,
getNodeType: nodeTypesStore.getNodeType, getNodeType: nodeTypesStore.getNodeType,
}); });
const { sendMessage, getChatMessages } = useChatMessaging({ const { sendMessage, getChatMessages, isLoading } = useChatMessaging({
chatTrigger: chatTriggerNode, chatTrigger: chatTriggerNode,
connectedNode, connectedNode,
messages, messages,
sessionId: currentSessionId, sessionId: currentSessionId,
workflow, workflow,
isLoading,
executionResultData: computed(() => workflowsStore.getWorkflowExecution?.data?.resultData), executionResultData: computed(() => workflowsStore.getWorkflowExecution?.data?.resultData),
getWorkflowResultDataByNodeName: workflowsStore.getWorkflowResultDataByNodeName, getWorkflowResultDataByNodeName: workflowsStore.getWorkflowResultDataByNodeName,
onRunChatWorkflow, onRunChatWorkflow,
@@ -132,7 +131,7 @@ function createChatConfig(params: {
showWindowCloseButton: true, showWindowCloseButton: true,
disabled: params.isDisabled, disabled: params.isDisabled,
allowFileUploads: params.allowFileUploads, allowFileUploads: params.allowFileUploads,
allowedFilesMimeTypes: '', allowedFilesMimeTypes,
}; };
return { chatConfig, chatOptions }; return { chatConfig, chatOptions };
@@ -173,16 +172,51 @@ const closePanel = () => {
workflowsStore.setPanelOpen('chat', false); workflowsStore.setPanelOpen('chat', false);
}; };
// This function creates a promise that resolves when the workflow execution completes
// It's used to handle the loading state while waiting for the workflow to finish
async function createExecutionPromise() {
let resolvePromise: () => void;
const promise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
// Watch for changes in the workflow execution status
const stopWatch = watch(
() => workflowsStore.getWorkflowExecution?.status,
(newStatus) => {
// If the status is no longer 'running', resolve the promise
if (newStatus && newStatus !== 'running') {
resolvePromise();
// Stop the watcher when the promise is resolved
stopWatch();
}
},
{ immediate: true }, // Check the status immediately when the watcher is set up
);
// Return the promise, which will resolve when the workflow execution is complete
// This allows the caller to await the execution and handle the loading state appropriately
return await promise;
}
async function onRunChatWorkflow(payload: RunWorkflowChatPayload) { async function onRunChatWorkflow(payload: RunWorkflowChatPayload) {
try {
const response = await runWorkflow({ const response = await runWorkflow({
triggerNode: payload.triggerNode, triggerNode: payload.triggerNode,
nodeData: payload.nodeData, nodeData: payload.nodeData,
source: payload.source, source: payload.source,
}); });
if (response) {
await createExecutionPromise();
workflowsStore.appendChatMessage(payload.message); workflowsStore.appendChatMessage(payload.message);
return response; return response;
} }
return;
} catch (error) {
throw error;
}
}
// Initialize chat config // Initialize chat config
const { chatConfig, chatOptions } = createChatConfig({ const { chatConfig, chatOptions } = createChatConfig({
@@ -264,6 +298,8 @@ watchEffect(() => {
:messages="messages" :messages="messages"
:session-id="currentSessionId" :session-id="currentSessionId"
:past-chat-messages="previousChatMessages" :past-chat-messages="previousChatMessages"
:show-close-button="!connectedNode"
@close="closePanel"
@refresh-session="handleRefreshSession" @refresh-session="handleRefreshSession"
@display-execution="handleDisplayExecution" @display-execution="handleDisplayExecution"
@send-message="sendMessage" @send-message="sendMessage"
@@ -272,7 +308,7 @@ watchEffect(() => {
</n8n-resize-wrapper> </n8n-resize-wrapper>
<div v-if="isLogsOpen && connectedNode" :class="$style.logs"> <div v-if="isLogsOpen && connectedNode" :class="$style.logs">
<ChatLogsPanel <ChatLogsPanel
:key="messages.length" :key="`${resultData?.length ?? messages?.length}`"
:workflow="workflow" :workflow="workflow"
data-test-id="canvas-chat-logs" data-test-id="canvas-chat-logs"
:node="connectedNode" :node="connectedNode"

View File

@@ -17,6 +17,7 @@ interface Props {
pastChatMessages: string[]; pastChatMessages: string[];
messages: ChatMessage[]; messages: ChatMessage[];
sessionId: string; sessionId: string;
showCloseButton?: boolean;
} }
const props = defineProps<Props>(); const props = defineProps<Props>();
@@ -25,6 +26,7 @@ const emit = defineEmits<{
displayExecution: [id: string]; displayExecution: [id: string];
sendMessage: [message: string]; sendMessage: [message: string];
refreshSession: []; refreshSession: [];
close: [];
}>(); }>();
const messageComposable = useMessage(); const messageComposable = useMessage();
@@ -142,7 +144,7 @@ function copySessionId() {
<template> <template>
<div :class="$style.chat" data-test-id="workflow-lm-chat-dialog"> <div :class="$style.chat" data-test-id="workflow-lm-chat-dialog">
<header :class="$style.chatHeader"> <header :class="$style.chatHeader">
<span>{{ locale.baseText('chat.window.title') }}</span> <span :class="$style.chatTitle">{{ locale.baseText('chat.window.title') }}</span>
<div :class="$style.session"> <div :class="$style.session">
<span>{{ locale.baseText('chat.window.session.title') }}</span> <span>{{ locale.baseText('chat.window.session.title') }}</span>
<n8n-tooltip placement="left"> <n8n-tooltip placement="left">
@@ -154,15 +156,24 @@ function copySessionId() {
}}</span> }}</span>
</n8n-tooltip> </n8n-tooltip>
<n8n-icon-button <n8n-icon-button
:class="$style.refreshSession" :class="$style.headerButton"
data-test-id="refresh-session-button" data-test-id="refresh-session-button"
type="tertiary" outline
text type="secondary"
size="mini" size="mini"
icon="undo" icon="undo"
:title="locale.baseText('chat.window.session.reset.confirm')" :title="locale.baseText('chat.window.session.reset.confirm')"
@click="onRefreshSession" @click="onRefreshSession"
/> />
<n8n-icon-button
v-if="showCloseButton"
:class="$style.headerButton"
outline
type="secondary"
size="mini"
icon="times"
@click="emit('close')"
/>
</div> </div>
</header> </header>
<main :class="$style.chatBody"> <main :class="$style.chatBody">
@@ -199,7 +210,13 @@ function copySessionId() {
</main> </main>
<div :class="$style.messagesInput"> <div :class="$style.messagesInput">
<div v-if="pastChatMessages.length > 0" :class="$style.messagesHistory"> <ChatInput
data-test-id="lm-chat-inputs"
:placeholder="inputPlaceholder"
@arrow-key-down="onArrowKeyDown"
>
<template v-if="pastChatMessages.length > 0" #leftPanel>
<div :class="$style.messagesHistory">
<n8n-button <n8n-button
title="Navigate to previous message" title="Navigate to previous message"
icon="chevron-up" icon="chevron-up"
@@ -217,11 +234,8 @@ function copySessionId() {
@click="onArrowKeyDown({ currentInputValue: '', key: 'ArrowDown' })" @click="onArrowKeyDown({ currentInputValue: '', key: 'ArrowDown' })"
/> />
</div> </div>
<ChatInput </template>
data-test-id="lm-chat-inputs" </ChatInput>
:placeholder="inputPlaceholder"
@arrow-key-down="onArrowKeyDown"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -229,19 +243,22 @@ function copySessionId() {
<style lang="scss" module> <style lang="scss" module>
.chat { .chat {
--chat--spacing: var(--spacing-xs); --chat--spacing: var(--spacing-xs);
--chat--message--padding: var(--spacing-xs); --chat--message--padding: var(--spacing-2xs);
--chat--message--font-size: var(--font-size-s); --chat--message--font-size: var(--font-size-xs);
--chat--input--font-size: var(--font-size-s); --chat--input--font-size: var(--font-size-s);
--chat--input--placeholder--font-size: var(--font-size-xs);
--chat--message--bot--background: transparent; --chat--message--bot--background: transparent;
--chat--message--user--background: var(--color-text-lighter); --chat--message--user--background: var(--color-text-lighter);
--chat--message--bot--color: var(--color-text-dark); --chat--message--bot--color: var(--color-text-dark);
--chat--message--user--color: var(--color-text-dark); --chat--message--user--color: var(--color-text-dark);
--chat--message--bot--border: none; --chat--message--bot--border: none;
--chat--message--user--border: none; --chat--message--user--border: none;
--chat--message--user--border: none;
--chat--input--padding: var(--spacing-xs);
--chat--color-typing: var(--color-text-light); --chat--color-typing: var(--color-text-light);
--chat--textarea--max-height: calc(var(--panel-height) * 0.5); --chat--textarea--max-height: calc(var(--panel-height) * 0.3);
--chat--message--pre--background: var(--color-foreground-light); --chat--message--pre--background: var(--color-foreground-light);
--chat--textarea--height: 2.5rem;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -260,6 +277,9 @@ function copySessionId() {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.chatTitle {
font-weight: 600;
}
.session { .session {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -275,8 +295,9 @@ function copySessionId() {
cursor: pointer; cursor: pointer;
} }
.refreshSession { .headerButton {
max-height: 1.1rem; max-height: 1.1rem;
border: none;
} }
.chatBody { .chatBody {
display: flex; display: flex;
@@ -306,7 +327,7 @@ function copySessionId() {
--chat--input--file--button--background: transparent; --chat--input--file--button--background: transparent;
--chat--input--file--button--color: var(--color-primary); --chat--input--file--button--color: var(--color-primary);
--chat--input--border-active: var(--input-focus-border-color, var(--color-secondary)); --chat--input--border-active: var(--input-focus-border-color, var(--color-secondary));
--chat--files-spacing: var(--spacing-2xs) 0; --chat--files-spacing: var(--spacing-2xs);
--chat--input--background: transparent; --chat--input--background: transparent;
--chat--input--file--button--color: var(--color-button-secondary-font); --chat--input--file--button--color: var(--color-button-secondary-font);
--chat--input--file--button--color-hover: var(--color-primary); --chat--input--file--button--color-hover: var(--color-primary);
@@ -318,7 +339,7 @@ function copySessionId() {
--chat--input--text-color: var(--input-font-color, var(--color-text-dark)); --chat--input--text-color: var(--input-font-color, var(--color-text-dark));
} }
padding: 0 0 0 var(--spacing-xs); padding: var(--spacing-5xs);
margin: 0 var(--chat--spacing) var(--chat--spacing); margin: 0 var(--chat--spacing) var(--chat--spacing);
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
@@ -333,16 +354,4 @@ function copySessionId() {
--input-border-color: #4538a3; --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> </style>

View File

@@ -1,5 +1,5 @@
import type { ComputedRef, Ref } from 'vue'; import type { ComputedRef, Ref } from 'vue';
import { ref } from 'vue'; import { computed, ref } from 'vue';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types'; import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
import { NodeConnectionType, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow'; import { NodeConnectionType, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
@@ -34,7 +34,6 @@ export interface ChatMessagingDependencies {
messages: Ref<ChatMessage[]>; messages: Ref<ChatMessage[]>;
sessionId: Ref<string>; sessionId: Ref<string>;
workflow: ComputedRef<Workflow>; workflow: ComputedRef<Workflow>;
isLoading: ComputedRef<boolean>;
executionResultData: ComputedRef<IRunExecutionData['resultData'] | undefined>; executionResultData: ComputedRef<IRunExecutionData['resultData'] | undefined>;
getWorkflowResultDataByNodeName: (nodeName: string) => ITaskData[] | null; getWorkflowResultDataByNodeName: (nodeName: string) => ITaskData[] | null;
onRunChatWorkflow: ( onRunChatWorkflow: (
@@ -48,7 +47,6 @@ export function useChatMessaging({
messages, messages,
sessionId, sessionId,
workflow, workflow,
isLoading,
executionResultData, executionResultData,
getWorkflowResultDataByNodeName, getWorkflowResultDataByNodeName,
onRunChatWorkflow, onRunChatWorkflow,
@@ -56,6 +54,7 @@ export function useChatMessaging({
const locale = useI18n(); const locale = useI18n();
const { showError } = useToast(); const { showError } = useToast();
const previousMessageIndex = ref(0); const previousMessageIndex = ref(0);
const isLoading = ref(false);
/** Converts a file to binary data */ /** Converts a file to binary data */
async function convertFileToBinaryData(file: File): Promise<IBinaryData> { async function convertFileToBinaryData(file: File): Promise<IBinaryData> {
@@ -147,37 +146,27 @@ export function useChatMessaging({
}, },
source: [null], source: [null],
}; };
isLoading.value = true;
const response = await onRunChatWorkflow({ const response = await onRunChatWorkflow({
triggerNode: triggerNode.name, triggerNode: triggerNode.name,
nodeData, nodeData,
source: 'RunData.ManualChatMessage', source: 'RunData.ManualChatMessage',
message, message,
}); });
isLoading.value = false;
if (!response?.executionId) { if (!response?.executionId) {
showError(
new Error('It was not possible to start workflow!'),
'Workflow could not be started',
);
return; return;
} }
waitForExecution(response.executionId); processExecutionResultData(response.executionId);
} }
/** Waits for workflow execution to complete */ function processExecutionResultData(executionId: string) {
function waitForExecution(executionId: string) {
const waitInterval = setInterval(() => {
if (!isLoading.value) {
clearInterval(waitInterval);
const lastNodeExecuted = executionResultData.value?.lastNodeExecuted; const lastNodeExecuted = executionResultData.value?.lastNodeExecuted;
if (!lastNodeExecuted) return; if (!lastNodeExecuted) return;
const nodeResponseDataArray = const nodeResponseDataArray = get(executionResultData.value.runData, lastNodeExecuted) ?? [];
get(executionResultData.value.runData, lastNodeExecuted) ?? [];
const nodeResponseData = nodeResponseDataArray[nodeResponseDataArray.length - 1]; const nodeResponseData = nodeResponseDataArray[nodeResponseDataArray.length - 1];
@@ -189,7 +178,7 @@ export function useChatMessaging({
const responseData = get(nodeResponseData, 'data.main[0][0].json'); const responseData = get(nodeResponseData, 'data.main[0][0].json');
responseMessage = extractResponseMessage(responseData); responseMessage = extractResponseMessage(responseData);
} }
isLoading.value = false;
messages.value.push({ messages.value.push({
text: responseMessage, text: responseMessage,
sender: 'bot', sender: 'bot',
@@ -197,8 +186,6 @@ export function useChatMessaging({
id: executionId ?? uuid(), id: executionId ?? uuid(),
}); });
} }
}, 500);
}
/** Extracts response message from workflow output */ /** Extracts response message from workflow output */
function extractResponseMessage(responseData?: IDataObject) { function extractResponseMessage(responseData?: IDataObject) {
@@ -291,9 +278,9 @@ export function useChatMessaging({
return { return {
previousMessageIndex, previousMessageIndex,
isLoading: computed(() => isLoading.value),
sendMessage, sendMessage,
extractResponseMessage, extractResponseMessage,
waitForExecution,
getChatMessages, getChatMessages,
}; };
} }

View File

@@ -114,7 +114,7 @@ defineExpose({ editor });
:global(.cm-content) { :global(.cm-content) {
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);
&[aria-readonly='true'] { &[aria-readonly='true'] {
--disabled-fill: var(--color-background-medium); --disabled-fill: var(--color-background-base);
background-color: var(--disabled-fill, var(--color-background-light)); background-color: var(--disabled-fill, var(--color-background-light));
color: var(--disabled-color, var(--color-text-base)); color: var(--disabled-color, var(--color-text-base));
cursor: not-allowed; cursor: not-allowed;

View File

@@ -134,7 +134,7 @@ defineExpose({
padding-left: 0; padding-left: 0;
} }
:deep(.cm-content) { :deep(.cm-content) {
--disabled-fill: var(--color-background-medium); --disabled-fill: var(--color-background-base);
padding-left: var(--spacing-2xs); padding-left: var(--spacing-2xs);
&[aria-readonly='true'] { &[aria-readonly='true'] {

View File

@@ -260,12 +260,13 @@ onMounted(() => {
.contentText { .contentText {
padding-top: var(--spacing-s); padding-top: var(--spacing-s);
padding-left: var(--spacing-m); padding-left: var(--spacing-m);
padding-right: var(--spacing-m);
font-size: var(--font-size-s); font-size: var(--font-size-s);
} }
.block { .block {
padding: 0 0 var(--spacing-2xs) var(--spacing-2xs); padding: var(--spacing-s) 0 var(--spacing-2xs) var(--spacing-2xs);
background: var(--color-foreground-light); border: 1px solid var(--color-foreground-light);
margin-top: var(--spacing-xl); margin-top: var(--spacing-s);
border-radius: var(--border-radius-base); border-radius: var(--border-radius-base);
} }
:root .blockContent { :root .blockContent {

View File

@@ -167,10 +167,6 @@ function getTreeNodeData(nodeName: string, currentDepth: number): TreeNode[] {
const aiData = computed<AIResult[]>(() => { const aiData = computed<AIResult[]>(() => {
const result: AIResult[] = []; const result: AIResult[] = [];
const connectedSubNodes = props.workflow.getParentNodes(props.node.name, 'ALL_NON_MAIN'); const connectedSubNodes = props.workflow.getParentNodes(props.node.name, 'ALL_NON_MAIN');
const rootNodeResult = workflowsStore.getWorkflowResultDataByNodeName(props.node.name);
const rootNodeStartTime = rootNodeResult?.[props.runIndex ?? 0]?.startTime ?? 0;
const rootNodeEndTime =
rootNodeStartTime + (rootNodeResult?.[props.runIndex ?? 0]?.executionTime ?? 0);
connectedSubNodes.forEach((nodeName) => { connectedSubNodes.forEach((nodeName) => {
const nodeRunData = workflowsStore.getWorkflowResultDataByNodeName(nodeName) ?? []; const nodeRunData = workflowsStore.getWorkflowResultDataByNodeName(nodeName) ?? [];
@@ -193,15 +189,7 @@ const aiData = computed<AIResult[]>(() => {
return aTime - bTime; return aTime - bTime;
}); });
// Only show data that is within the root node's execution time return result;
// This is because sub-node could be connected to multiple root nodes
const currentNodeResult = result.filter((r) => {
const startTime = r.data?.metadata?.startTime ?? 0;
return startTime >= rootNodeStartTime && startTime < rootNodeEndTime;
});
return currentNodeResult;
}); });
const executionTree = computed<TreeNode[]>(() => { const executionTree = computed<TreeNode[]>(() => {
@@ -361,10 +349,8 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
margin-right: var(--spacing-4xs); 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);

View File

@@ -1,17 +1,16 @@
<script lang="ts" setup> <script lang="ts" setup>
export interface Props { export interface Props {
outline?: boolean; type: 'primary' | 'tertiary';
label: string;
} }
defineProps<Props>(); defineProps<Props>();
</script> </script>
<template> <template>
<N8nButton <N8nButton
label="Chat" :label="label"
size="large" size="large"
icon="comment" icon="comment"
type="primary" :type="type"
:outline="outline"
data-test-id="workflow-chat-button" data-test-id="workflow-chat-button"
/> />
</template> </template>

View File

@@ -1,3 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CanvasChatButton > should render correctly 1`] = `"<button class="button button primary large withIcon" aria-live="polite" data-test-id="workflow-chat-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-comment fa-w-16 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="comment" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path class="" fill="currentColor" d="M256 32C114.6 32 0 125.1 0 240c0 49.6 21.4 95 57 130.7C44.5 421.1 2.7 466 2.2 466.5c-2.2 2.3-2.8 5.7-1.5 8.7S4.8 480 8 480c66.3 0 116-31.8 140.6-51.4 32.7 12.3 69 19.4 107.4 19.4 141.4 0 256-93.1 256-208S397.4 32 256 32z"></path></svg></span></span><span>Chat</span></button>"`; exports[`CanvasChatButton > should render correctly 1`] = `
"<button class="button button primary large withIcon" aria-live="polite" data-test-id="workflow-chat-button"><span class="icon"><span class="n8n-text compact size-large regular n8n-icon n8n-icon"><svg class="svg-inline--fa fa-comment fa-w-16 large" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="comment" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path class="" fill="currentColor" d="M256 32C114.6 32 0 125.1 0 240c0 49.6 21.4 95 57 130.7C44.5 421.1 2.7 466 2.2 466.5c-2.2 2.3-2.8 5.7-1.5 8.7S4.8 480 8 480c66.3 0 116-31.8 140.6-51.4 32.7 12.3 69 19.4 107.4 19.4 141.4 0 256-93.1 256-208S397.4 32 256 32z"></path></svg></span></span>
<!--v-if-->
</button>"
`;

View File

@@ -170,6 +170,7 @@
"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.hide": "Hide chat",
"chat.window.title": "Chat", "chat.window.title": "Chat",
"chat.window.logs": "Latest Logs", "chat.window.logs": "Latest Logs",
"chat.window.logsFromNode": "from {nodeName} node", "chat.window.logsFromNode": "from {nodeName} node",

View File

@@ -1616,7 +1616,8 @@ onBeforeUnmount(() => {
/> />
<CanvasChatButton <CanvasChatButton
v-if="containsChatTriggerNodes" v-if="containsChatTriggerNodes"
:outline="isChatOpen === false" :type="isChatOpen ? 'tertiary' : 'primary'"
:label="isChatOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.window.title')"
@click="onOpenChat" @click="onOpenChat"
/> />
<CanvasStopCurrentExecutionButton <CanvasStopCurrentExecutionButton

View File

@@ -4626,11 +4626,10 @@ export default defineComponent({
<n8n-button <n8n-button
v-if="containsChatNodes" v-if="containsChatNodes"
label="Chat" :label="isChatOpen ? i18n.baseText('chat.hide') : i18n.baseText('chat.window.title')"
size="large" size="large"
icon="comment" icon="comment"
type="primary" :type="isChatOpen ? 'tertiary' : 'primary'"
:outline="isChatOpen === false"
data-test-id="workflow-chat-button" data-test-id="workflow-chat-button"
@click.stop="onOpenChat" @click.stop="onOpenChat"
/> />