feat(editor): Redesign Canvas Chat (#11634)

This commit is contained in:
oleg 2024-11-13 11:05:19 +01:00 committed by GitHub
parent 93a6f858fa
commit a412ab7ebf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 2451 additions and 1063 deletions

View file

@ -3,7 +3,7 @@
*/
export function getManualChatModal() {
return cy.getByTestId('lmChat-modal');
return cy.getByTestId('canvas-chat');
}
export function getManualChatInput() {
@ -19,11 +19,11 @@ export function getManualChatMessages() {
}
export function getManualChatModalCloseButton() {
return getManualChatModal().get('.el-dialog__close');
return cy.getByTestId('workflow-chat-button');
}
export function getManualChatModalLogs() {
return getManualChatModal().getByTestId('lm-chat-logs');
return cy.getByTestId('canvas-chat-logs');
}
export function getManualChatDialog() {
return getManualChatModal().getByTestId('workflow-lm-chat-dialog');

View file

@ -14,7 +14,6 @@ import {
} from './../constants';
import {
closeManualChatModal,
getManualChatDialog,
getManualChatMessages,
getManualChatModal,
getManualChatModalLogs,
@ -168,7 +167,7 @@ describe('Langchain Integration', () => {
lastNodeExecuted: BASIC_LLM_CHAIN_NODE_NAME,
});
getManualChatDialog().should('contain', outputMessage);
getManualChatMessages().should('contain', outputMessage);
});
it('should be able to open and execute Agent node', () => {
@ -208,7 +207,7 @@ describe('Langchain Integration', () => {
lastNodeExecuted: AGENT_NODE_NAME,
});
getManualChatDialog().should('contain', outputMessage);
getManualChatMessages().should('contain', outputMessage);
});
it('should add and use Manual Chat Trigger node together with Agent node', () => {
@ -229,8 +228,6 @@ describe('Langchain Integration', () => {
clickManualChatButton();
getManualChatModalLogs().should('not.exist');
const inputMessage = 'Hello!';
const outputMessage = 'Hi there! How can I assist you today?';
const runData = [
@ -335,6 +332,8 @@ describe('Langchain Integration', () => {
getManualChatModalLogsEntries().should('have.length', 1);
closeManualChatModal();
getManualChatModalLogs().should('not.exist');
getManualChatModal().should('not.exist');
});
it('should auto-add chat trigger and basic LLM chain when adding LLM node', () => {

View file

@ -16,6 +16,7 @@
"build:nodes": "turbo run build:nodes",
"typecheck": "turbo typecheck",
"dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner",
"dev:be": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui",
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core",
"clean": "turbo run clean --parallel",
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",

View file

@ -1 +1,13 @@
import '@testing-library/jest-dom';
import '@testing-library/jest-dom';
import { configure } from '@testing-library/vue';
configure({ testIdAttribute: 'data-test-id' });
window.ResizeObserver =
window.ResizeObserver ||
vi.fn().mockImplementation(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn(),
}));

View file

@ -30,22 +30,23 @@ const TypeIcon = computed(() => {
});
function onClick() {
if (props.isRemovable) {
emit('remove', props.file);
}
if (props.isPreviewable) {
window.open(URL.createObjectURL(props.file));
}
}
function onDelete() {
emit('remove', props.file);
}
</script>
<template>
<div class="chat-file" @click="onClick">
<TypeIcon />
<p class="chat-file-name">{{ file.name }}</p>
<IconDelete v-if="isRemovable" class="chat-file-delete" />
<IconPreview v-if="isPreviewable" class="chat-file-preview" />
<span v-if="isRemovable" class="chat-file-delete" @click.stop="onDelete">
<IconDelete />
</span>
<IconPreview v-else-if="isPreviewable" class="chat-file-preview" />
</div>
</template>
@ -80,12 +81,25 @@ function onClick() {
.chat-file-preview {
background: none;
border: none;
display: none;
display: block;
cursor: pointer;
flex-shrink: 0;
}
.chat-file:hover & {
display: block;
.chat-file-delete {
position: relative;
&:hover {
color: red;
}
/* Increase hit area for better clickability */
&:before {
content: '';
position: absolute;
top: -10px;
right: -10px;
bottom: -10px;
left: -10px;
}
}
</style>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import { useFileDialog } from '@vueuse/core';
import IconFilePlus from 'virtual:icons/mdi/filePlus';
import IconPaperclip from 'virtual:icons/mdi/paperclip';
import IconSend from 'virtual:icons/mdi/send';
import { computed, onMounted, onUnmounted, ref, unref } from 'vue';
@ -9,10 +9,20 @@ import { chatEventBus } from '@n8n/chat/event-buses';
import ChatFile from './ChatFile.vue';
export interface ChatInputProps {
placeholder?: string;
}
const props = withDefaults(defineProps<ChatInputProps>(), {
placeholder: 'inputPlaceholder',
});
export interface ArrowKeyDownPayload {
key: 'ArrowUp' | 'ArrowDown';
currentInputValue: string;
}
const { t } = useI18n();
const emit = defineEmits<{
arrowKeyDown: [value: ArrowKeyDownPayload];
}>();
@ -20,12 +30,12 @@ const emit = defineEmits<{
const { options } = useOptions();
const chatStore = useChat();
const { waitingForResponse } = chatStore;
const { t } = useI18n();
const files = ref<FileList | null>(null);
const chatTextArea = ref<HTMLTextAreaElement | null>(null);
const input = ref('');
const isSubmitting = ref(false);
const resizeObserver = ref<ResizeObserver | null>(null);
const isSubmitDisabled = computed(() => {
return input.value === '' || waitingForResponse.value || options.disabled?.value === true;
@ -74,12 +84,30 @@ onMounted(() => {
chatEventBus.on('focusInput', focusChatInput);
chatEventBus.on('blurInput', blurChatInput);
chatEventBus.on('setInputValue', setInputValue);
if (chatTextArea.value) {
resizeObserver.value = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === chatTextArea.value) {
adjustHeight({ target: chatTextArea.value } as unknown as Event);
}
}
});
// Start observing the textarea
resizeObserver.value.observe(chatTextArea.value);
}
});
onUnmounted(() => {
chatEventBus.off('focusInput', focusChatInput);
chatEventBus.off('blurInput', blurChatInput);
chatEventBus.off('setInputValue', setInputValue);
if (resizeObserver.value) {
resizeObserver.value.disconnect();
resizeObserver.value = null;
}
});
function blurChatInput() {
@ -121,6 +149,7 @@ async function onSubmitKeydown(event: KeyboardEvent) {
}
await onSubmit(event);
adjustHeight({ target: chatTextArea.value } as unknown as Event);
}
function onFileRemove(file: File) {
@ -151,6 +180,15 @@ function onOpenFileDialog() {
if (isFileUploadDisabled.value) return;
openFileDialog({ accept: unref(allowedFileTypes) });
}
function adjustHeight(event: Event) {
const textarea = event.target as HTMLTextAreaElement;
// Set to content minimum to get the right scrollHeight
textarea.style.height = 'var(--chat--textarea--height)';
// Get the new height, with a small buffer for padding
const newHeight = Math.min(textarea.scrollHeight, 480); // 30rem
textarea.style.height = `${newHeight}px`;
}
</script>
<template>
@ -158,20 +196,25 @@ function onOpenFileDialog() {
<div class="chat-inputs">
<textarea
ref="chatTextArea"
data-test-id="chat-input"
v-model="input"
:disabled="isInputDisabled"
:placeholder="t('inputPlaceholder')"
:placeholder="t(props.placeholder)"
@keydown.enter="onSubmitKeydown"
@input="adjustHeight"
@mousedown="adjustHeight"
@focus="adjustHeight"
/>
<div class="chat-inputs-controls">
<button
v-if="isFileUploadAllowed"
:disabled="isFileUploadDisabled"
class="chat-input-send-button"
class="chat-input-file-button"
data-test-id="chat-attach-file-button"
@click="onOpenFileDialog"
>
<IconFilePlus height="24" width="24" />
<IconPaperclip height="24" width="24" />
</button>
<button :disabled="isSubmitDisabled" class="chat-input-send-button" @click="onSubmit">
<IconSend height="24" width="24" />
@ -184,6 +227,7 @@ function onOpenFileDialog() {
:key="file.name"
:file="file"
:is-removable="true"
:is-previewable="true"
@remove="onFileRemove"
/>
</div>
@ -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);
}
</style>

View file

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

View file

@ -34,7 +34,12 @@ onMounted(() => {
});
</script>
<template>
<Message ref="messageContainer" :class="classes" :message="message">
<Message
ref="messageContainer"
:class="classes"
:message="message"
data-test-id="chat-message-typing"
>
<div class="chat-message-typing-body">
<span class="chat-message-typing-circle"></span>
<span class="chat-message-typing-circle"></span>

View file

@ -37,7 +37,7 @@ body {
4. Prevent font size adjustment after orientation changes (IE, iOS)
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;
}

View file

@ -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<Element | null>(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);
});
</script>
<template>
@ -94,12 +100,17 @@ const updateGridWidth = async () => {
<router-view name="sidebar"></router-view>
</div>
<div id="content" :class="$style.content">
<router-view v-slot="{ Component }">
<keep-alive v-if="$route.meta.keepWorkflowAlive" include="NodeViewSwitcher" :max="1">
<component :is="Component" />
</keep-alive>
<component :is="Component" v-else />
</router-view>
<div :class="$style.contentWrapper">
<router-view v-slot="{ Component }">
<keep-alive v-if="$route.meta.keepWorkflowAlive" include="NodeViewSwitcher" :max="1">
<component :is="Component" />
</keep-alive>
<component :is="Component" v-else />
</router-view>
</div>
<div v-if="hasContentFooter" :class="$style.contentFooter">
<router-view name="footer" />
</div>
</div>
<div :id="APP_MODALS_ELEMENT_ID" :class="$style.modals">
<Modals />
@ -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;

View file

@ -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<INodeTypeDescription>({
@ -77,6 +79,7 @@ export const mockNodeTypeDescription = ({
group: EXECUTABLE_TRIGGER_NODE_TYPES.includes(name) ? ['trigger'] : [],
inputs,
outputs,
codex,
credentials,
documentationUrl: 'https://docs',
webhooks: undefined,

View file

@ -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` }"
>
<n8n-tooltip
:z-index="APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON_TOOLTIP"
@ -61,7 +64,7 @@ const onClick = () => {
<style lang="scss" module>
.container {
position: absolute;
bottom: var(--spacing-s);
bottom: calc(var(--canvas-panel-height-offset, 0px) + var(--spacing-s));
right: var(--spacing-s);
z-index: var(--z-index-ask-assistant-floating-button);
}

View file

@ -0,0 +1,586 @@
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
import { userEvent } from '@testing-library/user-event';
import { createRouter, createWebHistory } from 'vue-router';
import { computed, ref } from 'vue';
import { NodeConnectionType } from 'n8n-workflow';
import CanvasChat from './CanvasChat.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestWorkflowObject } from '@/__tests__/mocks';
import { mockedStore } from '@/__tests__/utils';
import { STORES } from '@/constants';
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
import { chatEventBus } from '@n8n/chat/event-buses';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useUIStore } from '@/stores/ui.store';
import { useCanvasStore } from '@/stores/canvas.store';
import * as useChatMessaging from './composables/useChatMessaging';
import * as useChatTrigger from './composables/useChatTrigger';
import { useToast } from '@/composables/useToast';
import type { IExecutionResponse, INodeUi } from '@/Interface';
import type { ChatMessage } from '@n8n/chat/types';
vi.mock('@/composables/useToast', () => {
const showMessage = vi.fn();
const showError = vi.fn();
return {
useToast: () => {
return {
showMessage,
showError,
clearAllStickyNotifications: vi.fn(),
};
},
};
});
// Test data
const mockNodes: INodeUi[] = [
{
parameters: {
options: {
allowFileUploads: true,
},
},
id: 'chat-trigger-id',
name: 'When chat message received',
type: '@n8n/n8n-nodes-langchain.chatTrigger',
typeVersion: 1.1,
position: [740, 860],
webhookId: 'webhook-id',
},
{
parameters: {},
id: 'agent-id',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 1.7,
position: [960, 860],
},
];
const mockConnections = {
'When chat message received': {
main: [
[
{
node: 'AI Agent',
type: NodeConnectionType.Main,
index: 0,
},
],
],
},
};
const mockWorkflowExecution = {
data: {
resultData: {
runData: {
'AI Agent': [
{
data: {
main: [[{ json: { output: 'AI response message' } }]],
},
},
],
},
lastNodeExecuted: 'AI Agent',
},
},
};
const router = createRouter({
history: createWebHistory(),
routes: [],
});
describe('CanvasChat', () => {
const renderComponent = createComponentRenderer(CanvasChat, {
global: {
provide: {
[ChatSymbol as symbol]: {},
[ChatOptionsSymbol as symbol]: {},
},
plugins: [router],
},
});
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
let canvasStore: ReturnType<typeof mockedStore<typeof useCanvasStore>>;
beforeEach(() => {
const pinia = createTestingPinia({
initialState: {
[STORES.WORKFLOWS]: {
workflow: {
nodes: mockNodes,
connections: mockConnections,
},
},
[STORES.UI]: {
chatPanelOpen: true,
},
},
});
setActivePinia(pinia);
workflowsStore = mockedStore(useWorkflowsStore);
uiStore = mockedStore(useUIStore);
canvasStore = mockedStore(useCanvasStore);
// Setup default mocks
workflowsStore.getCurrentWorkflow.mockReturnValue(
createTestWorkflowObject({
nodes: mockNodes,
connections: mockConnections,
}),
);
workflowsStore.getNodeByName.mockImplementation(
(name) => mockNodes.find((node) => node.name === name) ?? null,
);
workflowsStore.isChatPanelOpen = true;
workflowsStore.getWorkflowExecution = mockWorkflowExecution as unknown as IExecutionResponse;
workflowsStore.getPastChatMessages = ['Previous message 1', 'Previous message 2'];
});
afterEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
it('should render chat when panel is open', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('canvas-chat')).toBeInTheDocument();
});
it('should not render chat when panel is closed', async () => {
workflowsStore.isChatPanelOpen = false;
const { queryByTestId } = renderComponent();
await waitFor(() => {
expect(queryByTestId('canvas-chat')).not.toBeInTheDocument();
});
});
it('should show correct input placeholder', async () => {
const { findByTestId } = renderComponent();
expect(await findByTestId('chat-input')).toBeInTheDocument();
});
});
describe('message handling', () => {
beforeEach(() => {
vi.spyOn(chatEventBus, 'emit');
workflowsStore.runWorkflow.mockResolvedValue({ executionId: 'test-execution-id' });
});
it('should send message and show response', async () => {
const { findByTestId, findByText } = renderComponent();
// Send message
const input = await findByTestId('chat-input');
await userEvent.type(input, 'Hello AI!');
await userEvent.keyboard('{Enter}');
// Verify message and response
expect(await findByText('Hello AI!')).toBeInTheDocument();
await waitFor(async () => {
expect(await findByText('AI response message')).toBeInTheDocument();
});
// Verify workflow execution
expect(workflowsStore.runWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
runData: {
'When chat message received': [
{
data: {
main: [
[
{
json: {
action: 'sendMessage',
chatInput: 'Hello AI!',
sessionId: expect.any(String),
},
},
],
],
},
executionStatus: 'success',
executionTime: 0,
source: [null],
startTime: expect.any(Number),
},
],
},
}),
);
});
it('should show loading state during message processing', async () => {
const { findByTestId, queryByTestId } = renderComponent();
// Send message
const input = await findByTestId('chat-input');
await userEvent.type(input, 'Test message');
await userEvent.keyboard('{Enter}');
// Verify loading states
uiStore.isActionActive = { workflowRunning: true };
await waitFor(() => expect(queryByTestId('chat-message-typing')).toBeInTheDocument());
uiStore.isActionActive = { workflowRunning: false };
await waitFor(() => expect(queryByTestId('chat-message-typing')).not.toBeInTheDocument());
});
it('should handle workflow execution errors', async () => {
workflowsStore.runWorkflow.mockRejectedValueOnce(new Error());
const { findByTestId } = renderComponent();
const input = await findByTestId('chat-input');
await userEvent.type(input, 'Hello AI!');
await userEvent.keyboard('{Enter}');
const toast = useToast();
expect(toast.showError).toHaveBeenCalledWith(new Error(), 'Problem running workflow');
});
});
describe('session management', () => {
const mockMessages: ChatMessage[] = [
{
id: '1',
text: 'Existing message',
sender: 'user',
createdAt: new Date().toISOString(),
},
];
beforeEach(() => {
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
getChatMessages: vi.fn().mockReturnValue(mockMessages),
sendMessage: vi.fn(),
extractResponseMessage: vi.fn(),
previousMessageIndex: ref(0),
waitForExecution: vi.fn(),
});
});
it('should allow copying session ID', async () => {
const clipboardSpy = vi.fn();
document.execCommand = clipboardSpy;
const { getByTestId } = renderComponent();
await userEvent.click(getByTestId('chat-session-id'));
const toast = useToast();
expect(clipboardSpy).toHaveBeenCalledWith('copy');
expect(toast.showMessage).toHaveBeenCalledWith({
message: '',
title: 'Copied to clipboard',
type: 'success',
});
});
it('should refresh session with confirmation when messages exist', async () => {
const { getByTestId, getByRole } = renderComponent();
const originalSessionId = getByTestId('chat-session-id').textContent;
await userEvent.click(getByTestId('refresh-session-button'));
const confirmButton = getByRole('dialog').querySelector('button.btn--confirm');
if (!confirmButton) throw new Error('Confirm button not found');
await userEvent.click(confirmButton);
expect(getByTestId('chat-session-id').textContent).not.toEqual(originalSessionId);
});
});
describe('resize functionality', () => {
it('should handle panel resizing', async () => {
const { container } = renderComponent();
const resizeWrapper = container.querySelector('.resizeWrapper');
if (!resizeWrapper) throw new Error('Resize wrapper not found');
await userEvent.pointer([
{ target: resizeWrapper, coords: { clientX: 0, clientY: 0 } },
{ coords: { clientX: 0, clientY: 100 } },
]);
expect(canvasStore.setPanelHeight).toHaveBeenCalled();
});
it('should persist resize dimensions', () => {
const mockStorage = {
getItem: vi.fn(),
setItem: vi.fn(),
};
Object.defineProperty(window, 'localStorage', { value: mockStorage });
renderComponent();
expect(mockStorage.getItem).toHaveBeenCalledWith('N8N_CANVAS_CHAT_HEIGHT');
expect(mockStorage.getItem).toHaveBeenCalledWith('N8N_CANVAS_CHAT_WIDTH');
});
});
describe('file handling', () => {
beforeEach(() => {
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
getChatMessages: vi.fn().mockReturnValue([]),
sendMessage: vi.fn(),
extractResponseMessage: vi.fn(),
previousMessageIndex: ref(0),
waitForExecution: vi.fn(),
});
workflowsStore.isChatPanelOpen = true;
workflowsStore.allowFileUploads = true;
});
it('should enable file uploads when allowed by chat trigger node', async () => {
const allowFileUploads = ref(true);
const original = useChatTrigger.useChatTrigger;
vi.spyOn(useChatTrigger, 'useChatTrigger').mockImplementation((...args) => ({
...original(...args),
allowFileUploads: computed(() => allowFileUploads.value),
}));
const { getByTestId } = renderComponent();
const chatPanel = getByTestId('canvas-chat');
expect(chatPanel).toBeInTheDocument();
const fileInput = getByTestId('chat-attach-file-button');
expect(fileInput).toBeInTheDocument();
allowFileUploads.value = false;
await waitFor(() => {
expect(fileInput).not.toBeInTheDocument();
});
});
});
describe('message history handling', () => {
it('should properly navigate through message history with wrap-around', async () => {
const messages = ['Message 1', 'Message 2', 'Message 3'];
workflowsStore.getPastChatMessages = messages;
const { findByTestId } = renderComponent();
const input = await findByTestId('chat-input');
// First up should show most recent message
await userEvent.keyboard('{ArrowUp}');
expect(input).toHaveValue('Message 3');
// Second up should show second most recent
await userEvent.keyboard('{ArrowUp}');
expect(input).toHaveValue('Message 2');
// Third up should show oldest message
await userEvent.keyboard('{ArrowUp}');
expect(input).toHaveValue('Message 1');
// Fourth up should wrap around to most recent
await userEvent.keyboard('{ArrowUp}');
expect(input).toHaveValue('Message 3');
// Down arrow should go in reverse
await userEvent.keyboard('{ArrowDown}');
expect(input).toHaveValue('Message 1');
});
it('should reset message history navigation on new input', async () => {
workflowsStore.getPastChatMessages = ['Message 1', 'Message 2'];
const { findByTestId } = renderComponent();
const input = await findByTestId('chat-input');
// Navigate to oldest message
await userEvent.keyboard('{ArrowUp}'); // Most recent
await userEvent.keyboard('{ArrowUp}'); // Oldest
expect(input).toHaveValue('Message 1');
await userEvent.type(input, 'New message');
await userEvent.keyboard('{Enter}');
await userEvent.keyboard('{ArrowUp}');
expect(input).toHaveValue('Message 2');
});
});
describe('message reuse and repost', () => {
const sendMessageSpy = vi.fn();
beforeEach(() => {
const mockMessages: ChatMessage[] = [
{
id: '1',
text: 'Original message',
sender: 'user',
createdAt: new Date().toISOString(),
},
{
id: '2',
text: 'AI response',
sender: 'bot',
createdAt: new Date().toISOString(),
},
];
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
getChatMessages: vi.fn().mockReturnValue(mockMessages),
sendMessage: sendMessageSpy,
extractResponseMessage: vi.fn(),
previousMessageIndex: ref(0),
waitForExecution: vi.fn(),
});
workflowsStore.messages = mockMessages;
});
it('should repost user message with new execution', async () => {
const { findByTestId } = renderComponent();
const repostButton = await findByTestId('repost-message-button');
await userEvent.click(repostButton);
expect(sendMessageSpy).toHaveBeenCalledWith('Original message');
// expect.objectContaining({
// runData: expect.objectContaining({
// 'When chat message received': expect.arrayContaining([
// expect.objectContaining({
// data: expect.objectContaining({
// main: expect.arrayContaining([
// expect.arrayContaining([
// expect.objectContaining({
// json: expect.objectContaining({
// chatInput: 'Original message',
// }),
// }),
// ]),
// ]),
// }),
// }),
// ]),
// }),
// }),
// );
});
it('should show message options only for appropriate messages', async () => {
const { findByText, container } = renderComponent();
await findByText('Original message');
const userMessage = container.querySelector('.chat-message-from-user');
expect(
userMessage?.querySelector('[data-test-id="repost-message-button"]'),
).toBeInTheDocument();
expect(
userMessage?.querySelector('[data-test-id="reuse-message-button"]'),
).toBeInTheDocument();
await findByText('AI response');
const botMessage = container.querySelector('.chat-message-from-bot');
expect(
botMessage?.querySelector('[data-test-id="repost-message-button"]'),
).not.toBeInTheDocument();
expect(
botMessage?.querySelector('[data-test-id="reuse-message-button"]'),
).not.toBeInTheDocument();
});
});
describe('execution handling', () => {
it('should update UI when execution is completed', async () => {
const { findByTestId, queryByTestId } = renderComponent();
// Start execution
const input = await findByTestId('chat-input');
await userEvent.type(input, 'Test message');
await userEvent.keyboard('{Enter}');
// Simulate execution completion
uiStore.isActionActive = { workflowRunning: true };
await waitFor(() => {
expect(queryByTestId('chat-message-typing')).toBeInTheDocument();
});
uiStore.isActionActive = { workflowRunning: false };
workflowsStore.setWorkflowExecutionData(
mockWorkflowExecution as unknown as IExecutionResponse,
);
await waitFor(() => {
expect(queryByTestId('chat-message-typing')).not.toBeInTheDocument();
});
});
});
describe('panel state synchronization', () => {
it('should update canvas height when chat or logs panel state changes', async () => {
renderComponent();
// Toggle logs panel
workflowsStore.isLogsPanelOpen = true;
await waitFor(() => {
expect(canvasStore.setPanelHeight).toHaveBeenCalled();
});
// Close chat panel
workflowsStore.isChatPanelOpen = false;
await waitFor(() => {
expect(canvasStore.setPanelHeight).toHaveBeenCalledWith(0);
});
});
it('should preserve panel state across component remounts', async () => {
const { unmount, rerender } = renderComponent();
// Set initial state
workflowsStore.isChatPanelOpen = true;
workflowsStore.isLogsPanelOpen = true;
// Unmount and remount
unmount();
await rerender({});
expect(workflowsStore.isChatPanelOpen).toBe(true);
expect(workflowsStore.isLogsPanelOpen).toBe(true);
});
});
describe('keyboard shortcuts', () => {
it('should handle Enter key with modifier to start new line', async () => {
const { findByTestId } = renderComponent();
const input = await findByTestId('chat-input');
await userEvent.type(input, 'Line 1');
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
await userEvent.type(input, 'Line 2');
expect(input).toHaveValue('Line 1\nLine 2');
});
});
describe('chat synchronization', () => {
it('should load initial chat history when first opening panel', async () => {
const getChatMessagesSpy = vi.fn().mockReturnValue(['Previous message']);
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
...vi.fn()(),
getChatMessages: getChatMessagesSpy,
});
workflowsStore.isChatPanelOpen = false;
const { rerender } = renderComponent();
workflowsStore.isChatPanelOpen = true;
await rerender({});
expect(getChatMessagesSpy).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,350 @@
<script setup lang="ts">
import type { Ref } from 'vue';
import { provide, watch, computed, ref, watchEffect } from 'vue';
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
import type { Router } from 'vue-router';
import { useRouter } from 'vue-router';
import { chatEventBus } from '@n8n/chat/event-buses';
import { VIEWS } from '@/constants';
import { v4 as uuid } from 'uuid';
// Components
import ChatMessagesPanel from './components/ChatMessagesPanel.vue';
import ChatLogsPanel from './components/ChatLogsPanel.vue';
// Composables
import { useChatTrigger } from './composables/useChatTrigger';
import { useChatMessaging } from './composables/useChatMessaging';
import { useResize } from './composables/useResize';
import { useI18n } from '@/composables/useI18n';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
// Types
import type { Chat, ChatMessage, ChatOptions } from '@n8n/chat/types';
import type { RunWorkflowChatPayload } from './composables/useChatMessaging';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCanvasStore } from '@/stores/canvas.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
const workflowsStore = useWorkflowsStore();
const uiStore = useUIStore();
const canvasStore = useCanvasStore();
const nodeTypesStore = useNodeTypesStore();
const nodeHelpers = useNodeHelpers();
const router = useRouter();
// Component state
const messages = ref<ChatMessage[]>([]);
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
const isDisabled = ref(false);
const container = ref<HTMLElement>();
// Computed properties
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
const isLoading = computed(() => {
const result = uiStore.isActionActive.workflowRunning;
return result;
});
const allConnections = computed(() => workflowsStore.allConnections);
const isChatOpen = computed(() => {
const result = workflowsStore.isChatPanelOpen;
return result;
});
const isLogsOpen = computed(() => workflowsStore.isLogsPanelOpen);
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
// Expose internal state for testing
defineExpose({
messages,
currentSessionId,
isDisabled,
workflow,
isLoading,
});
const { runWorkflow } = useRunWorkflow({ router });
// Initialize features with injected dependencies
const { chatTriggerNode, connectedNode, allowFileUploads, setChatTriggerNode, setConnectedNode } =
useChatTrigger({
workflow,
canvasNodes: workflowsStore.allNodes,
getNodeByName: workflowsStore.getNodeByName,
getNodeType: nodeTypesStore.getNodeType,
});
const { sendMessage, getChatMessages } = useChatMessaging({
chatTrigger: chatTriggerNode,
connectedNode,
messages,
sessionId: currentSessionId,
workflow,
isLoading,
executionResultData: computed(() => workflowsStore.getWorkflowExecution?.data?.resultData),
getWorkflowResultDataByNodeName: workflowsStore.getWorkflowResultDataByNodeName,
onRunChatWorkflow,
});
const {
height,
chatWidth,
rootStyles,
logsWidth,
onResizeDebounced,
onResizeChatDebounced,
onWindowResize,
} = useResize(container);
// Extracted pure functions for better testability
function createChatConfig(params: {
messages: Chat['messages'];
sendMessage: Chat['sendMessage'];
currentSessionId: Chat['currentSessionId'];
isLoading: Ref<boolean>;
isDisabled: Ref<boolean>;
allowFileUploads: Ref<boolean>;
locale: ReturnType<typeof useI18n>;
}): { chatConfig: Chat; chatOptions: ChatOptions } {
const chatConfig: Chat = {
messages: params.messages,
sendMessage: params.sendMessage,
initialMessages: ref([]),
currentSessionId: params.currentSessionId,
waitingForResponse: params.isLoading,
};
const chatOptions: ChatOptions = {
i18n: {
en: {
title: '',
footer: '',
subtitle: '',
inputPlaceholder: params.locale.baseText('chat.window.chat.placeholder'),
getStarted: '',
closeButtonTooltip: '',
},
},
webhookUrl: '',
mode: 'window',
showWindowCloseButton: true,
disabled: params.isDisabled,
allowFileUploads: params.allowFileUploads,
allowedFilesMimeTypes: '',
};
return { chatConfig, chatOptions };
}
function displayExecution(params: { router: Router; workflowId: string; executionId: string }) {
const route = params.router.resolve({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: params.workflowId, executionId: params.executionId },
});
window.open(route.href, '_blank');
}
function refreshSession(params: { messages: Ref<ChatMessage[]>; currentSessionId: Ref<string> }) {
workflowsStore.setWorkflowExecutionData(null);
nodeHelpers.updateNodesExecutionIssues();
params.messages.value = [];
params.currentSessionId.value = uuid().replace(/-/g, '');
}
// Event handlers
const handleDisplayExecution = (executionId: string) => {
displayExecution({
router,
workflowId: workflow.value.id,
executionId,
});
};
const handleRefreshSession = () => {
refreshSession({
messages,
currentSessionId,
});
};
const closePanel = () => {
workflowsStore.setPanelOpen('chat', false);
};
async function onRunChatWorkflow(payload: RunWorkflowChatPayload) {
const response = await runWorkflow({
triggerNode: payload.triggerNode,
nodeData: payload.nodeData,
source: payload.source,
});
workflowsStore.appendChatMessage(payload.message);
return response;
}
// Initialize chat config
const { chatConfig, chatOptions } = createChatConfig({
messages,
sendMessage,
currentSessionId,
isLoading,
isDisabled,
allowFileUploads,
locale: useI18n(),
});
// Provide chat context
provide(ChatSymbol, chatConfig);
provide(ChatOptionsSymbol, chatOptions);
// Watchers
watch(
() => isChatOpen.value,
(isOpen) => {
if (isOpen) {
setChatTriggerNode();
setConnectedNode();
if (messages.value.length === 0) {
messages.value = getChatMessages();
}
setTimeout(() => {
onWindowResize();
chatEventBus.emit('focusInput');
}, 0);
}
},
{ immediate: true },
);
watch(
() => allConnections.value,
() => {
if (canvasStore.isLoading) return;
setTimeout(() => {
if (!chatTriggerNode.value) {
setChatTriggerNode();
}
setConnectedNode();
}, 0);
},
{ deep: true },
);
watchEffect(() => {
canvasStore.setPanelHeight(isChatOpen.value || isLogsOpen.value ? height.value : 0);
});
</script>
<template>
<n8n-resize-wrapper
v-if="chatTriggerNode"
:is-resizing-enabled="isChatOpen || isLogsOpen"
:supported-directions="['top']"
:class="[$style.resizeWrapper, !isChatOpen && !isLogsOpen && $style.empty]"
:height="height"
:style="rootStyles"
@resize="onResizeDebounced"
>
<div ref="container" :class="$style.container">
<div v-if="isChatOpen || isLogsOpen" :class="$style.chatResizer">
<n8n-resize-wrapper
v-if="isChatOpen"
:supported-directions="['right']"
:width="chatWidth"
:class="$style.chat"
@resize="onResizeChatDebounced"
>
<div :class="$style.inner">
<ChatMessagesPanel
data-test-id="canvas-chat"
:messages="messages"
:session-id="currentSessionId"
:past-chat-messages="previousChatMessages"
@refresh-session="handleRefreshSession"
@display-execution="handleDisplayExecution"
@send-message="sendMessage"
/>
</div>
</n8n-resize-wrapper>
<div v-if="isLogsOpen && connectedNode" :class="$style.logs">
<ChatLogsPanel
:key="messages.length"
:workflow="workflow"
data-test-id="canvas-chat-logs"
:node="connectedNode"
:slim="logsWidth < 700"
@close="closePanel"
/>
</div>
</div>
</div>
</n8n-resize-wrapper>
</template>
<style lang="scss" module>
.resizeWrapper {
height: var(--panel-height);
min-height: 4rem;
max-height: 90vh;
flex-basis: content;
border-top: 1px solid var(--color-foreground-base);
&.empty {
height: auto;
min-height: 0;
flex-basis: 0;
}
}
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chatResizer {
display: flex;
width: 100%;
height: 100%;
max-width: 100%;
}
.footer {
border-top: 1px solid var(--color-foreground-base);
width: 100%;
background-color: var(--color-background-light);
display: flex;
padding: var(--spacing-2xs);
gap: var(--spacing-2xs);
}
.chat {
width: var(--chat-width);
flex-shrink: 0;
border-right: 1px solid var(--color-foreground-base);
max-width: 100%;
&:only-child {
width: 100%;
}
}
.inner {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
width: 100%;
}
.logs {
flex-grow: 1;
flex-shrink: 1;
background-color: var(--color-background-light);
}
</style>

View file

@ -0,0 +1,91 @@
<script setup lang="ts">
import type { INode, Workflow } from 'n8n-workflow';
import RunDataAi from '@/components/RunDataAi/RunDataAi.vue';
import { useI18n } from '@/composables/useI18n';
const emit = defineEmits<{
close: [];
}>();
defineProps<{
node: INode | null;
slim?: boolean;
workflow: Workflow;
}>();
const locale = useI18n();
</script>
<template>
<div :class="$style.logsWrapper" data-test-id="lm-chat-logs">
<header :class="$style.logsHeader">
<div class="meta">
{{ locale.baseText('chat.window.logs') }}
<span v-if="node">
{{
locale.baseText('chat.window.logsFromNode', { interpolate: { nodeName: node.name } })
}}
</span>
</div>
<n8n-icon-button
:class="$style.close"
outline
icon="times"
type="secondary"
size="mini"
@click="emit('close')"
/>
</header>
<div :class="$style.logs">
<RunDataAi
v-if="node"
:class="$style.runData"
:node="node"
:workflow="workflow"
:slim="slim"
/>
</div>
</div>
</template>
<style lang="scss" module>
.logsHeader {
font-size: var(--font-size-s);
font-weight: var(--font-weight-bold);
height: 2.6875rem;
line-height: 18px;
text-align: left;
border-bottom: 1px solid var(--color-foreground-base);
padding: var(--spacing-xs);
background-color: var(--color-foreground-xlight);
display: flex;
justify-content: space-between;
align-items: center;
.close {
border: none;
}
span {
font-weight: 100;
}
}
.logsWrapper {
--node-icon-color: var(--color-text-base);
height: 100%;
overflow: hidden;
width: 100%;
display: flex;
flex-direction: column;
}
.logsTitle {
margin: 0 var(--spacing-s) var(--spacing-s);
}
.logs {
padding: var(--spacing-s) 0;
flex-grow: 1;
overflow: auto;
}
</style>

View file

@ -0,0 +1,348 @@
<script setup lang="ts">
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
import { useI18n } from '@/composables/useI18n';
import MessagesList from '@n8n/chat/components/MessagesList.vue';
import MessageOptionTooltip from './MessageOptionTooltip.vue';
import MessageOptionAction from './MessageOptionAction.vue';
import { chatEventBus } from '@n8n/chat/event-buses';
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
import ChatInput from '@n8n/chat/components/Input.vue';
import { useMessage } from '@/composables/useMessage';
import { MODAL_CONFIRM } from '@/constants';
import { computed, ref } from 'vue';
import { useClipboard } from '@/composables/useClipboard';
import { useToast } from '@/composables/useToast';
interface Props {
pastChatMessages: string[];
messages: ChatMessage[];
sessionId: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
displayExecution: [id: string];
sendMessage: [message: string];
refreshSession: [];
}>();
const messageComposable = useMessage();
const clipboard = useClipboard();
const locale = useI18n();
const toast = useToast();
const previousMessageIndex = ref(0);
const inputPlaceholder = computed(() => {
if (props.messages.length > 0) {
return locale.baseText('chat.window.chat.placeholder');
}
return locale.baseText('chat.window.chat.placeholderPristine');
});
/** Checks if message is a text message */
function isTextMessage(message: ChatMessage): message is ChatMessageText {
return message.type === 'text' || !message.type;
}
/** Reposts the message */
function repostMessage(message: ChatMessageText) {
void sendMessage(message.text);
}
/** Sets the message in input for reuse */
function reuseMessage(message: ChatMessageText) {
chatEventBus.emit('setInputValue', message.text);
}
function sendMessage(message: string) {
previousMessageIndex.value = 0;
emit('sendMessage', message);
}
async function onRefreshSession() {
// If there are no messages, refresh the session without asking
if (props.messages.length === 0) {
emit('refreshSession');
return;
}
const confirmResult = await messageComposable.confirm(
locale.baseText('chat.window.session.reset.warning'),
{
title: locale.baseText('chat.window.session.reset.title'),
type: 'warning',
confirmButtonText: locale.baseText('chat.window.session.reset.confirm'),
showClose: true,
},
);
if (confirmResult === MODAL_CONFIRM) {
emit('refreshSession');
}
}
function onArrowKeyDown({ currentInputValue, key }: ArrowKeyDownPayload) {
const pastMessages = props.pastChatMessages;
const isCurrentInputEmptyOrMatch =
currentInputValue.length === 0 || pastMessages.includes(currentInputValue);
if (isCurrentInputEmptyOrMatch && (key === 'ArrowUp' || key === 'ArrowDown')) {
// Exit if no messages
if (pastMessages.length === 0) return;
// Temporarily blur to avoid cursor position issues
chatEventBus.emit('blurInput');
if (pastMessages.length === 1) {
previousMessageIndex.value = 0;
} else {
if (key === 'ArrowUp') {
if (currentInputValue.length === 0 && previousMessageIndex.value === 0) {
// Start with most recent message
previousMessageIndex.value = pastMessages.length - 1;
} else {
// Move backwards through history
previousMessageIndex.value =
previousMessageIndex.value === 0
? pastMessages.length - 1
: previousMessageIndex.value - 1;
}
} else if (key === 'ArrowDown') {
// Move forwards through history
previousMessageIndex.value =
previousMessageIndex.value === pastMessages.length - 1
? 0
: previousMessageIndex.value + 1;
}
}
// Get message at current index
const selectedMessage = pastMessages[previousMessageIndex.value];
chatEventBus.emit('setInputValue', selectedMessage);
// Refocus and move cursor to end
chatEventBus.emit('focusInput');
}
// Reset history navigation when typing new content that doesn't match history
if (!isCurrentInputEmptyOrMatch) {
previousMessageIndex.value = 0;
}
}
function copySessionId() {
void clipboard.copy(props.sessionId);
toast.showMessage({
title: locale.baseText('generic.copiedToClipboard'),
message: '',
type: 'success',
});
}
</script>
<template>
<div :class="$style.chat" data-test-id="workflow-lm-chat-dialog">
<header :class="$style.chatHeader">
<span>{{ locale.baseText('chat.window.title') }}</span>
<div :class="$style.session">
<span>{{ locale.baseText('chat.window.session.title') }}</span>
<n8n-tooltip placement="left">
<template #content>
{{ sessionId }}
</template>
<span :class="$style.sessionId" data-test-id="chat-session-id" @click="copySessionId">{{
sessionId
}}</span>
</n8n-tooltip>
<n8n-icon-button
:class="$style.refreshSession"
data-test-id="refresh-session-button"
type="tertiary"
text
size="mini"
icon="undo"
:title="locale.baseText('chat.window.session.reset.confirm')"
@click="onRefreshSession"
/>
</div>
</header>
<main :class="$style.chatBody">
<MessagesList :messages="messages" :class="[$style.messages, 'ignore-key-press-canvas']">
<template #beforeMessage="{ message }">
<MessageOptionTooltip
v-if="message.sender === 'bot' && !message.id.includes('preload')"
placement="right"
data-test-id="execution-id-tooltip"
>
{{ locale.baseText('chat.window.chat.chatMessageOptions.executionId') }}:
<a href="#" @click="emit('displayExecution', message.id)">{{ message.id }}</a>
</MessageOptionTooltip>
<MessageOptionAction
v-if="isTextMessage(message) && message.sender === 'user'"
data-test-id="repost-message-button"
icon="redo"
:label="locale.baseText('chat.window.chat.chatMessageOptions.repostMessage')"
placement="left"
@click.once="repostMessage(message)"
/>
<MessageOptionAction
v-if="isTextMessage(message) && message.sender === 'user'"
data-test-id="reuse-message-button"
icon="copy"
:label="locale.baseText('chat.window.chat.chatMessageOptions.reuseMessage')"
placement="left"
@click="reuseMessage(message)"
/>
</template>
</MessagesList>
</main>
<div :class="$style.messagesInput">
<div v-if="pastChatMessages.length > 0" :class="$style.messagesHistory">
<n8n-button
title="Navigate to previous message"
icon="chevron-up"
type="tertiary"
text
size="mini"
@click="onArrowKeyDown({ currentInputValue: '', key: 'ArrowUp' })"
/>
<n8n-button
title="Navigate to next message"
icon="chevron-down"
type="tertiary"
text
size="mini"
@click="onArrowKeyDown({ currentInputValue: '', key: 'ArrowDown' })"
/>
</div>
<ChatInput
data-test-id="lm-chat-inputs"
:placeholder="inputPlaceholder"
@arrow-key-down="onArrowKeyDown"
/>
</div>
</div>
</template>
<style lang="scss" module>
.chat {
--chat--spacing: var(--spacing-xs);
--chat--message--padding: var(--spacing-xs);
--chat--message--font-size: var(--font-size-s);
--chat--input--font-size: var(--font-size-s);
--chat--message--bot--background: transparent;
--chat--message--user--background: var(--color-text-lighter);
--chat--message--bot--color: var(--color-text-dark);
--chat--message--user--color: var(--color-text-dark);
--chat--message--bot--border: none;
--chat--message--user--border: none;
--chat--color-typing: var(--color-text-light);
--chat--textarea--max-height: calc(var(--panel-height) * 0.5);
--chat--message--pre--background: var(--color-foreground-light);
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: var(--color-background-light);
}
.chatHeader {
font-size: var(--font-size-s);
font-weight: 400;
line-height: 18px;
text-align: left;
border-bottom: 1px solid var(--color-foreground-base);
padding: var(--chat--spacing);
background-color: var(--color-foreground-xlight);
display: flex;
justify-content: space-between;
align-items: center;
}
.session {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
color: var(--color-text-base);
max-width: 70%;
}
.sessionId {
display: inline-block;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
cursor: pointer;
}
.refreshSession {
max-height: 1.1rem;
}
.chatBody {
display: flex;
height: 100%;
overflow: auto;
}
.messages {
border-radius: var(--border-radius-base);
height: 100%;
width: 100%;
overflow: auto;
padding-top: 1.5em;
&:not(:last-child) {
margin-right: 1em;
}
}
.messagesInput {
--input-border-color: var(--border-color-base);
--chat--input--border: none;
--chat--input--border-radius: 0.5rem;
--chat--input--send--button--background: transparent;
--chat--input--send--button--color: var(--color-primary);
--chat--input--file--button--background: transparent;
--chat--input--file--button--color: var(--color-primary);
--chat--input--border-active: var(--input-focus-border-color, var(--color-secondary));
--chat--files-spacing: var(--spacing-2xs) 0;
--chat--input--background: transparent;
--chat--input--file--button--color: var(--color-button-secondary-font);
--chat--input--file--button--color-hover: var(--color-primary);
[data-theme='dark'] & {
--chat--input--text-color: var(--input-font-color, var(--color-text-dark));
}
@media (prefers-color-scheme: dark) {
--chat--input--text-color: var(--input-font-color, var(--color-text-dark));
}
padding: 0 0 0 var(--spacing-xs);
margin: 0 var(--chat--spacing) var(--chat--spacing);
flex-grow: 1;
display: flex;
background: var(--color-lm-chat-bot-background);
border-radius: var(--chat--input--border-radius);
transition: border-color 200ms ease-in-out;
border: var(--input-border-color, var(--border-color-base))
var(--input-border-style, var(--border-style-base))
var(--input-border-width, var(--border-width-base));
&:focus-within {
--input-border-color: #4538a3;
}
}
.messagesHistory {
display: flex;
flex-direction: column;
justify-content: flex-end;
margin-bottom: var(--spacing-3xs);
button:first-child {
margin-top: var(--spacing-4xs);
margin-bottom: calc(-1 * var(--spacing-4xs));
}
}
</style>

View file

@ -0,0 +1,46 @@
<script setup lang="ts">
import type { PropType } from 'vue';
defineProps({
label: {
type: String,
required: true,
},
icon: {
type: String,
required: true,
},
placement: {
type: String as PropType<'left' | 'right' | 'top' | 'bottom'>,
default: 'top',
},
});
</script>
<template>
<div :class="$style.container">
<n8n-tooltip :placement="placement">
<template #content>
{{ label }}
</template>
<n8n-icon :class="$style.icon" :icon="icon" size="xsmall" @click="$attrs.onClick" />
</n8n-tooltip>
</div>
</template>
<style lang="scss" module>
.container {
display: inline-flex;
align-items: center;
margin: 0 var(--spacing-4xs);
}
.icon {
color: var(--color-foreground-dark);
cursor: pointer;
&:hover {
color: var(--color-primary);
}
}
</style>

View file

@ -0,0 +1,40 @@
<script setup lang="ts">
import type { PropType } from 'vue';
defineProps({
placement: {
type: String as PropType<'left' | 'right' | 'top' | 'bottom'>,
default: 'top',
},
});
</script>
<template>
<div :class="$style.container">
<n8n-tooltip :placement="placement">
<template #content>
<slot />
</template>
<span :class="$style.icon">
<n8n-icon icon="info" size="xsmall" />
</span>
</n8n-tooltip>
</div>
</template>
<style lang="scss" module>
.container {
display: inline-flex;
align-items: center;
margin: 0 var(--spacing-4xs);
}
.icon {
color: var(--color-foreground-dark);
cursor: help;
&:hover {
color: var(--color-primary);
}
}
</style>

View file

@ -0,0 +1,299 @@
import type { ComputedRef, Ref } from 'vue';
import { ref } from 'vue';
import { v4 as uuid } from 'uuid';
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
import { NodeConnectionType, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
import type {
ITaskData,
INodeExecutionData,
IBinaryKeyData,
IDataObject,
IBinaryData,
BinaryFileType,
Workflow,
IRunExecutionData,
} from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
import { useMessage } from '@/composables/useMessage';
import { usePinnedData } from '@/composables/usePinnedData';
import { get, isEmpty, last } from 'lodash-es';
import { MANUAL_CHAT_TRIGGER_NODE_TYPE, MODAL_CONFIRM } from '@/constants';
import { useI18n } from '@/composables/useI18n';
import type { MemoryOutput } from '../types/chat';
import type { IExecutionPushResponse, INodeUi } from '@/Interface';
export type RunWorkflowChatPayload = {
triggerNode: string;
nodeData: ITaskData;
source: string;
message: string;
};
export interface ChatMessagingDependencies {
chatTrigger: Ref<INodeUi | null>;
connectedNode: Ref<INodeUi | null>;
messages: Ref<ChatMessage[]>;
sessionId: Ref<string>;
workflow: ComputedRef<Workflow>;
isLoading: ComputedRef<boolean>;
executionResultData: ComputedRef<IRunExecutionData['resultData'] | undefined>;
getWorkflowResultDataByNodeName: (nodeName: string) => ITaskData[] | null;
onRunChatWorkflow: (
payload: RunWorkflowChatPayload,
) => Promise<IExecutionPushResponse | undefined>;
}
export function useChatMessaging({
chatTrigger,
connectedNode,
messages,
sessionId,
workflow,
isLoading,
executionResultData,
getWorkflowResultDataByNodeName,
onRunChatWorkflow,
}: ChatMessagingDependencies) {
const locale = useI18n();
const { showError } = useToast();
const previousMessageIndex = ref(0);
/** Converts a file to binary data */
async function convertFileToBinaryData(file: File): Promise<IBinaryData> {
const reader = new FileReader();
return await new Promise((resolve, reject) => {
reader.onload = () => {
const binaryData: IBinaryData = {
data: (reader.result as string).split('base64,')?.[1] ?? '',
mimeType: file.type,
fileName: file.name,
fileSize: `${file.size} bytes`,
fileExtension: file.name.split('.').pop() ?? '',
fileType: file.type.split('/')[0] as BinaryFileType,
};
resolve(binaryData);
};
reader.onerror = () => {
reject(new Error('Failed to convert file to binary data'));
};
reader.readAsDataURL(file);
});
}
/** Gets keyed files for the workflow input */
async function getKeyedFiles(files: File[]): Promise<IBinaryKeyData> {
const binaryData: IBinaryKeyData = {};
await Promise.all(
files.map(async (file, index) => {
const data = await convertFileToBinaryData(file);
const key = `data${index}`;
binaryData[key] = data;
}),
);
return binaryData;
}
/** Extracts file metadata */
function extractFileMeta(file: File): IDataObject {
return {
fileName: file.name,
fileSize: `${file.size} bytes`,
fileExtension: file.name.split('.').pop() ?? '',
fileType: file.type.split('/')[0],
mimeType: file.type,
};
}
/** Starts workflow execution with the message */
async function startWorkflowWithMessage(message: string, files?: File[]): Promise<void> {
const triggerNode = chatTrigger.value;
if (!triggerNode) {
showError(new Error('Chat Trigger Node could not be found!'), 'Trigger Node not found');
return;
}
let inputKey = 'chatInput';
if (triggerNode.type === MANUAL_CHAT_TRIGGER_NODE_TYPE && triggerNode.typeVersion < 1.1) {
inputKey = 'input';
}
if (triggerNode.type === CHAT_TRIGGER_NODE_TYPE) {
inputKey = 'chatInput';
}
const inputPayload: INodeExecutionData = {
json: {
sessionId: sessionId.value,
action: 'sendMessage',
[inputKey]: message,
},
};
if (files && files.length > 0) {
const filesMeta = files.map((file) => extractFileMeta(file));
const binaryData = await getKeyedFiles(files);
inputPayload.json.files = filesMeta;
inputPayload.binary = binaryData;
}
const nodeData: ITaskData = {
startTime: new Date().getTime(),
executionTime: 0,
executionStatus: 'success',
data: {
main: [[inputPayload]],
},
source: [null],
};
const response = await onRunChatWorkflow({
triggerNode: triggerNode.name,
nodeData,
source: 'RunData.ManualChatMessage',
message,
});
if (!response?.executionId) {
showError(
new Error('It was not possible to start workflow!'),
'Workflow could not be started',
);
return;
}
waitForExecution(response.executionId);
}
/** Waits for workflow execution to complete */
function waitForExecution(executionId: string) {
const waitInterval = setInterval(() => {
if (!isLoading.value) {
clearInterval(waitInterval);
const lastNodeExecuted = executionResultData.value?.lastNodeExecuted;
if (!lastNodeExecuted) return;
const nodeResponseDataArray =
get(executionResultData.value.runData, lastNodeExecuted) ?? [];
const nodeResponseData = nodeResponseDataArray[nodeResponseDataArray.length - 1];
let responseMessage: string;
if (get(nodeResponseData, 'error')) {
responseMessage = '[ERROR: ' + get(nodeResponseData, 'error.message') + ']';
} else {
const responseData = get(nodeResponseData, 'data.main[0][0].json');
responseMessage = extractResponseMessage(responseData);
}
messages.value.push({
text: responseMessage,
sender: 'bot',
createdAt: new Date().toISOString(),
id: executionId ?? uuid(),
});
}
}, 500);
}
/** Extracts response message from workflow output */
function extractResponseMessage(responseData?: IDataObject) {
if (!responseData || isEmpty(responseData)) {
return locale.baseText('chat.window.chat.response.empty');
}
// Paths where the response message might be located
const paths = ['output', 'text', 'response.text'];
const matchedPath = paths.find((path) => get(responseData, path));
if (!matchedPath) return JSON.stringify(responseData, null, 2);
const matchedOutput = get(responseData, matchedPath);
if (typeof matchedOutput === 'object') {
return '```json\n' + JSON.stringify(matchedOutput, null, 2) + '\n```';
}
return matchedOutput?.toString() ?? '';
}
/** Sends a message to the chat */
async function sendMessage(message: string, files?: File[]) {
previousMessageIndex.value = 0;
if (message.trim() === '' && (!files || files.length === 0)) {
showError(
new Error(locale.baseText('chat.window.chat.provideMessage')),
locale.baseText('chat.window.chat.emptyChatMessage'),
);
return;
}
const pinnedChatData = usePinnedData(chatTrigger.value);
if (pinnedChatData.hasData.value) {
const confirmResult = await useMessage().confirm(
locale.baseText('chat.window.chat.unpinAndExecute.description'),
locale.baseText('chat.window.chat.unpinAndExecute.title'),
{
confirmButtonText: locale.baseText('chat.window.chat.unpinAndExecute.confirm'),
cancelButtonText: locale.baseText('chat.window.chat.unpinAndExecute.cancel'),
},
);
if (!(confirmResult === MODAL_CONFIRM)) return;
pinnedChatData.unsetData('unpin-and-send-chat-message-modal');
}
const newMessage: ChatMessage & { sessionId: string } = {
text: message,
sender: 'user',
createdAt: new Date().toISOString(),
sessionId: sessionId.value,
id: uuid(),
files,
};
messages.value.push(newMessage);
await startWorkflowWithMessage(newMessage.text, files);
}
function getChatMessages(): ChatMessageText[] {
if (!connectedNode.value) return [];
const connectedMemoryInputs =
workflow.value.connectionsByDestinationNode?.[connectedNode.value.name]?.[
NodeConnectionType.AiMemory
];
if (!connectedMemoryInputs) return [];
const memoryConnection = (connectedMemoryInputs ?? []).find((i) => i.length > 0)?.[0];
if (!memoryConnection) return [];
const nodeResultData = getWorkflowResultDataByNodeName(memoryConnection.node);
const memoryOutputData = (nodeResultData ?? [])
.map((data) => get(data, ['data', NodeConnectionType.AiMemory, 0, 0, 'json']) as MemoryOutput)
.find((data) => data && data.action === 'saveContext');
return (memoryOutputData?.chatHistory ?? []).map((message, index) => {
return {
createdAt: new Date().toISOString(),
text: message.kwargs.content,
id: `preload__${index}`,
sender: last(message.id) === 'HumanMessage' ? 'user' : 'bot',
};
});
}
return {
previousMessageIndex,
sendMessage,
extractResponseMessage,
waitForExecution,
getChatMessages,
};
}

View file

@ -0,0 +1,138 @@
import type { ComputedRef } from 'vue';
import { ref, computed } from 'vue';
import {
CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE,
NodeConnectionType,
NodeHelpers,
} from 'n8n-workflow';
import type { INodeTypeDescription, Workflow, INode, INodeParameters } from 'n8n-workflow';
import {
AI_CATEGORY_AGENTS,
AI_CATEGORY_CHAINS,
AI_CODE_NODE_TYPE,
AI_SUBCATEGORY,
CHAT_TRIGGER_NODE_TYPE,
MANUAL_CHAT_TRIGGER_NODE_TYPE,
} from '@/constants';
import type { INodeUi } from '@/Interface';
export interface ChatTriggerDependencies {
getNodeByName: (name: string) => INodeUi | null;
getNodeType: (type: string, version: number) => INodeTypeDescription | null;
canvasNodes: INodeUi[];
workflow: ComputedRef<Workflow>;
}
export function useChatTrigger({
getNodeByName,
getNodeType,
canvasNodes,
workflow,
}: ChatTriggerDependencies) {
const chatTriggerName = ref<string | null>(null);
const connectedNode = ref<INode | null>(null);
const chatTriggerNode = computed(() =>
chatTriggerName.value ? getNodeByName(chatTriggerName.value) : null,
);
const allowFileUploads = computed(() => {
return (
(chatTriggerNode.value?.parameters?.options as INodeParameters)?.allowFileUploads === true
);
});
const allowedFilesMimeTypes = computed(() => {
return (
(
chatTriggerNode.value?.parameters?.options as INodeParameters
)?.allowedFilesMimeTypes?.toString() ?? ''
);
});
/** Gets the chat trigger node from the workflow */
function setChatTriggerNode() {
const triggerNode = canvasNodes.find((node) =>
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(node.type),
);
if (!triggerNode) {
return;
}
chatTriggerName.value = triggerNode.name;
}
/** Sets the connected node after finding the trigger */
function setConnectedNode() {
const triggerNode = chatTriggerNode.value;
if (!triggerNode) {
return;
}
const chatChildren = workflow.value.getChildNodes(triggerNode.name);
const chatRootNode = chatChildren
.reverse()
.map((nodeName: string) => getNodeByName(nodeName))
.filter((n): n is INodeUi => n !== null)
// Reverse the nodes to match the last node logs first
.reverse()
.find((storeNode: INodeUi): boolean => {
// Skip summarization nodes
if (storeNode.type === CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE) return false;
const nodeType = getNodeType(storeNode.type, storeNode.typeVersion);
if (!nodeType) return false;
// Check if node is an AI agent or chain based on its metadata
const isAgent =
nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_AGENTS);
const isChain =
nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_CHAINS);
// Handle custom AI Langchain Code nodes that could act as chains or agents
let isCustomChainOrAgent = false;
if (nodeType.name === AI_CODE_NODE_TYPE) {
// Get node connection types for inputs and outputs
const inputs = NodeHelpers.getNodeInputs(workflow.value, storeNode, nodeType);
const inputTypes = NodeHelpers.getConnectionTypes(inputs);
const outputs = NodeHelpers.getNodeOutputs(workflow.value, storeNode, nodeType);
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
// Validate if node has required AI connection types
if (
inputTypes.includes(NodeConnectionType.AiLanguageModel) &&
inputTypes.includes(NodeConnectionType.Main) &&
outputTypes.includes(NodeConnectionType.Main)
) {
isCustomChainOrAgent = true;
}
}
// Skip if node is not an AI component
if (!isAgent && !isChain && !isCustomChainOrAgent) return false;
// Check if this node is connected to the trigger node
const parentNodes = workflow.value.getParentNodes(storeNode.name);
const isChatChild = parentNodes.some(
(parentNodeName) => parentNodeName === triggerNode.name,
);
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const result = Boolean(isChatChild && (isAgent || isChain || isCustomChainOrAgent));
return result;
});
connectedNode.value = chatRootNode ?? null;
}
return {
allowFileUploads,
allowedFilesMimeTypes,
chatTriggerNode,
connectedNode: computed(() => connectedNode.value),
setChatTriggerNode,
setConnectedNode,
};
}

View file

@ -0,0 +1,137 @@
import type { Ref } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount, watchEffect } from 'vue';
import type { ResizeData } from 'n8n-design-system/components/N8nResizeWrapper/ResizeWrapper.vue';
import { useDebounce } from '@/composables/useDebounce';
import type { IChatResizeStyles } from '../types/chat';
import { useStorage } from '@/composables/useStorage';
const LOCAL_STORAGE_PANEL_HEIGHT = 'N8N_CANVAS_CHAT_HEIGHT';
const LOCAL_STORAGE_PANEL_WIDTH = 'N8N_CANVAS_CHAT_WIDTH';
// Percentage of container width for chat panel constraints
const MAX_WIDTH_PERCENTAGE = 0.8;
const MIN_WIDTH_PERCENTAGE = 0.3;
// Percentage of window height for panel constraints
const MIN_HEIGHT_PERCENTAGE = 0.3;
const MAX_HEIGHT_PERCENTAGE = 0.75;
export function useResize(container: Ref<HTMLElement | undefined>) {
const storage = {
height: useStorage(LOCAL_STORAGE_PANEL_HEIGHT),
width: useStorage(LOCAL_STORAGE_PANEL_WIDTH),
};
const dimensions = {
container: ref(0), // Container width
minHeight: ref(0),
maxHeight: ref(0),
chat: ref(0), // Chat panel width
logs: ref(0),
height: ref(0),
};
/** Computed styles for root element based on current dimensions */
const rootStyles = computed<IChatResizeStyles>(() => ({
'--panel-height': `${dimensions.height.value}px`,
'--chat-width': `${dimensions.chat.value}px`,
}));
const panelToContainerRatio = computed(() => {
const chatRatio = dimensions.chat.value / dimensions.container.value;
const containerRatio = dimensions.container.value / window.screen.width;
return {
chat: chatRatio.toFixed(2),
logs: (1 - chatRatio).toFixed(2),
container: containerRatio.toFixed(2),
};
});
/**
* Constrains height to min/max bounds and updates panel height
*/
function onResize(newHeight: number) {
const { minHeight, maxHeight } = dimensions;
dimensions.height.value = Math.min(Math.max(newHeight, minHeight.value), maxHeight.value);
}
function onResizeDebounced(data: ResizeData) {
void useDebounce().callDebounced(onResize, { debounceTime: 10, trailing: true }, data.height);
}
/**
* Constrains chat width to min/max percentage of container width
*/
function onResizeChat(width: number) {
const containerWidth = dimensions.container.value;
const maxWidth = containerWidth * MAX_WIDTH_PERCENTAGE;
const minWidth = containerWidth * MIN_WIDTH_PERCENTAGE;
dimensions.chat.value = Math.min(Math.max(width, minWidth), maxWidth);
dimensions.logs.value = dimensions.container.value - dimensions.chat.value;
}
function onResizeChatDebounced(data: ResizeData) {
void useDebounce().callDebounced(
onResizeChat,
{ debounceTime: 10, trailing: true },
data.width,
);
}
/**
* Initializes dimensions from localStorage if available
*/
function restorePersistedDimensions() {
const persistedHeight = parseInt(storage.height.value ?? '0', 10);
const persistedWidth = parseInt(storage.width.value ?? '0', 10);
if (persistedHeight) onResize(persistedHeight);
if (persistedWidth) onResizeChat(persistedWidth);
}
/**
* Updates container width and height constraints on window resize
*/
function onWindowResize() {
if (!container.value) return;
// Update container width and adjust chat panel if needed
dimensions.container.value = container.value.getBoundingClientRect().width;
onResizeChat(dimensions.chat.value);
// Update height constraints and adjust panel height if needed
dimensions.minHeight.value = window.innerHeight * MIN_HEIGHT_PERCENTAGE;
dimensions.maxHeight.value = window.innerHeight * MAX_HEIGHT_PERCENTAGE;
onResize(dimensions.height.value);
}
// Persist dimensions to localStorage when they change
watchEffect(() => {
const { chat, height } = dimensions;
if (chat.value > 0) storage.width.value = chat.value.toString();
if (height.value > 0) storage.height.value = height.value.toString();
});
// Initialize dimensions when container is available
watchEffect(() => {
if (container.value) {
onWindowResize();
restorePersistedDimensions();
}
});
// Window resize handling
onMounted(() => window.addEventListener('resize', onWindowResize));
onBeforeUnmount(() => window.removeEventListener('resize', onWindowResize));
return {
height: dimensions.height,
chatWidth: dimensions.chat,
logsWidth: dimensions.logs,
rootStyles,
onWindowResize,
onResizeDebounced,
onResizeChatDebounced,
panelToContainerRatio,
};
}

View file

@ -0,0 +1,22 @@
export interface LangChainMessage {
id: string[];
kwargs: {
content: string;
};
}
export interface MemoryOutput {
action: string;
chatHistory?: LangChainMessage[];
}
export interface IChatMessageResponse {
executionId?: string;
success: boolean;
error?: Error;
}
export interface IChatResizeStyles {
'--panel-height': string;
'--chat-width': string;
}

View file

@ -18,7 +18,6 @@ import {
NEW_ASSISTANT_SESSION_MODAL,
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';
</template>
</ModalRoot>
<ModalRoot :name="WORKFLOW_LM_CHAT_MODAL_KEY">
<WorkflowLMChat />
</ModalRoot>
<ModalRoot :name="WORKFLOW_SETTINGS_MODAL_KEY">
<WorkflowSettings />
</ModalRoot>

View file

@ -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(() => {
<div :class="$style.block">
<header :class="$style.blockHeader" @click="onBlockHeaderClick">
<button :class="$style.blockToggle">
<font-awesome-icon :icon="isExpanded ? 'angle-down' : 'angle-up'" size="lg" />
<font-awesome-icon :icon="isExpanded ? 'angle-down' : 'angle-right'" size="lg" />
</button>
<p :class="$style.blockTitle">{{ capitalize(runData.inOut) }}</p>
<!-- @click.stop to prevent event from bubbling to blockHeader and toggling expanded state when clicking on rawSwitch -->
<el-switch
v-if="contentParsed && !error"
v-model="isShowRaw"
<n8n-radio-buttons
v-if="contentParsed && !error && isExpanded"
size="small"
:model-value="renderType"
:class="$style.rawSwitch"
active-text="RAW JSON"
@click.stop
:options="[
{ label: 'Rendered', value: 'rendered' },
{ label: 'JSON', value: 'json' },
]"
@update:model-value="onRenderTypeChange"
/>
</header>
<main
@ -172,7 +179,7 @@ onMounted(() => {
:class="$style.contentText"
:data-content-type="parsedContent?.type"
>
<template v-if="parsedContent && !isShowRaw">
<template v-if="parsedContent && renderType === 'rendered'">
<template v-if="parsedContent.type === 'json'">
<VueMarkdown
:source="jsonToMarkdown(parsedContent.data as JsonMarkdown)"
@ -226,17 +233,17 @@ onMounted(() => {
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;

View file

@ -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<AIResult[]>(() => {
const executionTree = computed<TreeNode[]>(() => {
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 });
</script>
<template>
<div v-if="aiData.length > 0" :class="$style.container">
<div :class="{ [$style.tree]: true, [$style.slim]: slim }">
<ElTree
:data="executionTree"
:props="{ label: 'node' }"
default-expand-all
:indent="12"
:expand-on-click-node="false"
data-test-id="lm-chat-logs-tree"
@node-click="onItemClick"
>
<template #default="{ node, data }">
<div
:class="{
[$style.treeNode]: true,
[$style.isSelected]: isTreeNodeSelected(data),
}"
:data-tree-depth="data.depth"
:style="{ '--item-depth': data.depth }"
>
<button
v-if="data.children.length"
:class="$style.treeToggle"
@click="toggleTreeItem(node)"
<div :class="$style.container">
<template v-if="aiData.length > 0">
<div :class="{ [$style.tree]: true, [$style.slim]: slim }">
<ElTree
:data="executionTree"
:props="{ label: 'node' }"
default-expand-all
:indent="12"
:expand-on-click-node="false"
data-test-id="lm-chat-logs-tree"
@node-click="onItemClick"
>
<template #default="{ node, data }">
<div
:class="{
[$style.treeNode]: true,
[$style.isSelected]: isTreeNodeSelected(data),
}"
:data-tree-depth="data.depth"
:style="{ '--item-depth': data.depth }"
>
<font-awesome-icon :icon="node.expanded ? 'angle-down' : 'angle-up'" />
</button>
<n8n-tooltip :disabled="!slim" placement="right">
<template #content>
{{ node.label }}
</template>
<span :class="$style.leafLabel">
<NodeIcon :node-type="getNodeType(data.node)!" :size="17" />
<span v-if="!slim" v-text="node.label" />
</span>
</n8n-tooltip>
</div>
</template>
</ElTree>
</div>
<div :class="$style.runData">
<div v-if="selectedRun.length === 0" :class="$style.empty">
<n8n-text size="large">
{{
$locale.baseText('ndv.output.ai.empty', {
interpolate: {
node: props.node.name,
},
})
}}
</n8n-text>
<button
v-if="data.children.length"
:class="$style.treeToggle"
@click="toggleTreeItem(node)"
>
<font-awesome-icon :icon="node.expanded ? 'angle-down' : 'angle-right'" />
</button>
<n8n-tooltip :disabled="!slim" placement="right">
<template #content>
{{ node.label }}
</template>
<span :class="$style.leafLabel">
<NodeIcon
:node-type="getNodeType(data.node)!"
:size="17"
:class="$style.nodeIcon"
/>
<span v-if="!slim" v-text="node.label" />
</span>
</n8n-tooltip>
</div>
</template>
</ElTree>
</div>
<div
v-for="(data, index) in selectedRun"
:key="`${data.node}__${data.runIndex}__index`"
data-test-id="lm-chat-logs-entry"
>
<RunDataAiContent :input-data="data" :content-index="index" />
<div :class="$style.runData">
<div v-if="selectedRun.length === 0" :class="$style.empty">
<n8n-text size="large">
{{
$locale.baseText('ndv.output.ai.empty', {
interpolate: {
node: props.node.name,
},
})
}}
</n8n-text>
</div>
<div
v-for="(data, index) in selectedRun"
:key="`${data.node}__${data.runIndex}__index`"
data-test-id="lm-chat-logs-entry"
>
<RunDataAiContent :input-data="data" :content-index="index" />
</div>
</div>
</div>
</template>
<div v-else :class="$style.noData">{{ $locale.baseText('ndv.output.ai.waiting') }}</div>
</div>
</template>
@ -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);
}
}
</style>

View file

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

View file

@ -1,31 +0,0 @@
<script setup lang="ts">
const emit = defineEmits<{
click: [];
}>();
defineProps<{
label: string;
icon: string;
placement: 'left' | 'right' | 'top' | 'bottom';
}>();
</script>
<template>
<N8nTooltip :placement="placement">
<button :class="$style.button" :style="{ color: '#aaa' }" @click="emit('click')">
<N8nIcon :icon="icon" size="small" />
</button>
<template #content>
{{ label }}
</template>
</N8nTooltip>
</template>
<style module>
.button {
background: none;
border: none;
cursor: pointer;
padding: 0;
}
</style>

View file

@ -1,15 +0,0 @@
<script setup lang="ts">
defineProps<{
placement: 'left' | 'right';
}>();
</script>
<template>
<n8n-info-tip type="tooltip" theme="info-light" :tooltip-placement="placement">
<n8n-text :bold="true" size="small">
<slot />
</n8n-text>
</n8n-info-tip>
</template>
<style module lang="scss"></style>

View file

@ -1,699 +0,0 @@
<script setup lang="ts">
import type { Ref } from 'vue';
import { defineAsyncComponent, provide, ref, computed, onMounted, nextTick } from 'vue';
import { v4 as uuid } from 'uuid';
import Modal from '@/components/Modal.vue';
import {
AI_CATEGORY_AGENTS,
AI_CATEGORY_CHAINS,
AI_CODE_NODE_TYPE,
AI_SUBCATEGORY,
CHAT_EMBED_MODAL_KEY,
CHAT_TRIGGER_NODE_TYPE,
MANUAL_CHAT_TRIGGER_NODE_TYPE,
MODAL_CONFIRM,
VIEWS,
WORKFLOW_LM_CHAT_MODAL_KEY,
} from '@/constants';
import { useUsersStore } from '@/stores/users.store';
// eslint-disable-next-line import/no-unresolved
import MessagesList from '@n8n/chat/components/MessagesList.vue';
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
import ChatInput from '@n8n/chat/components/Input.vue';
import { useRouter } from 'vue-router';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import type { Chat, ChatMessage, ChatMessageText, ChatOptions } from '@n8n/chat/types';
import { useI18n } from '@/composables/useI18n';
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
import MessageOptionTooltip from './MessageOptionTooltip.vue';
import MessageOptionAction from './MessageOptionAction.vue';
import type {
BinaryFileType,
IBinaryData,
IBinaryKeyData,
IDataObject,
INode,
INodeExecutionData,
INodeParameters,
INodeType,
ITaskData,
IUser,
} from 'n8n-workflow';
import {
CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE,
NodeConnectionType,
NodeHelpers,
} from 'n8n-workflow';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useToast } from '@/composables/useToast';
import type { INodeUi } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { createEventBus } from 'n8n-design-system';
import { useUIStore } from '@/stores/ui.store';
import { useMessage } from '@/composables/useMessage';
import { usePinnedData } from '@/composables/usePinnedData';
import { get, last } from 'lodash-es';
import { isEmpty } from '@/utils/typesUtils';
import { chatEventBus } from '@n8n/chat/event-buses';
const LazyRunDataAi = defineAsyncComponent(
async () => await import('@/components/RunDataAi/RunDataAi.vue'),
);
// TODO: Add proper type
interface LangChainMessage {
id: string[];
kwargs: {
content: string;
};
}
interface MemoryOutput {
action: string;
chatHistory?: LangChainMessage[];
}
const router = useRouter();
const { runWorkflow } = useRunWorkflow({ router });
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
const { showError } = useToast();
const messages: Ref<ChatMessage[]> = ref([]);
const currentSessionId = ref<string>(String(Date.now()));
const isDisabled = ref(false);
const connectedNode = ref<INode | null>(null);
const chatTrigger = ref<INode | null>(null);
const modalBus = createEventBus();
const node = ref<INode | null>(null);
const previousMessageIndex = ref(0);
const isLoading = computed(() => uiStore.isActionActive.workflowRunning);
const allowFileUploads = computed(() => {
return (chatTrigger.value?.parameters?.options as INodeParameters)?.allowFileUploads === true;
});
const allowedFilesMimeTypes = computed(() => {
return (
(
chatTrigger.value?.parameters?.options as INodeParameters
)?.allowedFilesMimeTypes?.toString() ?? ''
);
});
const locale = useI18n();
const chatOptions: ChatOptions = {
i18n: {
en: {
title: '',
footer: '',
subtitle: '',
inputPlaceholder: locale.baseText('chat.window.chat.placeholder'),
getStarted: '',
closeButtonTooltip: '',
},
},
webhookUrl: '',
mode: 'window',
showWindowCloseButton: true,
disabled: isDisabled,
allowFileUploads,
allowedFilesMimeTypes,
};
const chatConfig: Chat = {
messages,
sendMessage,
initialMessages: ref([]),
currentSessionId,
waitingForResponse: isLoading,
};
const messageVars = {
'--chat--message--bot--background': 'var(--color-lm-chat-bot-background)',
'--chat--message--user--background': 'var(--color-lm-chat-user-background)',
'--chat--message--bot--color': 'var(--color-text-dark)',
'--chat--message--user--color': 'var(--color-lm-chat-user-color)',
'--chat--message--bot--border': 'none',
'--chat--message--user--border': 'none',
'--chat--color-typing': 'var(--color-text-dark)',
};
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
function getTriggerNode() {
const triggerNode = workflow.value.queryNodes((nodeType: INodeType) =>
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(nodeType.description.name),
);
if (!triggerNode.length) {
chatTrigger.value = null;
}
chatTrigger.value = triggerNode[0];
}
function setNode() {
const triggerNode = chatTrigger.value;
if (!triggerNode) {
return;
}
const childNodes = workflow.value.getChildNodes(triggerNode.name);
for (const childNode of childNodes) {
// Look for the first connected node with metadata
// TODO: Allow later users to change that in the UI
const connectedSubNodes = workflow.value.getParentNodes(childNode, 'ALL_NON_MAIN');
const resultData = connectedSubNodes.map(workflowsStore.getWorkflowResultDataByNodeName);
if (!resultData && !Array.isArray(resultData)) {
continue;
}
if (resultData.some((data) => data?.[0].metadata)) {
node.value = workflowsStore.getNodeByName(childNode);
break;
}
}
}
function setConnectedNode() {
const triggerNode = chatTrigger.value;
if (!triggerNode) {
showError(new Error('Chat Trigger Node could not be found!'), 'Trigger Node not found');
return;
}
const chatNode = workflowsStore.getNodes().find((storeNode: INodeUi): boolean => {
if (storeNode.type === CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE) return false;
const nodeType = nodeTypesStore.getNodeType(storeNode.type, storeNode.typeVersion);
if (!nodeType) return false;
const isAgent = nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_AGENTS);
const isChain = nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_CHAINS);
let isCustomChainOrAgent = false;
if (nodeType.name === AI_CODE_NODE_TYPE) {
const inputs = NodeHelpers.getNodeInputs(workflow.value, storeNode, nodeType);
const inputTypes = NodeHelpers.getConnectionTypes(inputs);
const outputs = NodeHelpers.getNodeOutputs(workflow.value, storeNode, nodeType);
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
if (
inputTypes.includes(NodeConnectionType.AiLanguageModel) &&
inputTypes.includes(NodeConnectionType.Main) &&
outputTypes.includes(NodeConnectionType.Main)
) {
isCustomChainOrAgent = true;
}
}
if (!isAgent && !isChain && !isCustomChainOrAgent) return false;
const parentNodes = workflow.value.getParentNodes(storeNode.name);
const isChatChild = parentNodes.some((parentNodeName) => parentNodeName === triggerNode.name);
return Boolean(isChatChild && (isAgent || isChain || isCustomChainOrAgent));
});
if (!chatNode) {
return;
}
connectedNode.value = chatNode;
}
async function convertFileToBinaryData(file: File): Promise<IBinaryData> {
const reader = new FileReader();
return await new Promise((resolve, reject) => {
reader.onload = () => {
const binaryData: IBinaryData = {
data: (reader.result as string).split('base64,')?.[1] ?? '',
mimeType: file.type,
fileName: file.name,
fileSize: `${file.size} bytes`,
fileExtension: file.name.split('.').pop() ?? '',
fileType: file.type.split('/')[0] as BinaryFileType,
};
resolve(binaryData);
};
reader.onerror = () => {
reject(new Error('Failed to convert file to binary data'));
};
reader.readAsDataURL(file);
});
}
async function getKeyedFiles(files: File[]): Promise<IBinaryKeyData> {
const binaryData: IBinaryKeyData = {};
await Promise.all(
files.map(async (file, index) => {
const data = await convertFileToBinaryData(file);
const key = `data${index}`;
binaryData[key] = data;
}),
);
return binaryData;
}
function extractFileMeta(file: File): IDataObject {
return {
fileName: file.name,
fileSize: `${file.size} bytes`,
fileExtension: file.name.split('.').pop() ?? '',
fileType: file.type.split('/')[0],
mimeType: file.type,
};
}
async function startWorkflowWithMessage(message: string, files?: File[]): Promise<void> {
const triggerNode = chatTrigger.value;
if (!triggerNode) {
showError(new Error('Chat Trigger Node could not be found!'), 'Trigger Node not found');
return;
}
let inputKey = 'chatInput';
if (triggerNode.type === MANUAL_CHAT_TRIGGER_NODE_TYPE && triggerNode.typeVersion < 1.1) {
inputKey = 'input';
}
if (triggerNode.type === CHAT_TRIGGER_NODE_TYPE) {
inputKey = 'chatInput';
}
const usersStore = useUsersStore();
const currentUser = usersStore.currentUser ?? ({} as IUser);
const inputPayload: INodeExecutionData = {
json: {
sessionId: `test-${currentUser.id || 'unknown'}`,
action: 'sendMessage',
[inputKey]: message,
},
};
if (files && files.length > 0) {
const filesMeta = files.map((file) => extractFileMeta(file));
const binaryData = await getKeyedFiles(files);
inputPayload.json.files = filesMeta;
inputPayload.binary = binaryData;
}
const nodeData: ITaskData = {
startTime: new Date().getTime(),
executionTime: 0,
executionStatus: 'success',
data: {
main: [[inputPayload]],
},
source: [null],
};
const response = await runWorkflow({
triggerNode: triggerNode.name,
nodeData,
source: 'RunData.ManualChatMessage',
});
workflowsStore.appendChatMessage(message);
if (!response) {
showError(new Error('It was not possible to start workflow!'), 'Workflow could not be started');
return;
}
waitForExecution(response.executionId);
}
function waitForExecution(executionId?: string) {
const waitInterval = setInterval(() => {
if (!isLoading.value) {
clearInterval(waitInterval);
const lastNodeExecuted =
workflowsStore.getWorkflowExecution?.data?.resultData.lastNodeExecuted;
if (!lastNodeExecuted) return;
const nodeResponseDataArray =
get(workflowsStore.getWorkflowExecution?.data?.resultData.runData, lastNodeExecuted) ?? [];
const nodeResponseData = nodeResponseDataArray[nodeResponseDataArray.length - 1];
let responseMessage: string;
if (get(nodeResponseData, 'error')) {
responseMessage = '[ERROR: ' + get(nodeResponseData, 'error.message') + ']';
} else {
const responseData = get(nodeResponseData, 'data.main[0][0].json');
responseMessage = extractResponseMessage(responseData);
}
messages.value.push({
text: responseMessage,
sender: 'bot',
createdAt: new Date().toISOString(),
id: executionId ?? uuid(),
});
void nextTick(setNode);
}
}, 500);
}
function extractResponseMessage(responseData?: IDataObject) {
if (!responseData || isEmpty(responseData)) {
return locale.baseText('chat.window.chat.response.empty');
}
// Paths where the response message might be located
const paths = ['output', 'text', 'response.text'];
const matchedPath = paths.find((path) => get(responseData, path));
if (!matchedPath) return JSON.stringify(responseData, null, 2);
const matchedOutput = get(responseData, matchedPath);
if (typeof matchedOutput === 'object') {
return '```json\n' + JSON.stringify(matchedOutput, null, 2) + '\n```';
}
return matchedOutput?.toString() ?? '';
}
async function sendMessage(message: string, files?: File[]) {
previousMessageIndex.value = 0;
if (message.trim() === '' && (!files || files.length === 0)) {
showError(
new Error(locale.baseText('chat.window.chat.provideMessage')),
locale.baseText('chat.window.chat.emptyChatMessage'),
);
return;
}
const pinnedChatData = usePinnedData(chatTrigger.value);
if (pinnedChatData.hasData.value) {
const confirmResult = await useMessage().confirm(
locale.baseText('chat.window.chat.unpinAndExecute.description'),
locale.baseText('chat.window.chat.unpinAndExecute.title'),
{
confirmButtonText: locale.baseText('chat.window.chat.unpinAndExecute.confirm'),
cancelButtonText: locale.baseText('chat.window.chat.unpinAndExecute.cancel'),
},
);
if (!(confirmResult === MODAL_CONFIRM)) return;
pinnedChatData.unsetData('unpin-and-send-chat-message-modal');
}
const newMessage: ChatMessage = {
text: message,
sender: 'user',
createdAt: new Date().toISOString(),
id: uuid(),
files,
};
messages.value.push(newMessage);
await startWorkflowWithMessage(newMessage.text, files);
}
function displayExecution(executionId: string) {
const route = router.resolve({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: workflow.value.id, executionId },
});
window.open(route.href, '_blank');
}
function isTextMessage(message: ChatMessage): message is ChatMessageText {
return message.type === 'text' || !message.type;
}
function repostMessage(message: ChatMessageText) {
void sendMessage(message.text);
}
function reuseMessage(message: ChatMessageText) {
chatEventBus.emit('setInputValue', message.text);
}
function getChatMessages(): ChatMessageText[] {
if (!connectedNode.value) return [];
const connectedMemoryInputs =
workflow.value.connectionsByDestinationNode[connectedNode.value.name][
NodeConnectionType.AiMemory
];
if (!connectedMemoryInputs) return [];
const memoryConnection = (connectedMemoryInputs ?? []).find((i) => i.length > 0)?.[0];
if (!memoryConnection) return [];
const nodeResultData = workflowsStore.getWorkflowResultDataByNodeName(memoryConnection.node);
const memoryOutputData = (nodeResultData ?? [])
.map((data) => get(data, ['data', NodeConnectionType.AiMemory, 0, 0, 'json']) as MemoryOutput)
.find((data) => data.action === 'saveContext');
return (memoryOutputData?.chatHistory ?? []).map((message, index) => {
return {
createdAt: new Date().toISOString(),
text: message.kwargs.content,
id: `preload__${index}`,
sender: last(message.id) === 'HumanMessage' ? 'user' : 'bot',
};
});
}
function onArrowKeyDown({ currentInputValue, key }: ArrowKeyDownPayload) {
const pastMessages = workflowsStore.getPastChatMessages;
const isCurrentInputEmptyOrMatch =
currentInputValue.length === 0 || pastMessages.includes(currentInputValue);
if (isCurrentInputEmptyOrMatch && (key === 'ArrowUp' || key === 'ArrowDown')) {
// Blur the input when the user presses the up or down arrow key
chatEventBus.emit('blurInput');
if (pastMessages.length === 1) {
previousMessageIndex.value = 0;
} else if (key === 'ArrowUp') {
previousMessageIndex.value = (previousMessageIndex.value + 1) % pastMessages.length;
} else if (key === 'ArrowDown') {
previousMessageIndex.value =
(previousMessageIndex.value - 1 + pastMessages.length) % pastMessages.length;
}
chatEventBus.emit(
'setInputValue',
pastMessages[pastMessages.length - 1 - previousMessageIndex.value] ?? '',
);
// Refocus to move the cursor to the end of the input
chatEventBus.emit('focusInput');
}
}
provide(ChatSymbol, chatConfig);
provide(ChatOptionsSymbol, chatOptions);
onMounted(() => {
getTriggerNode();
setConnectedNode();
messages.value = getChatMessages();
setNode();
setTimeout(() => chatEventBus.emit('focusInput'), 0);
});
</script>
<template>
<Modal
:name="WORKFLOW_LM_CHAT_MODAL_KEY"
width="80%"
max-height="80%"
:title="
locale.baseText('chat.window.title', {
interpolate: {
nodeName: connectedNode?.name || locale.baseText('chat.window.noChatNode'),
},
})
"
:event-bus="modalBus"
:scrollable="false"
@keydown.stop
>
<template #content>
<div
:class="$style.workflowLmChat"
data-test-id="workflow-lm-chat-dialog"
:style="messageVars"
>
<MessagesList :messages="messages" :class="[$style.messages, 'ignore-key-press-canvas']">
<template #beforeMessage="{ message }">
<MessageOptionTooltip
v-if="message.sender === 'bot' && !message.id.includes('preload')"
placement="right"
>
{{ locale.baseText('chat.window.chat.chatMessageOptions.executionId') }}:
<a href="#" @click="displayExecution(message.id)">{{ message.id }}</a>
</MessageOptionTooltip>
<MessageOptionAction
v-if="isTextMessage(message) && message.sender === 'user'"
data-test-id="repost-message-button"
icon="redo"
:label="locale.baseText('chat.window.chat.chatMessageOptions.repostMessage')"
placement="left"
@click="repostMessage(message)"
/>
<MessageOptionAction
v-if="isTextMessage(message) && message.sender === 'user'"
data-test-id="reuse-message-button"
icon="copy"
:label="locale.baseText('chat.window.chat.chatMessageOptions.reuseMessage')"
placement="left"
@click="reuseMessage(message)"
/>
</template>
</MessagesList>
<div v-if="node" :class="$style.logsWrapper" data-test-id="lm-chat-logs">
<n8n-text :class="$style.logsTitle" tag="p" size="large">{{
locale.baseText('chat.window.logs')
}}</n8n-text>
<div :class="$style.logs">
<LazyRunDataAi
:key="messages.length"
:node="node"
hide-title
slim
:workflow="workflow"
/>
</div>
</div>
</div>
</template>
<template #footer>
<ChatInput
:class="$style.messagesInput"
data-test-id="lm-chat-inputs"
@arrow-key-down="onArrowKeyDown"
/>
<n8n-info-tip class="mt-s">
{{ locale.baseText('chatEmbed.infoTip.description') }}
<a @click="uiStore.openModal(CHAT_EMBED_MODAL_KEY)">
{{ locale.baseText('chatEmbed.infoTip.link') }}
</a>
</n8n-info-tip>
</template>
</Modal>
</template>
<style lang="scss">
.chat-message-markdown ul,
.chat-message-markdown ol {
padding: 0 0 0 1em;
}
</style>
<style module lang="scss">
.no-node-connected {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.workflowLmChat {
--chat--spacing: var(--spacing-m);
--chat--message--padding: var(--spacing-xs);
display: flex;
height: 100%;
z-index: 9999;
min-height: 10rem;
@media (min-height: 34rem) {
min-height: 14.5rem;
}
@media (min-height: 47rem) {
min-height: 25rem;
}
& ::-webkit-scrollbar {
width: 4px;
}
& ::-webkit-scrollbar-thumb {
border-radius: var(--border-radius-base);
background: var(--color-foreground-dark);
border: 1px solid white;
}
& ::-webkit-scrollbar-thumb:hover {
background: var(--color-foreground-xdark);
}
}
.logsWrapper {
--node-icon-color: var(--color-text-base);
border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-base);
height: 100%;
overflow: auto;
width: 100%;
padding: var(--spacing-xs) 0;
}
.logsTitle {
margin: 0 var(--spacing-s) var(--spacing-s);
}
.messages {
background-color: var(--color-lm-chat-messages-background);
border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-base);
height: 100%;
width: 100%;
overflow: auto;
padding-top: 1.5em;
&:not(:last-child) {
margin-right: 1em;
}
& * {
font-size: var(--font-size-s);
}
}
.messagesInput {
--chat--input--border: var(--input-border-color, var(--border-color-base))
var(--input-border-style, var(--border-style-base))
var(--input-border-width, var(--border-width-base));
--chat--input--border-radius: var(--border-radius-base) 0 0 var(--border-radius-base);
--chat--input--send--button--background: transparent;
--chat--input--send--button--color: var(--color-button-secondary-font);
--chat--input--send--button--color-hover: var(--color-primary);
--chat--input--border-active: var(--input-focus-border-color, var(--color-secondary));
--chat--files-spacing: var(--spacing-2xs) 0;
--chat--input--background: var(--color-lm-chat-bot-background);
[data-theme='dark'] & {
--chat--input--text-color: var(--input-font-color, var(--color-text-dark));
}
@media (prefers-color-scheme: dark) {
--chat--input--text-color: var(--input-font-color, var(--color-text-dark));
}
border-bottom-right-radius: var(--border-radius-base);
border-top-right-radius: var(--border-radius-base);
overflow: hidden;
}
</style>

View file

@ -1,127 +0,0 @@
import { createPinia, setActivePinia } from 'pinia';
import { fireEvent, waitFor } from '@testing-library/vue';
import { mock } from 'vitest-mock-extended';
import { NodeConnectionType } from 'n8n-workflow';
import type { IConnections, INode } from 'n8n-workflow';
import WorkflowLMChatModal from '@/components/WorkflowLMChat/WorkflowLMChat.vue';
import { WORKFLOW_LM_CHAT_MODAL_KEY } from '@/constants';
import type { IWorkflowDb } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createComponentRenderer } from '@/__tests__/render';
import { setupServer } from '@/__tests__/server';
import { defaultNodeDescriptions, mockNodes } from '@/__tests__/mocks';
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
const connections: IConnections = {
'Chat Trigger': {
main: [
[
{
node: 'Agent',
type: NodeConnectionType.Main,
index: 0,
},
],
],
},
};
const renderComponent = createComponentRenderer(WorkflowLMChatModal, {
props: {
teleported: false,
appendToBody: false,
},
});
async function createPiniaWithAINodes(options = { withConnections: true, withAgentNode: true }) {
const { withConnections, withAgentNode } = options;
const chatTriggerNode = mockNodes[4];
const agentNode = mockNodes[5];
const nodes: INode[] = [chatTriggerNode];
if (withAgentNode) nodes.push(agentNode);
const workflow = mock<IWorkflowDb>({
nodes,
...(withConnections ? { connections } : {}),
});
const pinia = createPinia();
setActivePinia(pinia);
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const uiStore = useUIStore();
nodeTypesStore.setNodeTypes(defaultNodeDescriptions);
workflowsStore.workflow = workflow;
await useSettingsStore().getSettings();
await useUsersStore().loginWithCookie();
uiStore.openModal(WORKFLOW_LM_CHAT_MODAL_KEY);
return pinia;
}
describe('WorkflowLMChatModal', () => {
let server: ReturnType<typeof setupServer>;
beforeAll(() => {
server = setupServer();
});
beforeEach(() => {
createAppModals();
});
afterEach(() => {
cleanupAppModals();
vi.clearAllMocks();
});
afterAll(() => {
server.shutdown();
});
it('should render correctly', async () => {
const { getByTestId } = renderComponent({
pinia: await createPiniaWithAINodes(),
});
await waitFor(() => expect(getByTestId('lmChat-modal')).toBeInTheDocument());
expect(getByTestId('workflow-lm-chat-dialog')).toBeInTheDocument();
});
it('should send and display chat message', async () => {
const { getByTestId } = renderComponent({
pinia: await createPiniaWithAINodes({
withConnections: true,
withAgentNode: true,
}),
});
await waitFor(() => expect(getByTestId('lmChat-modal')).toBeInTheDocument());
const chatDialog = getByTestId('workflow-lm-chat-dialog');
const chatInputsContainer = getByTestId('lm-chat-inputs');
const chatSendButton = chatInputsContainer.querySelector('.chat-input-send-button');
const chatInput = chatInputsContainer.querySelector('textarea');
if (chatInput && chatSendButton) {
await fireEvent.update(chatInput, 'Hello!');
await fireEvent.click(chatSendButton);
}
await waitFor(() =>
expect(chatDialog.querySelectorAll('.chat-message-from-user')).toHaveLength(1),
);
expect(chatDialog.querySelector('.chat-message-from-user')).toHaveTextContent('Hello!');
});
});

View file

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

View file

@ -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<typeof u
const uiStore = useUIStore();
const workflowsStore = useWorkflowsStore();
const executionsStore = useExecutionsStore();
// Starts to execute a workflow on server
async function runWorkflowApi(runData: IStartRunData): Promise<IExecutionPushResponse> {
if (!rootStore.pushConnectionActive) {
@ -175,7 +169,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
// If the chat node has no input data or pin data, open the chat modal
// and halt the execution
if (!chatHasInputData && !chatHasPinData) {
uiStore.openModal(WORKFLOW_LM_CHAT_MODAL_KEY);
workflowsStore.setPanelOpen('chat', true);
return;
}
}

View file

@ -10,6 +10,7 @@ import { useExternalHooks } from './useExternalHooks';
import { VIEWS } from '@/constants';
import type { ApplicationError } from 'n8n-workflow';
import { useStyles } from './useStyles';
import { useCanvasStore } from '@/stores/canvas.store';
import { useSettingsStore } from '@/stores/settings.store';
export interface NotificationErrorWithNodeAndDescription extends ApplicationError {
@ -29,12 +30,13 @@ export function useToast() {
const i18n = useI18n();
const settingsStore = useSettingsStore();
const { APP_Z_INDEXES } = useStyles();
const canvasStore = useCanvasStore();
const messageDefaults: Partial<Omit<NotificationOptions, 'message'>> = {
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);
}

View file

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

View file

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

View file

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

View file

@ -54,8 +54,8 @@ export const useCanvasStore = defineStore('canvas', () => {
const jsPlumbInstanceRef = ref<BrowserJsPlumbInstance>();
const isDragging = ref<boolean>(false);
const lastSelectedConnection = ref<Connection>();
const newNodeInsertPosition = ref<XYPosition | null>(null);
const panelHeight = ref(0);
const nodes = computed<INodeUi[]>(() => workflowStore.allNodes);
const triggerNodes = computed<INodeUi[]>(() =>
@ -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,

View file

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

View file

@ -139,6 +139,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
const nodeMetadata = ref<NodeMetadataMap>({});
const isInDebugMode = ref(false);
const chatMessages = ref<string[]>([]);
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,

View file

@ -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"
/>
<CanvasChatButton v-if="containsChatTriggerNodes" @click="onOpenChat" />
<CanvasChatButton
v-if="containsChatTriggerNodes"
:outline="isChatOpen === false"
@click="onOpenChat"
/>
<CanvasStopCurrentExecutionButton
v-if="isStopExecutionButtonVisible"
:stopping="isStoppingExecution"

View file

@ -34,7 +34,6 @@ import {
NODE_CREATOR_OPEN_SOURCES,
CHAT_TRIGGER_NODE_TYPE,
MANUAL_CHAT_TRIGGER_NODE_TYPE,
WORKFLOW_LM_CHAT_MODAL_KEY,
AI_NODE_CREATOR_VIEW,
DRAG_EVENT_DATA_KEY,
UPDATE_WEBHOOK_ID_NODE_TYPES,
@ -455,14 +454,14 @@ export default defineComponent({
)
);
},
canvasChatNode() {
return this.nodes.find((node) => node.type === CHAT_TRIGGER_NODE_TYPE);
},
isManualChatOnly(): boolean {
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"
/>