mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-14 08:34:07 -08:00
Merge remote-tracking branch 'origin/master' into ADO-2729/feature-set-field-default-value-of-added-node-based-on-previous
This commit is contained in:
commit
c33b3a3894
|
@ -3,7 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function getManualChatModal() {
|
export function getManualChatModal() {
|
||||||
return cy.getByTestId('lmChat-modal');
|
return cy.getByTestId('canvas-chat');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getManualChatInput() {
|
export function getManualChatInput() {
|
||||||
|
@ -19,11 +19,11 @@ export function getManualChatMessages() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getManualChatModalCloseButton() {
|
export function getManualChatModalCloseButton() {
|
||||||
return getManualChatModal().get('.el-dialog__close');
|
return cy.getByTestId('workflow-chat-button');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getManualChatModalLogs() {
|
export function getManualChatModalLogs() {
|
||||||
return getManualChatModal().getByTestId('lm-chat-logs');
|
return cy.getByTestId('canvas-chat-logs');
|
||||||
}
|
}
|
||||||
export function getManualChatDialog() {
|
export function getManualChatDialog() {
|
||||||
return getManualChatModal().getByTestId('workflow-lm-chat-dialog');
|
return getManualChatModal().getByTestId('workflow-lm-chat-dialog');
|
||||||
|
|
|
@ -11,6 +11,7 @@ export const getAddProjectButton = () =>
|
||||||
export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a');
|
export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a');
|
||||||
export const getProjectTabWorkflows = () => getProjectTabs().filter('a[href$="/workflows"]');
|
export const getProjectTabWorkflows = () => getProjectTabs().filter('a[href$="/workflows"]');
|
||||||
export const getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]');
|
export const getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]');
|
||||||
|
export const getProjectTabExecutions = () => getProjectTabs().filter('a[href$="/executions"]');
|
||||||
export const getProjectTabSettings = () => getProjectTabs().filter('a[href$="/settings"]');
|
export const getProjectTabSettings = () => getProjectTabs().filter('a[href$="/settings"]');
|
||||||
export const getProjectSettingsNameInput = () =>
|
export const getProjectSettingsNameInput = () =>
|
||||||
cy.getByTestId('project-settings-name-input').find('input');
|
cy.getByTestId('project-settings-name-input').find('input');
|
||||||
|
|
|
@ -14,7 +14,6 @@ import {
|
||||||
} from './../constants';
|
} from './../constants';
|
||||||
import {
|
import {
|
||||||
closeManualChatModal,
|
closeManualChatModal,
|
||||||
getManualChatDialog,
|
|
||||||
getManualChatMessages,
|
getManualChatMessages,
|
||||||
getManualChatModal,
|
getManualChatModal,
|
||||||
getManualChatModalLogs,
|
getManualChatModalLogs,
|
||||||
|
@ -168,7 +167,7 @@ describe('Langchain Integration', () => {
|
||||||
lastNodeExecuted: BASIC_LLM_CHAIN_NODE_NAME,
|
lastNodeExecuted: BASIC_LLM_CHAIN_NODE_NAME,
|
||||||
});
|
});
|
||||||
|
|
||||||
getManualChatDialog().should('contain', outputMessage);
|
getManualChatMessages().should('contain', outputMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be able to open and execute Agent node', () => {
|
it('should be able to open and execute Agent node', () => {
|
||||||
|
@ -208,7 +207,7 @@ describe('Langchain Integration', () => {
|
||||||
lastNodeExecuted: AGENT_NODE_NAME,
|
lastNodeExecuted: AGENT_NODE_NAME,
|
||||||
});
|
});
|
||||||
|
|
||||||
getManualChatDialog().should('contain', outputMessage);
|
getManualChatMessages().should('contain', outputMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add and use Manual Chat Trigger node together with Agent node', () => {
|
it('should add and use Manual Chat Trigger node together with Agent node', () => {
|
||||||
|
@ -229,8 +228,6 @@ describe('Langchain Integration', () => {
|
||||||
|
|
||||||
clickManualChatButton();
|
clickManualChatButton();
|
||||||
|
|
||||||
getManualChatModalLogs().should('not.exist');
|
|
||||||
|
|
||||||
const inputMessage = 'Hello!';
|
const inputMessage = 'Hello!';
|
||||||
const outputMessage = 'Hi there! How can I assist you today?';
|
const outputMessage = 'Hi there! How can I assist you today?';
|
||||||
const runData = [
|
const runData = [
|
||||||
|
@ -335,6 +332,8 @@ describe('Langchain Integration', () => {
|
||||||
getManualChatModalLogsEntries().should('have.length', 1);
|
getManualChatModalLogsEntries().should('have.length', 1);
|
||||||
|
|
||||||
closeManualChatModal();
|
closeManualChatModal();
|
||||||
|
getManualChatModalLogs().should('not.exist');
|
||||||
|
getManualChatModal().should('not.exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should auto-add chat trigger and basic LLM chain when adding LLM node', () => {
|
it('should auto-add chat trigger and basic LLM chain when adding LLM node', () => {
|
||||||
|
|
|
@ -51,7 +51,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
projects.getHomeButton().click();
|
projects.getHomeButton().click();
|
||||||
projects.getProjectTabs().should('have.length', 2);
|
projects.getProjectTabs().should('have.length', 3);
|
||||||
|
|
||||||
projects.getProjectTabCredentials().click();
|
projects.getProjectTabCredentials().click();
|
||||||
credentialsPage.getters.credentialCards().should('not.have.length');
|
credentialsPage.getters.credentialCards().should('not.have.length');
|
||||||
|
@ -101,7 +101,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
|
|
||||||
projects.getMenuItems().first().click();
|
projects.getMenuItems().first().click();
|
||||||
workflowsPage.getters.workflowCards().should('not.have.length');
|
workflowsPage.getters.workflowCards().should('not.have.length');
|
||||||
projects.getProjectTabs().should('have.length', 3);
|
projects.getProjectTabs().should('have.length', 4);
|
||||||
|
|
||||||
workflowsPage.getters.newWorkflowButtonCard().click();
|
workflowsPage.getters.newWorkflowButtonCard().click();
|
||||||
|
|
||||||
|
@ -441,9 +441,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
.should('contain.text', 'Notion account personal project');
|
.should('contain.text', 'Notion account personal project');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Skip flaky test
|
it('should move resources between projects', () => {
|
||||||
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
|
|
||||||
it.skip('should move resources between projects', () => {
|
|
||||||
cy.signinAsOwner();
|
cy.signinAsOwner();
|
||||||
cy.visit(workflowsPage.url);
|
cy.visit(workflowsPage.url);
|
||||||
|
|
||||||
|
@ -686,9 +684,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
.should('have.length', 1);
|
.should('have.length', 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Skip flaky test
|
it('should allow to change inaccessible credential when the workflow was moved to a team project', () => {
|
||||||
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
|
|
||||||
it.skip('should allow to change inaccessible credential when the workflow was moved to a team project', () => {
|
|
||||||
cy.signinAsOwner();
|
cy.signinAsOwner();
|
||||||
cy.visit(workflowsPage.url);
|
cy.visit(workflowsPage.url);
|
||||||
|
|
||||||
|
@ -701,9 +697,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
projects.getHomeButton().click();
|
projects.getHomeButton().click();
|
||||||
workflowsPage.getters.workflowCards().should('not.have.length');
|
workflowsPage.getters.workflowCards().should('not.have.length');
|
||||||
workflowsPage.getters.newWorkflowButtonCard().click();
|
workflowsPage.getters.newWorkflowButtonCard().click();
|
||||||
workflowsPage.getters.workflowCards().should('not.have.length');
|
|
||||||
|
|
||||||
workflowsPage.getters.newWorkflowButtonCard().click();
|
|
||||||
workflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
workflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||||
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
|
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
|
||||||
ndv.getters.backToCanvas().click();
|
ndv.getters.backToCanvas().click();
|
||||||
|
@ -789,7 +783,8 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
cy.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password);
|
cy.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password);
|
||||||
cy.getByTestId('form-submit-button').click();
|
cy.getByTestId('form-submit-button').click();
|
||||||
|
|
||||||
mainSidebar.getters.executions().click();
|
projects.getMenuItems().last().click();
|
||||||
|
projects.getProjectTabExecutions().click();
|
||||||
cy.getByTestId('global-execution-list-item').first().find('td:last button').click();
|
cy.getByTestId('global-execution-list-item').first().find('td:last button').click();
|
||||||
getVisibleDropdown()
|
getVisibleDropdown()
|
||||||
.find('li')
|
.find('li')
|
||||||
|
|
|
@ -224,6 +224,54 @@ describe('AI Assistant::enabled', () => {
|
||||||
.should('contain.text', 'item.json.myNewField = 1');
|
.should('contain.text', 'item.json.myNewField = 1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Should ignore node execution success and error messages after the node run successfully once', () => {
|
||||||
|
const getParameter = () => ndv.getters.parameterInput('jsCode').should('be.visible');
|
||||||
|
|
||||||
|
const getEditor = () => getParameter().find('.cm-content').should('exist');
|
||||||
|
|
||||||
|
cy.intercept('POST', '/rest/ai/chat', {
|
||||||
|
statusCode: 200,
|
||||||
|
fixture: 'aiAssistant/responses/code_diff_suggestion_response.json',
|
||||||
|
}).as('chatRequest');
|
||||||
|
|
||||||
|
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
|
||||||
|
wf.actions.openNode('Code');
|
||||||
|
ndv.getters.nodeExecuteButton().click();
|
||||||
|
aiAssistant.getters.nodeErrorViewAssistantButton().click({ force: true });
|
||||||
|
cy.wait('@chatRequest');
|
||||||
|
|
||||||
|
cy.intercept('POST', '/rest/ai/chat', {
|
||||||
|
statusCode: 200,
|
||||||
|
fixture: 'aiAssistant/responses/node_execution_succeeded_response.json',
|
||||||
|
}).as('chatRequest2');
|
||||||
|
|
||||||
|
getEditor()
|
||||||
|
.type('{selectall}')
|
||||||
|
.paste(
|
||||||
|
'for (const item of $input.all()) {\n item.json.myNewField = 1;\n}\n\nreturn $input.all();',
|
||||||
|
);
|
||||||
|
|
||||||
|
ndv.getters.nodeExecuteButton().click();
|
||||||
|
|
||||||
|
getEditor()
|
||||||
|
.type('{selectall}')
|
||||||
|
.paste(
|
||||||
|
'for (const item of $input.all()) {\n item.json.myNewField = 1aaaa!;\n}\n\nreturn $input.all();',
|
||||||
|
);
|
||||||
|
|
||||||
|
ndv.getters.nodeExecuteButton().click();
|
||||||
|
|
||||||
|
aiAssistant.getters.chatMessagesAssistant().should('have.length', 3);
|
||||||
|
|
||||||
|
aiAssistant.getters
|
||||||
|
.chatMessagesAssistant()
|
||||||
|
.eq(2)
|
||||||
|
.should(
|
||||||
|
'contain.text',
|
||||||
|
'Code node ran successfully, did my solution help resolve your issue?\nQuick reply 👇Yes, thanksNo, I am still stuck',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should end chat session when `end_session` event is received', () => {
|
it('should end chat session when `end_session` event is received', () => {
|
||||||
cy.intercept('POST', '/rest/ai/chat', {
|
cy.intercept('POST', '/rest/ai/chat', {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
|
|
|
@ -81,7 +81,7 @@ describe('NDV', () => {
|
||||||
ndv.getters.backToCanvas().click();
|
ndv.getters.backToCanvas().click();
|
||||||
workflowPage.actions.executeWorkflow();
|
workflowPage.actions.executeWorkflow();
|
||||||
workflowPage.actions.openNode('Merge');
|
workflowPage.actions.openNode('Merge');
|
||||||
ndv.getters.outputPanel().contains('1 item').should('exist');
|
ndv.getters.outputPanel().contains('2 items').should('exist');
|
||||||
cy.contains('span', 'zero').should('exist');
|
cy.contains('span', 'zero').should('exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"sessionId": "1",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"type": "message",
|
||||||
|
"text": "**Code** node ran successfully, did my solution help resolve your issue?",
|
||||||
|
"quickReplies": [
|
||||||
|
{
|
||||||
|
"text": "Yes, thanks",
|
||||||
|
"type": "all-good",
|
||||||
|
"isFeedback": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "No, I am still stuck",
|
||||||
|
"type": "still-stuck",
|
||||||
|
"isFeedback": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -16,6 +16,7 @@
|
||||||
"build:nodes": "turbo run build:nodes",
|
"build:nodes": "turbo run build:nodes",
|
||||||
"typecheck": "turbo typecheck",
|
"typecheck": "turbo typecheck",
|
||||||
"dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner",
|
"dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner",
|
||||||
|
"dev:be": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui",
|
||||||
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core",
|
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core",
|
||||||
"clean": "turbo run clean --parallel",
|
"clean": "turbo run clean --parallel",
|
||||||
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",
|
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",
|
||||||
|
|
|
@ -1 +1,13 @@
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { configure } from '@testing-library/vue';
|
||||||
|
|
||||||
|
configure({ testIdAttribute: 'data-test-id' });
|
||||||
|
|
||||||
|
window.ResizeObserver =
|
||||||
|
window.ResizeObserver ||
|
||||||
|
vi.fn().mockImplementation(() => ({
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
observe: vi.fn(),
|
||||||
|
unobserve: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
|
@ -30,22 +30,23 @@ const TypeIcon = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
function onClick() {
|
function onClick() {
|
||||||
if (props.isRemovable) {
|
|
||||||
emit('remove', props.file);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.isPreviewable) {
|
if (props.isPreviewable) {
|
||||||
window.open(URL.createObjectURL(props.file));
|
window.open(URL.createObjectURL(props.file));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function onDelete() {
|
||||||
|
emit('remove', props.file);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="chat-file" @click="onClick">
|
<div class="chat-file" @click="onClick">
|
||||||
<TypeIcon />
|
<TypeIcon />
|
||||||
<p class="chat-file-name">{{ file.name }}</p>
|
<p class="chat-file-name">{{ file.name }}</p>
|
||||||
<IconDelete v-if="isRemovable" class="chat-file-delete" />
|
<span v-if="isRemovable" class="chat-file-delete" @click.stop="onDelete">
|
||||||
<IconPreview v-if="isPreviewable" class="chat-file-preview" />
|
<IconDelete />
|
||||||
|
</span>
|
||||||
|
<IconPreview v-else-if="isPreviewable" class="chat-file-preview" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -80,12 +81,25 @@ function onClick() {
|
||||||
.chat-file-preview {
|
.chat-file-preview {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
display: none;
|
display: block;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-file:hover & {
|
.chat-file-delete {
|
||||||
display: block;
|
position: relative;
|
||||||
|
&:hover {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Increase hit area for better clickability */
|
||||||
|
&:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
right: -10px;
|
||||||
|
bottom: -10px;
|
||||||
|
left: -10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useFileDialog } from '@vueuse/core';
|
import { useFileDialog } from '@vueuse/core';
|
||||||
import IconFilePlus from 'virtual:icons/mdi/filePlus';
|
import IconPaperclip from 'virtual:icons/mdi/paperclip';
|
||||||
import IconSend from 'virtual:icons/mdi/send';
|
import IconSend from 'virtual:icons/mdi/send';
|
||||||
import { computed, onMounted, onUnmounted, ref, unref } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, unref } from 'vue';
|
||||||
|
|
||||||
|
@ -9,10 +9,20 @@ import { chatEventBus } from '@n8n/chat/event-buses';
|
||||||
|
|
||||||
import ChatFile from './ChatFile.vue';
|
import ChatFile from './ChatFile.vue';
|
||||||
|
|
||||||
|
export interface ChatInputProps {
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ChatInputProps>(), {
|
||||||
|
placeholder: 'inputPlaceholder',
|
||||||
|
});
|
||||||
|
|
||||||
export interface ArrowKeyDownPayload {
|
export interface ArrowKeyDownPayload {
|
||||||
key: 'ArrowUp' | 'ArrowDown';
|
key: 'ArrowUp' | 'ArrowDown';
|
||||||
currentInputValue: string;
|
currentInputValue: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
arrowKeyDown: [value: ArrowKeyDownPayload];
|
arrowKeyDown: [value: ArrowKeyDownPayload];
|
||||||
}>();
|
}>();
|
||||||
|
@ -20,12 +30,12 @@ const emit = defineEmits<{
|
||||||
const { options } = useOptions();
|
const { options } = useOptions();
|
||||||
const chatStore = useChat();
|
const chatStore = useChat();
|
||||||
const { waitingForResponse } = chatStore;
|
const { waitingForResponse } = chatStore;
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const files = ref<FileList | null>(null);
|
const files = ref<FileList | null>(null);
|
||||||
const chatTextArea = ref<HTMLTextAreaElement | null>(null);
|
const chatTextArea = ref<HTMLTextAreaElement | null>(null);
|
||||||
const input = ref('');
|
const input = ref('');
|
||||||
const isSubmitting = ref(false);
|
const isSubmitting = ref(false);
|
||||||
|
const resizeObserver = ref<ResizeObserver | null>(null);
|
||||||
|
|
||||||
const isSubmitDisabled = computed(() => {
|
const isSubmitDisabled = computed(() => {
|
||||||
return input.value === '' || waitingForResponse.value || options.disabled?.value === true;
|
return input.value === '' || waitingForResponse.value || options.disabled?.value === true;
|
||||||
|
@ -74,12 +84,30 @@ onMounted(() => {
|
||||||
chatEventBus.on('focusInput', focusChatInput);
|
chatEventBus.on('focusInput', focusChatInput);
|
||||||
chatEventBus.on('blurInput', blurChatInput);
|
chatEventBus.on('blurInput', blurChatInput);
|
||||||
chatEventBus.on('setInputValue', setInputValue);
|
chatEventBus.on('setInputValue', setInputValue);
|
||||||
|
|
||||||
|
if (chatTextArea.value) {
|
||||||
|
resizeObserver.value = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.target === chatTextArea.value) {
|
||||||
|
adjustHeight({ target: chatTextArea.value } as unknown as Event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start observing the textarea
|
||||||
|
resizeObserver.value.observe(chatTextArea.value);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
chatEventBus.off('focusInput', focusChatInput);
|
chatEventBus.off('focusInput', focusChatInput);
|
||||||
chatEventBus.off('blurInput', blurChatInput);
|
chatEventBus.off('blurInput', blurChatInput);
|
||||||
chatEventBus.off('setInputValue', setInputValue);
|
chatEventBus.off('setInputValue', setInputValue);
|
||||||
|
|
||||||
|
if (resizeObserver.value) {
|
||||||
|
resizeObserver.value.disconnect();
|
||||||
|
resizeObserver.value = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function blurChatInput() {
|
function blurChatInput() {
|
||||||
|
@ -121,6 +149,7 @@ async function onSubmitKeydown(event: KeyboardEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
await onSubmit(event);
|
await onSubmit(event);
|
||||||
|
adjustHeight({ target: chatTextArea.value } as unknown as Event);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFileRemove(file: File) {
|
function onFileRemove(file: File) {
|
||||||
|
@ -151,6 +180,15 @@ function onOpenFileDialog() {
|
||||||
if (isFileUploadDisabled.value) return;
|
if (isFileUploadDisabled.value) return;
|
||||||
openFileDialog({ accept: unref(allowedFileTypes) });
|
openFileDialog({ accept: unref(allowedFileTypes) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function adjustHeight(event: Event) {
|
||||||
|
const textarea = event.target as HTMLTextAreaElement;
|
||||||
|
// Set to content minimum to get the right scrollHeight
|
||||||
|
textarea.style.height = 'var(--chat--textarea--height)';
|
||||||
|
// Get the new height, with a small buffer for padding
|
||||||
|
const newHeight = Math.min(textarea.scrollHeight, 480); // 30rem
|
||||||
|
textarea.style.height = `${newHeight}px`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -158,20 +196,25 @@ function onOpenFileDialog() {
|
||||||
<div class="chat-inputs">
|
<div class="chat-inputs">
|
||||||
<textarea
|
<textarea
|
||||||
ref="chatTextArea"
|
ref="chatTextArea"
|
||||||
|
data-test-id="chat-input"
|
||||||
v-model="input"
|
v-model="input"
|
||||||
:disabled="isInputDisabled"
|
:disabled="isInputDisabled"
|
||||||
:placeholder="t('inputPlaceholder')"
|
:placeholder="t(props.placeholder)"
|
||||||
@keydown.enter="onSubmitKeydown"
|
@keydown.enter="onSubmitKeydown"
|
||||||
|
@input="adjustHeight"
|
||||||
|
@mousedown="adjustHeight"
|
||||||
|
@focus="adjustHeight"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="chat-inputs-controls">
|
<div class="chat-inputs-controls">
|
||||||
<button
|
<button
|
||||||
v-if="isFileUploadAllowed"
|
v-if="isFileUploadAllowed"
|
||||||
:disabled="isFileUploadDisabled"
|
:disabled="isFileUploadDisabled"
|
||||||
class="chat-input-send-button"
|
class="chat-input-file-button"
|
||||||
|
data-test-id="chat-attach-file-button"
|
||||||
@click="onOpenFileDialog"
|
@click="onOpenFileDialog"
|
||||||
>
|
>
|
||||||
<IconFilePlus height="24" width="24" />
|
<IconPaperclip height="24" width="24" />
|
||||||
</button>
|
</button>
|
||||||
<button :disabled="isSubmitDisabled" class="chat-input-send-button" @click="onSubmit">
|
<button :disabled="isSubmitDisabled" class="chat-input-send-button" @click="onSubmit">
|
||||||
<IconSend height="24" width="24" />
|
<IconSend height="24" width="24" />
|
||||||
|
@ -184,6 +227,7 @@ function onOpenFileDialog() {
|
||||||
:key="file.name"
|
:key="file.name"
|
||||||
:file="file"
|
:file="file"
|
||||||
:is-removable="true"
|
:is-removable="true"
|
||||||
|
:is-previewable="true"
|
||||||
@remove="onFileRemove"
|
@remove="onFileRemove"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -217,13 +261,15 @@ function onOpenFileDialog() {
|
||||||
border-radius: var(--chat--input--border-radius, 0);
|
border-radius: var(--chat--input--border-radius, 0);
|
||||||
padding: 0.8rem;
|
padding: 0.8rem;
|
||||||
padding-right: calc(0.8rem + (var(--controls-count, 1) * var(--chat--textarea--height)));
|
padding-right: calc(0.8rem + (var(--controls-count, 1) * var(--chat--textarea--height)));
|
||||||
min-height: var(--chat--textarea--height);
|
min-height: var(--chat--textarea--height, 2.5rem); // Set a smaller initial height
|
||||||
max-height: var(--chat--textarea--max-height, var(--chat--textarea--height));
|
max-height: var(--chat--textarea--max-height, 30rem);
|
||||||
height: 100%;
|
height: var(--chat--textarea--height, 2.5rem); // Set initial height same as min-height
|
||||||
|
resize: none;
|
||||||
|
overflow-y: auto;
|
||||||
background: var(--chat--input--background, white);
|
background: var(--chat--input--background, white);
|
||||||
resize: var(--chat--textarea--resize, none);
|
|
||||||
color: var(--chat--input--text-color, initial);
|
color: var(--chat--input--text-color, initial);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
line-height: var(--chat--input--line-height, 1.5);
|
||||||
|
|
||||||
&:focus,
|
&:focus,
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@ -235,8 +281,10 @@ function onOpenFileDialog() {
|
||||||
display: flex;
|
display: flex;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0.5rem;
|
right: 0.5rem;
|
||||||
|
bottom: 0;
|
||||||
}
|
}
|
||||||
.chat-input-send-button {
|
.chat-input-send-button,
|
||||||
|
.chat-input-file-button {
|
||||||
height: var(--chat--textarea--height);
|
height: var(--chat--textarea--height);
|
||||||
width: var(--chat--textarea--height);
|
width: var(--chat--textarea--height);
|
||||||
background: var(--chat--input--send--button--background, white);
|
background: var(--chat--input--send--button--background, white);
|
||||||
|
@ -253,19 +301,33 @@ function onOpenFileDialog() {
|
||||||
min-width: fit-content;
|
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] {
|
&[disabled] {
|
||||||
cursor: no-drop;
|
cursor: no-drop;
|
||||||
color: var(--chat--color-disabled);
|
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 {
|
.chat-files {
|
||||||
|
@ -275,7 +337,7 @@ function onOpenFileDialog() {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.25rem;
|
gap: 0.5rem;
|
||||||
padding: var(--chat--files-spacing, 0.25rem);
|
padding: var(--chat--files-spacing, 0.25rem);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -60,7 +60,7 @@ const linksNewTabPlugin = (vueMarkdownItInstance: MarkdownIt) => {
|
||||||
const scrollToView = () => {
|
const scrollToView = () => {
|
||||||
if (messageContainer.value?.scrollIntoView) {
|
if (messageContainer.value?.scrollIntoView) {
|
||||||
messageContainer.value.scrollIntoView({
|
messageContainer.value.scrollIntoView({
|
||||||
block: 'center',
|
block: 'start',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -132,14 +132,14 @@ onMounted(async () => {
|
||||||
.chat-message {
|
.chat-message {
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
max-width: 80%;
|
max-width: fit-content;
|
||||||
font-size: var(--chat--message--font-size, 1rem);
|
font-size: var(--chat--message--font-size, 1rem);
|
||||||
padding: var(--chat--message--padding, var(--chat--spacing));
|
padding: var(--chat--message--padding, var(--chat--spacing));
|
||||||
border-radius: var(--chat--message--border-radius, var(--chat--border-radius));
|
border-radius: var(--chat--message--border-radius, var(--chat--border-radius));
|
||||||
|
scroll-margin: 100px;
|
||||||
.chat-message-actions {
|
.chat-message-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 100%;
|
bottom: calc(100% - 0.5rem);
|
||||||
left: 0;
|
left: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-0.25rem);
|
transform: translateY(-0.25rem);
|
||||||
|
@ -151,6 +151,9 @@ onMounted(async () => {
|
||||||
left: auto;
|
left: auto;
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
&.chat-message-from-bot .chat-message-actions {
|
||||||
|
bottom: calc(100% - 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.chat-message-actions {
|
.chat-message-actions {
|
||||||
|
@ -159,7 +162,7 @@ onMounted(async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
line-height: var(--chat--message-line-height, 1.8);
|
line-height: var(--chat--message-line-height, 1.5);
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -34,7 +34,12 @@ onMounted(() => {
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Message ref="messageContainer" :class="classes" :message="message">
|
<Message
|
||||||
|
ref="messageContainer"
|
||||||
|
:class="classes"
|
||||||
|
:message="message"
|
||||||
|
data-test-id="chat-message-typing"
|
||||||
|
>
|
||||||
<div class="chat-message-typing-body">
|
<div class="chat-message-typing-body">
|
||||||
<span class="chat-message-typing-circle"></span>
|
<span class="chat-message-typing-circle"></span>
|
||||||
<span class="chat-message-typing-circle"></span>
|
<span class="chat-message-typing-circle"></span>
|
||||||
|
|
|
@ -37,7 +37,7 @@ body {
|
||||||
4. Prevent font size adjustment after orientation changes (IE, iOS)
|
4. Prevent font size adjustment after orientation changes (IE, iOS)
|
||||||
5. Prevent overflow from long words (all)
|
5. Prevent overflow from long words (all)
|
||||||
*/
|
*/
|
||||||
font-size: 125%; /* 2 */
|
font-size: 110%; /* 2 */
|
||||||
line-height: 1.6; /* 3 */
|
line-height: 1.6; /* 3 */
|
||||||
-webkit-text-size-adjust: 100%; /* 4 */
|
-webkit-text-size-adjust: 100%; /* 4 */
|
||||||
word-break: break-word; /* 5 */
|
word-break: break-word; /* 5 */
|
||||||
|
@ -596,7 +596,7 @@ body {
|
||||||
|
|
||||||
pre code {
|
pre code {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 0.3em 0.7em;
|
padding: 0 0 0.5rem 0.5rem;
|
||||||
word-break: normal;
|
word-break: normal;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ import { sqlAgentAgentProperties } from './agents/SqlAgent/description';
|
||||||
import { sqlAgentAgentExecute } from './agents/SqlAgent/execute';
|
import { sqlAgentAgentExecute } from './agents/SqlAgent/execute';
|
||||||
import { toolsAgentProperties } from './agents/ToolsAgent/description';
|
import { toolsAgentProperties } from './agents/ToolsAgent/description';
|
||||||
import { toolsAgentExecute } from './agents/ToolsAgent/execute';
|
import { toolsAgentExecute } from './agents/ToolsAgent/execute';
|
||||||
import { promptTypeOptions, textInput } from '../../../utils/descriptions';
|
import { promptTypeOptions, textFromPreviousNode, textInput } from '../../../utils/descriptions';
|
||||||
|
|
||||||
// Function used in the inputs expression to figure out which inputs to
|
// Function used in the inputs expression to figure out which inputs to
|
||||||
// display based on the agent type
|
// display based on the agent type
|
||||||
|
@ -341,6 +341,17 @@ export class Agent implements INodeType {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
...textFromPreviousNode,
|
||||||
|
displayOptions: {
|
||||||
|
show: { promptType: ['auto'], '@version': [{ _cnd: { gte: 1.7 } }] },
|
||||||
|
// SQL Agent has data source and credentials parameters so we need to include this input there manually
|
||||||
|
// to preserve the order
|
||||||
|
hide: {
|
||||||
|
agent: ['sqlAgent'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
...textInput,
|
...textInput,
|
||||||
displayOptions: {
|
displayOptions: {
|
||||||
|
|
|
@ -1,6 +1,11 @@
|
||||||
import type { INodeProperties } from 'n8n-workflow';
|
import type { INodeProperties } from 'n8n-workflow';
|
||||||
import { promptTypeOptions, textInput } from '../../../../../utils/descriptions';
|
|
||||||
import { SQL_PREFIX, SQL_SUFFIX } from './other/prompts';
|
import { SQL_PREFIX, SQL_SUFFIX } from './other/prompts';
|
||||||
|
import {
|
||||||
|
promptTypeOptions,
|
||||||
|
textFromPreviousNode,
|
||||||
|
textInput,
|
||||||
|
} from '../../../../../utils/descriptions';
|
||||||
|
|
||||||
const dataSourceOptions: INodeProperties = {
|
const dataSourceOptions: INodeProperties = {
|
||||||
displayName: 'Data Source',
|
displayName: 'Data Source',
|
||||||
|
@ -114,6 +119,12 @@ export const sqlAgentAgentProperties: INodeProperties[] = [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
...textFromPreviousNode,
|
||||||
|
displayOptions: {
|
||||||
|
show: { promptType: ['auto'], '@version': [{ _cnd: { gte: 1.7 } }], agent: ['sqlAgent'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
...textInput,
|
...textInput,
|
||||||
displayOptions: {
|
displayOptions: {
|
||||||
|
|
|
@ -1,3 +1,9 @@
|
||||||
|
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
|
||||||
|
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||||
|
import type { DataSource } from '@n8n/typeorm';
|
||||||
|
import type { SqlCreatePromptArgs } from 'langchain/agents/toolkits/sql';
|
||||||
|
import { SqlToolkit, createSqlAgent } from 'langchain/agents/toolkits/sql';
|
||||||
|
import { SqlDatabase } from 'langchain/sql_db';
|
||||||
import {
|
import {
|
||||||
type IExecuteFunctions,
|
type IExecuteFunctions,
|
||||||
type INodeExecutionData,
|
type INodeExecutionData,
|
||||||
|
@ -6,19 +12,12 @@ import {
|
||||||
type IDataObject,
|
type IDataObject,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { SqlDatabase } from 'langchain/sql_db';
|
import { getMysqlDataSource } from './other/handlers/mysql';
|
||||||
import type { SqlCreatePromptArgs } from 'langchain/agents/toolkits/sql';
|
import { getPostgresDataSource } from './other/handlers/postgres';
|
||||||
import { SqlToolkit, createSqlAgent } from 'langchain/agents/toolkits/sql';
|
import { getSqliteDataSource } from './other/handlers/sqlite';
|
||||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
import { SQL_PREFIX, SQL_SUFFIX } from './other/prompts';
|
||||||
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
|
|
||||||
import type { DataSource } from '@n8n/typeorm';
|
|
||||||
|
|
||||||
import { getPromptInputByType, serializeChatHistory } from '../../../../../utils/helpers';
|
import { getPromptInputByType, serializeChatHistory } from '../../../../../utils/helpers';
|
||||||
import { getTracingConfig } from '../../../../../utils/tracing';
|
import { getTracingConfig } from '../../../../../utils/tracing';
|
||||||
import { getSqliteDataSource } from './other/handlers/sqlite';
|
|
||||||
import { getPostgresDataSource } from './other/handlers/postgres';
|
|
||||||
import { SQL_PREFIX, SQL_SUFFIX } from './other/prompts';
|
|
||||||
import { getMysqlDataSource } from './other/handlers/mysql';
|
|
||||||
|
|
||||||
const parseTablesString = (tablesString: string) =>
|
const parseTablesString = (tablesString: string) =>
|
||||||
tablesString
|
tablesString
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { AgentExecutor } from 'langchain/agents';
|
import { AgentExecutor } from 'langchain/agents';
|
||||||
import { OpenAI as OpenAIClient } from 'openai';
|
import type { OpenAIToolType } from 'langchain/dist/experimental/openai_assistant/schema';
|
||||||
import { OpenAIAssistantRunnable } from 'langchain/experimental/openai_assistant';
|
import { OpenAIAssistantRunnable } from 'langchain/experimental/openai_assistant';
|
||||||
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
|
@ -8,10 +8,11 @@ import type {
|
||||||
INodeType,
|
INodeType,
|
||||||
INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import type { OpenAIToolType } from 'langchain/dist/experimental/openai_assistant/schema';
|
import { OpenAI as OpenAIClient } from 'openai';
|
||||||
|
|
||||||
|
import { formatToOpenAIAssistantTool } from './utils';
|
||||||
import { getConnectedTools } from '../../../utils/helpers';
|
import { getConnectedTools } from '../../../utils/helpers';
|
||||||
import { getTracingConfig } from '../../../utils/tracing';
|
import { getTracingConfig } from '../../../utils/tracing';
|
||||||
import { formatToOpenAIAssistantTool } from './utils';
|
|
||||||
|
|
||||||
export class OpenAiAssistant implements INodeType {
|
export class OpenAiAssistant implements INodeType {
|
||||||
description: INodeTypeDescription = {
|
description: INodeTypeDescription = {
|
||||||
|
|
|
@ -36,6 +36,7 @@ import {
|
||||||
getCustomErrorMessage as getCustomOpenAiErrorMessage,
|
getCustomErrorMessage as getCustomOpenAiErrorMessage,
|
||||||
isOpenAiError,
|
isOpenAiError,
|
||||||
} from '../../vendors/OpenAi/helpers/error-handling';
|
} from '../../vendors/OpenAi/helpers/error-handling';
|
||||||
|
import { promptTypeOptions, textFromPreviousNode } from '../../../utils/descriptions';
|
||||||
|
|
||||||
interface MessagesTemplate {
|
interface MessagesTemplate {
|
||||||
type: string;
|
type: string;
|
||||||
|
@ -253,7 +254,7 @@ export class ChainLlm implements INodeType {
|
||||||
name: 'chainLlm',
|
name: 'chainLlm',
|
||||||
icon: 'fa:link',
|
icon: 'fa:link',
|
||||||
group: ['transform'],
|
group: ['transform'],
|
||||||
version: [1, 1.1, 1.2, 1.3, 1.4],
|
version: [1, 1.1, 1.2, 1.3, 1.4, 1.5],
|
||||||
description: 'A simple chain to prompt a large language model',
|
description: 'A simple chain to prompt a large language model',
|
||||||
defaults: {
|
defaults: {
|
||||||
name: 'Basic LLM Chain',
|
name: 'Basic LLM Chain',
|
||||||
|
@ -315,30 +316,16 @@ export class ChainLlm implements INodeType {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Prompt',
|
...promptTypeOptions,
|
||||||
name: 'promptType',
|
|
||||||
type: 'options',
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
|
||||||
name: 'Take from previous node automatically',
|
|
||||||
value: 'auto',
|
|
||||||
description: 'Looks for an input field called chatInput',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
|
||||||
name: 'Define below',
|
|
||||||
value: 'define',
|
|
||||||
description:
|
|
||||||
'Use an expression to reference data in previous nodes or enter static text',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
displayOptions: {
|
displayOptions: {
|
||||||
hide: {
|
hide: {
|
||||||
'@version': [1, 1.1, 1.2, 1.3],
|
'@version': [1, 1.1, 1.2, 1.3],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
default: 'auto',
|
},
|
||||||
|
{
|
||||||
|
...textFromPreviousNode,
|
||||||
|
displayOptions: { show: { promptType: ['auto'], '@version': [{ _cnd: { gte: 1.5 } }] } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Text',
|
displayName: 'Text',
|
||||||
|
|
|
@ -1,3 +1,12 @@
|
||||||
|
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||||
|
import {
|
||||||
|
ChatPromptTemplate,
|
||||||
|
SystemMessagePromptTemplate,
|
||||||
|
HumanMessagePromptTemplate,
|
||||||
|
PromptTemplate,
|
||||||
|
} from '@langchain/core/prompts';
|
||||||
|
import type { BaseRetriever } from '@langchain/core/retrievers';
|
||||||
|
import { RetrievalQAChain } from 'langchain/chains';
|
||||||
import {
|
import {
|
||||||
NodeConnectionType,
|
NodeConnectionType,
|
||||||
type IExecuteFunctions,
|
type IExecuteFunctions,
|
||||||
|
@ -7,20 +16,12 @@ import {
|
||||||
NodeOperationError,
|
NodeOperationError,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { RetrievalQAChain } from 'langchain/chains';
|
import { promptTypeOptions, textFromPreviousNode } from '../../../utils/descriptions';
|
||||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
|
||||||
import type { BaseRetriever } from '@langchain/core/retrievers';
|
|
||||||
import {
|
|
||||||
ChatPromptTemplate,
|
|
||||||
SystemMessagePromptTemplate,
|
|
||||||
HumanMessagePromptTemplate,
|
|
||||||
PromptTemplate,
|
|
||||||
} from '@langchain/core/prompts';
|
|
||||||
import { getTemplateNoticeField } from '../../../utils/sharedFields';
|
|
||||||
import { getPromptInputByType, isChatInstance } from '../../../utils/helpers';
|
import { getPromptInputByType, isChatInstance } from '../../../utils/helpers';
|
||||||
|
import { getTemplateNoticeField } from '../../../utils/sharedFields';
|
||||||
import { getTracingConfig } from '../../../utils/tracing';
|
import { getTracingConfig } from '../../../utils/tracing';
|
||||||
|
|
||||||
const SYSTEM_PROMPT_TEMPLATE = `Use the following pieces of context to answer the users question.
|
const SYSTEM_PROMPT_TEMPLATE = `Use the following pieces of context to answer the users question.
|
||||||
If you don't know the answer, just say that you don't know, don't try to make up an answer.
|
If you don't know the answer, just say that you don't know, don't try to make up an answer.
|
||||||
----------------
|
----------------
|
||||||
{context}`;
|
{context}`;
|
||||||
|
@ -31,7 +32,7 @@ export class ChainRetrievalQa implements INodeType {
|
||||||
name: 'chainRetrievalQa',
|
name: 'chainRetrievalQa',
|
||||||
icon: 'fa:link',
|
icon: 'fa:link',
|
||||||
group: ['transform'],
|
group: ['transform'],
|
||||||
version: [1, 1.1, 1.2, 1.3],
|
version: [1, 1.1, 1.2, 1.3, 1.4],
|
||||||
description: 'Answer questions about retrieved documents',
|
description: 'Answer questions about retrieved documents',
|
||||||
defaults: {
|
defaults: {
|
||||||
name: 'Question and Answer Chain',
|
name: 'Question and Answer Chain',
|
||||||
|
@ -108,30 +109,16 @@ export class ChainRetrievalQa implements INodeType {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Prompt',
|
...promptTypeOptions,
|
||||||
name: 'promptType',
|
|
||||||
type: 'options',
|
|
||||||
options: [
|
|
||||||
{
|
|
||||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
|
||||||
name: 'Take from previous node automatically',
|
|
||||||
value: 'auto',
|
|
||||||
description: 'Looks for an input field called chatInput',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
|
||||||
name: 'Define below',
|
|
||||||
value: 'define',
|
|
||||||
description:
|
|
||||||
'Use an expression to reference data in previous nodes or enter static text',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
displayOptions: {
|
displayOptions: {
|
||||||
hide: {
|
hide: {
|
||||||
'@version': [{ _cnd: { lte: 1.2 } }],
|
'@version': [{ _cnd: { lte: 1.2 } }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
default: 'auto',
|
},
|
||||||
|
{
|
||||||
|
...textFromPreviousNode,
|
||||||
|
displayOptions: { show: { promptType: ['auto'], '@version': [{ _cnd: { gte: 1.4 } }] } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Text',
|
displayName: 'Text',
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
||||||
|
import type { BufferWindowMemoryInput } from 'langchain/memory';
|
||||||
|
import { BufferWindowMemory } from 'langchain/memory';
|
||||||
import {
|
import {
|
||||||
NodeConnectionType,
|
NodeConnectionType,
|
||||||
type INodeType,
|
type INodeType,
|
||||||
|
@ -6,12 +8,16 @@ import {
|
||||||
type ISupplyDataFunctions,
|
type ISupplyDataFunctions,
|
||||||
type SupplyData,
|
type SupplyData,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import type { BufferWindowMemoryInput } from 'langchain/memory';
|
|
||||||
import { BufferWindowMemory } from 'langchain/memory';
|
import { getSessionId } from '../../../utils/helpers';
|
||||||
import { logWrapper } from '../../../utils/logWrapper';
|
import { logWrapper } from '../../../utils/logWrapper';
|
||||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||||
import { sessionIdOption, sessionKeyProperty, contextWindowLengthProperty } from '../descriptions';
|
import {
|
||||||
import { getSessionId } from '../../../utils/helpers';
|
sessionIdOption,
|
||||||
|
sessionKeyProperty,
|
||||||
|
contextWindowLengthProperty,
|
||||||
|
expressionSessionKeyProperty,
|
||||||
|
} from '../descriptions';
|
||||||
|
|
||||||
class MemoryChatBufferSingleton {
|
class MemoryChatBufferSingleton {
|
||||||
private static instance: MemoryChatBufferSingleton;
|
private static instance: MemoryChatBufferSingleton;
|
||||||
|
@ -72,7 +78,7 @@ export class MemoryBufferWindow implements INodeType {
|
||||||
name: 'memoryBufferWindow',
|
name: 'memoryBufferWindow',
|
||||||
icon: 'fa:database',
|
icon: 'fa:database',
|
||||||
group: ['transform'],
|
group: ['transform'],
|
||||||
version: [1, 1.1, 1.2],
|
version: [1, 1.1, 1.2, 1.3],
|
||||||
description: 'Stores in n8n memory, so no credentials required',
|
description: 'Stores in n8n memory, so no credentials required',
|
||||||
defaults: {
|
defaults: {
|
||||||
name: 'Window Buffer Memory',
|
name: 'Window Buffer Memory',
|
||||||
|
@ -129,6 +135,7 @@ export class MemoryBufferWindow implements INodeType {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
expressionSessionKeyProperty(1.3),
|
||||||
sessionKeyProperty,
|
sessionKeyProperty,
|
||||||
contextWindowLengthProperty,
|
contextWindowLengthProperty,
|
||||||
],
|
],
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
||||||
|
import { MotorheadMemory } from '@langchain/community/memory/motorhead_memory';
|
||||||
import {
|
import {
|
||||||
NodeConnectionType,
|
NodeConnectionType,
|
||||||
type INodeType,
|
type INodeType,
|
||||||
|
@ -7,11 +8,10 @@ import {
|
||||||
type SupplyData,
|
type SupplyData,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { MotorheadMemory } from '@langchain/community/memory/motorhead_memory';
|
import { getSessionId } from '../../../utils/helpers';
|
||||||
import { logWrapper } from '../../../utils/logWrapper';
|
import { logWrapper } from '../../../utils/logWrapper';
|
||||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||||
import { sessionIdOption, sessionKeyProperty } from '../descriptions';
|
import { expressionSessionKeyProperty, sessionIdOption, sessionKeyProperty } from '../descriptions';
|
||||||
import { getSessionId } from '../../../utils/helpers';
|
|
||||||
|
|
||||||
export class MemoryMotorhead implements INodeType {
|
export class MemoryMotorhead implements INodeType {
|
||||||
description: INodeTypeDescription = {
|
description: INodeTypeDescription = {
|
||||||
|
@ -19,7 +19,7 @@ export class MemoryMotorhead implements INodeType {
|
||||||
name: 'memoryMotorhead',
|
name: 'memoryMotorhead',
|
||||||
icon: 'fa:file-export',
|
icon: 'fa:file-export',
|
||||||
group: ['transform'],
|
group: ['transform'],
|
||||||
version: [1, 1.1, 1.2],
|
version: [1, 1.1, 1.2, 1.3],
|
||||||
description: 'Use Motorhead Memory',
|
description: 'Use Motorhead Memory',
|
||||||
defaults: {
|
defaults: {
|
||||||
name: 'Motorhead',
|
name: 'Motorhead',
|
||||||
|
@ -82,6 +82,7 @@ export class MemoryMotorhead implements INodeType {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
expressionSessionKeyProperty(1.3),
|
||||||
sessionKeyProperty,
|
sessionKeyProperty,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,9 @@
|
||||||
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
||||||
|
import { PostgresChatMessageHistory } from '@langchain/community/stores/message/postgres';
|
||||||
|
import { BufferMemory, BufferWindowMemory } from 'langchain/memory';
|
||||||
|
import type { PostgresNodeCredentials } from 'n8n-nodes-base/dist/nodes/Postgres/v2/helpers/interfaces';
|
||||||
|
import { postgresConnectionTest } from 'n8n-nodes-base/dist/nodes/Postgres/v2/methods/credentialTest';
|
||||||
|
import { configurePostgres } from 'n8n-nodes-base/dist/nodes/Postgres/v2/transport';
|
||||||
import type {
|
import type {
|
||||||
ISupplyDataFunctions,
|
ISupplyDataFunctions,
|
||||||
INodeType,
|
INodeType,
|
||||||
|
@ -6,16 +11,17 @@ import type {
|
||||||
SupplyData,
|
SupplyData,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
import { BufferMemory, BufferWindowMemory } from 'langchain/memory';
|
|
||||||
import { PostgresChatMessageHistory } from '@langchain/community/stores/message/postgres';
|
|
||||||
import type pg from 'pg';
|
import type pg from 'pg';
|
||||||
import { configurePostgres } from 'n8n-nodes-base/dist/nodes/Postgres/v2/transport';
|
|
||||||
import type { PostgresNodeCredentials } from 'n8n-nodes-base/dist/nodes/Postgres/v2/helpers/interfaces';
|
import { getSessionId } from '../../../utils/helpers';
|
||||||
import { postgresConnectionTest } from 'n8n-nodes-base/dist/nodes/Postgres/v2/methods/credentialTest';
|
|
||||||
import { logWrapper } from '../../../utils/logWrapper';
|
import { logWrapper } from '../../../utils/logWrapper';
|
||||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||||
import { sessionIdOption, sessionKeyProperty, contextWindowLengthProperty } from '../descriptions';
|
import {
|
||||||
import { getSessionId } from '../../../utils/helpers';
|
sessionIdOption,
|
||||||
|
sessionKeyProperty,
|
||||||
|
contextWindowLengthProperty,
|
||||||
|
expressionSessionKeyProperty,
|
||||||
|
} from '../descriptions';
|
||||||
|
|
||||||
export class MemoryPostgresChat implements INodeType {
|
export class MemoryPostgresChat implements INodeType {
|
||||||
description: INodeTypeDescription = {
|
description: INodeTypeDescription = {
|
||||||
|
@ -23,7 +29,7 @@ export class MemoryPostgresChat implements INodeType {
|
||||||
name: 'memoryPostgresChat',
|
name: 'memoryPostgresChat',
|
||||||
icon: 'file:postgres.svg',
|
icon: 'file:postgres.svg',
|
||||||
group: ['transform'],
|
group: ['transform'],
|
||||||
version: [1, 1.1],
|
version: [1, 1.1, 1.2, 1.3],
|
||||||
description: 'Stores the chat history in Postgres table.',
|
description: 'Stores the chat history in Postgres table.',
|
||||||
defaults: {
|
defaults: {
|
||||||
name: 'Postgres Chat Memory',
|
name: 'Postgres Chat Memory',
|
||||||
|
@ -56,6 +62,7 @@ export class MemoryPostgresChat implements INodeType {
|
||||||
properties: [
|
properties: [
|
||||||
getConnectionHintNoticeField([NodeConnectionType.AiAgent]),
|
getConnectionHintNoticeField([NodeConnectionType.AiAgent]),
|
||||||
sessionIdOption,
|
sessionIdOption,
|
||||||
|
expressionSessionKeyProperty(1.2),
|
||||||
sessionKeyProperty,
|
sessionKeyProperty,
|
||||||
{
|
{
|
||||||
displayName: 'Table Name',
|
displayName: 'Table Name',
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
||||||
|
import type { RedisChatMessageHistoryInput } from '@langchain/redis';
|
||||||
|
import { RedisChatMessageHistory } from '@langchain/redis';
|
||||||
|
import { BufferMemory, BufferWindowMemory } from 'langchain/memory';
|
||||||
import {
|
import {
|
||||||
NodeOperationError,
|
NodeOperationError,
|
||||||
type INodeType,
|
type INodeType,
|
||||||
|
@ -7,15 +10,18 @@ import {
|
||||||
type SupplyData,
|
type SupplyData,
|
||||||
NodeConnectionType,
|
NodeConnectionType,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { BufferMemory, BufferWindowMemory } from 'langchain/memory';
|
|
||||||
import type { RedisChatMessageHistoryInput } from '@langchain/redis';
|
|
||||||
import { RedisChatMessageHistory } from '@langchain/redis';
|
|
||||||
import type { RedisClientOptions } from 'redis';
|
import type { RedisClientOptions } from 'redis';
|
||||||
import { createClient } from 'redis';
|
import { createClient } from 'redis';
|
||||||
|
|
||||||
|
import { getSessionId } from '../../../utils/helpers';
|
||||||
import { logWrapper } from '../../../utils/logWrapper';
|
import { logWrapper } from '../../../utils/logWrapper';
|
||||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||||
import { sessionIdOption, sessionKeyProperty, contextWindowLengthProperty } from '../descriptions';
|
import {
|
||||||
import { getSessionId } from '../../../utils/helpers';
|
sessionIdOption,
|
||||||
|
sessionKeyProperty,
|
||||||
|
contextWindowLengthProperty,
|
||||||
|
expressionSessionKeyProperty,
|
||||||
|
} from '../descriptions';
|
||||||
|
|
||||||
export class MemoryRedisChat implements INodeType {
|
export class MemoryRedisChat implements INodeType {
|
||||||
description: INodeTypeDescription = {
|
description: INodeTypeDescription = {
|
||||||
|
@ -23,7 +29,7 @@ export class MemoryRedisChat implements INodeType {
|
||||||
name: 'memoryRedisChat',
|
name: 'memoryRedisChat',
|
||||||
icon: 'file:redis.svg',
|
icon: 'file:redis.svg',
|
||||||
group: ['transform'],
|
group: ['transform'],
|
||||||
version: [1, 1.1, 1.2, 1.3],
|
version: [1, 1.1, 1.2, 1.3, 1.4],
|
||||||
description: 'Stores the chat history in Redis.',
|
description: 'Stores the chat history in Redis.',
|
||||||
defaults: {
|
defaults: {
|
||||||
name: 'Redis Chat Memory',
|
name: 'Redis Chat Memory',
|
||||||
|
@ -86,6 +92,7 @@ export class MemoryRedisChat implements INodeType {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
expressionSessionKeyProperty(1.4),
|
||||||
sessionKeyProperty,
|
sessionKeyProperty,
|
||||||
{
|
{
|
||||||
displayName: 'Session Time To Live',
|
displayName: 'Session Time To Live',
|
||||||
|
@ -120,6 +127,7 @@ export class MemoryRedisChat implements INodeType {
|
||||||
socket: {
|
socket: {
|
||||||
host: credentials.host as string,
|
host: credentials.host as string,
|
||||||
port: credentials.port as number,
|
port: credentials.port as number,
|
||||||
|
tls: credentials.ssl === true,
|
||||||
},
|
},
|
||||||
database: credentials.database as number,
|
database: credentials.database as number,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
||||||
|
import { XataChatMessageHistory } from '@langchain/community/stores/message/xata';
|
||||||
|
import { BaseClient } from '@xata.io/client';
|
||||||
|
import { BufferMemory, BufferWindowMemory } from 'langchain/memory';
|
||||||
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
||||||
import type {
|
import type {
|
||||||
ISupplyDataFunctions,
|
ISupplyDataFunctions,
|
||||||
|
@ -6,13 +9,16 @@ import type {
|
||||||
INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
SupplyData,
|
SupplyData,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { XataChatMessageHistory } from '@langchain/community/stores/message/xata';
|
|
||||||
import { BufferMemory, BufferWindowMemory } from 'langchain/memory';
|
import { getSessionId } from '../../../utils/helpers';
|
||||||
import { BaseClient } from '@xata.io/client';
|
|
||||||
import { logWrapper } from '../../../utils/logWrapper';
|
import { logWrapper } from '../../../utils/logWrapper';
|
||||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||||
import { sessionIdOption, sessionKeyProperty, contextWindowLengthProperty } from '../descriptions';
|
import {
|
||||||
import { getSessionId } from '../../../utils/helpers';
|
sessionIdOption,
|
||||||
|
sessionKeyProperty,
|
||||||
|
contextWindowLengthProperty,
|
||||||
|
expressionSessionKeyProperty,
|
||||||
|
} from '../descriptions';
|
||||||
|
|
||||||
export class MemoryXata implements INodeType {
|
export class MemoryXata implements INodeType {
|
||||||
description: INodeTypeDescription = {
|
description: INodeTypeDescription = {
|
||||||
|
@ -20,7 +26,7 @@ export class MemoryXata implements INodeType {
|
||||||
name: 'memoryXata',
|
name: 'memoryXata',
|
||||||
icon: 'file:xata.svg',
|
icon: 'file:xata.svg',
|
||||||
group: ['transform'],
|
group: ['transform'],
|
||||||
version: [1, 1.1, 1.2, 1.3],
|
version: [1, 1.1, 1.2, 1.3, 1.4],
|
||||||
description: 'Use Xata Memory',
|
description: 'Use Xata Memory',
|
||||||
defaults: {
|
defaults: {
|
||||||
name: 'Xata',
|
name: 'Xata',
|
||||||
|
@ -86,6 +92,7 @@ export class MemoryXata implements INodeType {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sessionKeyProperty,
|
sessionKeyProperty,
|
||||||
|
expressionSessionKeyProperty(1.4),
|
||||||
{
|
{
|
||||||
...contextWindowLengthProperty,
|
...contextWindowLengthProperty,
|
||||||
displayOptions: { hide: { '@version': [{ _cnd: { lt: 1.3 } }] } },
|
displayOptions: { hide: { '@version': [{ _cnd: { lt: 1.3 } }] } },
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { ZepCloudMemory } from '@langchain/community/memory/zep_cloud';
|
||||||
|
|
||||||
import { logWrapper } from '../../../utils/logWrapper';
|
import { logWrapper } from '../../../utils/logWrapper';
|
||||||
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
|
||||||
import { sessionIdOption, sessionKeyProperty } from '../descriptions';
|
import { expressionSessionKeyProperty, sessionIdOption, sessionKeyProperty } from '../descriptions';
|
||||||
import { getSessionId } from '../../../utils/helpers';
|
import { getSessionId } from '../../../utils/helpers';
|
||||||
import type { BaseChatMemory } from '@langchain/community/dist/memory/chat_memory';
|
import type { BaseChatMemory } from '@langchain/community/dist/memory/chat_memory';
|
||||||
import type { InputValues, MemoryVariables } from '@langchain/core/memory';
|
import type { InputValues, MemoryVariables } from '@langchain/core/memory';
|
||||||
|
@ -36,7 +36,7 @@ export class MemoryZep implements INodeType {
|
||||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-icon-not-svg
|
// eslint-disable-next-line n8n-nodes-base/node-class-description-icon-not-svg
|
||||||
icon: 'file:zep.png',
|
icon: 'file:zep.png',
|
||||||
group: ['transform'],
|
group: ['transform'],
|
||||||
version: [1, 1.1, 1.2],
|
version: [1, 1.1, 1.2, 1.3],
|
||||||
description: 'Use Zep Memory',
|
description: 'Use Zep Memory',
|
||||||
defaults: {
|
defaults: {
|
||||||
name: 'Zep',
|
name: 'Zep',
|
||||||
|
@ -99,6 +99,7 @@ export class MemoryZep implements INodeType {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
expressionSessionKeyProperty(1.3),
|
||||||
sessionKeyProperty,
|
sessionKeyProperty,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
@ -21,6 +21,20 @@ export const sessionIdOption: INodeProperties = {
|
||||||
default: 'fromInput',
|
default: 'fromInput',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const expressionSessionKeyProperty = (fromVersion: number): INodeProperties => ({
|
||||||
|
displayName: 'Session Key From Previous Node',
|
||||||
|
name: 'sessionKey',
|
||||||
|
type: 'string',
|
||||||
|
default: '={{ $json.sessionId }}',
|
||||||
|
disabledOptions: { show: { sessionIdType: ['fromInput'] } },
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
sessionIdType: ['fromInput'],
|
||||||
|
'@version': [{ _cnd: { gte: fromVersion } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const sessionKeyProperty: INodeProperties = {
|
export const sessionKeyProperty: INodeProperties = {
|
||||||
displayName: 'Key',
|
displayName: 'Key',
|
||||||
name: 'sessionKey',
|
name: 'sessionKey',
|
||||||
|
|
|
@ -35,6 +35,7 @@ describe('OutputParserItemList', () => {
|
||||||
|
|
||||||
const { response } = await outputParser.supplyData.call(thisArg, 0);
|
const { response } = await outputParser.supplyData.call(thisArg, 0);
|
||||||
expect(response).toBeInstanceOf(N8nItemListOutputParser);
|
expect(response).toBeInstanceOf(N8nItemListOutputParser);
|
||||||
|
expect((response as any).numberOfItems).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create a parser with custom number of items', async () => {
|
it('should create a parser with custom number of items', async () => {
|
||||||
|
@ -50,6 +51,20 @@ describe('OutputParserItemList', () => {
|
||||||
expect((response as any).numberOfItems).toBe(5);
|
expect((response as any).numberOfItems).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should create a parser with unlimited number of items', async () => {
|
||||||
|
thisArg.getNodeParameter.mockImplementation((parameterName) => {
|
||||||
|
if (parameterName === 'options') {
|
||||||
|
return { numberOfItems: -1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApplicationError('Not implemented');
|
||||||
|
});
|
||||||
|
|
||||||
|
const { response } = await outputParser.supplyData.call(thisArg, 0);
|
||||||
|
expect(response).toBeInstanceOf(N8nItemListOutputParser);
|
||||||
|
expect((response as any).numberOfItems).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('should create a parser with custom separator', async () => {
|
it('should create a parser with custom separator', async () => {
|
||||||
thisArg.getNodeParameter.mockImplementation((parameterName) => {
|
thisArg.getNodeParameter.mockImplementation((parameterName) => {
|
||||||
if (parameterName === 'options') {
|
if (parameterName === 'options') {
|
||||||
|
|
|
@ -54,6 +54,6 @@ export class VectorStoreInMemory extends createVectorStoreNode({
|
||||||
const workflowId = context.getWorkflow().id;
|
const workflowId = context.getWorkflow().id;
|
||||||
const vectorStoreInstance = MemoryVectorStoreManager.getInstance(embeddings);
|
const vectorStoreInstance = MemoryVectorStoreManager.getInstance(embeddings);
|
||||||
|
|
||||||
void vectorStoreInstance.addDocuments(`${workflowId}__${memoryKey}`, documents, clearStore);
|
await vectorStoreInstance.addDocuments(`${workflowId}__${memoryKey}`, documents, clearStore);
|
||||||
},
|
},
|
||||||
}) {}
|
}) {}
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
import type { OpenAIEmbeddings } from '@langchain/openai';
|
||||||
|
|
||||||
|
import { MemoryVectorStoreManager } from './MemoryVectorStoreManager';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
|
||||||
|
describe('MemoryVectorStoreManager', () => {
|
||||||
|
it('should create an instance of MemoryVectorStoreManager', () => {
|
||||||
|
const embeddings = mock<OpenAIEmbeddings>();
|
||||||
|
|
||||||
|
const instance = MemoryVectorStoreManager.getInstance(embeddings);
|
||||||
|
expect(instance).toBeInstanceOf(MemoryVectorStoreManager);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return existing instance', () => {
|
||||||
|
const embeddings = mock<OpenAIEmbeddings>();
|
||||||
|
|
||||||
|
const instance1 = MemoryVectorStoreManager.getInstance(embeddings);
|
||||||
|
const instance2 = MemoryVectorStoreManager.getInstance(embeddings);
|
||||||
|
expect(instance1).toBe(instance2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update embeddings in existing instance', () => {
|
||||||
|
const embeddings1 = mock<OpenAIEmbeddings>();
|
||||||
|
const embeddings2 = mock<OpenAIEmbeddings>();
|
||||||
|
|
||||||
|
const instance = MemoryVectorStoreManager.getInstance(embeddings1);
|
||||||
|
MemoryVectorStoreManager.getInstance(embeddings2);
|
||||||
|
|
||||||
|
expect((instance as any).embeddings).toBe(embeddings2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update embeddings in existing vector store instances', async () => {
|
||||||
|
const embeddings1 = mock<OpenAIEmbeddings>();
|
||||||
|
const embeddings2 = mock<OpenAIEmbeddings>();
|
||||||
|
|
||||||
|
const instance1 = MemoryVectorStoreManager.getInstance(embeddings1);
|
||||||
|
await instance1.getVectorStore('test');
|
||||||
|
|
||||||
|
const instance2 = MemoryVectorStoreManager.getInstance(embeddings2);
|
||||||
|
const vectorStoreInstance2 = await instance2.getVectorStore('test');
|
||||||
|
|
||||||
|
expect((vectorStoreInstance2 as any).embeddings).toBe(embeddings2);
|
||||||
|
});
|
||||||
|
});
|
|
@ -14,7 +14,16 @@ export class MemoryVectorStoreManager {
|
||||||
public static getInstance(embeddings: Embeddings): MemoryVectorStoreManager {
|
public static getInstance(embeddings: Embeddings): MemoryVectorStoreManager {
|
||||||
if (!MemoryVectorStoreManager.instance) {
|
if (!MemoryVectorStoreManager.instance) {
|
||||||
MemoryVectorStoreManager.instance = new MemoryVectorStoreManager(embeddings);
|
MemoryVectorStoreManager.instance = new MemoryVectorStoreManager(embeddings);
|
||||||
|
} else {
|
||||||
|
// We need to update the embeddings in the existing instance.
|
||||||
|
// This is important as embeddings instance is wrapped in a logWrapper,
|
||||||
|
// which relies on supplyDataFunctions context which changes on each workflow run
|
||||||
|
MemoryVectorStoreManager.instance.embeddings = embeddings;
|
||||||
|
MemoryVectorStoreManager.instance.vectorStoreBuffer.forEach((vectorStoreInstance) => {
|
||||||
|
vectorStoreInstance.embeddings = embeddings;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return MemoryVectorStoreManager.instance;
|
return MemoryVectorStoreManager.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { OpenAI as OpenAIClient } from 'openai';
|
import { OpenAI as OpenAIClient } from 'openai';
|
||||||
|
|
||||||
|
import { promptTypeOptions, textFromPreviousNode } from '../../../../../utils/descriptions';
|
||||||
import { getConnectedTools } from '../../../../../utils/helpers';
|
import { getConnectedTools } from '../../../../../utils/helpers';
|
||||||
import { getTracingConfig } from '../../../../../utils/tracing';
|
import { getTracingConfig } from '../../../../../utils/tracing';
|
||||||
import { formatToOpenAIAssistantTool } from '../../helpers/utils';
|
import { formatToOpenAIAssistantTool } from '../../helpers/utils';
|
||||||
|
@ -26,24 +27,18 @@ import { assistantRLC } from '../descriptions';
|
||||||
const properties: INodeProperties[] = [
|
const properties: INodeProperties[] = [
|
||||||
assistantRLC,
|
assistantRLC,
|
||||||
{
|
{
|
||||||
displayName: 'Prompt',
|
...promptTypeOptions,
|
||||||
name: 'prompt',
|
name: 'prompt',
|
||||||
type: 'options',
|
},
|
||||||
options: [
|
{
|
||||||
{
|
...textFromPreviousNode,
|
||||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
disabledOptions: { show: { prompt: ['auto'] } },
|
||||||
name: 'Take from previous node automatically',
|
displayOptions: {
|
||||||
value: 'auto',
|
show: {
|
||||||
description: 'Looks for an input field called chatInput',
|
prompt: ['auto'],
|
||||||
|
'@version': [{ _cnd: { gte: 1.7 } }],
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
|
||||||
name: 'Define below',
|
|
||||||
value: 'define',
|
|
||||||
description: 'Use an expression to reference data in previous nodes or enter static text',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
default: 'auto',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName: 'Text',
|
displayName: 'Text',
|
||||||
|
|
|
@ -77,7 +77,7 @@ export const versionDescription: INodeTypeDescription = {
|
||||||
name: 'openAi',
|
name: 'openAi',
|
||||||
icon: { light: 'file:openAi.svg', dark: 'file:openAi.dark.svg' },
|
icon: { light: 'file:openAi.svg', dark: 'file:openAi.dark.svg' },
|
||||||
group: ['transform'],
|
group: ['transform'],
|
||||||
version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6],
|
version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7],
|
||||||
subtitle: `={{(${prettifyOperation})($parameter.resource, $parameter.operation)}}`,
|
subtitle: `={{(${prettifyOperation})($parameter.resource, $parameter.operation)}}`,
|
||||||
description: 'Message an assistant or GPT, analyze images, generate audio, etc.',
|
description: 'Message an assistant or GPT, analyze images, generate audio, etc.',
|
||||||
defaults: {
|
defaults: {
|
||||||
|
|
|
@ -66,7 +66,7 @@ export const inputSchemaField: INodeProperties = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const promptTypeOptions: INodeProperties = {
|
export const promptTypeOptions: INodeProperties = {
|
||||||
displayName: 'Prompt',
|
displayName: 'Prompt Source',
|
||||||
name: 'promptType',
|
name: 'promptType',
|
||||||
type: 'options',
|
type: 'options',
|
||||||
options: [
|
options: [
|
||||||
|
@ -97,3 +97,15 @@ export const textInput: INodeProperties = {
|
||||||
rows: 2,
|
rows: 2,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const textFromPreviousNode: INodeProperties = {
|
||||||
|
displayName: 'Text From Previous Node',
|
||||||
|
name: 'text',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
default: '={{ $json.chatInput }}',
|
||||||
|
typeOptions: {
|
||||||
|
rows: 2,
|
||||||
|
},
|
||||||
|
disabledOptions: { show: { promptType: ['auto'] } },
|
||||||
|
};
|
||||||
|
|
|
@ -3,16 +3,21 @@ import { BaseOutputParser, OutputParserException } from '@langchain/core/output_
|
||||||
export class N8nItemListOutputParser extends BaseOutputParser<string[]> {
|
export class N8nItemListOutputParser extends BaseOutputParser<string[]> {
|
||||||
lc_namespace = ['n8n-nodes-langchain', 'output_parsers', 'list_items'];
|
lc_namespace = ['n8n-nodes-langchain', 'output_parsers', 'list_items'];
|
||||||
|
|
||||||
private numberOfItems: number = 3;
|
private numberOfItems: number | undefined;
|
||||||
|
|
||||||
private separator: string;
|
private separator: string;
|
||||||
|
|
||||||
constructor(options: { numberOfItems?: number; separator?: string }) {
|
constructor(options: { numberOfItems?: number; separator?: string }) {
|
||||||
super();
|
super();
|
||||||
if (options.numberOfItems && options.numberOfItems > 0) {
|
|
||||||
this.numberOfItems = options.numberOfItems;
|
const { numberOfItems = 3, separator = '\n' } = options;
|
||||||
|
|
||||||
|
if (numberOfItems && numberOfItems > 0) {
|
||||||
|
this.numberOfItems = numberOfItems;
|
||||||
}
|
}
|
||||||
this.separator = options.separator ?? '\\n';
|
|
||||||
|
this.separator = separator;
|
||||||
|
|
||||||
if (this.separator === '\\n') {
|
if (this.separator === '\\n') {
|
||||||
this.separator = '\n';
|
this.separator = '\n';
|
||||||
}
|
}
|
||||||
|
@ -39,7 +44,7 @@ export class N8nItemListOutputParser extends BaseOutputParser<string[]> {
|
||||||
this.numberOfItems ? this.numberOfItems + ' ' : ''
|
this.numberOfItems ? this.numberOfItems + ' ' : ''
|
||||||
}items separated by`;
|
}items separated by`;
|
||||||
|
|
||||||
const numberOfExamples = this.numberOfItems;
|
const numberOfExamples = this.numberOfItems ?? 3; // Default number of examples in case numberOfItems is not set
|
||||||
|
|
||||||
const examples: string[] = [];
|
const examples: string[] = [];
|
||||||
for (let i = 1; i <= numberOfExamples; i++) {
|
for (let i = 1; i <= numberOfExamples; i++) {
|
||||||
|
|
|
@ -103,6 +103,14 @@ export function sendErrorResponse(res: Response, error: Error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error.errorCode === 409 && originalUrl && originalUrl.includes('form-waiting')) {
|
||||||
|
//codes other than 200 breaks redirection to form-waiting page from form trigger
|
||||||
|
//render form page instead of json
|
||||||
|
return res.render('form-trigger-409', {
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
httpStatusCode = error.httpStatusCode;
|
httpStatusCode = error.httpStatusCode;
|
||||||
|
|
||||||
if (error.errorCode) {
|
if (error.errorCode) {
|
||||||
|
|
74
packages/cli/templates/form-trigger-409.handlebars
Normal file
74
packages/cli/templates/form-trigger-409.handlebars
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
<html lang='en'>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset='UTF-8' />
|
||||||
|
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
|
||||||
|
<link rel='icon' type='image/png' href='https://n8n.io/favicon.ico' />
|
||||||
|
<link
|
||||||
|
href='http://fonts.googleapis.com/css?family=Open+Sans'
|
||||||
|
rel='stylesheet'
|
||||||
|
type='text/css'
|
||||||
|
/>
|
||||||
|
<title>Problem loading form</title>
|
||||||
|
<style>
|
||||||
|
*, ::after, ::before { box-sizing: border-box; margin: 0; padding: 0; } body { font-family:
|
||||||
|
Open Sans, sans-serif; font-weight: 400; font-size: 12px; display: flex; flex-direction:
|
||||||
|
column; justify-content: start; background-color: #FBFCFE; } .container { margin: auto;
|
||||||
|
text-align: center; padding-top: 24px; width: 448px; } .card { padding: 24px;
|
||||||
|
background-color: white; border: 1px solid #DBDFE7; border-radius: 8px; box-shadow: 0px 4px
|
||||||
|
16px 0px #634DFF0F; margin-bottom: 16px; } .n8n-link a { color: #7E8186; font-weight: 600;
|
||||||
|
font-size: 12px; text-decoration: none; } .n8n-link svg { display: inline-block;
|
||||||
|
vertical-align: middle; } .header h1 { color: #525356; font-size: 20px; font-weight: 400;
|
||||||
|
padding-bottom: 8px; } .header p { color: #7E8186; font-size: 14px; font-weight: 400; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class='container'>
|
||||||
|
<section>
|
||||||
|
<div class='card'>
|
||||||
|
<div class='header'>
|
||||||
|
<h1>Problem loading form</h1>
|
||||||
|
<p>{{message}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class='n8n-link'>
|
||||||
|
<a
|
||||||
|
href='https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger&'
|
||||||
|
target='_blank'
|
||||||
|
>
|
||||||
|
Form automated with
|
||||||
|
<svg
|
||||||
|
width='73'
|
||||||
|
height='20'
|
||||||
|
viewBox='0 0 73 20'
|
||||||
|
fill='none'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule='evenodd'
|
||||||
|
clip-rule='evenodd'
|
||||||
|
d='M40.2373 4C40.2373 6.20915 38.4464 8 36.2373 8C34.3735 8 32.8074 6.72525 32.3633 5H26.7787C25.801 5 24.9666 5.70685 24.8059 6.6712L24.6415 7.6576C24.4854 8.59415 24.0116 9.40925 23.3417 10C24.0116 10.5907 24.4854 11.4058 24.6415 12.3424L24.8059 13.3288C24.9666 14.2931 25.801 15 26.7787 15H28.3633C28.8074 13.2747 30.3735 12 32.2373 12C34.4464 12 36.2373 13.7908 36.2373 16C36.2373 18.2092 34.4464 20 32.2373 20C30.3735 20 28.8074 18.7253 28.3633 17H26.7787C24.8233 17 23.1546 15.5864 22.8331 13.6576L22.6687 12.6712C22.508 11.7069 21.6736 11 20.6959 11H19.0645C18.5652 12.64 17.0406 13.8334 15.2373 13.8334C13.434 13.8334 11.9094 12.64 11.4101 11H9.06449C8.56519 12.64 7.04059 13.8334 5.2373 13.8334C3.02817 13.8334 1.2373 12.0424 1.2373 9.83335C1.2373 7.6242 3.02817 5.83335 5.2373 5.83335C7.16069 5.83335 8.76699 7.19085 9.15039 9H11.3242C11.7076 7.19085 13.3139 5.83335 15.2373 5.83335C17.1607 5.83335 18.767 7.19085 19.1504 9H20.6959C21.6736 9 22.508 8.29315 22.6687 7.3288L22.8331 6.3424C23.1546 4.41365 24.8233 3 26.7787 3H32.3633C32.8074 1.27478 34.3735 0 36.2373 0C38.4464 0 40.2373 1.79086 40.2373 4ZM38.2373 4C38.2373 5.10455 37.3419 6 36.2373 6C35.1327 6 34.2373 5.10455 34.2373 4C34.2373 2.89543 35.1327 2 36.2373 2C37.3419 2 38.2373 2.89543 38.2373 4ZM5.2373 11.8334C6.34189 11.8334 7.23729 10.9379 7.23729 9.83335C7.23729 8.72875 6.34189 7.83335 5.2373 7.83335C4.13273 7.83335 3.2373 8.72875 3.2373 9.83335C3.2373 10.9379 4.13273 11.8334 5.2373 11.8334ZM15.2373 11.8334C16.3419 11.8334 17.2373 10.9379 17.2373 9.83335C17.2373 8.72875 16.3419 7.83335 15.2373 7.83335C14.1327 7.83335 13.2373 8.72875 13.2373 9.83335C13.2373 10.9379 14.1327 11.8334 15.2373 11.8334ZM32.2373 18C33.3419 18 34.2373 17.1045 34.2373 16C34.2373 14.8954 33.3419 14 32.2373 14C31.1327 14 30.2373 14.8954 30.2373 16C30.2373 17.1045 31.1327 18 32.2373 18Z'
|
||||||
|
fill='#EA4B71'
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d='M44.2393 15.0007H46.3277V10.5791C46.3277 9.12704 47.2088 8.49074 48.204 8.49074C49.183 8.49074 49.9498 9.14334 49.9498 10.4812V15.0007H52.038V10.057C52.038 7.91969 50.798 6.67969 48.8567 6.67969C47.633 6.67969 46.9477 7.16914 46.4582 7.80544H46.3277L46.1482 6.84284H44.2393V15.0007Z'
|
||||||
|
fill='#101330'
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d='M60.0318 9.50205V9.40415C60.7498 9.0452 61.4678 8.4252 61.4678 7.20155C61.4678 5.43945 60.0153 4.37891 58.0088 4.37891C55.9528 4.37891 54.4843 5.5047 54.4843 7.23415C54.4843 8.4089 55.1698 9.0452 55.9203 9.40415V9.50205C55.0883 9.79575 54.0928 10.6768 54.0928 12.1452C54.0928 13.9237 55.5613 15.1637 57.9923 15.1637C60.4233 15.1637 61.8428 13.9237 61.8428 12.1452C61.8428 10.6768 60.8638 9.81205 60.0318 9.50205ZM57.9923 5.87995C58.8083 5.87995 59.4118 6.40205 59.4118 7.2831C59.4118 8.16415 58.7918 8.6863 57.9923 8.6863C57.1928 8.6863 56.5238 8.16415 56.5238 7.2831C56.5238 6.38575 57.1603 5.87995 57.9923 5.87995ZM57.9923 13.5974C57.0458 13.5974 56.2793 12.9937 56.2793 11.9658C56.2793 11.0358 56.9153 10.3342 57.9758 10.3342C59.0203 10.3342 59.6568 11.0195 59.6568 11.9984C59.6568 12.9937 58.9223 13.5974 57.9923 13.5974Z'
|
||||||
|
fill='#101330'
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
d='M63.9639 15.0007H66.0524V10.5791C66.0524 9.12704 66.9334 8.49074 67.9289 8.49074C68.9079 8.49074 69.6744 9.14334 69.6744 10.4812V15.0007H71.7629V10.057C71.7629 7.91969 70.5229 6.67969 68.5814 6.67969C67.3579 6.67969 66.6724 7.16914 66.1829 7.80544H66.0524L65.8729 6.84284H63.9639V15.0007Z'
|
||||||
|
fill='#101330'
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
|
@ -33,15 +33,11 @@ const loading = ref(true);
|
||||||
const defaultLocale = computed(() => rootStore.defaultLocale);
|
const defaultLocale = computed(() => rootStore.defaultLocale);
|
||||||
const isDemoMode = computed(() => route.name === VIEWS.DEMO);
|
const isDemoMode = computed(() => route.name === VIEWS.DEMO);
|
||||||
const showAssistantButton = computed(() => assistantStore.canShowAssistantButtonsOnCanvas);
|
const showAssistantButton = computed(() => assistantStore.canShowAssistantButtonsOnCanvas);
|
||||||
|
const hasContentFooter = ref(false);
|
||||||
const appGrid = ref<Element | null>(null);
|
const appGrid = ref<Element | null>(null);
|
||||||
|
|
||||||
const assistantSidebarWidth = computed(() => assistantStore.chatWidth);
|
const assistantSidebarWidth = computed(() => assistantStore.chatWidth);
|
||||||
|
|
||||||
watch(defaultLocale, (newLocale) => {
|
|
||||||
void loadLanguage(newLocale);
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
setAppZIndexes();
|
setAppZIndexes();
|
||||||
logHiringBanner();
|
logHiringBanner();
|
||||||
|
@ -54,11 +50,6 @@ onBeforeUnmount(() => {
|
||||||
window.removeEventListener('resize', updateGridWidth);
|
window.removeEventListener('resize', updateGridWidth);
|
||||||
});
|
});
|
||||||
|
|
||||||
// As assistant sidebar width changes, recalculate the total width regularly
|
|
||||||
watch(assistantSidebarWidth, async () => {
|
|
||||||
await updateGridWidth();
|
|
||||||
});
|
|
||||||
|
|
||||||
const logHiringBanner = () => {
|
const logHiringBanner = () => {
|
||||||
if (settingsStore.isHiringBannerEnabled && !isDemoMode.value) {
|
if (settingsStore.isHiringBannerEnabled && !isDemoMode.value) {
|
||||||
console.log(HIRING_BANNER);
|
console.log(HIRING_BANNER);
|
||||||
|
@ -71,6 +62,21 @@ const updateGridWidth = async () => {
|
||||||
uiStore.appGridWidth = appGrid.value.clientWidth;
|
uiStore.appGridWidth = appGrid.value.clientWidth;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// As assistant sidebar width changes, recalculate the total width regularly
|
||||||
|
watch(assistantSidebarWidth, async () => {
|
||||||
|
await updateGridWidth();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(route, (r) => {
|
||||||
|
hasContentFooter.value = r.matched.some(
|
||||||
|
(matchedRoute) => matchedRoute.components?.footer !== undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(defaultLocale, (newLocale) => {
|
||||||
|
void loadLanguage(newLocale);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -94,12 +100,17 @@ const updateGridWidth = async () => {
|
||||||
<router-view name="sidebar"></router-view>
|
<router-view name="sidebar"></router-view>
|
||||||
</div>
|
</div>
|
||||||
<div id="content" :class="$style.content">
|
<div id="content" :class="$style.content">
|
||||||
<router-view v-slot="{ Component }">
|
<div :class="$style.contentWrapper">
|
||||||
<keep-alive v-if="$route.meta.keepWorkflowAlive" include="NodeViewSwitcher" :max="1">
|
<router-view v-slot="{ Component }">
|
||||||
<component :is="Component" />
|
<keep-alive v-if="$route.meta.keepWorkflowAlive" include="NodeViewSwitcher" :max="1">
|
||||||
</keep-alive>
|
<component :is="Component" />
|
||||||
<component :is="Component" v-else />
|
</keep-alive>
|
||||||
</router-view>
|
<component :is="Component" v-else />
|
||||||
|
</router-view>
|
||||||
|
</div>
|
||||||
|
<div v-if="hasContentFooter" :class="$style.contentFooter">
|
||||||
|
<router-view name="footer" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div :id="APP_MODALS_ELEMENT_ID" :class="$style.modals">
|
<div :id="APP_MODALS_ELEMENT_ID" :class="$style.modals">
|
||||||
<Modals />
|
<Modals />
|
||||||
|
@ -138,8 +149,26 @@ const updateGridWidth = async () => {
|
||||||
grid-area: banners;
|
grid-area: banners;
|
||||||
z-index: var(--z-index-top-banners);
|
z-index: var(--z-index-top-banners);
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
overflow: auto;
|
||||||
|
grid-area: content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contentFooter {
|
||||||
|
height: auto;
|
||||||
|
z-index: 10;
|
||||||
|
width: 100%;
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
// Only show footer if there's content
|
||||||
|
&:has(*) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentWrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
grid-area: content;
|
grid-area: content;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
@ -1305,6 +1305,7 @@ export type ExecutionFilterType = {
|
||||||
|
|
||||||
export type ExecutionsQueryFilter = {
|
export type ExecutionsQueryFilter = {
|
||||||
status?: ExecutionStatus[];
|
status?: ExecutionStatus[];
|
||||||
|
projectId?: string;
|
||||||
workflowId?: string;
|
workflowId?: string;
|
||||||
finished?: boolean;
|
finished?: boolean;
|
||||||
waitTill?: boolean;
|
waitTill?: boolean;
|
||||||
|
|
|
@ -31,7 +31,7 @@ export function createTestProject(data: Partial<Project>): Project {
|
||||||
name: faker.lorem.words({ min: 1, max: 3 }),
|
name: faker.lorem.words({ min: 1, max: 3 }),
|
||||||
createdAt: faker.date.past().toISOString(),
|
createdAt: faker.date.past().toISOString(),
|
||||||
updatedAt: faker.date.recent().toISOString(),
|
updatedAt: faker.date.recent().toISOString(),
|
||||||
type: 'team',
|
type: ProjectTypes.Team,
|
||||||
relations: [],
|
relations: [],
|
||||||
scopes: [],
|
scopes: [],
|
||||||
...data,
|
...data,
|
||||||
|
|
|
@ -54,26 +54,32 @@ export const mockNodeTypeDescription = ({
|
||||||
credentials = [],
|
credentials = [],
|
||||||
inputs = [NodeConnectionType.Main],
|
inputs = [NodeConnectionType.Main],
|
||||||
outputs = [NodeConnectionType.Main],
|
outputs = [NodeConnectionType.Main],
|
||||||
|
codex = {},
|
||||||
|
properties = [],
|
||||||
}: {
|
}: {
|
||||||
name?: INodeTypeDescription['name'];
|
name?: INodeTypeDescription['name'];
|
||||||
version?: INodeTypeDescription['version'];
|
version?: INodeTypeDescription['version'];
|
||||||
credentials?: INodeTypeDescription['credentials'];
|
credentials?: INodeTypeDescription['credentials'];
|
||||||
inputs?: INodeTypeDescription['inputs'];
|
inputs?: INodeTypeDescription['inputs'];
|
||||||
outputs?: INodeTypeDescription['outputs'];
|
outputs?: INodeTypeDescription['outputs'];
|
||||||
|
codex?: INodeTypeDescription['codex'];
|
||||||
|
properties?: INodeTypeDescription['properties'];
|
||||||
} = {}) =>
|
} = {}) =>
|
||||||
mock<INodeTypeDescription>({
|
mock<INodeTypeDescription>({
|
||||||
name,
|
name,
|
||||||
displayName: name,
|
displayName: name,
|
||||||
|
description: '',
|
||||||
version,
|
version,
|
||||||
defaults: {
|
defaults: {
|
||||||
name,
|
name,
|
||||||
},
|
},
|
||||||
defaultVersion: Array.isArray(version) ? version[version.length - 1] : version,
|
defaultVersion: Array.isArray(version) ? version[version.length - 1] : version,
|
||||||
properties: [],
|
properties: properties as [],
|
||||||
maxNodes: Infinity,
|
maxNodes: Infinity,
|
||||||
group: EXECUTABLE_TRIGGER_NODE_TYPES.includes(name) ? ['trigger'] : [],
|
group: EXECUTABLE_TRIGGER_NODE_TYPES.includes(name) ? ['trigger'] : [],
|
||||||
inputs,
|
inputs,
|
||||||
outputs,
|
outputs,
|
||||||
|
codex,
|
||||||
credentials,
|
credentials,
|
||||||
documentationUrl: 'https://docs',
|
documentationUrl: 'https://docs',
|
||||||
webhooks: undefined,
|
webhooks: undefined,
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useStyles } from '@/composables/useStyles';
|
import { useStyles } from '@/composables/useStyles';
|
||||||
import { useAssistantStore } from '@/stores/assistant.store';
|
import { useAssistantStore } from '@/stores/assistant.store';
|
||||||
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
import AssistantAvatar from 'n8n-design-system/components/AskAssistantAvatar/AssistantAvatar.vue';
|
import AssistantAvatar from 'n8n-design-system/components/AskAssistantAvatar/AssistantAvatar.vue';
|
||||||
import AskAssistantButton from 'n8n-design-system/components/AskAssistantButton/AskAssistantButton.vue';
|
import AskAssistantButton from 'n8n-design-system/components/AskAssistantButton/AskAssistantButton.vue';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
@ -9,6 +10,7 @@ import { computed } from 'vue';
|
||||||
const assistantStore = useAssistantStore();
|
const assistantStore = useAssistantStore();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const { APP_Z_INDEXES } = useStyles();
|
const { APP_Z_INDEXES } = useStyles();
|
||||||
|
const canvasStore = useCanvasStore();
|
||||||
|
|
||||||
const lastUnread = computed(() => {
|
const lastUnread = computed(() => {
|
||||||
const msg = assistantStore.lastUnread;
|
const msg = assistantStore.lastUnread;
|
||||||
|
@ -39,6 +41,7 @@ const onClick = () => {
|
||||||
v-if="assistantStore.canShowAssistantButtonsOnCanvas && !assistantStore.isAssistantOpen"
|
v-if="assistantStore.canShowAssistantButtonsOnCanvas && !assistantStore.isAssistantOpen"
|
||||||
:class="$style.container"
|
:class="$style.container"
|
||||||
data-test-id="ask-assistant-floating-button"
|
data-test-id="ask-assistant-floating-button"
|
||||||
|
:style="{ '--canvas-panel-height-offset': `${canvasStore.panelHeight}px` }"
|
||||||
>
|
>
|
||||||
<n8n-tooltip
|
<n8n-tooltip
|
||||||
:z-index="APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON_TOOLTIP"
|
:z-index="APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON_TOOLTIP"
|
||||||
|
@ -61,7 +64,7 @@ const onClick = () => {
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.container {
|
.container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: var(--spacing-s);
|
bottom: calc(var(--canvas-panel-height-offset, 0px) + var(--spacing-s));
|
||||||
right: var(--spacing-s);
|
right: var(--spacing-s);
|
||||||
z-index: var(--z-index-ask-assistant-floating-button);
|
z-index: var(--z-index-ask-assistant-floating-button);
|
||||||
}
|
}
|
||||||
|
|
586
packages/editor-ui/src/components/CanvasChat/CanvasChat.test.ts
Normal file
586
packages/editor-ui/src/components/CanvasChat/CanvasChat.test.ts
Normal file
|
@ -0,0 +1,586 @@
|
||||||
|
import { setActivePinia } from 'pinia';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { waitFor } from '@testing-library/vue';
|
||||||
|
import { userEvent } from '@testing-library/user-event';
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { NodeConnectionType } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import CanvasChat from './CanvasChat.vue';
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import { createTestWorkflowObject } from '@/__tests__/mocks';
|
||||||
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
|
import { STORES } from '@/constants';
|
||||||
|
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
||||||
|
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||||
|
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
|
import * as useChatMessaging from './composables/useChatMessaging';
|
||||||
|
import * as useChatTrigger from './composables/useChatTrigger';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
|
||||||
|
import type { IExecutionResponse, INodeUi } from '@/Interface';
|
||||||
|
import type { ChatMessage } from '@n8n/chat/types';
|
||||||
|
|
||||||
|
vi.mock('@/composables/useToast', () => {
|
||||||
|
const showMessage = vi.fn();
|
||||||
|
const showError = vi.fn();
|
||||||
|
return {
|
||||||
|
useToast: () => {
|
||||||
|
return {
|
||||||
|
showMessage,
|
||||||
|
showError,
|
||||||
|
clearAllStickyNotifications: vi.fn(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Test data
|
||||||
|
const mockNodes: INodeUi[] = [
|
||||||
|
{
|
||||||
|
parameters: {
|
||||||
|
options: {
|
||||||
|
allowFileUploads: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
id: 'chat-trigger-id',
|
||||||
|
name: 'When chat message received',
|
||||||
|
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||||
|
typeVersion: 1.1,
|
||||||
|
position: [740, 860],
|
||||||
|
webhookId: 'webhook-id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parameters: {},
|
||||||
|
id: 'agent-id',
|
||||||
|
name: 'AI Agent',
|
||||||
|
type: '@n8n/n8n-nodes-langchain.agent',
|
||||||
|
typeVersion: 1.7,
|
||||||
|
position: [960, 860],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockConnections = {
|
||||||
|
'When chat message received': {
|
||||||
|
main: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
node: 'AI Agent',
|
||||||
|
type: NodeConnectionType.Main,
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWorkflowExecution = {
|
||||||
|
data: {
|
||||||
|
resultData: {
|
||||||
|
runData: {
|
||||||
|
'AI Agent': [
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
main: [[{ json: { output: 'AI response message' } }]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
lastNodeExecuted: 'AI Agent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CanvasChat', () => {
|
||||||
|
const renderComponent = createComponentRenderer(CanvasChat, {
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
[ChatSymbol as symbol]: {},
|
||||||
|
[ChatOptionsSymbol as symbol]: {},
|
||||||
|
},
|
||||||
|
plugins: [router],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||||
|
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
|
||||||
|
let canvasStore: ReturnType<typeof mockedStore<typeof useCanvasStore>>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const pinia = createTestingPinia({
|
||||||
|
initialState: {
|
||||||
|
[STORES.WORKFLOWS]: {
|
||||||
|
workflow: {
|
||||||
|
nodes: mockNodes,
|
||||||
|
connections: mockConnections,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[STORES.UI]: {
|
||||||
|
chatPanelOpen: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setActivePinia(pinia);
|
||||||
|
|
||||||
|
workflowsStore = mockedStore(useWorkflowsStore);
|
||||||
|
uiStore = mockedStore(useUIStore);
|
||||||
|
canvasStore = mockedStore(useCanvasStore);
|
||||||
|
|
||||||
|
// Setup default mocks
|
||||||
|
workflowsStore.getCurrentWorkflow.mockReturnValue(
|
||||||
|
createTestWorkflowObject({
|
||||||
|
nodes: mockNodes,
|
||||||
|
connections: mockConnections,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
workflowsStore.getNodeByName.mockImplementation(
|
||||||
|
(name) => mockNodes.find((node) => node.name === name) ?? null,
|
||||||
|
);
|
||||||
|
workflowsStore.isChatPanelOpen = true;
|
||||||
|
workflowsStore.getWorkflowExecution = mockWorkflowExecution as unknown as IExecutionResponse;
|
||||||
|
workflowsStore.getPastChatMessages = ['Previous message 1', 'Previous message 2'];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render chat when panel is open', () => {
|
||||||
|
const { getByTestId } = renderComponent();
|
||||||
|
expect(getByTestId('canvas-chat')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render chat when panel is closed', async () => {
|
||||||
|
workflowsStore.isChatPanelOpen = false;
|
||||||
|
const { queryByTestId } = renderComponent();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryByTestId('canvas-chat')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show correct input placeholder', async () => {
|
||||||
|
const { findByTestId } = renderComponent();
|
||||||
|
expect(await findByTestId('chat-input')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('message handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(chatEventBus, 'emit');
|
||||||
|
workflowsStore.runWorkflow.mockResolvedValue({ executionId: 'test-execution-id' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send message and show response', async () => {
|
||||||
|
const { findByTestId, findByText } = renderComponent();
|
||||||
|
|
||||||
|
// Send message
|
||||||
|
const input = await findByTestId('chat-input');
|
||||||
|
await userEvent.type(input, 'Hello AI!');
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
|
||||||
|
// Verify message and response
|
||||||
|
expect(await findByText('Hello AI!')).toBeInTheDocument();
|
||||||
|
await waitFor(async () => {
|
||||||
|
expect(await findByText('AI response message')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify workflow execution
|
||||||
|
expect(workflowsStore.runWorkflow).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
runData: {
|
||||||
|
'When chat message received': [
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
main: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
action: 'sendMessage',
|
||||||
|
chatInput: 'Hello AI!',
|
||||||
|
sessionId: expect.any(String),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
executionStatus: 'success',
|
||||||
|
executionTime: 0,
|
||||||
|
source: [null],
|
||||||
|
startTime: expect.any(Number),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show loading state during message processing', async () => {
|
||||||
|
const { findByTestId, queryByTestId } = renderComponent();
|
||||||
|
|
||||||
|
// Send message
|
||||||
|
const input = await findByTestId('chat-input');
|
||||||
|
await userEvent.type(input, 'Test message');
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
|
||||||
|
// Verify loading states
|
||||||
|
uiStore.isActionActive = { workflowRunning: true };
|
||||||
|
await waitFor(() => expect(queryByTestId('chat-message-typing')).toBeInTheDocument());
|
||||||
|
|
||||||
|
uiStore.isActionActive = { workflowRunning: false };
|
||||||
|
await waitFor(() => expect(queryByTestId('chat-message-typing')).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle workflow execution errors', async () => {
|
||||||
|
workflowsStore.runWorkflow.mockRejectedValueOnce(new Error());
|
||||||
|
|
||||||
|
const { findByTestId } = renderComponent();
|
||||||
|
|
||||||
|
const input = await findByTestId('chat-input');
|
||||||
|
await userEvent.type(input, 'Hello AI!');
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
expect(toast.showError).toHaveBeenCalledWith(new Error(), 'Problem running workflow');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('session management', () => {
|
||||||
|
const mockMessages: ChatMessage[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
text: 'Existing message',
|
||||||
|
sender: 'user',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
|
||||||
|
getChatMessages: vi.fn().mockReturnValue(mockMessages),
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
extractResponseMessage: vi.fn(),
|
||||||
|
previousMessageIndex: ref(0),
|
||||||
|
waitForExecution: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow copying session ID', async () => {
|
||||||
|
const clipboardSpy = vi.fn();
|
||||||
|
document.execCommand = clipboardSpy;
|
||||||
|
const { getByTestId } = renderComponent();
|
||||||
|
|
||||||
|
await userEvent.click(getByTestId('chat-session-id'));
|
||||||
|
const toast = useToast();
|
||||||
|
expect(clipboardSpy).toHaveBeenCalledWith('copy');
|
||||||
|
expect(toast.showMessage).toHaveBeenCalledWith({
|
||||||
|
message: '',
|
||||||
|
title: 'Copied to clipboard',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should refresh session with confirmation when messages exist', async () => {
|
||||||
|
const { getByTestId, getByRole } = renderComponent();
|
||||||
|
|
||||||
|
const originalSessionId = getByTestId('chat-session-id').textContent;
|
||||||
|
await userEvent.click(getByTestId('refresh-session-button'));
|
||||||
|
|
||||||
|
const confirmButton = getByRole('dialog').querySelector('button.btn--confirm');
|
||||||
|
|
||||||
|
if (!confirmButton) throw new Error('Confirm button not found');
|
||||||
|
await userEvent.click(confirmButton);
|
||||||
|
|
||||||
|
expect(getByTestId('chat-session-id').textContent).not.toEqual(originalSessionId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resize functionality', () => {
|
||||||
|
it('should handle panel resizing', async () => {
|
||||||
|
const { container } = renderComponent();
|
||||||
|
|
||||||
|
const resizeWrapper = container.querySelector('.resizeWrapper');
|
||||||
|
if (!resizeWrapper) throw new Error('Resize wrapper not found');
|
||||||
|
|
||||||
|
await userEvent.pointer([
|
||||||
|
{ target: resizeWrapper, coords: { clientX: 0, clientY: 0 } },
|
||||||
|
{ coords: { clientX: 0, clientY: 100 } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(canvasStore.setPanelHeight).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should persist resize dimensions', () => {
|
||||||
|
const mockStorage = {
|
||||||
|
getItem: vi.fn(),
|
||||||
|
setItem: vi.fn(),
|
||||||
|
};
|
||||||
|
Object.defineProperty(window, 'localStorage', { value: mockStorage });
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(mockStorage.getItem).toHaveBeenCalledWith('N8N_CANVAS_CHAT_HEIGHT');
|
||||||
|
expect(mockStorage.getItem).toHaveBeenCalledWith('N8N_CANVAS_CHAT_WIDTH');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('file handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
|
||||||
|
getChatMessages: vi.fn().mockReturnValue([]),
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
extractResponseMessage: vi.fn(),
|
||||||
|
previousMessageIndex: ref(0),
|
||||||
|
waitForExecution: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowsStore.isChatPanelOpen = true;
|
||||||
|
workflowsStore.allowFileUploads = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable file uploads when allowed by chat trigger node', async () => {
|
||||||
|
const allowFileUploads = ref(true);
|
||||||
|
const original = useChatTrigger.useChatTrigger;
|
||||||
|
vi.spyOn(useChatTrigger, 'useChatTrigger').mockImplementation((...args) => ({
|
||||||
|
...original(...args),
|
||||||
|
allowFileUploads: computed(() => allowFileUploads.value),
|
||||||
|
}));
|
||||||
|
const { getByTestId } = renderComponent();
|
||||||
|
|
||||||
|
const chatPanel = getByTestId('canvas-chat');
|
||||||
|
expect(chatPanel).toBeInTheDocument();
|
||||||
|
|
||||||
|
const fileInput = getByTestId('chat-attach-file-button');
|
||||||
|
expect(fileInput).toBeInTheDocument();
|
||||||
|
|
||||||
|
allowFileUploads.value = false;
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fileInput).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('message history handling', () => {
|
||||||
|
it('should properly navigate through message history with wrap-around', async () => {
|
||||||
|
const messages = ['Message 1', 'Message 2', 'Message 3'];
|
||||||
|
workflowsStore.getPastChatMessages = messages;
|
||||||
|
|
||||||
|
const { findByTestId } = renderComponent();
|
||||||
|
const input = await findByTestId('chat-input');
|
||||||
|
|
||||||
|
// First up should show most recent message
|
||||||
|
await userEvent.keyboard('{ArrowUp}');
|
||||||
|
expect(input).toHaveValue('Message 3');
|
||||||
|
|
||||||
|
// Second up should show second most recent
|
||||||
|
await userEvent.keyboard('{ArrowUp}');
|
||||||
|
expect(input).toHaveValue('Message 2');
|
||||||
|
|
||||||
|
// Third up should show oldest message
|
||||||
|
await userEvent.keyboard('{ArrowUp}');
|
||||||
|
expect(input).toHaveValue('Message 1');
|
||||||
|
|
||||||
|
// Fourth up should wrap around to most recent
|
||||||
|
await userEvent.keyboard('{ArrowUp}');
|
||||||
|
expect(input).toHaveValue('Message 3');
|
||||||
|
|
||||||
|
// Down arrow should go in reverse
|
||||||
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
expect(input).toHaveValue('Message 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset message history navigation on new input', async () => {
|
||||||
|
workflowsStore.getPastChatMessages = ['Message 1', 'Message 2'];
|
||||||
|
const { findByTestId } = renderComponent();
|
||||||
|
const input = await findByTestId('chat-input');
|
||||||
|
|
||||||
|
// Navigate to oldest message
|
||||||
|
await userEvent.keyboard('{ArrowUp}'); // Most recent
|
||||||
|
await userEvent.keyboard('{ArrowUp}'); // Oldest
|
||||||
|
expect(input).toHaveValue('Message 1');
|
||||||
|
|
||||||
|
await userEvent.type(input, 'New message');
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
|
||||||
|
await userEvent.keyboard('{ArrowUp}');
|
||||||
|
expect(input).toHaveValue('Message 2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('message reuse and repost', () => {
|
||||||
|
const sendMessageSpy = vi.fn();
|
||||||
|
beforeEach(() => {
|
||||||
|
const mockMessages: ChatMessage[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
text: 'Original message',
|
||||||
|
sender: 'user',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
text: 'AI response',
|
||||||
|
sender: 'bot',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
|
||||||
|
getChatMessages: vi.fn().mockReturnValue(mockMessages),
|
||||||
|
sendMessage: sendMessageSpy,
|
||||||
|
extractResponseMessage: vi.fn(),
|
||||||
|
previousMessageIndex: ref(0),
|
||||||
|
waitForExecution: vi.fn(),
|
||||||
|
});
|
||||||
|
workflowsStore.messages = mockMessages;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should repost user message with new execution', async () => {
|
||||||
|
const { findByTestId } = renderComponent();
|
||||||
|
const repostButton = await findByTestId('repost-message-button');
|
||||||
|
|
||||||
|
await userEvent.click(repostButton);
|
||||||
|
|
||||||
|
expect(sendMessageSpy).toHaveBeenCalledWith('Original message');
|
||||||
|
// expect.objectContaining({
|
||||||
|
// runData: expect.objectContaining({
|
||||||
|
// 'When chat message received': expect.arrayContaining([
|
||||||
|
// expect.objectContaining({
|
||||||
|
// data: expect.objectContaining({
|
||||||
|
// main: expect.arrayContaining([
|
||||||
|
// expect.arrayContaining([
|
||||||
|
// expect.objectContaining({
|
||||||
|
// json: expect.objectContaining({
|
||||||
|
// chatInput: 'Original message',
|
||||||
|
// }),
|
||||||
|
// }),
|
||||||
|
// ]),
|
||||||
|
// ]),
|
||||||
|
// }),
|
||||||
|
// }),
|
||||||
|
// ]),
|
||||||
|
// }),
|
||||||
|
// }),
|
||||||
|
// );
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show message options only for appropriate messages', async () => {
|
||||||
|
const { findByText, container } = renderComponent();
|
||||||
|
|
||||||
|
await findByText('Original message');
|
||||||
|
const userMessage = container.querySelector('.chat-message-from-user');
|
||||||
|
expect(
|
||||||
|
userMessage?.querySelector('[data-test-id="repost-message-button"]'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
userMessage?.querySelector('[data-test-id="reuse-message-button"]'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
await findByText('AI response');
|
||||||
|
const botMessage = container.querySelector('.chat-message-from-bot');
|
||||||
|
expect(
|
||||||
|
botMessage?.querySelector('[data-test-id="repost-message-button"]'),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
botMessage?.querySelector('[data-test-id="reuse-message-button"]'),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('execution handling', () => {
|
||||||
|
it('should update UI when execution is completed', async () => {
|
||||||
|
const { findByTestId, queryByTestId } = renderComponent();
|
||||||
|
|
||||||
|
// Start execution
|
||||||
|
const input = await findByTestId('chat-input');
|
||||||
|
await userEvent.type(input, 'Test message');
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
|
||||||
|
// Simulate execution completion
|
||||||
|
uiStore.isActionActive = { workflowRunning: true };
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryByTestId('chat-message-typing')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
uiStore.isActionActive = { workflowRunning: false };
|
||||||
|
workflowsStore.setWorkflowExecutionData(
|
||||||
|
mockWorkflowExecution as unknown as IExecutionResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryByTestId('chat-message-typing')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('panel state synchronization', () => {
|
||||||
|
it('should update canvas height when chat or logs panel state changes', async () => {
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
// Toggle logs panel
|
||||||
|
workflowsStore.isLogsPanelOpen = true;
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(canvasStore.setPanelHeight).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close chat panel
|
||||||
|
workflowsStore.isChatPanelOpen = false;
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(canvasStore.setPanelHeight).toHaveBeenCalledWith(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve panel state across component remounts', async () => {
|
||||||
|
const { unmount, rerender } = renderComponent();
|
||||||
|
|
||||||
|
// Set initial state
|
||||||
|
workflowsStore.isChatPanelOpen = true;
|
||||||
|
workflowsStore.isLogsPanelOpen = true;
|
||||||
|
|
||||||
|
// Unmount and remount
|
||||||
|
unmount();
|
||||||
|
await rerender({});
|
||||||
|
|
||||||
|
expect(workflowsStore.isChatPanelOpen).toBe(true);
|
||||||
|
expect(workflowsStore.isLogsPanelOpen).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('keyboard shortcuts', () => {
|
||||||
|
it('should handle Enter key with modifier to start new line', async () => {
|
||||||
|
const { findByTestId } = renderComponent();
|
||||||
|
|
||||||
|
const input = await findByTestId('chat-input');
|
||||||
|
await userEvent.type(input, 'Line 1');
|
||||||
|
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
|
||||||
|
await userEvent.type(input, 'Line 2');
|
||||||
|
|
||||||
|
expect(input).toHaveValue('Line 1\nLine 2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('chat synchronization', () => {
|
||||||
|
it('should load initial chat history when first opening panel', async () => {
|
||||||
|
const getChatMessagesSpy = vi.fn().mockReturnValue(['Previous message']);
|
||||||
|
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
|
||||||
|
...vi.fn()(),
|
||||||
|
getChatMessages: getChatMessagesSpy,
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowsStore.isChatPanelOpen = false;
|
||||||
|
const { rerender } = renderComponent();
|
||||||
|
|
||||||
|
workflowsStore.isChatPanelOpen = true;
|
||||||
|
await rerender({});
|
||||||
|
|
||||||
|
expect(getChatMessagesSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
350
packages/editor-ui/src/components/CanvasChat/CanvasChat.vue
Normal file
350
packages/editor-ui/src/components/CanvasChat/CanvasChat.vue
Normal file
|
@ -0,0 +1,350 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
import { provide, watch, computed, ref, watchEffect } from 'vue';
|
||||||
|
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
||||||
|
import type { Router } from 'vue-router';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import ChatMessagesPanel from './components/ChatMessagesPanel.vue';
|
||||||
|
import ChatLogsPanel from './components/ChatLogsPanel.vue';
|
||||||
|
|
||||||
|
// Composables
|
||||||
|
import { useChatTrigger } from './composables/useChatTrigger';
|
||||||
|
import { useChatMessaging } from './composables/useChatMessaging';
|
||||||
|
import { useResize } from './composables/useResize';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
|
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { Chat, ChatMessage, ChatOptions } from '@n8n/chat/types';
|
||||||
|
import type { RunWorkflowChatPayload } from './composables/useChatMessaging';
|
||||||
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
const canvasStore = useCanvasStore();
|
||||||
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
|
const nodeHelpers = useNodeHelpers();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Component state
|
||||||
|
const messages = ref<ChatMessage[]>([]);
|
||||||
|
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
|
||||||
|
const isDisabled = ref(false);
|
||||||
|
const container = ref<HTMLElement>();
|
||||||
|
|
||||||
|
// Computed properties
|
||||||
|
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
||||||
|
const isLoading = computed(() => {
|
||||||
|
const result = uiStore.isActionActive.workflowRunning;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
const allConnections = computed(() => workflowsStore.allConnections);
|
||||||
|
const isChatOpen = computed(() => {
|
||||||
|
const result = workflowsStore.isChatPanelOpen;
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
const isLogsOpen = computed(() => workflowsStore.isLogsPanelOpen);
|
||||||
|
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
|
||||||
|
|
||||||
|
// Expose internal state for testing
|
||||||
|
defineExpose({
|
||||||
|
messages,
|
||||||
|
currentSessionId,
|
||||||
|
isDisabled,
|
||||||
|
workflow,
|
||||||
|
isLoading,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { runWorkflow } = useRunWorkflow({ router });
|
||||||
|
|
||||||
|
// Initialize features with injected dependencies
|
||||||
|
const { chatTriggerNode, connectedNode, allowFileUploads, setChatTriggerNode, setConnectedNode } =
|
||||||
|
useChatTrigger({
|
||||||
|
workflow,
|
||||||
|
canvasNodes: workflowsStore.allNodes,
|
||||||
|
getNodeByName: workflowsStore.getNodeByName,
|
||||||
|
getNodeType: nodeTypesStore.getNodeType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { sendMessage, getChatMessages } = useChatMessaging({
|
||||||
|
chatTrigger: chatTriggerNode,
|
||||||
|
connectedNode,
|
||||||
|
messages,
|
||||||
|
sessionId: currentSessionId,
|
||||||
|
workflow,
|
||||||
|
isLoading,
|
||||||
|
executionResultData: computed(() => workflowsStore.getWorkflowExecution?.data?.resultData),
|
||||||
|
getWorkflowResultDataByNodeName: workflowsStore.getWorkflowResultDataByNodeName,
|
||||||
|
onRunChatWorkflow,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
height,
|
||||||
|
chatWidth,
|
||||||
|
rootStyles,
|
||||||
|
logsWidth,
|
||||||
|
onResizeDebounced,
|
||||||
|
onResizeChatDebounced,
|
||||||
|
onWindowResize,
|
||||||
|
} = useResize(container);
|
||||||
|
|
||||||
|
// Extracted pure functions for better testability
|
||||||
|
function createChatConfig(params: {
|
||||||
|
messages: Chat['messages'];
|
||||||
|
sendMessage: Chat['sendMessage'];
|
||||||
|
currentSessionId: Chat['currentSessionId'];
|
||||||
|
isLoading: Ref<boolean>;
|
||||||
|
isDisabled: Ref<boolean>;
|
||||||
|
allowFileUploads: Ref<boolean>;
|
||||||
|
locale: ReturnType<typeof useI18n>;
|
||||||
|
}): { chatConfig: Chat; chatOptions: ChatOptions } {
|
||||||
|
const chatConfig: Chat = {
|
||||||
|
messages: params.messages,
|
||||||
|
sendMessage: params.sendMessage,
|
||||||
|
initialMessages: ref([]),
|
||||||
|
currentSessionId: params.currentSessionId,
|
||||||
|
waitingForResponse: params.isLoading,
|
||||||
|
};
|
||||||
|
|
||||||
|
const chatOptions: ChatOptions = {
|
||||||
|
i18n: {
|
||||||
|
en: {
|
||||||
|
title: '',
|
||||||
|
footer: '',
|
||||||
|
subtitle: '',
|
||||||
|
inputPlaceholder: params.locale.baseText('chat.window.chat.placeholder'),
|
||||||
|
getStarted: '',
|
||||||
|
closeButtonTooltip: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
webhookUrl: '',
|
||||||
|
mode: 'window',
|
||||||
|
showWindowCloseButton: true,
|
||||||
|
disabled: params.isDisabled,
|
||||||
|
allowFileUploads: params.allowFileUploads,
|
||||||
|
allowedFilesMimeTypes: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return { chatConfig, chatOptions };
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayExecution(params: { router: Router; workflowId: string; executionId: string }) {
|
||||||
|
const route = params.router.resolve({
|
||||||
|
name: VIEWS.EXECUTION_PREVIEW,
|
||||||
|
params: { name: params.workflowId, executionId: params.executionId },
|
||||||
|
});
|
||||||
|
window.open(route.href, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSession(params: { messages: Ref<ChatMessage[]>; currentSessionId: Ref<string> }) {
|
||||||
|
workflowsStore.setWorkflowExecutionData(null);
|
||||||
|
nodeHelpers.updateNodesExecutionIssues();
|
||||||
|
params.messages.value = [];
|
||||||
|
params.currentSessionId.value = uuid().replace(/-/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
const handleDisplayExecution = (executionId: string) => {
|
||||||
|
displayExecution({
|
||||||
|
router,
|
||||||
|
workflowId: workflow.value.id,
|
||||||
|
executionId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefreshSession = () => {
|
||||||
|
refreshSession({
|
||||||
|
messages,
|
||||||
|
currentSessionId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePanel = () => {
|
||||||
|
workflowsStore.setPanelOpen('chat', false);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function onRunChatWorkflow(payload: RunWorkflowChatPayload) {
|
||||||
|
const response = await runWorkflow({
|
||||||
|
triggerNode: payload.triggerNode,
|
||||||
|
nodeData: payload.nodeData,
|
||||||
|
source: payload.source,
|
||||||
|
});
|
||||||
|
|
||||||
|
workflowsStore.appendChatMessage(payload.message);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize chat config
|
||||||
|
const { chatConfig, chatOptions } = createChatConfig({
|
||||||
|
messages,
|
||||||
|
sendMessage,
|
||||||
|
currentSessionId,
|
||||||
|
isLoading,
|
||||||
|
isDisabled,
|
||||||
|
allowFileUploads,
|
||||||
|
locale: useI18n(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provide chat context
|
||||||
|
provide(ChatSymbol, chatConfig);
|
||||||
|
provide(ChatOptionsSymbol, chatOptions);
|
||||||
|
|
||||||
|
// Watchers
|
||||||
|
watch(
|
||||||
|
() => isChatOpen.value,
|
||||||
|
(isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
setChatTriggerNode();
|
||||||
|
setConnectedNode();
|
||||||
|
|
||||||
|
if (messages.value.length === 0) {
|
||||||
|
messages.value = getChatMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
onWindowResize();
|
||||||
|
chatEventBus.emit('focusInput');
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => allConnections.value,
|
||||||
|
() => {
|
||||||
|
if (canvasStore.isLoading) return;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!chatTriggerNode.value) {
|
||||||
|
setChatTriggerNode();
|
||||||
|
}
|
||||||
|
setConnectedNode();
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
canvasStore.setPanelHeight(isChatOpen.value || isLogsOpen.value ? height.value : 0);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n8n-resize-wrapper
|
||||||
|
v-if="chatTriggerNode"
|
||||||
|
:is-resizing-enabled="isChatOpen || isLogsOpen"
|
||||||
|
:supported-directions="['top']"
|
||||||
|
:class="[$style.resizeWrapper, !isChatOpen && !isLogsOpen && $style.empty]"
|
||||||
|
:height="height"
|
||||||
|
:style="rootStyles"
|
||||||
|
@resize="onResizeDebounced"
|
||||||
|
>
|
||||||
|
<div ref="container" :class="$style.container">
|
||||||
|
<div v-if="isChatOpen || isLogsOpen" :class="$style.chatResizer">
|
||||||
|
<n8n-resize-wrapper
|
||||||
|
v-if="isChatOpen"
|
||||||
|
:supported-directions="['right']"
|
||||||
|
:width="chatWidth"
|
||||||
|
:class="$style.chat"
|
||||||
|
@resize="onResizeChatDebounced"
|
||||||
|
>
|
||||||
|
<div :class="$style.inner">
|
||||||
|
<ChatMessagesPanel
|
||||||
|
data-test-id="canvas-chat"
|
||||||
|
:messages="messages"
|
||||||
|
:session-id="currentSessionId"
|
||||||
|
:past-chat-messages="previousChatMessages"
|
||||||
|
@refresh-session="handleRefreshSession"
|
||||||
|
@display-execution="handleDisplayExecution"
|
||||||
|
@send-message="sendMessage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</n8n-resize-wrapper>
|
||||||
|
<div v-if="isLogsOpen && connectedNode" :class="$style.logs">
|
||||||
|
<ChatLogsPanel
|
||||||
|
:key="messages.length"
|
||||||
|
:workflow="workflow"
|
||||||
|
data-test-id="canvas-chat-logs"
|
||||||
|
:node="connectedNode"
|
||||||
|
:slim="logsWidth < 700"
|
||||||
|
@close="closePanel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n8n-resize-wrapper>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.resizeWrapper {
|
||||||
|
height: var(--panel-height);
|
||||||
|
min-height: 4rem;
|
||||||
|
max-height: 90vh;
|
||||||
|
flex-basis: content;
|
||||||
|
border-top: 1px solid var(--color-foreground-base);
|
||||||
|
|
||||||
|
&.empty {
|
||||||
|
height: auto;
|
||||||
|
min-height: 0;
|
||||||
|
flex-basis: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatResizer {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
border-top: 1px solid var(--color-foreground-base);
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--color-background-light);
|
||||||
|
display: flex;
|
||||||
|
padding: var(--spacing-2xs);
|
||||||
|
gap: var(--spacing-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat {
|
||||||
|
width: var(--chat-width);
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid var(--color-foreground-base);
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
&:only-child {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
background-color: var(--color-background-light);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,91 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { INode, Workflow } from 'n8n-workflow';
|
||||||
|
import RunDataAi from '@/components/RunDataAi/RunDataAi.vue';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
node: INode | null;
|
||||||
|
slim?: boolean;
|
||||||
|
workflow: Workflow;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const locale = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="$style.logsWrapper" data-test-id="lm-chat-logs">
|
||||||
|
<header :class="$style.logsHeader">
|
||||||
|
<div class="meta">
|
||||||
|
{{ locale.baseText('chat.window.logs') }}
|
||||||
|
<span v-if="node">
|
||||||
|
{{
|
||||||
|
locale.baseText('chat.window.logsFromNode', { interpolate: { nodeName: node.name } })
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<n8n-icon-button
|
||||||
|
:class="$style.close"
|
||||||
|
outline
|
||||||
|
icon="times"
|
||||||
|
type="secondary"
|
||||||
|
size="mini"
|
||||||
|
@click="emit('close')"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
<div :class="$style.logs">
|
||||||
|
<RunDataAi
|
||||||
|
v-if="node"
|
||||||
|
:class="$style.runData"
|
||||||
|
:node="node"
|
||||||
|
:workflow="workflow"
|
||||||
|
:slim="slim"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.logsHeader {
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
height: 2.6875rem;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-foreground-base);
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
background-color: var(--color-foreground-xlight);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.close {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-weight: 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.logsWrapper {
|
||||||
|
--node-icon-color: var(--color-text-base);
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logsTitle {
|
||||||
|
margin: 0 var(--spacing-s) var(--spacing-s);
|
||||||
|
}
|
||||||
|
.logs {
|
||||||
|
padding: var(--spacing-s) 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,348 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import MessagesList from '@n8n/chat/components/MessagesList.vue';
|
||||||
|
import MessageOptionTooltip from './MessageOptionTooltip.vue';
|
||||||
|
import MessageOptionAction from './MessageOptionAction.vue';
|
||||||
|
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||||
|
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
|
||||||
|
import ChatInput from '@n8n/chat/components/Input.vue';
|
||||||
|
import { useMessage } from '@/composables/useMessage';
|
||||||
|
import { MODAL_CONFIRM } from '@/constants';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useClipboard } from '@/composables/useClipboard';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
pastChatMessages: string[];
|
||||||
|
messages: ChatMessage[];
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
displayExecution: [id: string];
|
||||||
|
sendMessage: [message: string];
|
||||||
|
refreshSession: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const messageComposable = useMessage();
|
||||||
|
const clipboard = useClipboard();
|
||||||
|
const locale = useI18n();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const previousMessageIndex = ref(0);
|
||||||
|
|
||||||
|
const inputPlaceholder = computed(() => {
|
||||||
|
if (props.messages.length > 0) {
|
||||||
|
return locale.baseText('chat.window.chat.placeholder');
|
||||||
|
}
|
||||||
|
return locale.baseText('chat.window.chat.placeholderPristine');
|
||||||
|
});
|
||||||
|
/** Checks if message is a text message */
|
||||||
|
function isTextMessage(message: ChatMessage): message is ChatMessageText {
|
||||||
|
return message.type === 'text' || !message.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reposts the message */
|
||||||
|
function repostMessage(message: ChatMessageText) {
|
||||||
|
void sendMessage(message.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the message in input for reuse */
|
||||||
|
function reuseMessage(message: ChatMessageText) {
|
||||||
|
chatEventBus.emit('setInputValue', message.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMessage(message: string) {
|
||||||
|
previousMessageIndex.value = 0;
|
||||||
|
emit('sendMessage', message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRefreshSession() {
|
||||||
|
// If there are no messages, refresh the session without asking
|
||||||
|
if (props.messages.length === 0) {
|
||||||
|
emit('refreshSession');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmResult = await messageComposable.confirm(
|
||||||
|
locale.baseText('chat.window.session.reset.warning'),
|
||||||
|
{
|
||||||
|
title: locale.baseText('chat.window.session.reset.title'),
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: locale.baseText('chat.window.session.reset.confirm'),
|
||||||
|
showClose: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (confirmResult === MODAL_CONFIRM) {
|
||||||
|
emit('refreshSession');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onArrowKeyDown({ currentInputValue, key }: ArrowKeyDownPayload) {
|
||||||
|
const pastMessages = props.pastChatMessages;
|
||||||
|
const isCurrentInputEmptyOrMatch =
|
||||||
|
currentInputValue.length === 0 || pastMessages.includes(currentInputValue);
|
||||||
|
|
||||||
|
if (isCurrentInputEmptyOrMatch && (key === 'ArrowUp' || key === 'ArrowDown')) {
|
||||||
|
// Exit if no messages
|
||||||
|
if (pastMessages.length === 0) return;
|
||||||
|
|
||||||
|
// Temporarily blur to avoid cursor position issues
|
||||||
|
chatEventBus.emit('blurInput');
|
||||||
|
|
||||||
|
if (pastMessages.length === 1) {
|
||||||
|
previousMessageIndex.value = 0;
|
||||||
|
} else {
|
||||||
|
if (key === 'ArrowUp') {
|
||||||
|
if (currentInputValue.length === 0 && previousMessageIndex.value === 0) {
|
||||||
|
// Start with most recent message
|
||||||
|
previousMessageIndex.value = pastMessages.length - 1;
|
||||||
|
} else {
|
||||||
|
// Move backwards through history
|
||||||
|
previousMessageIndex.value =
|
||||||
|
previousMessageIndex.value === 0
|
||||||
|
? pastMessages.length - 1
|
||||||
|
: previousMessageIndex.value - 1;
|
||||||
|
}
|
||||||
|
} else if (key === 'ArrowDown') {
|
||||||
|
// Move forwards through history
|
||||||
|
previousMessageIndex.value =
|
||||||
|
previousMessageIndex.value === pastMessages.length - 1
|
||||||
|
? 0
|
||||||
|
: previousMessageIndex.value + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get message at current index
|
||||||
|
const selectedMessage = pastMessages[previousMessageIndex.value];
|
||||||
|
chatEventBus.emit('setInputValue', selectedMessage);
|
||||||
|
|
||||||
|
// Refocus and move cursor to end
|
||||||
|
chatEventBus.emit('focusInput');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset history navigation when typing new content that doesn't match history
|
||||||
|
if (!isCurrentInputEmptyOrMatch) {
|
||||||
|
previousMessageIndex.value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function copySessionId() {
|
||||||
|
void clipboard.copy(props.sessionId);
|
||||||
|
toast.showMessage({
|
||||||
|
title: locale.baseText('generic.copiedToClipboard'),
|
||||||
|
message: '',
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="$style.chat" data-test-id="workflow-lm-chat-dialog">
|
||||||
|
<header :class="$style.chatHeader">
|
||||||
|
<span>{{ locale.baseText('chat.window.title') }}</span>
|
||||||
|
<div :class="$style.session">
|
||||||
|
<span>{{ locale.baseText('chat.window.session.title') }}</span>
|
||||||
|
<n8n-tooltip placement="left">
|
||||||
|
<template #content>
|
||||||
|
{{ sessionId }}
|
||||||
|
</template>
|
||||||
|
<span :class="$style.sessionId" data-test-id="chat-session-id" @click="copySessionId">{{
|
||||||
|
sessionId
|
||||||
|
}}</span>
|
||||||
|
</n8n-tooltip>
|
||||||
|
<n8n-icon-button
|
||||||
|
:class="$style.refreshSession"
|
||||||
|
data-test-id="refresh-session-button"
|
||||||
|
type="tertiary"
|
||||||
|
text
|
||||||
|
size="mini"
|
||||||
|
icon="undo"
|
||||||
|
:title="locale.baseText('chat.window.session.reset.confirm')"
|
||||||
|
@click="onRefreshSession"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main :class="$style.chatBody">
|
||||||
|
<MessagesList :messages="messages" :class="[$style.messages, 'ignore-key-press-canvas']">
|
||||||
|
<template #beforeMessage="{ message }">
|
||||||
|
<MessageOptionTooltip
|
||||||
|
v-if="message.sender === 'bot' && !message.id.includes('preload')"
|
||||||
|
placement="right"
|
||||||
|
data-test-id="execution-id-tooltip"
|
||||||
|
>
|
||||||
|
{{ locale.baseText('chat.window.chat.chatMessageOptions.executionId') }}:
|
||||||
|
<a href="#" @click="emit('displayExecution', message.id)">{{ message.id }}</a>
|
||||||
|
</MessageOptionTooltip>
|
||||||
|
|
||||||
|
<MessageOptionAction
|
||||||
|
v-if="isTextMessage(message) && message.sender === 'user'"
|
||||||
|
data-test-id="repost-message-button"
|
||||||
|
icon="redo"
|
||||||
|
:label="locale.baseText('chat.window.chat.chatMessageOptions.repostMessage')"
|
||||||
|
placement="left"
|
||||||
|
@click.once="repostMessage(message)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MessageOptionAction
|
||||||
|
v-if="isTextMessage(message) && message.sender === 'user'"
|
||||||
|
data-test-id="reuse-message-button"
|
||||||
|
icon="copy"
|
||||||
|
:label="locale.baseText('chat.window.chat.chatMessageOptions.reuseMessage')"
|
||||||
|
placement="left"
|
||||||
|
@click="reuseMessage(message)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MessagesList>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div :class="$style.messagesInput">
|
||||||
|
<div v-if="pastChatMessages.length > 0" :class="$style.messagesHistory">
|
||||||
|
<n8n-button
|
||||||
|
title="Navigate to previous message"
|
||||||
|
icon="chevron-up"
|
||||||
|
type="tertiary"
|
||||||
|
text
|
||||||
|
size="mini"
|
||||||
|
@click="onArrowKeyDown({ currentInputValue: '', key: 'ArrowUp' })"
|
||||||
|
/>
|
||||||
|
<n8n-button
|
||||||
|
title="Navigate to next message"
|
||||||
|
icon="chevron-down"
|
||||||
|
type="tertiary"
|
||||||
|
text
|
||||||
|
size="mini"
|
||||||
|
@click="onArrowKeyDown({ currentInputValue: '', key: 'ArrowDown' })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ChatInput
|
||||||
|
data-test-id="lm-chat-inputs"
|
||||||
|
:placeholder="inputPlaceholder"
|
||||||
|
@arrow-key-down="onArrowKeyDown"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.chat {
|
||||||
|
--chat--spacing: var(--spacing-xs);
|
||||||
|
--chat--message--padding: var(--spacing-xs);
|
||||||
|
--chat--message--font-size: var(--font-size-s);
|
||||||
|
--chat--input--font-size: var(--font-size-s);
|
||||||
|
--chat--message--bot--background: transparent;
|
||||||
|
--chat--message--user--background: var(--color-text-lighter);
|
||||||
|
--chat--message--bot--color: var(--color-text-dark);
|
||||||
|
--chat--message--user--color: var(--color-text-dark);
|
||||||
|
--chat--message--bot--border: none;
|
||||||
|
--chat--message--user--border: none;
|
||||||
|
--chat--color-typing: var(--color-text-light);
|
||||||
|
--chat--textarea--max-height: calc(var(--panel-height) * 0.5);
|
||||||
|
--chat--message--pre--background: var(--color-foreground-light);
|
||||||
|
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--color-background-light);
|
||||||
|
}
|
||||||
|
.chatHeader {
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--color-foreground-base);
|
||||||
|
padding: var(--chat--spacing);
|
||||||
|
background-color: var(--color-foreground-xlight);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.session {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-2xs);
|
||||||
|
color: var(--color-text-base);
|
||||||
|
max-width: 70%;
|
||||||
|
}
|
||||||
|
.sessionId {
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.refreshSession {
|
||||||
|
max-height: 1.1rem;
|
||||||
|
}
|
||||||
|
.chatBody {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
padding-top: 1.5em;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagesInput {
|
||||||
|
--input-border-color: var(--border-color-base);
|
||||||
|
--chat--input--border: none;
|
||||||
|
|
||||||
|
--chat--input--border-radius: 0.5rem;
|
||||||
|
--chat--input--send--button--background: transparent;
|
||||||
|
--chat--input--send--button--color: var(--color-primary);
|
||||||
|
--chat--input--file--button--background: transparent;
|
||||||
|
--chat--input--file--button--color: var(--color-primary);
|
||||||
|
--chat--input--border-active: var(--input-focus-border-color, var(--color-secondary));
|
||||||
|
--chat--files-spacing: var(--spacing-2xs) 0;
|
||||||
|
--chat--input--background: transparent;
|
||||||
|
--chat--input--file--button--color: var(--color-button-secondary-font);
|
||||||
|
--chat--input--file--button--color-hover: var(--color-primary);
|
||||||
|
|
||||||
|
[data-theme='dark'] & {
|
||||||
|
--chat--input--text-color: var(--input-font-color, var(--color-text-dark));
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
--chat--input--text-color: var(--input-font-color, var(--color-text-dark));
|
||||||
|
}
|
||||||
|
|
||||||
|
padding: 0 0 0 var(--spacing-xs);
|
||||||
|
margin: 0 var(--chat--spacing) var(--chat--spacing);
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
background: var(--color-lm-chat-bot-background);
|
||||||
|
border-radius: var(--chat--input--border-radius);
|
||||||
|
transition: border-color 200ms ease-in-out;
|
||||||
|
border: var(--input-border-color, var(--border-color-base))
|
||||||
|
var(--input-border-style, var(--border-style-base))
|
||||||
|
var(--input-border-width, var(--border-width-base));
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
--input-border-color: #4538a3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.messagesHistory {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: var(--spacing-3xs);
|
||||||
|
|
||||||
|
button:first-child {
|
||||||
|
margin-top: var(--spacing-4xs);
|
||||||
|
margin-bottom: calc(-1 * var(--spacing-4xs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,46 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PropType } from 'vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
placement: {
|
||||||
|
type: String as PropType<'left' | 'right' | 'top' | 'bottom'>,
|
||||||
|
default: 'top',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="$style.container">
|
||||||
|
<n8n-tooltip :placement="placement">
|
||||||
|
<template #content>
|
||||||
|
{{ label }}
|
||||||
|
</template>
|
||||||
|
<n8n-icon :class="$style.icon" :icon="icon" size="xsmall" @click="$attrs.onClick" />
|
||||||
|
</n8n-tooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.container {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 var(--spacing-4xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--color-foreground-dark);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,40 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { PropType } from 'vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
placement: {
|
||||||
|
type: String as PropType<'left' | 'right' | 'top' | 'bottom'>,
|
||||||
|
default: 'top',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="$style.container">
|
||||||
|
<n8n-tooltip :placement="placement">
|
||||||
|
<template #content>
|
||||||
|
<slot />
|
||||||
|
</template>
|
||||||
|
<span :class="$style.icon">
|
||||||
|
<n8n-icon icon="info" size="xsmall" />
|
||||||
|
</span>
|
||||||
|
</n8n-tooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.container {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0 var(--spacing-4xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
color: var(--color-foreground-dark);
|
||||||
|
cursor: help;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,299 @@
|
||||||
|
import type { ComputedRef, Ref } from 'vue';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import type { ChatMessage, ChatMessageText } from '@n8n/chat/types';
|
||||||
|
import { NodeConnectionType, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
||||||
|
import type {
|
||||||
|
ITaskData,
|
||||||
|
INodeExecutionData,
|
||||||
|
IBinaryKeyData,
|
||||||
|
IDataObject,
|
||||||
|
IBinaryData,
|
||||||
|
BinaryFileType,
|
||||||
|
Workflow,
|
||||||
|
IRunExecutionData,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { useMessage } from '@/composables/useMessage';
|
||||||
|
import { usePinnedData } from '@/composables/usePinnedData';
|
||||||
|
import { get, isEmpty, last } from 'lodash-es';
|
||||||
|
import { MANUAL_CHAT_TRIGGER_NODE_TYPE, MODAL_CONFIRM } from '@/constants';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import type { MemoryOutput } from '../types/chat';
|
||||||
|
import type { IExecutionPushResponse, INodeUi } from '@/Interface';
|
||||||
|
|
||||||
|
export type RunWorkflowChatPayload = {
|
||||||
|
triggerNode: string;
|
||||||
|
nodeData: ITaskData;
|
||||||
|
source: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
export interface ChatMessagingDependencies {
|
||||||
|
chatTrigger: Ref<INodeUi | null>;
|
||||||
|
connectedNode: Ref<INodeUi | null>;
|
||||||
|
messages: Ref<ChatMessage[]>;
|
||||||
|
sessionId: Ref<string>;
|
||||||
|
workflow: ComputedRef<Workflow>;
|
||||||
|
isLoading: ComputedRef<boolean>;
|
||||||
|
executionResultData: ComputedRef<IRunExecutionData['resultData'] | undefined>;
|
||||||
|
getWorkflowResultDataByNodeName: (nodeName: string) => ITaskData[] | null;
|
||||||
|
onRunChatWorkflow: (
|
||||||
|
payload: RunWorkflowChatPayload,
|
||||||
|
) => Promise<IExecutionPushResponse | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatMessaging({
|
||||||
|
chatTrigger,
|
||||||
|
connectedNode,
|
||||||
|
messages,
|
||||||
|
sessionId,
|
||||||
|
workflow,
|
||||||
|
isLoading,
|
||||||
|
executionResultData,
|
||||||
|
getWorkflowResultDataByNodeName,
|
||||||
|
onRunChatWorkflow,
|
||||||
|
}: ChatMessagingDependencies) {
|
||||||
|
const locale = useI18n();
|
||||||
|
const { showError } = useToast();
|
||||||
|
const previousMessageIndex = ref(0);
|
||||||
|
|
||||||
|
/** Converts a file to binary data */
|
||||||
|
async function convertFileToBinaryData(file: File): Promise<IBinaryData> {
|
||||||
|
const reader = new FileReader();
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
reader.onload = () => {
|
||||||
|
const binaryData: IBinaryData = {
|
||||||
|
data: (reader.result as string).split('base64,')?.[1] ?? '',
|
||||||
|
mimeType: file.type,
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: `${file.size} bytes`,
|
||||||
|
fileExtension: file.name.split('.').pop() ?? '',
|
||||||
|
fileType: file.type.split('/')[0] as BinaryFileType,
|
||||||
|
};
|
||||||
|
resolve(binaryData);
|
||||||
|
};
|
||||||
|
reader.onerror = () => {
|
||||||
|
reject(new Error('Failed to convert file to binary data'));
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets keyed files for the workflow input */
|
||||||
|
async function getKeyedFiles(files: File[]): Promise<IBinaryKeyData> {
|
||||||
|
const binaryData: IBinaryKeyData = {};
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
files.map(async (file, index) => {
|
||||||
|
const data = await convertFileToBinaryData(file);
|
||||||
|
const key = `data${index}`;
|
||||||
|
|
||||||
|
binaryData[key] = data;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return binaryData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts file metadata */
|
||||||
|
function extractFileMeta(file: File): IDataObject {
|
||||||
|
return {
|
||||||
|
fileName: file.name,
|
||||||
|
fileSize: `${file.size} bytes`,
|
||||||
|
fileExtension: file.name.split('.').pop() ?? '',
|
||||||
|
fileType: file.type.split('/')[0],
|
||||||
|
mimeType: file.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts workflow execution with the message */
|
||||||
|
async function startWorkflowWithMessage(message: string, files?: File[]): Promise<void> {
|
||||||
|
const triggerNode = chatTrigger.value;
|
||||||
|
|
||||||
|
if (!triggerNode) {
|
||||||
|
showError(new Error('Chat Trigger Node could not be found!'), 'Trigger Node not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inputKey = 'chatInput';
|
||||||
|
if (triggerNode.type === MANUAL_CHAT_TRIGGER_NODE_TYPE && triggerNode.typeVersion < 1.1) {
|
||||||
|
inputKey = 'input';
|
||||||
|
}
|
||||||
|
if (triggerNode.type === CHAT_TRIGGER_NODE_TYPE) {
|
||||||
|
inputKey = 'chatInput';
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputPayload: INodeExecutionData = {
|
||||||
|
json: {
|
||||||
|
sessionId: sessionId.value,
|
||||||
|
action: 'sendMessage',
|
||||||
|
[inputKey]: message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
const filesMeta = files.map((file) => extractFileMeta(file));
|
||||||
|
const binaryData = await getKeyedFiles(files);
|
||||||
|
|
||||||
|
inputPayload.json.files = filesMeta;
|
||||||
|
inputPayload.binary = binaryData;
|
||||||
|
}
|
||||||
|
const nodeData: ITaskData = {
|
||||||
|
startTime: new Date().getTime(),
|
||||||
|
executionTime: 0,
|
||||||
|
executionStatus: 'success',
|
||||||
|
data: {
|
||||||
|
main: [[inputPayload]],
|
||||||
|
},
|
||||||
|
source: [null],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await onRunChatWorkflow({
|
||||||
|
triggerNode: triggerNode.name,
|
||||||
|
nodeData,
|
||||||
|
source: 'RunData.ManualChatMessage',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response?.executionId) {
|
||||||
|
showError(
|
||||||
|
new Error('It was not possible to start workflow!'),
|
||||||
|
'Workflow could not be started',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
waitForExecution(response.executionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Waits for workflow execution to complete */
|
||||||
|
function waitForExecution(executionId: string) {
|
||||||
|
const waitInterval = setInterval(() => {
|
||||||
|
if (!isLoading.value) {
|
||||||
|
clearInterval(waitInterval);
|
||||||
|
|
||||||
|
const lastNodeExecuted = executionResultData.value?.lastNodeExecuted;
|
||||||
|
|
||||||
|
if (!lastNodeExecuted) return;
|
||||||
|
|
||||||
|
const nodeResponseDataArray =
|
||||||
|
get(executionResultData.value.runData, lastNodeExecuted) ?? [];
|
||||||
|
|
||||||
|
const nodeResponseData = nodeResponseDataArray[nodeResponseDataArray.length - 1];
|
||||||
|
|
||||||
|
let responseMessage: string;
|
||||||
|
|
||||||
|
if (get(nodeResponseData, 'error')) {
|
||||||
|
responseMessage = '[ERROR: ' + get(nodeResponseData, 'error.message') + ']';
|
||||||
|
} else {
|
||||||
|
const responseData = get(nodeResponseData, 'data.main[0][0].json');
|
||||||
|
responseMessage = extractResponseMessage(responseData);
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.value.push({
|
||||||
|
text: responseMessage,
|
||||||
|
sender: 'bot',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
id: executionId ?? uuid(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts response message from workflow output */
|
||||||
|
function extractResponseMessage(responseData?: IDataObject) {
|
||||||
|
if (!responseData || isEmpty(responseData)) {
|
||||||
|
return locale.baseText('chat.window.chat.response.empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paths where the response message might be located
|
||||||
|
const paths = ['output', 'text', 'response.text'];
|
||||||
|
const matchedPath = paths.find((path) => get(responseData, path));
|
||||||
|
|
||||||
|
if (!matchedPath) return JSON.stringify(responseData, null, 2);
|
||||||
|
|
||||||
|
const matchedOutput = get(responseData, matchedPath);
|
||||||
|
if (typeof matchedOutput === 'object') {
|
||||||
|
return '```json\n' + JSON.stringify(matchedOutput, null, 2) + '\n```';
|
||||||
|
}
|
||||||
|
|
||||||
|
return matchedOutput?.toString() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends a message to the chat */
|
||||||
|
async function sendMessage(message: string, files?: File[]) {
|
||||||
|
previousMessageIndex.value = 0;
|
||||||
|
if (message.trim() === '' && (!files || files.length === 0)) {
|
||||||
|
showError(
|
||||||
|
new Error(locale.baseText('chat.window.chat.provideMessage')),
|
||||||
|
locale.baseText('chat.window.chat.emptyChatMessage'),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinnedChatData = usePinnedData(chatTrigger.value);
|
||||||
|
if (pinnedChatData.hasData.value) {
|
||||||
|
const confirmResult = await useMessage().confirm(
|
||||||
|
locale.baseText('chat.window.chat.unpinAndExecute.description'),
|
||||||
|
locale.baseText('chat.window.chat.unpinAndExecute.title'),
|
||||||
|
{
|
||||||
|
confirmButtonText: locale.baseText('chat.window.chat.unpinAndExecute.confirm'),
|
||||||
|
cancelButtonText: locale.baseText('chat.window.chat.unpinAndExecute.cancel'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!(confirmResult === MODAL_CONFIRM)) return;
|
||||||
|
|
||||||
|
pinnedChatData.unsetData('unpin-and-send-chat-message-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMessage: ChatMessage & { sessionId: string } = {
|
||||||
|
text: message,
|
||||||
|
sender: 'user',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
sessionId: sessionId.value,
|
||||||
|
id: uuid(),
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
messages.value.push(newMessage);
|
||||||
|
|
||||||
|
await startWorkflowWithMessage(newMessage.text, files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChatMessages(): ChatMessageText[] {
|
||||||
|
if (!connectedNode.value) return [];
|
||||||
|
|
||||||
|
const connectedMemoryInputs =
|
||||||
|
workflow.value.connectionsByDestinationNode?.[connectedNode.value.name]?.[
|
||||||
|
NodeConnectionType.AiMemory
|
||||||
|
];
|
||||||
|
if (!connectedMemoryInputs) return [];
|
||||||
|
|
||||||
|
const memoryConnection = (connectedMemoryInputs ?? []).find((i) => i.length > 0)?.[0];
|
||||||
|
|
||||||
|
if (!memoryConnection) return [];
|
||||||
|
|
||||||
|
const nodeResultData = getWorkflowResultDataByNodeName(memoryConnection.node);
|
||||||
|
|
||||||
|
const memoryOutputData = (nodeResultData ?? [])
|
||||||
|
.map((data) => get(data, ['data', NodeConnectionType.AiMemory, 0, 0, 'json']) as MemoryOutput)
|
||||||
|
.find((data) => data && data.action === 'saveContext');
|
||||||
|
|
||||||
|
return (memoryOutputData?.chatHistory ?? []).map((message, index) => {
|
||||||
|
return {
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
text: message.kwargs.content,
|
||||||
|
id: `preload__${index}`,
|
||||||
|
sender: last(message.id) === 'HumanMessage' ? 'user' : 'bot',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
previousMessageIndex,
|
||||||
|
sendMessage,
|
||||||
|
extractResponseMessage,
|
||||||
|
waitForExecution,
|
||||||
|
getChatMessages,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,138 @@
|
||||||
|
import type { ComputedRef } from 'vue';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import {
|
||||||
|
CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE,
|
||||||
|
NodeConnectionType,
|
||||||
|
NodeHelpers,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import type { INodeTypeDescription, Workflow, INode, INodeParameters } from 'n8n-workflow';
|
||||||
|
import {
|
||||||
|
AI_CATEGORY_AGENTS,
|
||||||
|
AI_CATEGORY_CHAINS,
|
||||||
|
AI_CODE_NODE_TYPE,
|
||||||
|
AI_SUBCATEGORY,
|
||||||
|
CHAT_TRIGGER_NODE_TYPE,
|
||||||
|
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
||||||
|
} from '@/constants';
|
||||||
|
import type { INodeUi } from '@/Interface';
|
||||||
|
|
||||||
|
export interface ChatTriggerDependencies {
|
||||||
|
getNodeByName: (name: string) => INodeUi | null;
|
||||||
|
getNodeType: (type: string, version: number) => INodeTypeDescription | null;
|
||||||
|
canvasNodes: INodeUi[];
|
||||||
|
workflow: ComputedRef<Workflow>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatTrigger({
|
||||||
|
getNodeByName,
|
||||||
|
getNodeType,
|
||||||
|
canvasNodes,
|
||||||
|
workflow,
|
||||||
|
}: ChatTriggerDependencies) {
|
||||||
|
const chatTriggerName = ref<string | null>(null);
|
||||||
|
const connectedNode = ref<INode | null>(null);
|
||||||
|
|
||||||
|
const chatTriggerNode = computed(() =>
|
||||||
|
chatTriggerName.value ? getNodeByName(chatTriggerName.value) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const allowFileUploads = computed(() => {
|
||||||
|
return (
|
||||||
|
(chatTriggerNode.value?.parameters?.options as INodeParameters)?.allowFileUploads === true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const allowedFilesMimeTypes = computed(() => {
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
chatTriggerNode.value?.parameters?.options as INodeParameters
|
||||||
|
)?.allowedFilesMimeTypes?.toString() ?? ''
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Gets the chat trigger node from the workflow */
|
||||||
|
function setChatTriggerNode() {
|
||||||
|
const triggerNode = canvasNodes.find((node) =>
|
||||||
|
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(node.type),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!triggerNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chatTriggerName.value = triggerNode.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets the connected node after finding the trigger */
|
||||||
|
function setConnectedNode() {
|
||||||
|
const triggerNode = chatTriggerNode.value;
|
||||||
|
|
||||||
|
if (!triggerNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatChildren = workflow.value.getChildNodes(triggerNode.name);
|
||||||
|
|
||||||
|
const chatRootNode = chatChildren
|
||||||
|
.reverse()
|
||||||
|
.map((nodeName: string) => getNodeByName(nodeName))
|
||||||
|
.filter((n): n is INodeUi => n !== null)
|
||||||
|
// Reverse the nodes to match the last node logs first
|
||||||
|
.reverse()
|
||||||
|
.find((storeNode: INodeUi): boolean => {
|
||||||
|
// Skip summarization nodes
|
||||||
|
if (storeNode.type === CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE) return false;
|
||||||
|
const nodeType = getNodeType(storeNode.type, storeNode.typeVersion);
|
||||||
|
|
||||||
|
if (!nodeType) return false;
|
||||||
|
|
||||||
|
// Check if node is an AI agent or chain based on its metadata
|
||||||
|
const isAgent =
|
||||||
|
nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_AGENTS);
|
||||||
|
const isChain =
|
||||||
|
nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_CHAINS);
|
||||||
|
|
||||||
|
// Handle custom AI Langchain Code nodes that could act as chains or agents
|
||||||
|
let isCustomChainOrAgent = false;
|
||||||
|
if (nodeType.name === AI_CODE_NODE_TYPE) {
|
||||||
|
// Get node connection types for inputs and outputs
|
||||||
|
const inputs = NodeHelpers.getNodeInputs(workflow.value, storeNode, nodeType);
|
||||||
|
const inputTypes = NodeHelpers.getConnectionTypes(inputs);
|
||||||
|
|
||||||
|
const outputs = NodeHelpers.getNodeOutputs(workflow.value, storeNode, nodeType);
|
||||||
|
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
|
||||||
|
|
||||||
|
// Validate if node has required AI connection types
|
||||||
|
if (
|
||||||
|
inputTypes.includes(NodeConnectionType.AiLanguageModel) &&
|
||||||
|
inputTypes.includes(NodeConnectionType.Main) &&
|
||||||
|
outputTypes.includes(NodeConnectionType.Main)
|
||||||
|
) {
|
||||||
|
isCustomChainOrAgent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if node is not an AI component
|
||||||
|
if (!isAgent && !isChain && !isCustomChainOrAgent) return false;
|
||||||
|
|
||||||
|
// Check if this node is connected to the trigger node
|
||||||
|
const parentNodes = workflow.value.getParentNodes(storeNode.name);
|
||||||
|
const isChatChild = parentNodes.some(
|
||||||
|
(parentNodeName) => parentNodeName === triggerNode.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||||
|
const result = Boolean(isChatChild && (isAgent || isChain || isCustomChainOrAgent));
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
connectedNode.value = chatRootNode ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowFileUploads,
|
||||||
|
allowedFilesMimeTypes,
|
||||||
|
chatTriggerNode,
|
||||||
|
connectedNode: computed(() => connectedNode.value),
|
||||||
|
setChatTriggerNode,
|
||||||
|
setConnectedNode,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,137 @@
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount, watchEffect } from 'vue';
|
||||||
|
import type { ResizeData } from 'n8n-design-system/components/N8nResizeWrapper/ResizeWrapper.vue';
|
||||||
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
|
import type { IChatResizeStyles } from '../types/chat';
|
||||||
|
import { useStorage } from '@/composables/useStorage';
|
||||||
|
|
||||||
|
const LOCAL_STORAGE_PANEL_HEIGHT = 'N8N_CANVAS_CHAT_HEIGHT';
|
||||||
|
const LOCAL_STORAGE_PANEL_WIDTH = 'N8N_CANVAS_CHAT_WIDTH';
|
||||||
|
|
||||||
|
// Percentage of container width for chat panel constraints
|
||||||
|
const MAX_WIDTH_PERCENTAGE = 0.8;
|
||||||
|
const MIN_WIDTH_PERCENTAGE = 0.3;
|
||||||
|
|
||||||
|
// Percentage of window height for panel constraints
|
||||||
|
const MIN_HEIGHT_PERCENTAGE = 0.3;
|
||||||
|
const MAX_HEIGHT_PERCENTAGE = 0.75;
|
||||||
|
|
||||||
|
export function useResize(container: Ref<HTMLElement | undefined>) {
|
||||||
|
const storage = {
|
||||||
|
height: useStorage(LOCAL_STORAGE_PANEL_HEIGHT),
|
||||||
|
width: useStorage(LOCAL_STORAGE_PANEL_WIDTH),
|
||||||
|
};
|
||||||
|
|
||||||
|
const dimensions = {
|
||||||
|
container: ref(0), // Container width
|
||||||
|
minHeight: ref(0),
|
||||||
|
maxHeight: ref(0),
|
||||||
|
chat: ref(0), // Chat panel width
|
||||||
|
logs: ref(0),
|
||||||
|
height: ref(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Computed styles for root element based on current dimensions */
|
||||||
|
const rootStyles = computed<IChatResizeStyles>(() => ({
|
||||||
|
'--panel-height': `${dimensions.height.value}px`,
|
||||||
|
'--chat-width': `${dimensions.chat.value}px`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const panelToContainerRatio = computed(() => {
|
||||||
|
const chatRatio = dimensions.chat.value / dimensions.container.value;
|
||||||
|
const containerRatio = dimensions.container.value / window.screen.width;
|
||||||
|
return {
|
||||||
|
chat: chatRatio.toFixed(2),
|
||||||
|
logs: (1 - chatRatio).toFixed(2),
|
||||||
|
container: containerRatio.toFixed(2),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constrains height to min/max bounds and updates panel height
|
||||||
|
*/
|
||||||
|
function onResize(newHeight: number) {
|
||||||
|
const { minHeight, maxHeight } = dimensions;
|
||||||
|
dimensions.height.value = Math.min(Math.max(newHeight, minHeight.value), maxHeight.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResizeDebounced(data: ResizeData) {
|
||||||
|
void useDebounce().callDebounced(onResize, { debounceTime: 10, trailing: true }, data.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constrains chat width to min/max percentage of container width
|
||||||
|
*/
|
||||||
|
function onResizeChat(width: number) {
|
||||||
|
const containerWidth = dimensions.container.value;
|
||||||
|
const maxWidth = containerWidth * MAX_WIDTH_PERCENTAGE;
|
||||||
|
const minWidth = containerWidth * MIN_WIDTH_PERCENTAGE;
|
||||||
|
|
||||||
|
dimensions.chat.value = Math.min(Math.max(width, minWidth), maxWidth);
|
||||||
|
dimensions.logs.value = dimensions.container.value - dimensions.chat.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResizeChatDebounced(data: ResizeData) {
|
||||||
|
void useDebounce().callDebounced(
|
||||||
|
onResizeChat,
|
||||||
|
{ debounceTime: 10, trailing: true },
|
||||||
|
data.width,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Initializes dimensions from localStorage if available
|
||||||
|
*/
|
||||||
|
function restorePersistedDimensions() {
|
||||||
|
const persistedHeight = parseInt(storage.height.value ?? '0', 10);
|
||||||
|
const persistedWidth = parseInt(storage.width.value ?? '0', 10);
|
||||||
|
|
||||||
|
if (persistedHeight) onResize(persistedHeight);
|
||||||
|
if (persistedWidth) onResizeChat(persistedWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates container width and height constraints on window resize
|
||||||
|
*/
|
||||||
|
function onWindowResize() {
|
||||||
|
if (!container.value) return;
|
||||||
|
|
||||||
|
// Update container width and adjust chat panel if needed
|
||||||
|
dimensions.container.value = container.value.getBoundingClientRect().width;
|
||||||
|
onResizeChat(dimensions.chat.value);
|
||||||
|
|
||||||
|
// Update height constraints and adjust panel height if needed
|
||||||
|
dimensions.minHeight.value = window.innerHeight * MIN_HEIGHT_PERCENTAGE;
|
||||||
|
dimensions.maxHeight.value = window.innerHeight * MAX_HEIGHT_PERCENTAGE;
|
||||||
|
onResize(dimensions.height.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist dimensions to localStorage when they change
|
||||||
|
watchEffect(() => {
|
||||||
|
const { chat, height } = dimensions;
|
||||||
|
if (chat.value > 0) storage.width.value = chat.value.toString();
|
||||||
|
if (height.value > 0) storage.height.value = height.value.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize dimensions when container is available
|
||||||
|
watchEffect(() => {
|
||||||
|
if (container.value) {
|
||||||
|
onWindowResize();
|
||||||
|
restorePersistedDimensions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Window resize handling
|
||||||
|
onMounted(() => window.addEventListener('resize', onWindowResize));
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('resize', onWindowResize));
|
||||||
|
|
||||||
|
return {
|
||||||
|
height: dimensions.height,
|
||||||
|
chatWidth: dimensions.chat,
|
||||||
|
logsWidth: dimensions.logs,
|
||||||
|
rootStyles,
|
||||||
|
onWindowResize,
|
||||||
|
onResizeDebounced,
|
||||||
|
onResizeChatDebounced,
|
||||||
|
panelToContainerRatio,
|
||||||
|
};
|
||||||
|
}
|
22
packages/editor-ui/src/components/CanvasChat/types/chat.ts
Normal file
22
packages/editor-ui/src/components/CanvasChat/types/chat.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
export interface LangChainMessage {
|
||||||
|
id: string[];
|
||||||
|
kwargs: {
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryOutput {
|
||||||
|
action: string;
|
||||||
|
chatHistory?: LangChainMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IChatMessageResponse {
|
||||||
|
executionId?: string;
|
||||||
|
success: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IChatResizeStyles {
|
||||||
|
'--panel-height': string;
|
||||||
|
'--chat-width': string;
|
||||||
|
}
|
|
@ -178,9 +178,9 @@ export const useLinter = (
|
||||||
message: i18n.baseText('codeNodeEditor.linter.allItems.unavailableProperty'),
|
message: i18n.baseText('codeNodeEditor.linter.allItems.unavailableProperty'),
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
name: 'Remove',
|
name: 'Fix',
|
||||||
apply(view) {
|
apply(view) {
|
||||||
view.dispatch({ changes: { from: start - '.'.length, to: end } });
|
view.dispatch({ changes: { from: start, to: end, insert: 'first()' } });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -559,6 +559,76 @@ export const useLinter = (
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lint for `$(variable)` usage where variable is not a string, in both modes.
|
||||||
|
*
|
||||||
|
* $(nodeName) -> <no autofix>
|
||||||
|
*/
|
||||||
|
const isDollarSignWithVariable = (node: Node) =>
|
||||||
|
node.type === 'CallExpression' &&
|
||||||
|
node.callee.type === 'Identifier' &&
|
||||||
|
node.callee.name === '$' &&
|
||||||
|
node.arguments.length === 1 &&
|
||||||
|
((node.arguments[0].type !== 'Literal' && node.arguments[0].type !== 'TemplateLiteral') ||
|
||||||
|
(node.arguments[0].type === 'TemplateLiteral' && node.arguments[0].expressions.length > 0));
|
||||||
|
|
||||||
|
type TargetCallNode = RangeNode & {
|
||||||
|
callee: { name: string };
|
||||||
|
arguments: Array<{ type: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
walk<TargetCallNode>(ast, isDollarSignWithVariable).forEach((node) => {
|
||||||
|
const [start, end] = getRange(node);
|
||||||
|
|
||||||
|
lintings.push({
|
||||||
|
from: start,
|
||||||
|
to: end,
|
||||||
|
severity: 'warning',
|
||||||
|
message: i18n.baseText('codeNodeEditor.linter.bothModes.dollarSignVariable'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lint for $("myNode").item access in runOnceForAllItems mode
|
||||||
|
*
|
||||||
|
* $("myNode").item -> $("myNode").first()
|
||||||
|
*/
|
||||||
|
if (toValue(mode) === 'runOnceForEachItem') {
|
||||||
|
type DollarItemNode = RangeNode & {
|
||||||
|
property: { name: string; type: string } & RangeNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDollarNodeItemAccess = (node: Node) =>
|
||||||
|
node.type === 'MemberExpression' &&
|
||||||
|
!node.computed &&
|
||||||
|
node.object.type === 'CallExpression' &&
|
||||||
|
node.object.callee.type === 'Identifier' &&
|
||||||
|
node.object.callee.name === '$' &&
|
||||||
|
node.object.arguments.length === 1 &&
|
||||||
|
node.object.arguments[0].type === 'Literal' &&
|
||||||
|
node.property.type === 'Identifier' &&
|
||||||
|
node.property.name === 'item';
|
||||||
|
|
||||||
|
walk<DollarItemNode>(ast, isDollarNodeItemAccess).forEach((node) => {
|
||||||
|
const [start, end] = getRange(node.property);
|
||||||
|
|
||||||
|
lintings.push({
|
||||||
|
from: start,
|
||||||
|
to: end,
|
||||||
|
severity: 'warning',
|
||||||
|
message: i18n.baseText('codeNodeEditor.linter.eachItem.preferFirst'),
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
name: 'Fix',
|
||||||
|
apply(view) {
|
||||||
|
view.dispatch({ changes: { from: start, to: end, insert: 'first()' } });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return lintings;
|
return lintings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -74,7 +74,7 @@ const { segments, readEditorValue, editor, hasFocus, focus } = useExpressionEdit
|
||||||
editorRef: root,
|
editorRef: root,
|
||||||
editorValue,
|
editorValue,
|
||||||
extensions,
|
extensions,
|
||||||
isReadOnly: props.isReadOnly,
|
isReadOnly: computed(() => props.isReadOnly),
|
||||||
autocompleteTelemetry: { enabled: true, parameterPath: props.path },
|
autocompleteTelemetry: { enabled: true, parameterPath: props.path },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -110,7 +110,15 @@ defineExpose({ editor });
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
:global(.cm-content) {
|
.editor {
|
||||||
border-radius: var(--border-radius-base);
|
:global(.cm-content) {
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
&[aria-readonly='true'] {
|
||||||
|
--disabled-fill: var(--color-background-medium);
|
||||||
|
background-color: var(--disabled-fill, var(--color-background-light));
|
||||||
|
color: var(--disabled-color, var(--color-text-base));
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -56,6 +56,7 @@ const extensions = computed(() => [
|
||||||
infoBoxTooltips(),
|
infoBoxTooltips(),
|
||||||
]);
|
]);
|
||||||
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
|
const editorValue = ref<string>(removeExpressionPrefix(props.modelValue));
|
||||||
|
|
||||||
const {
|
const {
|
||||||
editor: editorRef,
|
editor: editorRef,
|
||||||
segments,
|
segments,
|
||||||
|
@ -68,7 +69,7 @@ const {
|
||||||
editorRef: root,
|
editorRef: root,
|
||||||
editorValue,
|
editorValue,
|
||||||
extensions,
|
extensions,
|
||||||
isReadOnly: props.isReadOnly,
|
isReadOnly: computed(() => props.isReadOnly),
|
||||||
autocompleteTelemetry: { enabled: true, parameterPath: props.path },
|
autocompleteTelemetry: { enabled: true, parameterPath: props.path },
|
||||||
additionalData: props.additionalData,
|
additionalData: props.additionalData,
|
||||||
});
|
});
|
||||||
|
@ -133,6 +134,17 @@ defineExpose({
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
:deep(.cm-content) {
|
:deep(.cm-content) {
|
||||||
|
--disabled-fill: var(--color-background-medium);
|
||||||
padding-left: var(--spacing-2xs);
|
padding-left: var(--spacing-2xs);
|
||||||
|
|
||||||
|
&[aria-readonly='true'] {
|
||||||
|
background-color: var(--disabled-fill, var(--color-background-light));
|
||||||
|
border-color: var(--disabled-border, var(--border-color-base));
|
||||||
|
color: var(--disabled-color, var(--color-text-base));
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -16,12 +16,14 @@ interface InlineExpressionEditorOutputProps {
|
||||||
editorState?: EditorState;
|
editorState?: EditorState;
|
||||||
selection?: SelectionRange;
|
selection?: SelectionRange;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
|
isReadOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<InlineExpressionEditorOutputProps>(), {
|
withDefaults(defineProps<InlineExpressionEditorOutputProps>(), {
|
||||||
visible: false,
|
visible: false,
|
||||||
editorState: undefined,
|
editorState: undefined,
|
||||||
selection: undefined,
|
selection: undefined,
|
||||||
|
isReadOnly: false,
|
||||||
unresolvedExpression: undefined,
|
unresolvedExpression: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -51,7 +53,7 @@ onBeforeUnmount(() => {
|
||||||
>
|
>
|
||||||
</ExpressionOutput>
|
</ExpressionOutput>
|
||||||
</n8n-text>
|
</n8n-text>
|
||||||
<div :class="$style.footer">
|
<div :class="$style.footer" v-if="!isReadOnly">
|
||||||
<InlineExpressionTip
|
<InlineExpressionTip
|
||||||
:editor-state="editorState"
|
:editor-state="editorState"
|
||||||
:selection="selection"
|
:selection="selection"
|
||||||
|
|
|
@ -98,13 +98,6 @@ const mainMenuItems = computed(() => [
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
route: { to: { name: VIEWS.VARIABLES } },
|
route: { to: { name: VIEWS.VARIABLES } },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'executions',
|
|
||||||
icon: 'tasks',
|
|
||||||
label: locale.baseText('mainSidebar.executions'),
|
|
||||||
position: 'bottom',
|
|
||||||
route: { to: { name: VIEWS.EXECUTIONS } },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'help',
|
id: 'help',
|
||||||
icon: 'question',
|
icon: 'question',
|
||||||
|
|
|
@ -18,7 +18,6 @@ import {
|
||||||
NEW_ASSISTANT_SESSION_MODAL,
|
NEW_ASSISTANT_SESSION_MODAL,
|
||||||
VERSIONS_MODAL_KEY,
|
VERSIONS_MODAL_KEY,
|
||||||
WORKFLOW_ACTIVE_MODAL_KEY,
|
WORKFLOW_ACTIVE_MODAL_KEY,
|
||||||
WORKFLOW_LM_CHAT_MODAL_KEY,
|
|
||||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||||
WORKFLOW_SHARE_MODAL_KEY,
|
WORKFLOW_SHARE_MODAL_KEY,
|
||||||
IMPORT_CURL_MODAL_KEY,
|
IMPORT_CURL_MODAL_KEY,
|
||||||
|
@ -51,7 +50,6 @@ import WorkflowTagsManager from '@/components/TagsManager/WorkflowTagsManager.vu
|
||||||
import AnnotationTagsManager from '@/components/TagsManager/AnnotationTagsManager.ee.vue';
|
import AnnotationTagsManager from '@/components/TagsManager/AnnotationTagsManager.ee.vue';
|
||||||
import UpdatesPanel from '@/components/UpdatesPanel.vue';
|
import UpdatesPanel from '@/components/UpdatesPanel.vue';
|
||||||
import NpsSurvey from '@/components/NpsSurvey.vue';
|
import NpsSurvey from '@/components/NpsSurvey.vue';
|
||||||
import WorkflowLMChat from '@/components/WorkflowLMChat/WorkflowLMChat.vue';
|
|
||||||
import WorkflowSettings from '@/components/WorkflowSettings.vue';
|
import WorkflowSettings from '@/components/WorkflowSettings.vue';
|
||||||
import DeleteUserModal from '@/components/DeleteUserModal.vue';
|
import DeleteUserModal from '@/components/DeleteUserModal.vue';
|
||||||
import ActivationModal from '@/components/ActivationModal.vue';
|
import ActivationModal from '@/components/ActivationModal.vue';
|
||||||
|
@ -125,10 +123,6 @@ import type { EventBus } from 'n8n-design-system';
|
||||||
</template>
|
</template>
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
||||||
<ModalRoot :name="WORKFLOW_LM_CHAT_MODAL_KEY">
|
|
||||||
<WorkflowLMChat />
|
|
||||||
</ModalRoot>
|
|
||||||
|
|
||||||
<ModalRoot :name="WORKFLOW_SETTINGS_MODAL_KEY">
|
<ModalRoot :name="WORKFLOW_SETTINGS_MODAL_KEY">
|
||||||
<WorkflowSettings />
|
<WorkflowSettings />
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
|
@ -6,7 +6,7 @@ import {
|
||||||
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
|
||||||
FORM_TRIGGER_NODE_TYPE,
|
FORM_TRIGGER_NODE_TYPE,
|
||||||
MANUAL_TRIGGER_NODE_TYPE,
|
MANUAL_TRIGGER_NODE_TYPE,
|
||||||
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
CHAT_TRIGGER_NODE_TYPE,
|
||||||
SCHEDULE_TRIGGER_NODE_TYPE,
|
SCHEDULE_TRIGGER_NODE_TYPE,
|
||||||
REGULAR_NODE_CREATOR_VIEW,
|
REGULAR_NODE_CREATOR_VIEW,
|
||||||
TRANSFORM_DATA_SUBCATEGORY,
|
TRANSFORM_DATA_SUBCATEGORY,
|
||||||
|
@ -405,14 +405,14 @@ export function TriggerView() {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
key: CHAT_TRIGGER_NODE_TYPE,
|
||||||
type: 'node',
|
type: 'node',
|
||||||
category: [CORE_NODES_CATEGORY],
|
category: [CORE_NODES_CATEGORY],
|
||||||
properties: {
|
properties: {
|
||||||
group: [],
|
group: [],
|
||||||
name: MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
name: CHAT_TRIGGER_NODE_TYPE,
|
||||||
displayName: i18n.baseText('nodeCreator.triggerHelperPanel.manualChatTriggerDisplayName'),
|
displayName: i18n.baseText('nodeCreator.triggerHelperPanel.chatTriggerDisplayName'),
|
||||||
description: i18n.baseText('nodeCreator.triggerHelperPanel.manualChatTriggerDescription'),
|
description: i18n.baseText('nodeCreator.triggerHelperPanel.chatTriggerDescription'),
|
||||||
icon: 'fa:comments',
|
icon: 'fa:comments',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -22,11 +22,7 @@ import type {
|
||||||
IUpdateInformation,
|
IUpdateInformation,
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
|
|
||||||
import {
|
import { COMMUNITY_NODES_INSTALLATION_DOCS_URL, CUSTOM_NODES_DOCS_URL } from '@/constants';
|
||||||
COMMUNITY_NODES_INSTALLATION_DOCS_URL,
|
|
||||||
CUSTOM_NODES_DOCS_URL,
|
|
||||||
SHOULD_CLEAR_NODE_OUTPUTS,
|
|
||||||
} from '@/constants';
|
|
||||||
|
|
||||||
import NodeTitle from '@/components/NodeTitle.vue';
|
import NodeTitle from '@/components/NodeTitle.vue';
|
||||||
import ParameterInputList from '@/components/ParameterInputList.vue';
|
import ParameterInputList from '@/components/ParameterInputList.vue';
|
||||||
|
@ -47,11 +43,11 @@ import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
import type { EventBus } from 'n8n-design-system';
|
import type { EventBus } from 'n8n-design-system';
|
||||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
import { useToast } from '@/composables/useToast';
|
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { importCurlEventBus, ndvEventBus } from '@/event-bus';
|
import { importCurlEventBus, ndvEventBus } from '@/event-bus';
|
||||||
import { ProjectTypes } from '@/types/projects.types';
|
import { ProjectTypes } from '@/types/projects.types';
|
||||||
|
import { updateDynamicConnections } from '@/utils/nodeSettingsUtils';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
@ -94,7 +90,6 @@ const telemetry = useTelemetry();
|
||||||
const nodeHelpers = useNodeHelpers();
|
const nodeHelpers = useNodeHelpers();
|
||||||
const externalHooks = useExternalHooks();
|
const externalHooks = useExternalHooks();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const { showMessage } = useToast();
|
|
||||||
|
|
||||||
const nodeValid = ref(true);
|
const nodeValid = ref(true);
|
||||||
const openPanel = ref<'params' | 'settings'>('params');
|
const openPanel = ref<'params' | 'settings'>('params');
|
||||||
|
@ -483,20 +478,6 @@ const valueChanged = (parameterData: IUpdateInformation) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
parameterData.type &&
|
|
||||||
workflowsStore.nodeHasOutputConnection(_node.name) &&
|
|
||||||
SHOULD_CLEAR_NODE_OUTPUTS[nodeType.name]?.eventTypes.includes(parameterData.type) &&
|
|
||||||
SHOULD_CLEAR_NODE_OUTPUTS[nodeType.name]?.parameterPaths.includes(parameterData.name)
|
|
||||||
) {
|
|
||||||
workflowsStore.removeAllNodeConnection(_node, { preserveInputConnections: true });
|
|
||||||
showMessage({
|
|
||||||
type: 'warning',
|
|
||||||
title: i18n.baseText('nodeSettings.outputCleared.title'),
|
|
||||||
message: i18n.baseText('nodeSettings.outputCleared.message'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get only the parameters which are different to the defaults
|
// Get only the parameters which are different to the defaults
|
||||||
let nodeParameters = NodeHelpers.getNodeParameters(
|
let nodeParameters = NodeHelpers.getNodeParameters(
|
||||||
nodeType.properties,
|
nodeType.properties,
|
||||||
|
@ -566,6 +547,14 @@ const valueChanged = (parameterData: IUpdateInformation) => {
|
||||||
value: nodeParameters,
|
value: nodeParameters,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const connections = workflowsStore.allConnections;
|
||||||
|
|
||||||
|
const updatedConnections = updateDynamicConnections(_node, connections, parameterData);
|
||||||
|
|
||||||
|
if (updatedConnections) {
|
||||||
|
workflowsStore.setConnections(updatedConnections, true);
|
||||||
|
}
|
||||||
|
|
||||||
workflowsStore.setNodeParameters(updateInformation);
|
workflowsStore.setNodeParameters(updateInformation);
|
||||||
|
|
||||||
void externalHooks.run('nodeSettings.valueChanged', {
|
void externalHooks.run('nodeSettings.valueChanged', {
|
||||||
|
|
|
@ -983,7 +983,7 @@ watch(remoteParameterOptionsLoading, () => {
|
||||||
|
|
||||||
// Focus input field when changing from fixed value to expression
|
// Focus input field when changing from fixed value to expression
|
||||||
watch(isModelValueExpression, async (isExpression, wasExpression) => {
|
watch(isModelValueExpression, async (isExpression, wasExpression) => {
|
||||||
if (isExpression && !wasExpression) {
|
if (!props.isReadOnly && isExpression && !wasExpression) {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
inputField.value?.focus();
|
inputField.value?.focus();
|
||||||
}
|
}
|
||||||
|
@ -1497,7 +1497,7 @@ onUpdated(async () => {
|
||||||
:disabled="isReadOnly"
|
:disabled="isReadOnly"
|
||||||
@update:model-value="valueChanged"
|
@update:model-value="valueChanged"
|
||||||
/>
|
/>
|
||||||
<div v-if="showDragnDropTip" :class="$style.tip">
|
<div v-if="!isReadOnly && showDragnDropTip" :class="$style.tip">
|
||||||
<InlineExpressionTip />
|
<InlineExpressionTip />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import type { IUpdateInformation } from '@/Interface';
|
import type { IUpdateInformation } from '@/Interface';
|
||||||
|
|
||||||
import DraggableTarget from '@/components/DraggableTarget.vue';
|
import DraggableTarget from '@/components/DraggableTarget.vue';
|
||||||
|
@ -189,6 +189,16 @@ function onDrop(newParamValue: string) {
|
||||||
forceShowExpression.value = false;
|
forceShowExpression.value = false;
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When switching to read-only mode, reset the value to the default value
|
||||||
|
watch(
|
||||||
|
() => props.isReadOnly,
|
||||||
|
(isReadOnly) => {
|
||||||
|
if (isReadOnly) {
|
||||||
|
valueChanged({ name: props.path, value: props.parameter.default });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -322,7 +322,10 @@ function mustHideDuringCustomApiCall(
|
||||||
return !MUST_REMAIN_VISIBLE.includes(parameter.name);
|
return !MUST_REMAIN_VISIBLE.includes(parameter.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayNodeParameter(parameter: INodeProperties): boolean {
|
function displayNodeParameter(
|
||||||
|
parameter: INodeProperties,
|
||||||
|
displayKey: 'displayOptions' | 'disabledOptions' = 'displayOptions',
|
||||||
|
): boolean {
|
||||||
if (parameter.type === 'hidden') {
|
if (parameter.type === 'hidden') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -343,7 +346,7 @@ function displayNodeParameter(parameter: INodeProperties): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parameter.displayOptions === undefined) {
|
if (parameter[displayKey] === undefined) {
|
||||||
// If it is not defined no need to do a proper check
|
// If it is not defined no need to do a proper check
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -402,13 +405,19 @@ function displayNodeParameter(parameter: INodeProperties): boolean {
|
||||||
if (props.path) {
|
if (props.path) {
|
||||||
rawValues = deepCopy(props.nodeValues);
|
rawValues = deepCopy(props.nodeValues);
|
||||||
set(rawValues, props.path, nodeValues);
|
set(rawValues, props.path, nodeValues);
|
||||||
return nodeHelpers.displayParameter(rawValues, parameter, props.path, node.value);
|
return nodeHelpers.displayParameter(rawValues, parameter, props.path, node.value, displayKey);
|
||||||
} else {
|
} else {
|
||||||
return nodeHelpers.displayParameter(nodeValues, parameter, '', node.value);
|
return nodeHelpers.displayParameter(nodeValues, parameter, '', node.value, displayKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodeHelpers.displayParameter(props.nodeValues, parameter, props.path, node.value);
|
return nodeHelpers.displayParameter(
|
||||||
|
props.nodeValues,
|
||||||
|
parameter,
|
||||||
|
props.path,
|
||||||
|
node.value,
|
||||||
|
displayKey,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function valueChanged(parameterData: IUpdateInformation): void {
|
function valueChanged(parameterData: IUpdateInformation): void {
|
||||||
|
@ -620,7 +629,10 @@ function getParameterValue<T extends NodeParameterValueType = NodeParameterValue
|
||||||
:value="getParameterValue(parameter.name)"
|
:value="getParameterValue(parameter.name)"
|
||||||
:display-options="shouldShowOptions(parameter)"
|
:display-options="shouldShowOptions(parameter)"
|
||||||
:path="getPath(parameter.name)"
|
:path="getPath(parameter.name)"
|
||||||
:is-read-only="isReadOnly"
|
:is-read-only="
|
||||||
|
isReadOnly ||
|
||||||
|
(parameter.disabledOptions && displayNodeParameter(parameter, 'disabledOptions'))
|
||||||
|
"
|
||||||
:hide-label="false"
|
:hide-label="false"
|
||||||
:node-values="nodeValues"
|
:node-values="nodeValues"
|
||||||
@update="valueChanged"
|
@update="valueChanged"
|
||||||
|
|
|
@ -39,7 +39,7 @@ const isDefault = computed(() => props.parameter.default === props.value);
|
||||||
const isValueAnExpression = computed(() => isValueExpression(props.parameter, props.value));
|
const isValueAnExpression = computed(() => isValueExpression(props.parameter, props.value));
|
||||||
const isHtmlEditor = computed(() => getArgument('editor') === 'htmlEditor');
|
const isHtmlEditor = computed(() => getArgument('editor') === 'htmlEditor');
|
||||||
const shouldShowExpressionSelector = computed(
|
const shouldShowExpressionSelector = computed(
|
||||||
() => !props.parameter.noDataExpression && props.showExpressionSelector,
|
() => !props.parameter.noDataExpression && props.showExpressionSelector && !props.isReadOnly,
|
||||||
);
|
);
|
||||||
const shouldShowOptions = computed(() => {
|
const shouldShowOptions = computed(() => {
|
||||||
if (props.isReadOnly) {
|
if (props.isReadOnly) {
|
||||||
|
|
120
packages/editor-ui/src/components/Projects/ProjectHeader.test.ts
Normal file
120
packages/editor-ui/src/components/Projects/ProjectHeader.test.ts
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
|
import { createTestProject } from '@/__tests__/data/projects';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||||
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
import type { Project } from '@/types/projects.types';
|
||||||
|
import { ProjectTypes } from '@/types/projects.types';
|
||||||
|
|
||||||
|
vi.mock('vue-router', async () => {
|
||||||
|
const actual = await vi.importActual('vue-router');
|
||||||
|
const params = {};
|
||||||
|
const location = {};
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useRoute: () => ({
|
||||||
|
params,
|
||||||
|
location,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectTabsSpy = vi.fn().mockReturnValue({
|
||||||
|
render: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderComponent = createComponentRenderer(ProjectHeader, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
ProjectTabs: projectTabsSpy,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let route: ReturnType<typeof useRoute>;
|
||||||
|
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
||||||
|
|
||||||
|
describe('ProjectHeader', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
createTestingPinia();
|
||||||
|
route = useRoute();
|
||||||
|
projectsStore = mockedStore(useProjectsStore);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the correct icon', async () => {
|
||||||
|
const { container, rerender } = renderComponent();
|
||||||
|
|
||||||
|
expect(container.querySelector('.fa-home')).toBeVisible();
|
||||||
|
|
||||||
|
projectsStore.currentProject = { type: ProjectTypes.Personal } as Project;
|
||||||
|
await rerender({});
|
||||||
|
expect(container.querySelector('.fa-user')).toBeVisible();
|
||||||
|
|
||||||
|
const projectName = 'My Project';
|
||||||
|
projectsStore.currentProject = { name: projectName } as Project;
|
||||||
|
await rerender({});
|
||||||
|
expect(container.querySelector('.fa-layer-group')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the correct title', async () => {
|
||||||
|
const { getByText, rerender } = renderComponent();
|
||||||
|
|
||||||
|
expect(getByText('Home')).toBeVisible();
|
||||||
|
|
||||||
|
projectsStore.currentProject = { type: ProjectTypes.Personal } as Project;
|
||||||
|
await rerender({});
|
||||||
|
expect(getByText('Personal')).toBeVisible();
|
||||||
|
|
||||||
|
const projectName = 'My Project';
|
||||||
|
projectsStore.currentProject = { name: projectName } as Project;
|
||||||
|
await rerender({});
|
||||||
|
expect(getByText(projectName)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render ProjectTabs Settings if project is team project and user has update scope', () => {
|
||||||
|
route.params.projectId = '123';
|
||||||
|
projectsStore.currentProject = createTestProject({ scopes: ['project:update'] });
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(projectTabsSpy).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
'show-settings': true,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render ProjectTabs without Settings if no project update permission', () => {
|
||||||
|
route.params.projectId = '123';
|
||||||
|
projectsStore.currentProject = createTestProject({ scopes: ['project:read'] });
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(projectTabsSpy).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
'show-settings': false,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render ProjectTabs without Settings if project is not team project', () => {
|
||||||
|
route.params.projectId = '123';
|
||||||
|
projectsStore.currentProject = createTestProject(
|
||||||
|
createTestProject({ type: ProjectTypes.Personal, scopes: ['project:update'] }),
|
||||||
|
);
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(projectTabsSpy).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
'show-settings': false,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
84
packages/editor-ui/src/components/Projects/ProjectHeader.vue
Normal file
84
packages/editor-ui/src/components/Projects/ProjectHeader.vue
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { ProjectTypes } from '@/types/projects.types';
|
||||||
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||||
|
import { getResourcePermissions } from '@/permissions';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const i18n = useI18n();
|
||||||
|
const projectsStore = useProjectsStore();
|
||||||
|
|
||||||
|
const headerIcon = computed(() => {
|
||||||
|
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
|
||||||
|
return 'user';
|
||||||
|
} else if (projectsStore.currentProject?.name) {
|
||||||
|
return 'layer-group';
|
||||||
|
} else {
|
||||||
|
return 'home';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectName = computed(() => {
|
||||||
|
if (!projectsStore.currentProject) {
|
||||||
|
return i18n.baseText('projects.menu.home');
|
||||||
|
} else if (projectsStore.currentProject.type === ProjectTypes.Personal) {
|
||||||
|
return i18n.baseText('projects.menu.personal');
|
||||||
|
} else {
|
||||||
|
return projectsStore.currentProject.name;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectPermissions = computed(
|
||||||
|
() => getResourcePermissions(projectsStore.currentProject?.scopes).project,
|
||||||
|
);
|
||||||
|
|
||||||
|
const showSettings = computed(
|
||||||
|
() =>
|
||||||
|
!!route?.params?.projectId &&
|
||||||
|
!!projectPermissions.value.update &&
|
||||||
|
projectsStore.currentProject?.type === ProjectTypes.Team,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div :class="[$style.projectHeader]">
|
||||||
|
<div :class="[$style.icon]">
|
||||||
|
<N8nIcon :icon="headerIcon" color="text-light"></N8nIcon>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<N8nHeading bold tag="h2" size="xlarge">{{ projectName }}</N8nHeading>
|
||||||
|
<N8nText v-if="$slots.subtitle" size="small" color="text-light">
|
||||||
|
<slot name="subtitle" />
|
||||||
|
</N8nText>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.actions" :class="[$style.actions]">
|
||||||
|
<slot name="actions"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ProjectTabs :show-settings="showSettings" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.projectHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-bottom: var(--spacing-m);
|
||||||
|
min-height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
border: 1px solid var(--color-foreground-light);
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,22 +1,14 @@
|
||||||
import { createPinia, setActivePinia } from 'pinia';
|
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import { createTestProject } from '@/__tests__/data/projects';
|
|
||||||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
|
||||||
import { ProjectTypes } from '@/types/projects.types';
|
|
||||||
|
|
||||||
vi.mock('vue-router', () => {
|
vi.mock('vue-router', async () => {
|
||||||
|
const actual = await vi.importActual('vue-router');
|
||||||
const params = {};
|
const params = {};
|
||||||
const push = vi.fn();
|
|
||||||
return {
|
return {
|
||||||
|
...actual,
|
||||||
useRoute: () => ({
|
useRoute: () => ({
|
||||||
params,
|
params,
|
||||||
}),
|
}),
|
||||||
useRouter: () => ({
|
|
||||||
push,
|
|
||||||
}),
|
|
||||||
RouterLink: vi.fn(),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const renderComponent = createComponentRenderer(ProjectTabs, {
|
const renderComponent = createComponentRenderer(ProjectTabs, {
|
||||||
|
@ -29,54 +21,22 @@ const renderComponent = createComponentRenderer(ProjectTabs, {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let route: ReturnType<typeof useRoute>;
|
|
||||||
let projectsStore: ReturnType<typeof useProjectsStore>;
|
|
||||||
|
|
||||||
describe('ProjectTabs', () => {
|
describe('ProjectTabs', () => {
|
||||||
beforeEach(() => {
|
|
||||||
const pinia = createPinia();
|
|
||||||
setActivePinia(pinia);
|
|
||||||
route = useRoute();
|
|
||||||
projectsStore = useProjectsStore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render home tabs', async () => {
|
it('should render home tabs', async () => {
|
||||||
const { getByText, queryByText } = renderComponent();
|
const { getByText, queryByText } = renderComponent();
|
||||||
|
|
||||||
expect(getByText('Workflows')).toBeInTheDocument();
|
expect(getByText('Workflows')).toBeInTheDocument();
|
||||||
expect(getByText('Credentials')).toBeInTheDocument();
|
expect(getByText('Credentials')).toBeInTheDocument();
|
||||||
|
expect(getByText('Executions')).toBeInTheDocument();
|
||||||
expect(queryByText('Project settings')).not.toBeInTheDocument();
|
expect(queryByText('Project settings')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render project tab Settings if user has permissions and current project is of type Team', () => {
|
it('should render project tab Settings', () => {
|
||||||
route.params.projectId = '123';
|
const { getByText } = renderComponent({ props: { showSettings: true } });
|
||||||
projectsStore.setCurrentProject(createTestProject({ scopes: ['project:update'] }));
|
|
||||||
const { getByText } = renderComponent();
|
|
||||||
|
|
||||||
expect(getByText('Workflows')).toBeInTheDocument();
|
expect(getByText('Workflows')).toBeInTheDocument();
|
||||||
expect(getByText('Credentials')).toBeInTheDocument();
|
expect(getByText('Credentials')).toBeInTheDocument();
|
||||||
|
expect(getByText('Executions')).toBeInTheDocument();
|
||||||
expect(getByText('Project settings')).toBeInTheDocument();
|
expect(getByText('Project settings')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render project tabs without Settings if no permission', () => {
|
|
||||||
route.params.projectId = '123';
|
|
||||||
projectsStore.setCurrentProject(createTestProject({ scopes: ['project:read'] }));
|
|
||||||
const { queryByText, getByText } = renderComponent();
|
|
||||||
|
|
||||||
expect(getByText('Workflows')).toBeInTheDocument();
|
|
||||||
expect(getByText('Credentials')).toBeInTheDocument();
|
|
||||||
expect(queryByText('Project settings')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render project tabs without Settings if project is the Personal project', () => {
|
|
||||||
route.params.projectId = '123';
|
|
||||||
projectsStore.setCurrentProject(
|
|
||||||
createTestProject({ type: ProjectTypes.Personal, scopes: ['project:update'] }),
|
|
||||||
);
|
|
||||||
const { queryByText, getByText } = renderComponent();
|
|
||||||
|
|
||||||
expect(getByText('Workflows')).toBeInTheDocument();
|
|
||||||
expect(getByText('Credentials')).toBeInTheDocument();
|
|
||||||
expect(queryByText('Project settings')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,19 +4,15 @@ import type { RouteRecordName } from 'vue-router';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
|
||||||
import { getResourcePermissions } from '@/permissions';
|
const props = defineProps<{
|
||||||
import { ProjectTypes } from '@/types/projects.types';
|
showSettings?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
const locale = useI18n();
|
const locale = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const projectsStore = useProjectsStore();
|
|
||||||
|
|
||||||
const selectedTab = ref<RouteRecordName | null | undefined>('');
|
const selectedTab = ref<RouteRecordName | null | undefined>('');
|
||||||
const projectPermissions = computed(
|
|
||||||
() => getResourcePermissions(projectsStore.currentProject?.scopes).project,
|
|
||||||
);
|
|
||||||
const options = computed(() => {
|
const options = computed(() => {
|
||||||
const projectId = route?.params?.projectId;
|
const projectId = route?.params?.projectId;
|
||||||
const to = projectId
|
const to = projectId
|
||||||
|
@ -29,6 +25,10 @@ const options = computed(() => {
|
||||||
name: VIEWS.PROJECTS_CREDENTIALS,
|
name: VIEWS.PROJECTS_CREDENTIALS,
|
||||||
params: { projectId },
|
params: { projectId },
|
||||||
},
|
},
|
||||||
|
executions: {
|
||||||
|
name: VIEWS.PROJECTS_EXECUTIONS,
|
||||||
|
params: { projectId },
|
||||||
|
},
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
workflows: {
|
workflows: {
|
||||||
|
@ -37,6 +37,9 @@ const options = computed(() => {
|
||||||
credentials: {
|
credentials: {
|
||||||
name: VIEWS.CREDENTIALS,
|
name: VIEWS.CREDENTIALS,
|
||||||
},
|
},
|
||||||
|
executions: {
|
||||||
|
name: VIEWS.EXECUTIONS,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
|
@ -49,13 +52,14 @@ const options = computed(() => {
|
||||||
value: to.credentials.name,
|
value: to.credentials.name,
|
||||||
to: to.credentials,
|
to: to.credentials,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: locale.baseText('mainSidebar.executions'),
|
||||||
|
value: to.executions.name,
|
||||||
|
to: to.executions,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (
|
if (props.showSettings) {
|
||||||
projectId &&
|
|
||||||
projectPermissions.value.update &&
|
|
||||||
projectsStore.currentProject?.type === ProjectTypes.Team
|
|
||||||
) {
|
|
||||||
tabs.push({
|
tabs.push({
|
||||||
label: locale.baseText('projects.settings'),
|
label: locale.baseText('projects.settings'),
|
||||||
value: VIEWS.PROJECT_SETTINGS,
|
value: VIEWS.PROJECT_SETTINGS,
|
||||||
|
|
|
@ -24,7 +24,7 @@ const contentParsers = useAiContentParsers();
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||||
const isExpanded = ref(getInitialExpandedState());
|
const isExpanded = ref(getInitialExpandedState());
|
||||||
const isShowRaw = ref(false);
|
const renderType = ref<'rendered' | 'json'>('rendered');
|
||||||
const contentParsed = ref(false);
|
const contentParsed = ref(false);
|
||||||
const parsedRun = ref(undefined as ParsedAiContent | undefined);
|
const parsedRun = ref(undefined as ParsedAiContent | undefined);
|
||||||
function getInitialExpandedState() {
|
function getInitialExpandedState() {
|
||||||
|
@ -134,6 +134,10 @@ function onCopyToClipboard(content: IDataObject | IDataObject[]) {
|
||||||
} catch (err) {}
|
} catch (err) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onRenderTypeChange(value: 'rendered' | 'json') {
|
||||||
|
renderType.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
parsedRun.value = parseAiRunData(props.runData);
|
parsedRun.value = parseAiRunData(props.runData);
|
||||||
if (parsedRun.value) {
|
if (parsedRun.value) {
|
||||||
|
@ -146,16 +150,19 @@ onMounted(() => {
|
||||||
<div :class="$style.block">
|
<div :class="$style.block">
|
||||||
<header :class="$style.blockHeader" @click="onBlockHeaderClick">
|
<header :class="$style.blockHeader" @click="onBlockHeaderClick">
|
||||||
<button :class="$style.blockToggle">
|
<button :class="$style.blockToggle">
|
||||||
<font-awesome-icon :icon="isExpanded ? 'angle-down' : 'angle-up'" size="lg" />
|
<font-awesome-icon :icon="isExpanded ? 'angle-down' : 'angle-right'" size="lg" />
|
||||||
</button>
|
</button>
|
||||||
<p :class="$style.blockTitle">{{ capitalize(runData.inOut) }}</p>
|
<p :class="$style.blockTitle">{{ capitalize(runData.inOut) }}</p>
|
||||||
<!-- @click.stop to prevent event from bubbling to blockHeader and toggling expanded state when clicking on rawSwitch -->
|
<n8n-radio-buttons
|
||||||
<el-switch
|
v-if="contentParsed && !error && isExpanded"
|
||||||
v-if="contentParsed && !error"
|
size="small"
|
||||||
v-model="isShowRaw"
|
:model-value="renderType"
|
||||||
:class="$style.rawSwitch"
|
:class="$style.rawSwitch"
|
||||||
active-text="RAW JSON"
|
:options="[
|
||||||
@click.stop
|
{ label: 'Rendered', value: 'rendered' },
|
||||||
|
{ label: 'JSON', value: 'json' },
|
||||||
|
]"
|
||||||
|
@update:model-value="onRenderTypeChange"
|
||||||
/>
|
/>
|
||||||
</header>
|
</header>
|
||||||
<main
|
<main
|
||||||
|
@ -172,7 +179,7 @@ onMounted(() => {
|
||||||
:class="$style.contentText"
|
:class="$style.contentText"
|
||||||
:data-content-type="parsedContent?.type"
|
:data-content-type="parsedContent?.type"
|
||||||
>
|
>
|
||||||
<template v-if="parsedContent && !isShowRaw">
|
<template v-if="parsedContent && renderType === 'rendered'">
|
||||||
<template v-if="parsedContent.type === 'json'">
|
<template v-if="parsedContent.type === 'json'">
|
||||||
<VueMarkdown
|
<VueMarkdown
|
||||||
:source="jsonToMarkdown(parsedContent.data as JsonMarkdown)"
|
:source="jsonToMarkdown(parsedContent.data as JsonMarkdown)"
|
||||||
|
@ -226,17 +233,17 @@ onMounted(() => {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: var(--font-size-xl);
|
font-size: var(--font-size-l);
|
||||||
line-height: var(--font-line-height-xloose);
|
line-height: var(--font-line-height-xloose);
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: var(--font-size-l);
|
font-size: var(--font-size-m);
|
||||||
line-height: var(--font-line-height-loose);
|
line-height: var(--font-line-height-loose);
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: var(--font-size-m);
|
font-size: var(--font-size-s);
|
||||||
line-height: var(--font-line-height-regular);
|
line-height: var(--font-line-height-regular);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -252,17 +259,16 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
.contentText {
|
.contentText {
|
||||||
padding-top: var(--spacing-s);
|
padding-top: var(--spacing-s);
|
||||||
font-size: var(--font-size-xs);
|
padding-left: var(--spacing-m);
|
||||||
// max-height: 100%;
|
font-size: var(--font-size-s);
|
||||||
}
|
}
|
||||||
.block {
|
.block {
|
||||||
border: 1px solid var(--color-foreground-base);
|
padding: 0 0 var(--spacing-2xs) var(--spacing-2xs);
|
||||||
background: var(--color-background-xlight);
|
background: var(--color-foreground-light);
|
||||||
padding: var(--spacing-xs);
|
margin-top: var(--spacing-xl);
|
||||||
border-radius: 4px;
|
border-radius: var(--border-radius-base);
|
||||||
margin-bottom: var(--spacing-2xs);
|
|
||||||
}
|
}
|
||||||
.blockContent {
|
:root .blockContent {
|
||||||
height: 0;
|
height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
@ -271,14 +277,17 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.runText {
|
.runText {
|
||||||
line-height: var(--font-line-height-regular);
|
line-height: var(--font-line-height-xloose);
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
.rawSwitch {
|
.rawSwitch {
|
||||||
|
opacity: 0;
|
||||||
|
height: fit-content;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
margin-right: var(--spacing-2xs);
|
||||||
|
|
||||||
& * {
|
.block:hover & {
|
||||||
font-size: var(--font-size-2xs);
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.blockHeader {
|
.blockHeader {
|
||||||
|
@ -287,21 +296,25 @@ onMounted(() => {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
/* This hack is needed to make the whole surface of header clickable */
|
/* This hack is needed to make the whole surface of header clickable */
|
||||||
margin: calc(-1 * var(--spacing-xs));
|
margin: calc(-1 * var(--spacing-xs));
|
||||||
padding: var(--spacing-xs);
|
padding: var(--spacing-2xs) var(--spacing-xs);
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
& * {
|
& * {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.blockTitle {
|
.blockTitle {
|
||||||
font-size: var(--font-size-2xs);
|
font-size: var(--font-size-s);
|
||||||
color: var(--color-text-dark);
|
color: var(--color-text-dark);
|
||||||
|
margin: 0;
|
||||||
|
padding-bottom: var(--spacing-4xs);
|
||||||
}
|
}
|
||||||
.blockToggle {
|
.blockToggle {
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
color: var(--color-text-base);
|
color: var(--color-text-base);
|
||||||
|
margin-top: calc(-1 * var(--spacing-3xs));
|
||||||
}
|
}
|
||||||
.error {
|
.error {
|
||||||
padding: var(--spacing-s) 0;
|
padding: var(--spacing-s) 0;
|
||||||
|
|
|
@ -25,7 +25,6 @@ interface TreeNode {
|
||||||
export interface Props {
|
export interface Props {
|
||||||
node: INodeUi;
|
node: INodeUi;
|
||||||
runIndex?: number;
|
runIndex?: number;
|
||||||
hideTitle?: boolean;
|
|
||||||
slim?: boolean;
|
slim?: boolean;
|
||||||
workflow: Workflow;
|
workflow: Workflow;
|
||||||
}
|
}
|
||||||
|
@ -203,7 +202,7 @@ const aiData = computed<AIResult[]>(() => {
|
||||||
const executionTree = computed<TreeNode[]>(() => {
|
const executionTree = computed<TreeNode[]>(() => {
|
||||||
const rootNode = props.node;
|
const rootNode = props.node;
|
||||||
|
|
||||||
const tree = getTreeNodeData(rootNode.name, 1);
|
const tree = getTreeNodeData(rootNode.name, 0);
|
||||||
return tree || [];
|
return tree || [];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -211,66 +210,73 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="aiData.length > 0" :class="$style.container">
|
<div :class="$style.container">
|
||||||
<div :class="{ [$style.tree]: true, [$style.slim]: slim }">
|
<template v-if="aiData.length > 0">
|
||||||
<ElTree
|
<div :class="{ [$style.tree]: true, [$style.slim]: slim }">
|
||||||
:data="executionTree"
|
<ElTree
|
||||||
:props="{ label: 'node' }"
|
:data="executionTree"
|
||||||
default-expand-all
|
:props="{ label: 'node' }"
|
||||||
:indent="12"
|
default-expand-all
|
||||||
:expand-on-click-node="false"
|
:indent="12"
|
||||||
data-test-id="lm-chat-logs-tree"
|
:expand-on-click-node="false"
|
||||||
@node-click="onItemClick"
|
data-test-id="lm-chat-logs-tree"
|
||||||
>
|
@node-click="onItemClick"
|
||||||
<template #default="{ node, data }">
|
>
|
||||||
<div
|
<template #default="{ node, data }">
|
||||||
:class="{
|
<div
|
||||||
[$style.treeNode]: true,
|
:class="{
|
||||||
[$style.isSelected]: isTreeNodeSelected(data),
|
[$style.treeNode]: true,
|
||||||
}"
|
[$style.isSelected]: isTreeNodeSelected(data),
|
||||||
:data-tree-depth="data.depth"
|
}"
|
||||||
:style="{ '--item-depth': data.depth }"
|
:data-tree-depth="data.depth"
|
||||||
>
|
:style="{ '--item-depth': data.depth }"
|
||||||
<button
|
|
||||||
v-if="data.children.length"
|
|
||||||
:class="$style.treeToggle"
|
|
||||||
@click="toggleTreeItem(node)"
|
|
||||||
>
|
>
|
||||||
<font-awesome-icon :icon="node.expanded ? 'angle-down' : 'angle-up'" />
|
<button
|
||||||
</button>
|
v-if="data.children.length"
|
||||||
<n8n-tooltip :disabled="!slim" placement="right">
|
:class="$style.treeToggle"
|
||||||
<template #content>
|
@click="toggleTreeItem(node)"
|
||||||
{{ node.label }}
|
>
|
||||||
</template>
|
<font-awesome-icon :icon="node.expanded ? 'angle-down' : 'angle-right'" />
|
||||||
<span :class="$style.leafLabel">
|
</button>
|
||||||
<NodeIcon :node-type="getNodeType(data.node)!" :size="17" />
|
<n8n-tooltip :disabled="!slim" placement="right">
|
||||||
<span v-if="!slim" v-text="node.label" />
|
<template #content>
|
||||||
</span>
|
{{ node.label }}
|
||||||
</n8n-tooltip>
|
</template>
|
||||||
</div>
|
<span :class="$style.leafLabel">
|
||||||
</template>
|
<NodeIcon
|
||||||
</ElTree>
|
:node-type="getNodeType(data.node)!"
|
||||||
</div>
|
:size="17"
|
||||||
<div :class="$style.runData">
|
:class="$style.nodeIcon"
|
||||||
<div v-if="selectedRun.length === 0" :class="$style.empty">
|
/>
|
||||||
<n8n-text size="large">
|
<span v-if="!slim" v-text="node.label" />
|
||||||
{{
|
</span>
|
||||||
$locale.baseText('ndv.output.ai.empty', {
|
</n8n-tooltip>
|
||||||
interpolate: {
|
</div>
|
||||||
node: props.node.name,
|
</template>
|
||||||
},
|
</ElTree>
|
||||||
})
|
|
||||||
}}
|
|
||||||
</n8n-text>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div :class="$style.runData">
|
||||||
v-for="(data, index) in selectedRun"
|
<div v-if="selectedRun.length === 0" :class="$style.empty">
|
||||||
:key="`${data.node}__${data.runIndex}__index`"
|
<n8n-text size="large">
|
||||||
data-test-id="lm-chat-logs-entry"
|
{{
|
||||||
>
|
$locale.baseText('ndv.output.ai.empty', {
|
||||||
<RunDataAiContent :input-data="data" :content-index="index" />
|
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>
|
||||||
</div>
|
</template>
|
||||||
|
<div v-else :class="$style.noData">{{ $locale.baseText('ndv.output.ai.waiting') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -287,6 +293,13 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-3xs);
|
gap: var(--spacing-3xs);
|
||||||
}
|
}
|
||||||
|
.noData {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--color-text-light);
|
||||||
|
}
|
||||||
.empty {
|
.empty {
|
||||||
padding: var(--spacing-l);
|
padding: var(--spacing-l);
|
||||||
}
|
}
|
||||||
|
@ -296,9 +309,9 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
|
||||||
}
|
}
|
||||||
.tree {
|
.tree {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
min-width: 12.8rem;
|
min-width: 8rem;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-right: 1px solid var(--color-foreground-base);
|
|
||||||
padding-right: var(--spacing-xs);
|
padding-right: var(--spacing-xs);
|
||||||
padding-left: var(--spacing-2xs);
|
padding-left: var(--spacing-2xs);
|
||||||
&.slim {
|
&.slim {
|
||||||
|
@ -337,20 +350,30 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
|
||||||
margin-left: var(--spacing-xs);
|
margin-left: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.nodeIcon {
|
||||||
|
padding: var(--spacing-3xs) var(--spacing-3xs);
|
||||||
|
border-radius: var(--border-radius-base);
|
||||||
|
margin-right: var(--spacing-4xs);
|
||||||
|
}
|
||||||
.isSelected {
|
.isSelected {
|
||||||
background-color: var(--color-foreground-base);
|
.nodeIcon {
|
||||||
|
background-color: var(--color-foreground-base);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.treeNode {
|
.treeNode {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-3xs);
|
padding-right: var(--spacing-3xs);
|
||||||
padding: var(--spacing-4xs) var(--spacing-3xs);
|
margin: var(--spacing-4xs) 0;
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-2xs);
|
||||||
color: var(--color-text-dark);
|
color: var(--color-text-dark);
|
||||||
margin-bottom: var(--spacing-3xs);
|
margin-bottom: var(--spacing-3xs);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.isSelected {
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--color-foreground-base);
|
background-color: var(--color-foreground-base);
|
||||||
}
|
}
|
||||||
|
@ -366,6 +389,7 @@ watch(() => props.runIndex, selectFirst, { immediate: true });
|
||||||
height: 0.125rem;
|
height: 0.125rem;
|
||||||
left: 0.75rem;
|
left: 0.75rem;
|
||||||
width: calc(var(--item-depth) * 0.625rem);
|
width: calc(var(--item-depth) * 0.625rem);
|
||||||
|
margin-top: var(--spacing-3xs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -134,13 +134,6 @@ const outputTypeParsers: {
|
||||||
} else if (content.id.includes('SystemMessage')) {
|
} else if (content.id.includes('SystemMessage')) {
|
||||||
message = `**System Message:** ${message}`;
|
message = `**System Message:** ${message}`;
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
execData.action &&
|
|
||||||
typeof execData.action !== 'object' &&
|
|
||||||
execData.action !== 'getMessages'
|
|
||||||
) {
|
|
||||||
message = `## Action: ${execData.action}\n\n${message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
@ -148,6 +141,9 @@ const outputTypeParsers: {
|
||||||
})
|
})
|
||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
|
|
||||||
|
if (responseText.length === 0) {
|
||||||
|
return fallbackParser(execData);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: 'markdown',
|
type: 'markdown',
|
||||||
data: responseText,
|
data: responseText,
|
||||||
|
|
|
@ -1,31 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
const emit = defineEmits<{
|
|
||||||
click: [];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
label: string;
|
|
||||||
icon: string;
|
|
||||||
placement: 'left' | 'right' | 'top' | 'bottom';
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<N8nTooltip :placement="placement">
|
|
||||||
<button :class="$style.button" :style="{ color: '#aaa' }" @click="emit('click')">
|
|
||||||
<N8nIcon :icon="icon" size="small" />
|
|
||||||
</button>
|
|
||||||
<template #content>
|
|
||||||
{{ label }}
|
|
||||||
</template>
|
|
||||||
</N8nTooltip>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style module>
|
|
||||||
.button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,15 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
defineProps<{
|
|
||||||
placement: 'left' | 'right';
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<n8n-info-tip type="tooltip" theme="info-light" :tooltip-placement="placement">
|
|
||||||
<n8n-text :bold="true" size="small">
|
|
||||||
<slot />
|
|
||||||
</n8n-text>
|
|
||||||
</n8n-info-tip>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style module lang="scss"></style>
|
|
|
@ -1,699 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Ref } from 'vue';
|
|
||||||
import { defineAsyncComponent, provide, ref, computed, onMounted, nextTick } from 'vue';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
import Modal from '@/components/Modal.vue';
|
|
||||||
import {
|
|
||||||
AI_CATEGORY_AGENTS,
|
|
||||||
AI_CATEGORY_CHAINS,
|
|
||||||
AI_CODE_NODE_TYPE,
|
|
||||||
AI_SUBCATEGORY,
|
|
||||||
CHAT_EMBED_MODAL_KEY,
|
|
||||||
CHAT_TRIGGER_NODE_TYPE,
|
|
||||||
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
|
||||||
MODAL_CONFIRM,
|
|
||||||
VIEWS,
|
|
||||||
WORKFLOW_LM_CHAT_MODAL_KEY,
|
|
||||||
} from '@/constants';
|
|
||||||
|
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-unresolved
|
|
||||||
import MessagesList from '@n8n/chat/components/MessagesList.vue';
|
|
||||||
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
|
|
||||||
import ChatInput from '@n8n/chat/components/Input.vue';
|
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
|
||||||
import type { Chat, ChatMessage, ChatMessageText, ChatOptions } from '@n8n/chat/types';
|
|
||||||
import { useI18n } from '@/composables/useI18n';
|
|
||||||
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
|
||||||
import MessageOptionTooltip from './MessageOptionTooltip.vue';
|
|
||||||
import MessageOptionAction from './MessageOptionAction.vue';
|
|
||||||
|
|
||||||
import type {
|
|
||||||
BinaryFileType,
|
|
||||||
IBinaryData,
|
|
||||||
IBinaryKeyData,
|
|
||||||
IDataObject,
|
|
||||||
INode,
|
|
||||||
INodeExecutionData,
|
|
||||||
INodeParameters,
|
|
||||||
INodeType,
|
|
||||||
ITaskData,
|
|
||||||
IUser,
|
|
||||||
} from 'n8n-workflow';
|
|
||||||
import {
|
|
||||||
CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE,
|
|
||||||
NodeConnectionType,
|
|
||||||
NodeHelpers,
|
|
||||||
} from 'n8n-workflow';
|
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
||||||
import { useToast } from '@/composables/useToast';
|
|
||||||
import type { INodeUi } from '@/Interface';
|
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
|
||||||
import { createEventBus } from 'n8n-design-system';
|
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
|
||||||
import { useMessage } from '@/composables/useMessage';
|
|
||||||
import { usePinnedData } from '@/composables/usePinnedData';
|
|
||||||
import { get, last } from 'lodash-es';
|
|
||||||
import { isEmpty } from '@/utils/typesUtils';
|
|
||||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
|
||||||
|
|
||||||
const LazyRunDataAi = defineAsyncComponent(
|
|
||||||
async () => await import('@/components/RunDataAi/RunDataAi.vue'),
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: Add proper type
|
|
||||||
interface LangChainMessage {
|
|
||||||
id: string[];
|
|
||||||
kwargs: {
|
|
||||||
content: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MemoryOutput {
|
|
||||||
action: string;
|
|
||||||
chatHistory?: LangChainMessage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const { runWorkflow } = useRunWorkflow({ router });
|
|
||||||
const workflowsStore = useWorkflowsStore();
|
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
|
||||||
const uiStore = useUIStore();
|
|
||||||
|
|
||||||
const { showError } = useToast();
|
|
||||||
const messages: Ref<ChatMessage[]> = ref([]);
|
|
||||||
const currentSessionId = ref<string>(String(Date.now()));
|
|
||||||
const isDisabled = ref(false);
|
|
||||||
|
|
||||||
const connectedNode = ref<INode | null>(null);
|
|
||||||
const chatTrigger = ref<INode | null>(null);
|
|
||||||
const modalBus = createEventBus();
|
|
||||||
const node = ref<INode | null>(null);
|
|
||||||
const previousMessageIndex = ref(0);
|
|
||||||
|
|
||||||
const isLoading = computed(() => uiStore.isActionActive.workflowRunning);
|
|
||||||
const allowFileUploads = computed(() => {
|
|
||||||
return (chatTrigger.value?.parameters?.options as INodeParameters)?.allowFileUploads === true;
|
|
||||||
});
|
|
||||||
const allowedFilesMimeTypes = computed(() => {
|
|
||||||
return (
|
|
||||||
(
|
|
||||||
chatTrigger.value?.parameters?.options as INodeParameters
|
|
||||||
)?.allowedFilesMimeTypes?.toString() ?? ''
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const locale = useI18n();
|
|
||||||
|
|
||||||
const chatOptions: ChatOptions = {
|
|
||||||
i18n: {
|
|
||||||
en: {
|
|
||||||
title: '',
|
|
||||||
footer: '',
|
|
||||||
subtitle: '',
|
|
||||||
inputPlaceholder: locale.baseText('chat.window.chat.placeholder'),
|
|
||||||
getStarted: '',
|
|
||||||
closeButtonTooltip: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
webhookUrl: '',
|
|
||||||
mode: 'window',
|
|
||||||
showWindowCloseButton: true,
|
|
||||||
disabled: isDisabled,
|
|
||||||
allowFileUploads,
|
|
||||||
allowedFilesMimeTypes,
|
|
||||||
};
|
|
||||||
|
|
||||||
const chatConfig: Chat = {
|
|
||||||
messages,
|
|
||||||
sendMessage,
|
|
||||||
initialMessages: ref([]),
|
|
||||||
currentSessionId,
|
|
||||||
waitingForResponse: isLoading,
|
|
||||||
};
|
|
||||||
|
|
||||||
const messageVars = {
|
|
||||||
'--chat--message--bot--background': 'var(--color-lm-chat-bot-background)',
|
|
||||||
'--chat--message--user--background': 'var(--color-lm-chat-user-background)',
|
|
||||||
'--chat--message--bot--color': 'var(--color-text-dark)',
|
|
||||||
'--chat--message--user--color': 'var(--color-lm-chat-user-color)',
|
|
||||||
'--chat--message--bot--border': 'none',
|
|
||||||
'--chat--message--user--border': 'none',
|
|
||||||
'--chat--color-typing': 'var(--color-text-dark)',
|
|
||||||
};
|
|
||||||
|
|
||||||
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
|
||||||
function getTriggerNode() {
|
|
||||||
const triggerNode = workflow.value.queryNodes((nodeType: INodeType) =>
|
|
||||||
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(nodeType.description.name),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!triggerNode.length) {
|
|
||||||
chatTrigger.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
chatTrigger.value = triggerNode[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function setNode() {
|
|
||||||
const triggerNode = chatTrigger.value;
|
|
||||||
if (!triggerNode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const childNodes = workflow.value.getChildNodes(triggerNode.name);
|
|
||||||
|
|
||||||
for (const childNode of childNodes) {
|
|
||||||
// Look for the first connected node with metadata
|
|
||||||
// TODO: Allow later users to change that in the UI
|
|
||||||
const connectedSubNodes = workflow.value.getParentNodes(childNode, 'ALL_NON_MAIN');
|
|
||||||
const resultData = connectedSubNodes.map(workflowsStore.getWorkflowResultDataByNodeName);
|
|
||||||
|
|
||||||
if (!resultData && !Array.isArray(resultData)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resultData.some((data) => data?.[0].metadata)) {
|
|
||||||
node.value = workflowsStore.getNodeByName(childNode);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setConnectedNode() {
|
|
||||||
const triggerNode = chatTrigger.value;
|
|
||||||
|
|
||||||
if (!triggerNode) {
|
|
||||||
showError(new Error('Chat Trigger Node could not be found!'), 'Trigger Node not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chatNode = workflowsStore.getNodes().find((storeNode: INodeUi): boolean => {
|
|
||||||
if (storeNode.type === CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE) return false;
|
|
||||||
const nodeType = nodeTypesStore.getNodeType(storeNode.type, storeNode.typeVersion);
|
|
||||||
if (!nodeType) return false;
|
|
||||||
|
|
||||||
const isAgent = nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_AGENTS);
|
|
||||||
const isChain = nodeType.codex?.subcategories?.[AI_SUBCATEGORY]?.includes(AI_CATEGORY_CHAINS);
|
|
||||||
|
|
||||||
let isCustomChainOrAgent = false;
|
|
||||||
if (nodeType.name === AI_CODE_NODE_TYPE) {
|
|
||||||
const inputs = NodeHelpers.getNodeInputs(workflow.value, storeNode, nodeType);
|
|
||||||
const inputTypes = NodeHelpers.getConnectionTypes(inputs);
|
|
||||||
|
|
||||||
const outputs = NodeHelpers.getNodeOutputs(workflow.value, storeNode, nodeType);
|
|
||||||
const outputTypes = NodeHelpers.getConnectionTypes(outputs);
|
|
||||||
|
|
||||||
if (
|
|
||||||
inputTypes.includes(NodeConnectionType.AiLanguageModel) &&
|
|
||||||
inputTypes.includes(NodeConnectionType.Main) &&
|
|
||||||
outputTypes.includes(NodeConnectionType.Main)
|
|
||||||
) {
|
|
||||||
isCustomChainOrAgent = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAgent && !isChain && !isCustomChainOrAgent) return false;
|
|
||||||
|
|
||||||
const parentNodes = workflow.value.getParentNodes(storeNode.name);
|
|
||||||
const isChatChild = parentNodes.some((parentNodeName) => parentNodeName === triggerNode.name);
|
|
||||||
|
|
||||||
return Boolean(isChatChild && (isAgent || isChain || isCustomChainOrAgent));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!chatNode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedNode.value = chatNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function convertFileToBinaryData(file: File): Promise<IBinaryData> {
|
|
||||||
const reader = new FileReader();
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
reader.onload = () => {
|
|
||||||
const binaryData: IBinaryData = {
|
|
||||||
data: (reader.result as string).split('base64,')?.[1] ?? '',
|
|
||||||
mimeType: file.type,
|
|
||||||
fileName: file.name,
|
|
||||||
fileSize: `${file.size} bytes`,
|
|
||||||
fileExtension: file.name.split('.').pop() ?? '',
|
|
||||||
fileType: file.type.split('/')[0] as BinaryFileType,
|
|
||||||
};
|
|
||||||
resolve(binaryData);
|
|
||||||
};
|
|
||||||
reader.onerror = () => {
|
|
||||||
reject(new Error('Failed to convert file to binary data'));
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getKeyedFiles(files: File[]): Promise<IBinaryKeyData> {
|
|
||||||
const binaryData: IBinaryKeyData = {};
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
files.map(async (file, index) => {
|
|
||||||
const data = await convertFileToBinaryData(file);
|
|
||||||
const key = `data${index}`;
|
|
||||||
|
|
||||||
binaryData[key] = data;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return binaryData;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractFileMeta(file: File): IDataObject {
|
|
||||||
return {
|
|
||||||
fileName: file.name,
|
|
||||||
fileSize: `${file.size} bytes`,
|
|
||||||
fileExtension: file.name.split('.').pop() ?? '',
|
|
||||||
fileType: file.type.split('/')[0],
|
|
||||||
mimeType: file.type,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startWorkflowWithMessage(message: string, files?: File[]): Promise<void> {
|
|
||||||
const triggerNode = chatTrigger.value;
|
|
||||||
|
|
||||||
if (!triggerNode) {
|
|
||||||
showError(new Error('Chat Trigger Node could not be found!'), 'Trigger Node not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let inputKey = 'chatInput';
|
|
||||||
if (triggerNode.type === MANUAL_CHAT_TRIGGER_NODE_TYPE && triggerNode.typeVersion < 1.1) {
|
|
||||||
inputKey = 'input';
|
|
||||||
}
|
|
||||||
if (triggerNode.type === CHAT_TRIGGER_NODE_TYPE) {
|
|
||||||
inputKey = 'chatInput';
|
|
||||||
}
|
|
||||||
|
|
||||||
const usersStore = useUsersStore();
|
|
||||||
const currentUser = usersStore.currentUser ?? ({} as IUser);
|
|
||||||
|
|
||||||
const inputPayload: INodeExecutionData = {
|
|
||||||
json: {
|
|
||||||
sessionId: `test-${currentUser.id || 'unknown'}`,
|
|
||||||
action: 'sendMessage',
|
|
||||||
[inputKey]: message,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (files && files.length > 0) {
|
|
||||||
const filesMeta = files.map((file) => extractFileMeta(file));
|
|
||||||
const binaryData = await getKeyedFiles(files);
|
|
||||||
|
|
||||||
inputPayload.json.files = filesMeta;
|
|
||||||
inputPayload.binary = binaryData;
|
|
||||||
}
|
|
||||||
const nodeData: ITaskData = {
|
|
||||||
startTime: new Date().getTime(),
|
|
||||||
executionTime: 0,
|
|
||||||
executionStatus: 'success',
|
|
||||||
data: {
|
|
||||||
main: [[inputPayload]],
|
|
||||||
},
|
|
||||||
source: [null],
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await runWorkflow({
|
|
||||||
triggerNode: triggerNode.name,
|
|
||||||
nodeData,
|
|
||||||
source: 'RunData.ManualChatMessage',
|
|
||||||
});
|
|
||||||
|
|
||||||
workflowsStore.appendChatMessage(message);
|
|
||||||
if (!response) {
|
|
||||||
showError(new Error('It was not possible to start workflow!'), 'Workflow could not be started');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
waitForExecution(response.executionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForExecution(executionId?: string) {
|
|
||||||
const waitInterval = setInterval(() => {
|
|
||||||
if (!isLoading.value) {
|
|
||||||
clearInterval(waitInterval);
|
|
||||||
|
|
||||||
const lastNodeExecuted =
|
|
||||||
workflowsStore.getWorkflowExecution?.data?.resultData.lastNodeExecuted;
|
|
||||||
|
|
||||||
if (!lastNodeExecuted) return;
|
|
||||||
|
|
||||||
const nodeResponseDataArray =
|
|
||||||
get(workflowsStore.getWorkflowExecution?.data?.resultData.runData, lastNodeExecuted) ?? [];
|
|
||||||
|
|
||||||
const nodeResponseData = nodeResponseDataArray[nodeResponseDataArray.length - 1];
|
|
||||||
|
|
||||||
let responseMessage: string;
|
|
||||||
|
|
||||||
if (get(nodeResponseData, 'error')) {
|
|
||||||
responseMessage = '[ERROR: ' + get(nodeResponseData, 'error.message') + ']';
|
|
||||||
} else {
|
|
||||||
const responseData = get(nodeResponseData, 'data.main[0][0].json');
|
|
||||||
responseMessage = extractResponseMessage(responseData);
|
|
||||||
}
|
|
||||||
|
|
||||||
messages.value.push({
|
|
||||||
text: responseMessage,
|
|
||||||
sender: 'bot',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
id: executionId ?? uuid(),
|
|
||||||
});
|
|
||||||
|
|
||||||
void nextTick(setNode);
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractResponseMessage(responseData?: IDataObject) {
|
|
||||||
if (!responseData || isEmpty(responseData)) {
|
|
||||||
return locale.baseText('chat.window.chat.response.empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paths where the response message might be located
|
|
||||||
const paths = ['output', 'text', 'response.text'];
|
|
||||||
const matchedPath = paths.find((path) => get(responseData, path));
|
|
||||||
|
|
||||||
if (!matchedPath) return JSON.stringify(responseData, null, 2);
|
|
||||||
|
|
||||||
const matchedOutput = get(responseData, matchedPath);
|
|
||||||
if (typeof matchedOutput === 'object') {
|
|
||||||
return '```json\n' + JSON.stringify(matchedOutput, null, 2) + '\n```';
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchedOutput?.toString() ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendMessage(message: string, files?: File[]) {
|
|
||||||
previousMessageIndex.value = 0;
|
|
||||||
if (message.trim() === '' && (!files || files.length === 0)) {
|
|
||||||
showError(
|
|
||||||
new Error(locale.baseText('chat.window.chat.provideMessage')),
|
|
||||||
locale.baseText('chat.window.chat.emptyChatMessage'),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pinnedChatData = usePinnedData(chatTrigger.value);
|
|
||||||
if (pinnedChatData.hasData.value) {
|
|
||||||
const confirmResult = await useMessage().confirm(
|
|
||||||
locale.baseText('chat.window.chat.unpinAndExecute.description'),
|
|
||||||
locale.baseText('chat.window.chat.unpinAndExecute.title'),
|
|
||||||
{
|
|
||||||
confirmButtonText: locale.baseText('chat.window.chat.unpinAndExecute.confirm'),
|
|
||||||
cancelButtonText: locale.baseText('chat.window.chat.unpinAndExecute.cancel'),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!(confirmResult === MODAL_CONFIRM)) return;
|
|
||||||
|
|
||||||
pinnedChatData.unsetData('unpin-and-send-chat-message-modal');
|
|
||||||
}
|
|
||||||
|
|
||||||
const newMessage: ChatMessage = {
|
|
||||||
text: message,
|
|
||||||
sender: 'user',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
id: uuid(),
|
|
||||||
files,
|
|
||||||
};
|
|
||||||
messages.value.push(newMessage);
|
|
||||||
|
|
||||||
await startWorkflowWithMessage(newMessage.text, files);
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayExecution(executionId: string) {
|
|
||||||
const route = router.resolve({
|
|
||||||
name: VIEWS.EXECUTION_PREVIEW,
|
|
||||||
params: { name: workflow.value.id, executionId },
|
|
||||||
});
|
|
||||||
window.open(route.href, '_blank');
|
|
||||||
}
|
|
||||||
function isTextMessage(message: ChatMessage): message is ChatMessageText {
|
|
||||||
return message.type === 'text' || !message.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
function repostMessage(message: ChatMessageText) {
|
|
||||||
void sendMessage(message.text);
|
|
||||||
}
|
|
||||||
function reuseMessage(message: ChatMessageText) {
|
|
||||||
chatEventBus.emit('setInputValue', message.text);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChatMessages(): ChatMessageText[] {
|
|
||||||
if (!connectedNode.value) return [];
|
|
||||||
|
|
||||||
const connectedMemoryInputs =
|
|
||||||
workflow.value.connectionsByDestinationNode[connectedNode.value.name][
|
|
||||||
NodeConnectionType.AiMemory
|
|
||||||
];
|
|
||||||
if (!connectedMemoryInputs) return [];
|
|
||||||
|
|
||||||
const memoryConnection = (connectedMemoryInputs ?? []).find((i) => i.length > 0)?.[0];
|
|
||||||
|
|
||||||
if (!memoryConnection) return [];
|
|
||||||
|
|
||||||
const nodeResultData = workflowsStore.getWorkflowResultDataByNodeName(memoryConnection.node);
|
|
||||||
|
|
||||||
const memoryOutputData = (nodeResultData ?? [])
|
|
||||||
.map((data) => get(data, ['data', NodeConnectionType.AiMemory, 0, 0, 'json']) as MemoryOutput)
|
|
||||||
.find((data) => data.action === 'saveContext');
|
|
||||||
|
|
||||||
return (memoryOutputData?.chatHistory ?? []).map((message, index) => {
|
|
||||||
return {
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
text: message.kwargs.content,
|
|
||||||
id: `preload__${index}`,
|
|
||||||
sender: last(message.id) === 'HumanMessage' ? 'user' : 'bot',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onArrowKeyDown({ currentInputValue, key }: ArrowKeyDownPayload) {
|
|
||||||
const pastMessages = workflowsStore.getPastChatMessages;
|
|
||||||
const isCurrentInputEmptyOrMatch =
|
|
||||||
currentInputValue.length === 0 || pastMessages.includes(currentInputValue);
|
|
||||||
|
|
||||||
if (isCurrentInputEmptyOrMatch && (key === 'ArrowUp' || key === 'ArrowDown')) {
|
|
||||||
// Blur the input when the user presses the up or down arrow key
|
|
||||||
chatEventBus.emit('blurInput');
|
|
||||||
|
|
||||||
if (pastMessages.length === 1) {
|
|
||||||
previousMessageIndex.value = 0;
|
|
||||||
} else if (key === 'ArrowUp') {
|
|
||||||
previousMessageIndex.value = (previousMessageIndex.value + 1) % pastMessages.length;
|
|
||||||
} else if (key === 'ArrowDown') {
|
|
||||||
previousMessageIndex.value =
|
|
||||||
(previousMessageIndex.value - 1 + pastMessages.length) % pastMessages.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
chatEventBus.emit(
|
|
||||||
'setInputValue',
|
|
||||||
pastMessages[pastMessages.length - 1 - previousMessageIndex.value] ?? '',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Refocus to move the cursor to the end of the input
|
|
||||||
chatEventBus.emit('focusInput');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
provide(ChatSymbol, chatConfig);
|
|
||||||
provide(ChatOptionsSymbol, chatOptions);
|
|
||||||
onMounted(() => {
|
|
||||||
getTriggerNode();
|
|
||||||
setConnectedNode();
|
|
||||||
messages.value = getChatMessages();
|
|
||||||
setNode();
|
|
||||||
|
|
||||||
setTimeout(() => chatEventBus.emit('focusInput'), 0);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Modal
|
|
||||||
:name="WORKFLOW_LM_CHAT_MODAL_KEY"
|
|
||||||
width="80%"
|
|
||||||
max-height="80%"
|
|
||||||
:title="
|
|
||||||
locale.baseText('chat.window.title', {
|
|
||||||
interpolate: {
|
|
||||||
nodeName: connectedNode?.name || locale.baseText('chat.window.noChatNode'),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
"
|
|
||||||
:event-bus="modalBus"
|
|
||||||
:scrollable="false"
|
|
||||||
@keydown.stop
|
|
||||||
>
|
|
||||||
<template #content>
|
|
||||||
<div
|
|
||||||
:class="$style.workflowLmChat"
|
|
||||||
data-test-id="workflow-lm-chat-dialog"
|
|
||||||
:style="messageVars"
|
|
||||||
>
|
|
||||||
<MessagesList :messages="messages" :class="[$style.messages, 'ignore-key-press-canvas']">
|
|
||||||
<template #beforeMessage="{ message }">
|
|
||||||
<MessageOptionTooltip
|
|
||||||
v-if="message.sender === 'bot' && !message.id.includes('preload')"
|
|
||||||
placement="right"
|
|
||||||
>
|
|
||||||
{{ locale.baseText('chat.window.chat.chatMessageOptions.executionId') }}:
|
|
||||||
<a href="#" @click="displayExecution(message.id)">{{ message.id }}</a>
|
|
||||||
</MessageOptionTooltip>
|
|
||||||
|
|
||||||
<MessageOptionAction
|
|
||||||
v-if="isTextMessage(message) && message.sender === 'user'"
|
|
||||||
data-test-id="repost-message-button"
|
|
||||||
icon="redo"
|
|
||||||
:label="locale.baseText('chat.window.chat.chatMessageOptions.repostMessage')"
|
|
||||||
placement="left"
|
|
||||||
@click="repostMessage(message)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MessageOptionAction
|
|
||||||
v-if="isTextMessage(message) && message.sender === 'user'"
|
|
||||||
data-test-id="reuse-message-button"
|
|
||||||
icon="copy"
|
|
||||||
:label="locale.baseText('chat.window.chat.chatMessageOptions.reuseMessage')"
|
|
||||||
placement="left"
|
|
||||||
@click="reuseMessage(message)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</MessagesList>
|
|
||||||
<div v-if="node" :class="$style.logsWrapper" data-test-id="lm-chat-logs">
|
|
||||||
<n8n-text :class="$style.logsTitle" tag="p" size="large">{{
|
|
||||||
locale.baseText('chat.window.logs')
|
|
||||||
}}</n8n-text>
|
|
||||||
<div :class="$style.logs">
|
|
||||||
<LazyRunDataAi
|
|
||||||
:key="messages.length"
|
|
||||||
:node="node"
|
|
||||||
hide-title
|
|
||||||
slim
|
|
||||||
:workflow="workflow"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #footer>
|
|
||||||
<ChatInput
|
|
||||||
:class="$style.messagesInput"
|
|
||||||
data-test-id="lm-chat-inputs"
|
|
||||||
@arrow-key-down="onArrowKeyDown"
|
|
||||||
/>
|
|
||||||
<n8n-info-tip class="mt-s">
|
|
||||||
{{ locale.baseText('chatEmbed.infoTip.description') }}
|
|
||||||
<a @click="uiStore.openModal(CHAT_EMBED_MODAL_KEY)">
|
|
||||||
{{ locale.baseText('chatEmbed.infoTip.link') }}
|
|
||||||
</a>
|
|
||||||
</n8n-info-tip>
|
|
||||||
</template>
|
|
||||||
</Modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.chat-message-markdown ul,
|
|
||||||
.chat-message-markdown ol {
|
|
||||||
padding: 0 0 0 1em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<style module lang="scss">
|
|
||||||
.no-node-connected {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.workflowLmChat {
|
|
||||||
--chat--spacing: var(--spacing-m);
|
|
||||||
--chat--message--padding: var(--spacing-xs);
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 9999;
|
|
||||||
min-height: 10rem;
|
|
||||||
|
|
||||||
@media (min-height: 34rem) {
|
|
||||||
min-height: 14.5rem;
|
|
||||||
}
|
|
||||||
@media (min-height: 47rem) {
|
|
||||||
min-height: 25rem;
|
|
||||||
}
|
|
||||||
& ::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
& ::-webkit-scrollbar-thumb {
|
|
||||||
border-radius: var(--border-radius-base);
|
|
||||||
background: var(--color-foreground-dark);
|
|
||||||
border: 1px solid white;
|
|
||||||
}
|
|
||||||
|
|
||||||
& ::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--color-foreground-xdark);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.logsWrapper {
|
|
||||||
--node-icon-color: var(--color-text-base);
|
|
||||||
border: 1px solid var(--color-foreground-base);
|
|
||||||
border-radius: var(--border-radius-base);
|
|
||||||
height: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--spacing-xs) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logsTitle {
|
|
||||||
margin: 0 var(--spacing-s) var(--spacing-s);
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages {
|
|
||||||
background-color: var(--color-lm-chat-messages-background);
|
|
||||||
border: 1px solid var(--color-foreground-base);
|
|
||||||
border-radius: var(--border-radius-base);
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
padding-top: 1.5em;
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
margin-right: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
& * {
|
|
||||||
font-size: var(--font-size-s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.messagesInput {
|
|
||||||
--chat--input--border: var(--input-border-color, var(--border-color-base))
|
|
||||||
var(--input-border-style, var(--border-style-base))
|
|
||||||
var(--input-border-width, var(--border-width-base));
|
|
||||||
|
|
||||||
--chat--input--border-radius: var(--border-radius-base) 0 0 var(--border-radius-base);
|
|
||||||
--chat--input--send--button--background: transparent;
|
|
||||||
--chat--input--send--button--color: var(--color-button-secondary-font);
|
|
||||||
--chat--input--send--button--color-hover: var(--color-primary);
|
|
||||||
--chat--input--border-active: var(--input-focus-border-color, var(--color-secondary));
|
|
||||||
--chat--files-spacing: var(--spacing-2xs) 0;
|
|
||||||
--chat--input--background: var(--color-lm-chat-bot-background);
|
|
||||||
|
|
||||||
[data-theme='dark'] & {
|
|
||||||
--chat--input--text-color: var(--input-font-color, var(--color-text-dark));
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
--chat--input--text-color: var(--input-font-color, var(--color-text-dark));
|
|
||||||
}
|
|
||||||
|
|
||||||
border-bottom-right-radius: var(--border-radius-base);
|
|
||||||
border-top-right-radius: var(--border-radius-base);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,127 +0,0 @@
|
||||||
import { createPinia, setActivePinia } from 'pinia';
|
|
||||||
import { fireEvent, waitFor } from '@testing-library/vue';
|
|
||||||
import { mock } from 'vitest-mock-extended';
|
|
||||||
import { NodeConnectionType } from 'n8n-workflow';
|
|
||||||
import type { IConnections, INode } from 'n8n-workflow';
|
|
||||||
|
|
||||||
import WorkflowLMChatModal from '@/components/WorkflowLMChat/WorkflowLMChat.vue';
|
|
||||||
import { WORKFLOW_LM_CHAT_MODAL_KEY } from '@/constants';
|
|
||||||
import type { IWorkflowDb } from '@/Interface';
|
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
|
||||||
|
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
|
||||||
import { setupServer } from '@/__tests__/server';
|
|
||||||
import { defaultNodeDescriptions, mockNodes } from '@/__tests__/mocks';
|
|
||||||
import { cleanupAppModals, createAppModals } from '@/__tests__/utils';
|
|
||||||
|
|
||||||
const connections: IConnections = {
|
|
||||||
'Chat Trigger': {
|
|
||||||
main: [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
node: 'Agent',
|
|
||||||
type: NodeConnectionType.Main,
|
|
||||||
index: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(WorkflowLMChatModal, {
|
|
||||||
props: {
|
|
||||||
teleported: false,
|
|
||||||
appendToBody: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function createPiniaWithAINodes(options = { withConnections: true, withAgentNode: true }) {
|
|
||||||
const { withConnections, withAgentNode } = options;
|
|
||||||
|
|
||||||
const chatTriggerNode = mockNodes[4];
|
|
||||||
const agentNode = mockNodes[5];
|
|
||||||
const nodes: INode[] = [chatTriggerNode];
|
|
||||||
if (withAgentNode) nodes.push(agentNode);
|
|
||||||
const workflow = mock<IWorkflowDb>({
|
|
||||||
nodes,
|
|
||||||
...(withConnections ? { connections } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const pinia = createPinia();
|
|
||||||
setActivePinia(pinia);
|
|
||||||
|
|
||||||
const workflowsStore = useWorkflowsStore();
|
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
|
||||||
const uiStore = useUIStore();
|
|
||||||
|
|
||||||
nodeTypesStore.setNodeTypes(defaultNodeDescriptions);
|
|
||||||
workflowsStore.workflow = workflow;
|
|
||||||
|
|
||||||
await useSettingsStore().getSettings();
|
|
||||||
await useUsersStore().loginWithCookie();
|
|
||||||
uiStore.openModal(WORKFLOW_LM_CHAT_MODAL_KEY);
|
|
||||||
|
|
||||||
return pinia;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('WorkflowLMChatModal', () => {
|
|
||||||
let server: ReturnType<typeof setupServer>;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
server = setupServer();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
createAppModals();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanupAppModals();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
server.shutdown();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render correctly', async () => {
|
|
||||||
const { getByTestId } = renderComponent({
|
|
||||||
pinia: await createPiniaWithAINodes(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(getByTestId('lmChat-modal')).toBeInTheDocument());
|
|
||||||
|
|
||||||
expect(getByTestId('workflow-lm-chat-dialog')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should send and display chat message', async () => {
|
|
||||||
const { getByTestId } = renderComponent({
|
|
||||||
pinia: await createPiniaWithAINodes({
|
|
||||||
withConnections: true,
|
|
||||||
withAgentNode: true,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => expect(getByTestId('lmChat-modal')).toBeInTheDocument());
|
|
||||||
|
|
||||||
const chatDialog = getByTestId('workflow-lm-chat-dialog');
|
|
||||||
const chatInputsContainer = getByTestId('lm-chat-inputs');
|
|
||||||
const chatSendButton = chatInputsContainer.querySelector('.chat-input-send-button');
|
|
||||||
const chatInput = chatInputsContainer.querySelector('textarea');
|
|
||||||
|
|
||||||
if (chatInput && chatSendButton) {
|
|
||||||
await fireEvent.update(chatInput, 'Hello!');
|
|
||||||
await fireEvent.click(chatSendButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(chatDialog.querySelectorAll('.chat-message-from-user')).toHaveLength(1),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(chatDialog.querySelector('.chat-message-from-user')).toHaveTextContent('Hello!');
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,9 +1,17 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
export interface Props {
|
||||||
|
outline?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<N8nButton
|
<N8nButton
|
||||||
label="Chat"
|
label="Chat"
|
||||||
size="large"
|
size="large"
|
||||||
icon="comment"
|
icon="comment"
|
||||||
type="primary"
|
type="primary"
|
||||||
|
:outline="outline"
|
||||||
data-test-id="workflow-chat-button"
|
data-test-id="workflow-chat-button"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { useExecutionsStore } from '@/stores/executions.store';
|
||||||
import type { PermissionsRecord } from '@/permissions';
|
import type { PermissionsRecord } from '@/permissions';
|
||||||
import { getResourcePermissions } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
@ -315,11 +316,9 @@ async function onAutoRefreshToggle(value: boolean) {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.execListWrapper">
|
<div :class="$style.execListWrapper">
|
||||||
|
<ProjectHeader />
|
||||||
<div :class="$style.execList">
|
<div :class="$style.execList">
|
||||||
<div :class="$style.execListHeader">
|
<div :class="$style.execListHeader">
|
||||||
<N8nHeading tag="h1" size="2xlarge">
|
|
||||||
{{ i18n.baseText('executionsList.workflowExecutions') }}
|
|
||||||
</N8nHeading>
|
|
||||||
<div :class="$style.execListHeaderControls">
|
<div :class="$style.execListHeaderControls">
|
||||||
<N8nLoading v-if="!isMounted" :class="$style.filterLoader" variant="custom" />
|
<N8nLoading v-if="!isMounted" :class="$style.filterLoader" variant="custom" />
|
||||||
<ElCheckbox
|
<ElCheckbox
|
||||||
|
@ -334,6 +333,7 @@ async function onAutoRefreshToggle(value: boolean) {
|
||||||
<ExecutionsFilter
|
<ExecutionsFilter
|
||||||
v-show="isMounted"
|
v-show="isMounted"
|
||||||
:workflows="workflows"
|
:workflows="workflows"
|
||||||
|
class="execFilter"
|
||||||
@filter-changed="onFilterChanged"
|
@filter-changed="onFilterChanged"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -455,27 +455,24 @@ async function onAutoRefreshToggle(value: boolean) {
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
.execListWrapper {
|
.execListWrapper {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: 1fr 0;
|
grid-template-rows: auto auto 1fr 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
|
padding: var(--spacing-l) var(--spacing-2xl) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.execList {
|
.execList {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: var(--spacing-l) var(--spacing-l) 0;
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
padding: var(--spacing-2xl) var(--spacing-2xl) 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.execListHeader {
|
.execListHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
margin-bottom: var(--spacing-s);
|
margin-bottom: var(--spacing-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -588,3 +585,15 @@ async function onAutoRefreshToggle(value: boolean) {
|
||||||
margin-bottom: var(--spacing-2xs);
|
margin-bottom: var(--spacing-2xs);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.execFilter:deep(button) {
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-checkbox) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -313,4 +313,9 @@ function scrollToActiveCard(): void {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.el-checkbox) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
|
||||||
import ResourceListHeader from './ResourceListHeader.vue';
|
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(ResourceListHeader);
|
|
||||||
|
|
||||||
describe('WorkflowHeader', () => {
|
|
||||||
it('should render icon prop', () => {
|
|
||||||
const icon = 'home';
|
|
||||||
const { container } = renderComponent({ props: { icon } });
|
|
||||||
expect(container.querySelector(`.fa-${icon}`)).toBeVisible();
|
|
||||||
});
|
|
||||||
test.each([
|
|
||||||
['title', 'title slot'],
|
|
||||||
['subtitle', 'subtitle slot'],
|
|
||||||
['actions', 'actions slot'],
|
|
||||||
])('should render "%s" slot', (slot, content) => {
|
|
||||||
const { getByText } = renderComponent({ props: { icon: 'home' }, slots: { [slot]: content } });
|
|
||||||
expect(getByText(content)).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,43 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import { N8nHeading, N8nText, N8nIcon } from 'n8n-design-system';
|
|
||||||
defineProps<{ icon: string }>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div :class="[$style.workflowHeader]">
|
|
||||||
<div :class="[$style.icon]">
|
|
||||||
<N8nIcon :icon color="text-light"></N8nIcon>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<N8nHeading bold tag="h2" size="xlarge">
|
|
||||||
<slot name="title" />
|
|
||||||
</N8nHeading>
|
|
||||||
<N8nText v-if="$slots.subtitle" size="small" color="text-light">
|
|
||||||
<slot name="subtitle" />
|
|
||||||
</N8nText>
|
|
||||||
</div>
|
|
||||||
<div v-if="$slots.actions" :class="[$style.actions]">
|
|
||||||
<slot name="actions"></slot>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss" module>
|
|
||||||
.workflowHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding-bottom: var(--spacing-m);
|
|
||||||
min-height: 64px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
border: 1px solid var(--color-foreground-light);
|
|
||||||
padding: 6px;
|
|
||||||
border-radius: var(--border-radius-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
|
@ -1,12 +1,8 @@
|
||||||
import { setActivePinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { within, waitFor } from '@testing-library/vue';
|
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
|
||||||
import type router from 'vue-router';
|
import type router from 'vue-router';
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
|
||||||
import { type Project, ProjectTypes } from '@/types/projects.types';
|
|
||||||
|
|
||||||
vi.mock('vue-router', async (importOriginal) => {
|
vi.mock('vue-router', async (importOriginal) => {
|
||||||
const { RouterLink } = await importOriginal<typeof router>();
|
const { RouterLink } = await importOriginal<typeof router>();
|
||||||
|
@ -36,46 +32,4 @@ describe('ResourcesListLayout', () => {
|
||||||
|
|
||||||
expect(container.querySelectorAll('.el-skeleton__p')).toHaveLength(25);
|
expect(container.querySelectorAll('.el-skeleton__p')).toHaveLength(25);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('header', () => {
|
|
||||||
it('should render the correct icon', async () => {
|
|
||||||
const projects = mockedStore(useProjectsStore);
|
|
||||||
const { getByTestId } = renderComponent();
|
|
||||||
|
|
||||||
expect(getByTestId('list-layout-header').querySelector('.fa-home')).toBeVisible();
|
|
||||||
|
|
||||||
projects.currentProject = { type: ProjectTypes.Personal } as Project;
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(getByTestId('list-layout-header').querySelector('.fa-user')).toBeVisible(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectName = 'My Project';
|
|
||||||
projects.currentProject = { name: projectName } as Project;
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(getByTestId('list-layout-header').querySelector('.fa-layer-group')).toBeVisible(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render the correct title', async () => {
|
|
||||||
const projects = mockedStore(useProjectsStore);
|
|
||||||
const { getByTestId } = renderComponent();
|
|
||||||
|
|
||||||
expect(within(getByTestId('list-layout-header')).getByText('Home')).toBeVisible();
|
|
||||||
|
|
||||||
projects.currentProject = { type: ProjectTypes.Personal } as Project;
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(within(getByTestId('list-layout-header')).getByText('Personal')).toBeVisible(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectName = 'My Project';
|
|
||||||
projects.currentProject = { name: projectName } as Project;
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(within(getByTestId('list-layout-header')).getByText(projectName)).toBeVisible(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,18 +1,16 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, nextTick, ref, onMounted, watch } from 'vue';
|
import { computed, nextTick, ref, onMounted, watch } from 'vue';
|
||||||
|
|
||||||
import { type ProjectSharingData, ProjectTypes } from '@/types/projects.types';
|
import { type ProjectSharingData } from '@/types/projects.types';
|
||||||
import PageViewLayout from '@/components/layouts/PageViewLayout.vue';
|
import PageViewLayout from '@/components/layouts/PageViewLayout.vue';
|
||||||
import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue';
|
import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue';
|
||||||
import ResourceFiltersDropdown from '@/components/forms/ResourceFiltersDropdown.vue';
|
import ResourceFiltersDropdown from '@/components/forms/ResourceFiltersDropdown.vue';
|
||||||
import ResourceListHeader from '@/components/layouts/ResourceListHeader.vue';
|
|
||||||
import { useUsersStore } from '@/stores/users.store';
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
import type { DatatableColumn } from 'n8n-design-system';
|
import type { DatatableColumn } from 'n8n-design-system';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useDebounce } from '@/composables/useDebounce';
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
|
||||||
|
|
||||||
import type { BaseTextKey } from '@/plugins/i18n';
|
import type { BaseTextKey } from '@/plugins/i18n';
|
||||||
import type { Scope } from '@n8n/permissions';
|
import type { Scope } from '@n8n/permissions';
|
||||||
|
@ -99,7 +97,6 @@ const route = useRoute();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const { callDebounced } = useDebounce();
|
const { callDebounced } = useDebounce();
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
const projectsStore = useProjectsStore();
|
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
const sortBy = ref(props.sortOptions[0]);
|
const sortBy = ref(props.sortOptions[0]);
|
||||||
|
@ -330,36 +327,11 @@ onMounted(async () => {
|
||||||
hasFilters.value = true;
|
hasFilters.value = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const headerIcon = computed(() => {
|
|
||||||
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
|
|
||||||
return 'user';
|
|
||||||
} else if (projectsStore.currentProject?.name) {
|
|
||||||
return 'layer-group';
|
|
||||||
} else {
|
|
||||||
return 'home';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const projectName = computed(() => {
|
|
||||||
if (!projectsStore.currentProject) {
|
|
||||||
return i18n.baseText('projects.menu.home');
|
|
||||||
} else if (projectsStore.currentProject.type === ProjectTypes.Personal) {
|
|
||||||
return i18n.baseText('projects.menu.personal');
|
|
||||||
} else {
|
|
||||||
return projectsStore.currentProject.name;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageViewLayout>
|
<PageViewLayout>
|
||||||
<template #header>
|
<template #header>
|
||||||
<ResourceListHeader :icon="headerIcon" data-test-id="list-layout-header">
|
|
||||||
<template #title>
|
|
||||||
{{ projectName }}
|
|
||||||
</template>
|
|
||||||
</ResourceListHeader>
|
|
||||||
<slot name="header" />
|
<slot name="header" />
|
||||||
</template>
|
</template>
|
||||||
<div v-if="loading" class="resource-list-loading">
|
<div v-if="loading" class="resource-list-loading">
|
||||||
|
|
|
@ -2050,6 +2050,36 @@ describe('useCanvasOperations', () => {
|
||||||
expect(workflowsStore.setConnections).toHaveBeenCalled();
|
expect(workflowsStore.setConnections).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should initialize node data from node type description', () => {
|
||||||
|
const nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||||
|
const type = SET_NODE_TYPE;
|
||||||
|
const version = 1;
|
||||||
|
const expectedDescription = mockNodeTypeDescription({
|
||||||
|
name: type,
|
||||||
|
version,
|
||||||
|
properties: [
|
||||||
|
{
|
||||||
|
displayName: 'Value',
|
||||||
|
name: 'value',
|
||||||
|
type: 'boolean',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
nodeTypesStore.nodeTypes = { [type]: { [version]: expectedDescription } };
|
||||||
|
|
||||||
|
const workflow = createTestWorkflow({
|
||||||
|
nodes: [createTestNode()],
|
||||||
|
connections: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { initializeWorkspace } = useCanvasOperations({ router });
|
||||||
|
initializeWorkspace(workflow);
|
||||||
|
|
||||||
|
expect(workflow.nodes[0].parameters).toEqual({ value: true });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildImportNodes() {
|
function buildImportNodes() {
|
||||||
|
|
|
@ -792,8 +792,8 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||||
parameters,
|
parameters,
|
||||||
};
|
};
|
||||||
|
|
||||||
resolveNodeParameters(nodeData);
|
|
||||||
resolveNodeName(nodeData);
|
resolveNodeName(nodeData);
|
||||||
|
resolveNodeParameters(nodeData, nodeTypeDescription);
|
||||||
resolveNodeWebhook(nodeData, nodeTypeDescription);
|
resolveNodeWebhook(nodeData, nodeTypeDescription);
|
||||||
|
|
||||||
return nodeData;
|
return nodeData;
|
||||||
|
@ -842,10 +842,9 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||||
return nodeVersion;
|
return nodeVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveNodeParameters(node: INodeUi) {
|
function resolveNodeParameters(node: INodeUi, nodeTypeDescription: INodeTypeDescription) {
|
||||||
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
|
|
||||||
const nodeParameters = NodeHelpers.getNodeParameters(
|
const nodeParameters = NodeHelpers.getNodeParameters(
|
||||||
nodeType?.properties ?? [],
|
nodeTypeDescription?.properties ?? [],
|
||||||
node.parameters,
|
node.parameters,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
|
@ -1397,7 +1396,10 @@ export function useCanvasOperations({ router }: { router: ReturnType<typeof useR
|
||||||
workflowHelpers.initState(data);
|
workflowHelpers.initState(data);
|
||||||
|
|
||||||
data.nodes.forEach((node) => {
|
data.nodes.forEach((node) => {
|
||||||
|
const nodeTypeDescription = requireNodeTypeDescription(node.type, node.typeVersion);
|
||||||
nodeHelpers.matchCredentials(node);
|
nodeHelpers.matchCredentials(node);
|
||||||
|
resolveNodeParameters(node, nodeTypeDescription);
|
||||||
|
resolveNodeWebhook(node, nodeTypeDescription);
|
||||||
});
|
});
|
||||||
|
|
||||||
workflowsStore.setNodes(data.nodes);
|
workflowsStore.setNodes(data.nodes);
|
||||||
|
|
|
@ -120,8 +120,9 @@ export function useNodeHelpers() {
|
||||||
parameter: INodeProperties | INodeCredentialDescription,
|
parameter: INodeProperties | INodeCredentialDescription,
|
||||||
path: string,
|
path: string,
|
||||||
node: INodeUi | null,
|
node: INodeUi | null,
|
||||||
|
displayKey: 'displayOptions' | 'disabledOptions' = 'displayOptions',
|
||||||
) {
|
) {
|
||||||
return NodeHelpers.displayParameterPath(nodeValues, parameter, path, node);
|
return NodeHelpers.displayParameterPath(nodeValues, parameter, path, node, displayKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshNodeIssues(): void {
|
function refreshNodeIssues(): void {
|
||||||
|
|
|
@ -71,6 +71,7 @@ vi.mock('vue-router', async (importOriginal) => {
|
||||||
useRouter: vi.fn().mockReturnValue({
|
useRouter: vi.fn().mockReturnValue({
|
||||||
push: vi.fn(),
|
push: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useRoute: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -22,12 +22,7 @@ import { FORM_NODE_TYPE, NodeConnectionType } from 'n8n-workflow';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
|
|
||||||
import {
|
import { CHAT_TRIGGER_NODE_TYPE, FORM_TRIGGER_NODE_TYPE, WAIT_NODE_TYPE } from '@/constants';
|
||||||
CHAT_TRIGGER_NODE_TYPE,
|
|
||||||
FORM_TRIGGER_NODE_TYPE,
|
|
||||||
WAIT_NODE_TYPE,
|
|
||||||
WORKFLOW_LM_CHAT_MODAL_KEY,
|
|
||||||
} from '@/constants';
|
|
||||||
|
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
@ -55,7 +50,6 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const executionsStore = useExecutionsStore();
|
const executionsStore = useExecutionsStore();
|
||||||
|
|
||||||
// Starts to execute a workflow on server
|
// Starts to execute a workflow on server
|
||||||
async function runWorkflowApi(runData: IStartRunData): Promise<IExecutionPushResponse> {
|
async function runWorkflowApi(runData: IStartRunData): Promise<IExecutionPushResponse> {
|
||||||
if (!rootStore.pushConnectionActive) {
|
if (!rootStore.pushConnectionActive) {
|
||||||
|
@ -175,7 +169,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
||||||
// If the chat node has no input data or pin data, open the chat modal
|
// If the chat node has no input data or pin data, open the chat modal
|
||||||
// and halt the execution
|
// and halt the execution
|
||||||
if (!chatHasInputData && !chatHasPinData) {
|
if (!chatHasInputData && !chatHasPinData) {
|
||||||
uiStore.openModal(WORKFLOW_LM_CHAT_MODAL_KEY);
|
workflowsStore.setPanelOpen('chat', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -359,7 +353,10 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
||||||
|
|
||||||
let isFormShown =
|
let isFormShown =
|
||||||
!options.destinationNode &&
|
!options.destinationNode &&
|
||||||
workflowsStore.allNodes.some((node) => node.type === FORM_TRIGGER_NODE_TYPE);
|
workflowsStore.allNodes.some(
|
||||||
|
(node) =>
|
||||||
|
node.type === FORM_TRIGGER_NODE_TYPE && !workflowsStore?.pinnedWorkflowData?.[node.name],
|
||||||
|
);
|
||||||
|
|
||||||
const resolveWaitingNodesData = async (): Promise<void> => {
|
const resolveWaitingNodesData = async (): Promise<void> => {
|
||||||
return await new Promise<void>((resolve) => {
|
return await new Promise<void>((resolve) => {
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { useExternalHooks } from './useExternalHooks';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import type { ApplicationError } from 'n8n-workflow';
|
import type { ApplicationError } from 'n8n-workflow';
|
||||||
import { useStyles } from './useStyles';
|
import { useStyles } from './useStyles';
|
||||||
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
|
||||||
export interface NotificationErrorWithNodeAndDescription extends ApplicationError {
|
export interface NotificationErrorWithNodeAndDescription extends ApplicationError {
|
||||||
|
@ -29,12 +30,13 @@ export function useToast() {
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const { APP_Z_INDEXES } = useStyles();
|
const { APP_Z_INDEXES } = useStyles();
|
||||||
|
const canvasStore = useCanvasStore();
|
||||||
|
|
||||||
const messageDefaults: Partial<Omit<NotificationOptions, 'message'>> = {
|
const messageDefaults: Partial<Omit<NotificationOptions, 'message'>> = {
|
||||||
dangerouslyUseHTMLString: false,
|
dangerouslyUseHTMLString: false,
|
||||||
position: 'bottom-right',
|
position: 'bottom-right',
|
||||||
zIndex: APP_Z_INDEXES.TOASTS, // above NDV and modal overlays
|
zIndex: APP_Z_INDEXES.TOASTS, // above NDV and modal overlays
|
||||||
offset: settingsStore.isAiAssistantEnabled ? 64 : 0,
|
offset: settingsStore.isAiAssistantEnabled || workflowsStore.isChatPanelOpen ? 64 : 0,
|
||||||
appendTo: '#app-grid',
|
appendTo: '#app-grid',
|
||||||
customClass: 'content-toast',
|
customClass: 'content-toast',
|
||||||
};
|
};
|
||||||
|
@ -43,6 +45,8 @@ export function useToast() {
|
||||||
const { message, title } = messageData;
|
const { message, title } = messageData;
|
||||||
const params = { ...messageDefaults, ...messageData };
|
const params = { ...messageDefaults, ...messageData };
|
||||||
|
|
||||||
|
params.offset = +canvasStore.panelHeight;
|
||||||
|
|
||||||
if (typeof message === 'string') {
|
if (typeof message === 'string') {
|
||||||
params.message = sanitizeHtml(message);
|
params.message = sanitizeHtml(message);
|
||||||
}
|
}
|
||||||
|
|
|
@ -51,7 +51,6 @@ export const TAGS_MANAGER_MODAL_KEY = 'tagsManager';
|
||||||
export const ANNOTATION_TAGS_MANAGER_MODAL_KEY = 'annotationTagsManager';
|
export const ANNOTATION_TAGS_MANAGER_MODAL_KEY = 'annotationTagsManager';
|
||||||
export const VERSIONS_MODAL_KEY = 'versions';
|
export const VERSIONS_MODAL_KEY = 'versions';
|
||||||
export const WORKFLOW_SETTINGS_MODAL_KEY = 'settings';
|
export const WORKFLOW_SETTINGS_MODAL_KEY = 'settings';
|
||||||
export const WORKFLOW_LM_CHAT_MODAL_KEY = 'lmChat';
|
|
||||||
export const WORKFLOW_SHARE_MODAL_KEY = 'workflowShare';
|
export const WORKFLOW_SHARE_MODAL_KEY = 'workflowShare';
|
||||||
export const PERSONALIZATION_MODAL_KEY = 'personalization';
|
export const PERSONALIZATION_MODAL_KEY = 'personalization';
|
||||||
export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt';
|
export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt';
|
||||||
|
@ -497,6 +496,7 @@ export const enum VIEWS {
|
||||||
PROJECTS_WORKFLOWS = 'ProjectsWorkflows',
|
PROJECTS_WORKFLOWS = 'ProjectsWorkflows',
|
||||||
PROJECTS_CREDENTIALS = 'ProjectsCredentials',
|
PROJECTS_CREDENTIALS = 'ProjectsCredentials',
|
||||||
PROJECT_SETTINGS = 'ProjectSettings',
|
PROJECT_SETTINGS = 'ProjectSettings',
|
||||||
|
PROJECTS_EXECUTIONS = 'ProjectsExecutions',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
|
export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];
|
||||||
|
|
|
@ -169,11 +169,13 @@
|
||||||
"binaryDataDisplay.backToOverviewPage": "Back to overview page",
|
"binaryDataDisplay.backToOverviewPage": "Back to overview page",
|
||||||
"binaryDataDisplay.noDataFoundToDisplay": "No data found to display",
|
"binaryDataDisplay.noDataFoundToDisplay": "No data found to display",
|
||||||
"binaryDataDisplay.yourBrowserDoesNotSupport": "Your browser does not support the video element. Kindly update it to latest version.",
|
"binaryDataDisplay.yourBrowserDoesNotSupport": "Your browser does not support the video element. Kindly update it to latest version.",
|
||||||
"chat.window.title": "Chat Window ({nodeName})",
|
"chat.window.title": "Chat",
|
||||||
"chat.window.logs": "Log (for last message)",
|
"chat.window.logs": "Latest Logs",
|
||||||
|
"chat.window.logsFromNode": "from {nodeName} node",
|
||||||
"chat.window.noChatNode": "No Chat Node",
|
"chat.window.noChatNode": "No Chat Node",
|
||||||
"chat.window.noExecution": "Nothing got executed yet",
|
"chat.window.noExecution": "Nothing got executed yet",
|
||||||
"chat.window.chat.placeholder": "Type a message, or press ‘up’ arrow for previous one",
|
"chat.window.chat.placeholder": "Type a message, or press ‘up’ arrow for previous one",
|
||||||
|
"chat.window.chat.placeholderPristine": "Type a message",
|
||||||
"chat.window.chat.sendButtonText": "Send",
|
"chat.window.chat.sendButtonText": "Send",
|
||||||
"chat.window.chat.provideMessage": "Please provide a message",
|
"chat.window.chat.provideMessage": "Please provide a message",
|
||||||
"chat.window.chat.emptyChatMessage": "Empty chat message",
|
"chat.window.chat.emptyChatMessage": "Empty chat message",
|
||||||
|
@ -185,6 +187,10 @@
|
||||||
"chat.window.chat.unpinAndExecute.confirm": "Unpin and send",
|
"chat.window.chat.unpinAndExecute.confirm": "Unpin and send",
|
||||||
"chat.window.chat.unpinAndExecute.cancel": "Cancel",
|
"chat.window.chat.unpinAndExecute.cancel": "Cancel",
|
||||||
"chat.window.chat.response.empty": "[No response. Make sure the last executed node outputs the content to display here]",
|
"chat.window.chat.response.empty": "[No response. Make sure the last executed node outputs the content to display here]",
|
||||||
|
"chat.window.session.title": "Session",
|
||||||
|
"chat.window.session.reset.title": "Reset session?",
|
||||||
|
"chat.window.session.reset.warning": "This will clear all chat messages and the current execution data",
|
||||||
|
"chat.window.session.reset.confirm": "Reset",
|
||||||
"chatEmbed.infoTip.description": "Add chat to external applications using the n8n chat package.",
|
"chatEmbed.infoTip.description": "Add chat to external applications using the n8n chat package.",
|
||||||
"chatEmbed.infoTip.link": "More info",
|
"chatEmbed.infoTip.link": "More info",
|
||||||
"chatEmbed.title": "Embed Chat in your website",
|
"chatEmbed.title": "Embed Chat in your website",
|
||||||
|
@ -447,7 +453,7 @@
|
||||||
"codeNodeEditor.linter.allItems.itemCall": "`item` is a property to access, not a method to call. Did you mean `.item` without brackets?",
|
"codeNodeEditor.linter.allItems.itemCall": "`item` is a property to access, not a method to call. Did you mean `.item` without brackets?",
|
||||||
"codeNodeEditor.linter.allItems.itemMatchingNoArg": "`.itemMatching()` expects an item index to be passed in as its argument.",
|
"codeNodeEditor.linter.allItems.itemMatchingNoArg": "`.itemMatching()` expects an item index to be passed in as its argument.",
|
||||||
"codeNodeEditor.linter.allItems.unavailableItem": "Legacy `item` is only available in the 'Run Once for Each Item' mode.",
|
"codeNodeEditor.linter.allItems.unavailableItem": "Legacy `item` is only available in the 'Run Once for Each Item' mode.",
|
||||||
"codeNodeEditor.linter.allItems.unavailableProperty": "`.item` is only available in the 'Run Once for Each Item' mode.",
|
"codeNodeEditor.linter.allItems.unavailableProperty": "`.item` is only available in the 'Run Once for Each Item' mode. Use `.first()` instead.",
|
||||||
"codeNodeEditor.linter.allItems.unavailableVar": "is only available in the 'Run Once for Each Item' mode.",
|
"codeNodeEditor.linter.allItems.unavailableVar": "is only available in the 'Run Once for Each Item' mode.",
|
||||||
"codeNodeEditor.linter.bothModes.directAccess.firstOrLastCall": "@:_reusableBaseText.codeNodeEditor.linter.useJson",
|
"codeNodeEditor.linter.bothModes.directAccess.firstOrLastCall": "@:_reusableBaseText.codeNodeEditor.linter.useJson",
|
||||||
"codeNodeEditor.linter.bothModes.directAccess.itemProperty": "@:_reusableBaseText.codeNodeEditor.linter.useJson",
|
"codeNodeEditor.linter.bothModes.directAccess.itemProperty": "@:_reusableBaseText.codeNodeEditor.linter.useJson",
|
||||||
|
@ -458,7 +464,9 @@
|
||||||
"codeNodeEditor.linter.eachItem.returnArray": "Code doesn't return an object. Array found instead. Please return an object representing the output item",
|
"codeNodeEditor.linter.eachItem.returnArray": "Code doesn't return an object. Array found instead. Please return an object representing the output item",
|
||||||
"codeNodeEditor.linter.eachItem.unavailableItems": "Legacy `items` is only available in the 'Run Once for All Items' mode.",
|
"codeNodeEditor.linter.eachItem.unavailableItems": "Legacy `items` is only available in the 'Run Once for All Items' mode.",
|
||||||
"codeNodeEditor.linter.eachItem.unavailableMethod": "Method `$input.{method}()` is only available in the 'Run Once for All Items' mode.",
|
"codeNodeEditor.linter.eachItem.unavailableMethod": "Method `$input.{method}()` is only available in the 'Run Once for All Items' mode.",
|
||||||
|
"codeNodeEditor.linter.eachItem.preferFirst": "Prefer `.first()` over `.item` so n8n can optimize execution",
|
||||||
"codeNodeEditor.linter.bothModes.syntaxError": "Syntax error",
|
"codeNodeEditor.linter.bothModes.syntaxError": "Syntax error",
|
||||||
|
"codeNodeEditor.linter.bothModes.dollarSignVariable": "Use a string literal instead of a variable so n8n can optimize execution.",
|
||||||
"codeNodeEditor.askAi.placeholder": "Tell AI what you want the code to achieve. You can reference input data fields using dot notation (e.g. user.email)",
|
"codeNodeEditor.askAi.placeholder": "Tell AI what you want the code to achieve. You can reference input data fields using dot notation (e.g. user.email)",
|
||||||
"codeNodeEditor.askAi.intro": "Hey AI, generate JavaScript code that...",
|
"codeNodeEditor.askAi.intro": "Hey AI, generate JavaScript code that...",
|
||||||
"codeNodeEditor.askAi.help": "Help",
|
"codeNodeEditor.askAi.help": "Help",
|
||||||
|
@ -891,7 +899,7 @@
|
||||||
"mainSidebar.workflows": "Workflows",
|
"mainSidebar.workflows": "Workflows",
|
||||||
"mainSidebar.workflows.readOnlyEnv.tooltip": "Protected instances prevent editing workflows (recommended for production environments). {link}",
|
"mainSidebar.workflows.readOnlyEnv.tooltip": "Protected instances prevent editing workflows (recommended for production environments). {link}",
|
||||||
"mainSidebar.workflows.readOnlyEnv.tooltip.link": "More info",
|
"mainSidebar.workflows.readOnlyEnv.tooltip.link": "More info",
|
||||||
"mainSidebar.executions": "All executions",
|
"mainSidebar.executions": "Executions",
|
||||||
"mainSidebar.workersView": "Workers",
|
"mainSidebar.workersView": "Workers",
|
||||||
"menuActions.duplicate": "Duplicate",
|
"menuActions.duplicate": "Duplicate",
|
||||||
"menuActions.download": "Download",
|
"menuActions.download": "Download",
|
||||||
|
@ -949,7 +957,8 @@
|
||||||
"ndv.input.disabled": "The '{nodeName}' node is disabled and won’t execute.",
|
"ndv.input.disabled": "The '{nodeName}' node is disabled and won’t execute.",
|
||||||
"ndv.input.disabled.cta": "Enable it",
|
"ndv.input.disabled.cta": "Enable it",
|
||||||
"ndv.output": "Output",
|
"ndv.output": "Output",
|
||||||
"ndv.output.ai.empty": "👈 This is {node}’s AI Logs. Click on a node to see the input it received and data it outputted.",
|
"ndv.output.ai.empty": "👈 Use these logs to see information on how the {node} node completed processing. You can click on a node to see the input it received and data it output.",
|
||||||
|
"ndv.output.ai.waiting": "Waiting for message",
|
||||||
"ndv.output.outType.logs": "Logs",
|
"ndv.output.outType.logs": "Logs",
|
||||||
"ndv.output.outType.regular": "Output",
|
"ndv.output.outType.regular": "Output",
|
||||||
"ndv.output.edit": "Edit Output",
|
"ndv.output.edit": "Edit Output",
|
||||||
|
@ -1116,6 +1125,8 @@
|
||||||
"nodeCreator.triggerHelperPanel.manualChatTriggerDisplayName": "On chat message",
|
"nodeCreator.triggerHelperPanel.manualChatTriggerDisplayName": "On chat message",
|
||||||
"nodeCreator.triggerHelperPanel.manualChatTriggerDescription": "Runs the flow when a user sends a chat message. For use with AI nodes",
|
"nodeCreator.triggerHelperPanel.manualChatTriggerDescription": "Runs the flow when a user sends a chat message. For use with AI nodes",
|
||||||
"nodeCreator.triggerHelperPanel.manualTriggerTag": "Recommended",
|
"nodeCreator.triggerHelperPanel.manualTriggerTag": "Recommended",
|
||||||
|
"nodeCreator.triggerHelperPanel.chatTriggerDisplayName": "On chat message",
|
||||||
|
"nodeCreator.triggerHelperPanel.chatTriggerDescription": "Runs the flow when a user sends a chat message. For use with AI nodes",
|
||||||
"nodeCreator.triggerHelperPanel.whatHappensNext": "What happens next?",
|
"nodeCreator.triggerHelperPanel.whatHappensNext": "What happens next?",
|
||||||
"nodeCreator.triggerHelperPanel.selectATrigger": "What triggers this workflow?",
|
"nodeCreator.triggerHelperPanel.selectATrigger": "What triggers this workflow?",
|
||||||
"nodeCreator.triggerHelperPanel.selectATriggerDescription": "A trigger is a step that starts your workflow",
|
"nodeCreator.triggerHelperPanel.selectATriggerDescription": "A trigger is a step that starts your workflow",
|
||||||
|
|
|
@ -24,6 +24,7 @@ const ErrorView = async () => await import('./views/ErrorView.vue');
|
||||||
const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordView.vue');
|
const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordView.vue');
|
||||||
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
|
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
|
||||||
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
||||||
|
const CanvasChat = async () => await import('@/components/CanvasChat/CanvasChat.vue');
|
||||||
const NodeView = async () => await import('@/views/NodeViewSwitcher.vue');
|
const NodeView = async () => await import('@/views/NodeViewSwitcher.vue');
|
||||||
const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
|
const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
|
||||||
const WorkflowExecutionsLandingPage = async () =>
|
const WorkflowExecutionsLandingPage = async () =>
|
||||||
|
@ -46,7 +47,6 @@ const TemplatesWorkflowView = async () => await import('@/views/TemplatesWorkflo
|
||||||
const SetupWorkflowFromTemplateView = async () =>
|
const SetupWorkflowFromTemplateView = async () =>
|
||||||
await import('@/views/SetupWorkflowFromTemplateView/SetupWorkflowFromTemplateView.vue');
|
await import('@/views/SetupWorkflowFromTemplateView/SetupWorkflowFromTemplateView.vue');
|
||||||
const TemplatesSearchView = async () => await import('@/views/TemplatesSearchView.vue');
|
const TemplatesSearchView = async () => await import('@/views/TemplatesSearchView.vue');
|
||||||
const ExecutionsView = async () => await import('@/views/ExecutionsView.vue');
|
|
||||||
const VariablesView = async () => await import('@/views/VariablesView.vue');
|
const VariablesView = async () => await import('@/views/VariablesView.vue');
|
||||||
const SettingsUsageAndPlan = async () => await import('./views/SettingsUsageAndPlan.vue');
|
const SettingsUsageAndPlan = async () => await import('./views/SettingsUsageAndPlan.vue');
|
||||||
const SettingsSso = async () => await import('./views/SettingsSso.vue');
|
const SettingsSso = async () => await import('./views/SettingsSso.vue');
|
||||||
|
@ -193,17 +193,6 @@ export const routes: RouteRecordRaw[] = [
|
||||||
},
|
},
|
||||||
meta: { middleware: ['authenticated'] },
|
meta: { middleware: ['authenticated'] },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/executions',
|
|
||||||
name: VIEWS.EXECUTIONS,
|
|
||||||
components: {
|
|
||||||
default: ExecutionsView,
|
|
||||||
sidebar: MainSidebar,
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
middleware: ['authenticated'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/workflow/:name/debug/:executionId',
|
path: '/workflow/:name/debug/:executionId',
|
||||||
name: VIEWS.EXECUTION_DEBUG,
|
name: VIEWS.EXECUTION_DEBUG,
|
||||||
|
@ -313,6 +302,7 @@ export const routes: RouteRecordRaw[] = [
|
||||||
default: NodeView,
|
default: NodeView,
|
||||||
header: MainHeader,
|
header: MainHeader,
|
||||||
sidebar: MainSidebar,
|
sidebar: MainSidebar,
|
||||||
|
footer: CanvasChat,
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
nodeView: true,
|
nodeView: true,
|
||||||
|
@ -345,6 +335,7 @@ export const routes: RouteRecordRaw[] = [
|
||||||
default: NodeView,
|
default: NodeView,
|
||||||
header: MainHeader,
|
header: MainHeader,
|
||||||
sidebar: MainSidebar,
|
sidebar: MainSidebar,
|
||||||
|
footer: CanvasChat,
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
nodeView: true,
|
nodeView: true,
|
||||||
|
|
|
@ -5,6 +5,7 @@ const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
||||||
const WorkflowsView = async () => await import('@/views/WorkflowsView.vue');
|
const WorkflowsView = async () => await import('@/views/WorkflowsView.vue');
|
||||||
const CredentialsView = async () => await import('@/views/CredentialsView.vue');
|
const CredentialsView = async () => await import('@/views/CredentialsView.vue');
|
||||||
const ProjectSettings = async () => await import('@/views/ProjectSettings.vue');
|
const ProjectSettings = async () => await import('@/views/ProjectSettings.vue');
|
||||||
|
const ExecutionsView = async () => await import('@/views/ExecutionsView.vue');
|
||||||
|
|
||||||
const commonChildRoutes: RouteRecordRaw[] = [
|
const commonChildRoutes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
|
@ -28,6 +29,16 @@ const commonChildRoutes: RouteRecordRaw[] = [
|
||||||
middleware: ['authenticated'],
|
middleware: ['authenticated'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'executions',
|
||||||
|
components: {
|
||||||
|
default: ExecutionsView,
|
||||||
|
sidebar: MainSidebar,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
middleware: ['authenticated'],
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const commonChildRouteExtensions = {
|
const commonChildRouteExtensions = {
|
||||||
|
@ -38,6 +49,9 @@ const commonChildRouteExtensions = {
|
||||||
{
|
{
|
||||||
name: VIEWS.CREDENTIALS,
|
name: VIEWS.CREDENTIALS,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: VIEWS.EXECUTIONS,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
|
@ -46,6 +60,9 @@ const commonChildRouteExtensions = {
|
||||||
{
|
{
|
||||||
name: VIEWS.PROJECTS_CREDENTIALS,
|
name: VIEWS.PROJECTS_CREDENTIALS,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: VIEWS.PROJECTS_EXECUTIONS,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -105,4 +122,8 @@ export const projectsRoutes: RouteRecordRaw[] = [
|
||||||
path: '/credentials',
|
path: '/credentials',
|
||||||
redirect: '/home/credentials',
|
redirect: '/home/credentials',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/executions',
|
||||||
|
redirect: '/home/executions',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
@ -72,13 +72,15 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
};
|
};
|
||||||
}>({});
|
}>({});
|
||||||
|
|
||||||
|
type NodeExecutionStatus = 'error' | 'not_executed' | 'success';
|
||||||
|
|
||||||
const chatSessionCredType = ref<ICredentialType | undefined>();
|
const chatSessionCredType = ref<ICredentialType | undefined>();
|
||||||
const chatSessionError = ref<ChatRequest.ErrorContext | undefined>();
|
const chatSessionError = ref<ChatRequest.ErrorContext | undefined>();
|
||||||
const currentSessionId = ref<string | undefined>();
|
const currentSessionId = ref<string | undefined>();
|
||||||
const currentSessionActiveExecutionId = ref<string | undefined>();
|
const currentSessionActiveExecutionId = ref<string | undefined>();
|
||||||
const currentSessionWorkflowId = ref<string | undefined>();
|
const currentSessionWorkflowId = ref<string | undefined>();
|
||||||
const lastUnread = ref<ChatUI.AssistantMessage | undefined>();
|
const lastUnread = ref<ChatUI.AssistantMessage | undefined>();
|
||||||
const nodeExecutionStatus = ref<'not_executed' | 'success' | 'error'>('not_executed');
|
const nodeExecutionStatus = ref<NodeExecutionStatus>('not_executed');
|
||||||
// This is used to show a message when the assistant is performing intermediate steps
|
// This is used to show a message when the assistant is performing intermediate steps
|
||||||
// We use streaming for assistants that support it, and this for agents
|
// We use streaming for assistants that support it, and this for agents
|
||||||
const assistantThinkingMessage = ref<string | undefined>();
|
const assistantThinkingMessage = ref<string | undefined>();
|
||||||
|
@ -536,10 +538,16 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
(e) => handleServiceError(e, id, async () => await sendEvent(eventName, error)),
|
(e) => handleServiceError(e, id, async () => await sendEvent(eventName, error)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onNodeExecution(pushEvent: PushPayload<'nodeExecuteAfter'>) {
|
async function onNodeExecution(pushEvent: PushPayload<'nodeExecuteAfter'>) {
|
||||||
if (!chatSessionError.value || pushEvent.nodeName !== chatSessionError.value.node.name) {
|
if (!chatSessionError.value || pushEvent.nodeName !== chatSessionError.value.node.name) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (nodeExecutionStatus.value === 'success') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (pushEvent.data.error && nodeExecutionStatus.value !== 'error') {
|
if (pushEvent.data.error && nodeExecutionStatus.value !== 'error') {
|
||||||
await sendEvent('node-execution-errored', pushEvent.data.error);
|
await sendEvent('node-execution-errored', pushEvent.data.error);
|
||||||
nodeExecutionStatus.value = 'error';
|
nodeExecutionStatus.value = 'error';
|
||||||
|
@ -550,7 +558,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => {
|
||||||
});
|
});
|
||||||
} else if (
|
} else if (
|
||||||
pushEvent.data.executionStatus === 'success' &&
|
pushEvent.data.executionStatus === 'success' &&
|
||||||
nodeExecutionStatus.value !== 'success'
|
['error', 'not_executed'].includes(nodeExecutionStatus.value)
|
||||||
) {
|
) {
|
||||||
await sendEvent('node-execution-succeeded');
|
await sendEvent('node-execution-succeeded');
|
||||||
nodeExecutionStatus.value = 'success';
|
nodeExecutionStatus.value = 'success';
|
||||||
|
|
|
@ -54,8 +54,8 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||||
const jsPlumbInstanceRef = ref<BrowserJsPlumbInstance>();
|
const jsPlumbInstanceRef = ref<BrowserJsPlumbInstance>();
|
||||||
const isDragging = ref<boolean>(false);
|
const isDragging = ref<boolean>(false);
|
||||||
const lastSelectedConnection = ref<Connection>();
|
const lastSelectedConnection = ref<Connection>();
|
||||||
|
|
||||||
const newNodeInsertPosition = ref<XYPosition | null>(null);
|
const newNodeInsertPosition = ref<XYPosition | null>(null);
|
||||||
|
const panelHeight = ref(0);
|
||||||
|
|
||||||
const nodes = computed<INodeUi[]>(() => workflowStore.allNodes);
|
const nodes = computed<INodeUi[]>(() => workflowStore.allNodes);
|
||||||
const triggerNodes = computed<INodeUi[]>(() =>
|
const triggerNodes = computed<INodeUi[]>(() =>
|
||||||
|
@ -109,9 +109,9 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||||
const manualTriggerNode = nodeTypesStore.getNodeType(MANUAL_TRIGGER_NODE_TYPE);
|
const manualTriggerNode = nodeTypesStore.getNodeType(MANUAL_TRIGGER_NODE_TYPE);
|
||||||
|
|
||||||
if (!manualTriggerNode) {
|
if (!manualTriggerNode) {
|
||||||
console.error('Could not find the manual trigger node');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
name: manualTriggerNode.defaults.name?.toString() ?? manualTriggerNode.displayName,
|
name: manualTriggerNode.defaults.name?.toString() ?? manualTriggerNode.displayName,
|
||||||
|
@ -324,6 +324,10 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||||
|
|
||||||
watch(readOnlyEnv, setReadOnly);
|
watch(readOnlyEnv, setReadOnly);
|
||||||
|
|
||||||
|
function setPanelHeight(height: number) {
|
||||||
|
panelHeight.value = height;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDemo,
|
isDemo,
|
||||||
nodeViewScale,
|
nodeViewScale,
|
||||||
|
@ -333,6 +337,8 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||||
isLoading: loadingService.isLoading,
|
isLoading: loadingService.isLoading,
|
||||||
aiNodes,
|
aiNodes,
|
||||||
lastSelectedConnection: lastSelectedConnectionComputed,
|
lastSelectedConnection: lastSelectedConnectionComputed,
|
||||||
|
panelHeight: computed(() => panelHeight.value),
|
||||||
|
setPanelHeight,
|
||||||
setReadOnly,
|
setReadOnly,
|
||||||
setLastSelectedConnection,
|
setLastSelectedConnection,
|
||||||
startLoading: loadingService.startLoading,
|
startLoading: loadingService.startLoading,
|
||||||
|
|
|
@ -14,9 +14,11 @@ import type {
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
import { makeRestApiRequest, unflattenExecutionData } from '@/utils/apiUtils';
|
import { makeRestApiRequest, unflattenExecutionData } from '@/utils/apiUtils';
|
||||||
import { executionFilterToQueryFilter, getDefaultExecutionFilters } from '@/utils/executionUtils';
|
import { executionFilterToQueryFilter, getDefaultExecutionFilters } from '@/utils/executionUtils';
|
||||||
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
|
||||||
export const useExecutionsStore = defineStore('executions', () => {
|
export const useExecutionsStore = defineStore('executions', () => {
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
|
const projectsStore = useProjectsStore();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const itemsPerPage = ref(10);
|
const itemsPerPage = ref(10);
|
||||||
|
@ -24,9 +26,15 @@ export const useExecutionsStore = defineStore('executions', () => {
|
||||||
const activeExecution = ref<ExecutionSummary | null>(null);
|
const activeExecution = ref<ExecutionSummary | null>(null);
|
||||||
|
|
||||||
const filters = ref<ExecutionFilterType>(getDefaultExecutionFilters());
|
const filters = ref<ExecutionFilterType>(getDefaultExecutionFilters());
|
||||||
const executionsFilters = computed<ExecutionsQueryFilter>(() =>
|
const executionsFilters = computed<ExecutionsQueryFilter>(() => {
|
||||||
executionFilterToQueryFilter(filters.value),
|
const filter = executionFilterToQueryFilter(filters.value);
|
||||||
);
|
|
||||||
|
if (projectsStore.currentProjectId) {
|
||||||
|
filter.projectId = projectsStore.currentProjectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
});
|
||||||
const currentExecutionsFilters = computed<Partial<ExecutionFilterType>>(() => ({
|
const currentExecutionsFilters = computed<Partial<ExecutionFilterType>>(() => ({
|
||||||
...(filters.value.workflowId !== 'all' ? { workflowId: filters.value.workflowId } : {}),
|
...(filters.value.workflowId !== 'all' ? { workflowId: filters.value.workflowId } : {}),
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -23,7 +23,6 @@ import {
|
||||||
VERSIONS_MODAL_KEY,
|
VERSIONS_MODAL_KEY,
|
||||||
VIEWS,
|
VIEWS,
|
||||||
WORKFLOW_ACTIVE_MODAL_KEY,
|
WORKFLOW_ACTIVE_MODAL_KEY,
|
||||||
WORKFLOW_LM_CHAT_MODAL_KEY,
|
|
||||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||||
WORKFLOW_SHARE_MODAL_KEY,
|
WORKFLOW_SHARE_MODAL_KEY,
|
||||||
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
|
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
|
||||||
|
@ -104,7 +103,6 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
||||||
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
|
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
|
||||||
NPS_SURVEY_MODAL_KEY,
|
NPS_SURVEY_MODAL_KEY,
|
||||||
VERSIONS_MODAL_KEY,
|
VERSIONS_MODAL_KEY,
|
||||||
WORKFLOW_LM_CHAT_MODAL_KEY,
|
|
||||||
WORKFLOW_SETTINGS_MODAL_KEY,
|
WORKFLOW_SETTINGS_MODAL_KEY,
|
||||||
WORKFLOW_SHARE_MODAL_KEY,
|
WORKFLOW_SHARE_MODAL_KEY,
|
||||||
WORKFLOW_ACTIVE_MODAL_KEY,
|
WORKFLOW_ACTIVE_MODAL_KEY,
|
||||||
|
|
|
@ -139,6 +139,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
const nodeMetadata = ref<NodeMetadataMap>({});
|
const nodeMetadata = ref<NodeMetadataMap>({});
|
||||||
const isInDebugMode = ref(false);
|
const isInDebugMode = ref(false);
|
||||||
const chatMessages = ref<string[]>([]);
|
const chatMessages = ref<string[]>([]);
|
||||||
|
const isChatPanelOpen = ref(false);
|
||||||
|
const isLogsPanelOpen = ref(false);
|
||||||
|
|
||||||
const workflowName = computed(() => workflow.value.name);
|
const workflowName = computed(() => workflow.value.name);
|
||||||
|
|
||||||
|
@ -1052,8 +1054,12 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
}, {});
|
}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setConnections(connections: IConnections): void {
|
function setConnections(connections: IConnections, updateWorkflow = false): void {
|
||||||
workflow.value.connections = connections;
|
workflow.value.connections = connections;
|
||||||
|
|
||||||
|
if (updateWorkflow) {
|
||||||
|
updateCachedWorkflow();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetAllNodesIssues(): boolean {
|
function resetAllNodesIssues(): boolean {
|
||||||
|
@ -1123,6 +1129,11 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
const { [node.name]: removedNodeMetadata, ...remainingNodeMetadata } = nodeMetadata.value;
|
const { [node.name]: removedNodeMetadata, ...remainingNodeMetadata } = nodeMetadata.value;
|
||||||
nodeMetadata.value = remainingNodeMetadata;
|
nodeMetadata.value = remainingNodeMetadata;
|
||||||
|
|
||||||
|
// If chat trigger node is removed, close chat
|
||||||
|
if (node.type === CHAT_TRIGGER_NODE_TYPE) {
|
||||||
|
setPanelOpen('chat', false);
|
||||||
|
}
|
||||||
|
|
||||||
if (workflow.value.pinData && workflow.value.pinData.hasOwnProperty(node.name)) {
|
if (workflow.value.pinData && workflow.value.pinData.hasOwnProperty(node.name)) {
|
||||||
const { [node.name]: removedPinData, ...remainingPinData } = workflow.value.pinData;
|
const { [node.name]: removedPinData, ...remainingPinData } = workflow.value.pinData;
|
||||||
workflow.value = {
|
workflow.value = {
|
||||||
|
@ -1621,6 +1632,14 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
// End Canvas V2 Functions
|
// End Canvas V2 Functions
|
||||||
//
|
//
|
||||||
|
|
||||||
|
function setPanelOpen(panel: 'chat' | 'logs', isOpen: boolean) {
|
||||||
|
if (panel === 'chat') {
|
||||||
|
isChatPanelOpen.value = isOpen;
|
||||||
|
}
|
||||||
|
// Logs panel open/close is tied to the chat panel open/close
|
||||||
|
isLogsPanelOpen.value = isOpen;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workflow,
|
workflow,
|
||||||
usedCredentials,
|
usedCredentials,
|
||||||
|
@ -1665,6 +1684,9 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
getWorkflowExecution,
|
getWorkflowExecution,
|
||||||
getTotalFinishedExecutionsCount,
|
getTotalFinishedExecutionsCount,
|
||||||
getPastChatMessages,
|
getPastChatMessages,
|
||||||
|
isChatPanelOpen: computed(() => isChatPanelOpen.value),
|
||||||
|
isLogsPanelOpen: computed(() => isLogsPanelOpen.value),
|
||||||
|
setPanelOpen,
|
||||||
outgoingConnectionsByNodeName,
|
outgoingConnectionsByNodeName,
|
||||||
incomingConnectionsByNodeName,
|
incomingConnectionsByNodeName,
|
||||||
nodeHasOutputConnection,
|
nodeHasOutputConnection,
|
||||||
|
|
162
packages/editor-ui/src/utils/nodeSettingsUtils.test.ts
Normal file
162
packages/editor-ui/src/utils/nodeSettingsUtils.test.ts
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
import { describe, it, expect, afterAll } from 'vitest';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
import type { IConnections, NodeParameterValueType } from 'n8n-workflow';
|
||||||
|
import { updateDynamicConnections } from './nodeSettingsUtils';
|
||||||
|
import { SWITCH_NODE_TYPE } from '@/constants';
|
||||||
|
import type { INodeUi, IUpdateInformation } from '@/Interface';
|
||||||
|
|
||||||
|
describe('updateDynamicConnections', () => {
|
||||||
|
afterAll(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
it('should remove extra outputs when the number of outputs decreases', () => {
|
||||||
|
const node = mock<INodeUi>({
|
||||||
|
name: 'TestNode',
|
||||||
|
type: SWITCH_NODE_TYPE,
|
||||||
|
parameters: { numberOutputs: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const connections = mock<IConnections>({
|
||||||
|
TestNode: {
|
||||||
|
main: [[{ node: 'Node1' }], [{ node: 'Node2' }], [{ node: 'Node3' }]],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const parameterData = mock<IUpdateInformation<number>>({
|
||||||
|
name: 'parameters.numberOutputs',
|
||||||
|
value: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedConnections = updateDynamicConnections(node, connections, parameterData);
|
||||||
|
|
||||||
|
expect(updatedConnections?.TestNode.main).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should splice connections when a rule is removed', () => {
|
||||||
|
const node = mock<INodeUi>({
|
||||||
|
name: 'TestNode',
|
||||||
|
type: SWITCH_NODE_TYPE,
|
||||||
|
parameters: {
|
||||||
|
rules: { values: [{}, {}, {}] },
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const connections = mock<IConnections>({
|
||||||
|
TestNode: {
|
||||||
|
main: [[{ node: 'Node1' }], [{ node: 'Node2' }], [{ node: 'Node3' }]],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const parameterData = mock<IUpdateInformation<number>>({
|
||||||
|
name: 'parameters.rules.values[1]',
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedConnections = updateDynamicConnections(node, connections, parameterData);
|
||||||
|
|
||||||
|
expect(updatedConnections?.TestNode.main).toHaveLength(2);
|
||||||
|
expect(updatedConnections?.TestNode.main[1][0].node).toEqual('Node3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle fallbackOutput === "extra" and all rules removed', () => {
|
||||||
|
const node = mock<INodeUi>({
|
||||||
|
name: 'TestNode',
|
||||||
|
type: SWITCH_NODE_TYPE,
|
||||||
|
parameters: {
|
||||||
|
options: { fallbackOutput: 'extra' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const connections = mock<IConnections>({
|
||||||
|
TestNode: {
|
||||||
|
main: [[{ node: 'Node1' }], [{ node: 'Node2' }], [{ node: 'Node3' }]],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const parameterData = mock<IUpdateInformation<number>>({
|
||||||
|
name: 'parameters.rules.values',
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedConnections = updateDynamicConnections(node, connections, parameterData);
|
||||||
|
|
||||||
|
expect(updatedConnections?.TestNode.main).toHaveLength(1);
|
||||||
|
expect(updatedConnections?.TestNode.main[0][0].node).toEqual('Node3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add a new connection when a rule is added', () => {
|
||||||
|
const node = mock<INodeUi>({
|
||||||
|
name: 'TestNode',
|
||||||
|
type: SWITCH_NODE_TYPE,
|
||||||
|
parameters: {
|
||||||
|
rules: { values: [{}, {}] },
|
||||||
|
options: { fallbackOutput: 'none' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const connections = mock<IConnections>({
|
||||||
|
TestNode: {
|
||||||
|
main: [[{ node: 'Node1' }], [{ node: 'Node2' }]],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const parameterData = mock<IUpdateInformation<NodeParameterValueType>>({
|
||||||
|
name: 'parameters.rules.values',
|
||||||
|
value: [{}, {}, {}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedConnections = updateDynamicConnections(node, connections, parameterData);
|
||||||
|
|
||||||
|
expect(updatedConnections?.TestNode.main).toHaveLength(3);
|
||||||
|
expect(updatedConnections?.TestNode.main[2]).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle extra output when rule is added and fallbackOutput is extra', () => {
|
||||||
|
const node = mock<INodeUi>({
|
||||||
|
name: 'TestNode',
|
||||||
|
type: SWITCH_NODE_TYPE,
|
||||||
|
parameters: {
|
||||||
|
rules: { values: [{}, {}] },
|
||||||
|
options: { fallbackOutput: 'extra' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const connections = mock<IConnections>({
|
||||||
|
TestNode: {
|
||||||
|
main: [[{ node: 'Node1' }], [{ node: 'Node2' }], [{ node: 'Node3' }]],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const parameterData = mock<IUpdateInformation<NodeParameterValueType>>({
|
||||||
|
name: 'parameters.rules.values',
|
||||||
|
value: [{}, {}, {}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedConnections = updateDynamicConnections(node, connections, parameterData);
|
||||||
|
|
||||||
|
expect(updatedConnections?.TestNode.main).toHaveLength(4);
|
||||||
|
expect(updatedConnections?.TestNode.main[2]).toEqual([]);
|
||||||
|
expect(updatedConnections?.TestNode.main[3][0].node).toEqual('Node3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null if no conditions are met', () => {
|
||||||
|
const node = mock<INodeUi>({
|
||||||
|
name: 'TestNode',
|
||||||
|
type: 'otherNodeType',
|
||||||
|
});
|
||||||
|
|
||||||
|
const connections = mock<IConnections>({
|
||||||
|
TestNode: { main: [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const parameterData = mock<IUpdateInformation<number>>({
|
||||||
|
name: 'parameters.otherParameter',
|
||||||
|
value: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = updateDynamicConnections(node, connections, parameterData);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
133
packages/editor-ui/src/utils/nodeSettingsUtils.ts
Normal file
133
packages/editor-ui/src/utils/nodeSettingsUtils.ts
Normal file
|
@ -0,0 +1,133 @@
|
||||||
|
import type {
|
||||||
|
IConnection,
|
||||||
|
IConnections,
|
||||||
|
IDataObject,
|
||||||
|
NodeInputConnections,
|
||||||
|
NodeParameterValueType,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import type { INodeUi, IUpdateInformation } from '@/Interface';
|
||||||
|
import { SWITCH_NODE_TYPE } from '@/constants';
|
||||||
|
import { isEqual } from 'lodash-es';
|
||||||
|
|
||||||
|
import { captureException } from '@sentry/vue';
|
||||||
|
|
||||||
|
export function updateDynamicConnections(
|
||||||
|
node: INodeUi,
|
||||||
|
workflowConnections: IConnections,
|
||||||
|
parameterData: IUpdateInformation<NodeParameterValueType>,
|
||||||
|
) {
|
||||||
|
const connections = { ...workflowConnections };
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (parameterData.name.includes('conditions') || !connections[node.name]?.main) return null;
|
||||||
|
|
||||||
|
if (node.type === SWITCH_NODE_TYPE && parameterData.name === 'parameters.numberOutputs') {
|
||||||
|
const curentNumberOutputs = node.parameters?.numberOutputs as number;
|
||||||
|
const newNumberOutputs = parameterData.value as number;
|
||||||
|
|
||||||
|
// remove extra outputs
|
||||||
|
if (newNumberOutputs < curentNumberOutputs) {
|
||||||
|
connections[node.name].main = connections[node.name].main.slice(0, newNumberOutputs);
|
||||||
|
return connections;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
node.type === SWITCH_NODE_TYPE &&
|
||||||
|
parameterData.name === 'parameters.options.fallbackOutput'
|
||||||
|
) {
|
||||||
|
const curentFallbackOutput = (node.parameters?.options as { fallbackOutput: string })
|
||||||
|
?.fallbackOutput as string;
|
||||||
|
if (curentFallbackOutput === 'extra') {
|
||||||
|
if (!parameterData.value || parameterData.value !== 'extra') {
|
||||||
|
connections[node.name].main = connections[node.name].main.slice(0, -1);
|
||||||
|
return connections;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === SWITCH_NODE_TYPE && parameterData.name.includes('parameters.rules.values')) {
|
||||||
|
const { fallbackOutput } = node.parameters?.options as { fallbackOutput: string };
|
||||||
|
|
||||||
|
if (parameterData.value === undefined) {
|
||||||
|
function extractIndex(path: string): number | null {
|
||||||
|
const match = path.match(/parameters\.rules\.values\[(\d+)\]$/);
|
||||||
|
return match ? parseInt(match[1], 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = extractIndex(parameterData.name);
|
||||||
|
|
||||||
|
// rule was removed
|
||||||
|
if (index !== null) {
|
||||||
|
connections[node.name].main.splice(index, 1);
|
||||||
|
return connections;
|
||||||
|
}
|
||||||
|
|
||||||
|
// all rules were removed
|
||||||
|
if (parameterData.name === 'parameters.rules.values') {
|
||||||
|
if (fallbackOutput === 'extra') {
|
||||||
|
connections[node.name].main = [
|
||||||
|
connections[node.name].main[connections[node.name].main.length - 1],
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
connections[node.name].main = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return connections;
|
||||||
|
}
|
||||||
|
} else if (parameterData.name === 'parameters.rules.values') {
|
||||||
|
const curentRulesvalues = (node.parameters?.rules as { values: IDataObject[] })?.values;
|
||||||
|
let lastConnection: IConnection[] | undefined = undefined;
|
||||||
|
if (
|
||||||
|
fallbackOutput === 'extra' &&
|
||||||
|
connections[node.name].main.length === curentRulesvalues.length + 1
|
||||||
|
) {
|
||||||
|
lastConnection = connections[node.name].main.pop();
|
||||||
|
}
|
||||||
|
// rule was added
|
||||||
|
const currentRulesLength = (node.parameters?.rules as { values: IDataObject[] })?.values
|
||||||
|
?.length;
|
||||||
|
|
||||||
|
const newRulesLength = (parameterData.value as IDataObject[])?.length;
|
||||||
|
|
||||||
|
if (newRulesLength - currentRulesLength === 1) {
|
||||||
|
connections[node.name].main = [...connections[node.name].main, []];
|
||||||
|
|
||||||
|
if (lastConnection) {
|
||||||
|
connections[node.name].main.push(lastConnection);
|
||||||
|
}
|
||||||
|
|
||||||
|
return connections;
|
||||||
|
} else {
|
||||||
|
// order was changed
|
||||||
|
const newRulesvalues = parameterData.value as IDataObject[];
|
||||||
|
const updatedConnectionsIndex: number[] = [];
|
||||||
|
|
||||||
|
for (const rule of curentRulesvalues) {
|
||||||
|
const index = newRulesvalues.findIndex((newRule) => isEqual(rule, newRule));
|
||||||
|
if (index !== -1) {
|
||||||
|
updatedConnectionsIndex.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorderedConnections: NodeInputConnections = [];
|
||||||
|
|
||||||
|
for (const index of updatedConnectionsIndex) {
|
||||||
|
reorderedConnections.push(connections[node.name].main[index] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastConnection) {
|
||||||
|
reorderedConnections.push(lastConnection);
|
||||||
|
}
|
||||||
|
|
||||||
|
connections[node.name].main = reorderedConnections;
|
||||||
|
return connections;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
captureException(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
|
@ -21,12 +21,12 @@ import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import useEnvironmentsStore from '@/stores/environments.ee.store';
|
import useEnvironmentsStore from '@/stores/environments.ee.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
|
||||||
import { getResourcePermissions } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { N8nButton, N8nInputLabel, N8nSelect, N8nOption } from 'n8n-design-system';
|
import { N8nButton, N8nInputLabel, N8nSelect, N8nOption } from 'n8n-design-system';
|
||||||
|
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
credentialId?: string;
|
credentialId?: string;
|
||||||
|
@ -190,7 +190,7 @@ onMounted(() => {
|
||||||
@update:filters="filters = $event"
|
@update:filters="filters = $event"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<ProjectTabs />
|
<ProjectHeader />
|
||||||
</template>
|
</template>
|
||||||
<template #add-button="{ disabled }">
|
<template #add-button="{ disabled }">
|
||||||
<div>
|
<div>
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue