fix(editor): Fix opening of chat window when executing a child node (#8789)

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
Co-authored-by: Michael Kret <michael.k@radency.com>
This commit is contained in:
oleg 2024-03-21 09:23:15 +01:00 committed by GitHub
parent 5e84c2ab89
commit 5f53d76e39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 97 additions and 25 deletions

View file

@ -25,6 +25,9 @@ export function getManualChatModalCloseButton() {
export function getManualChatModalLogs() { export function getManualChatModalLogs() {
return getManualChatModal().getByTestId('lm-chat-logs'); return getManualChatModal().getByTestId('lm-chat-logs');
} }
export function getManualChatDialog() {
return getManualChatModal().getByTestId('workflow-lm-chat-dialog');
}
export function getManualChatModalLogsTree() { export function getManualChatModalLogsTree() {
return getManualChatModalLogs().getByTestId('lm-chat-logs-tree'); return getManualChatModalLogs().getByTestId('lm-chat-logs-tree');

View file

@ -25,15 +25,12 @@ import {
clickCreateNewCredential, clickCreateNewCredential,
clickExecuteNode, clickExecuteNode,
clickGetBackToCanvas, clickGetBackToCanvas,
getOutputPanelTable,
getParameterInputByName,
setParameterInputByName,
setParameterSelectByContent,
toggleParameterCheckboxInputByName, toggleParameterCheckboxInputByName,
} from '../composables/ndv'; } from '../composables/ndv';
import { setCredentialValues } from '../composables/modals/credential-modal'; import { setCredentialValues } from '../composables/modals/credential-modal';
import { import {
closeManualChatModal, closeManualChatModal,
getManualChatDialog,
getManualChatMessages, getManualChatMessages,
getManualChatModalLogs, getManualChatModalLogs,
getManualChatModalLogsEntries, getManualChatModalLogsEntries,
@ -98,15 +95,12 @@ describe('Langchain Integration', () => {
clickGetBackToCanvas(); clickGetBackToCanvas();
openNode(BASIC_LLM_CHAIN_NODE_NAME); openNode(BASIC_LLM_CHAIN_NODE_NAME);
setParameterSelectByContent('promptType', 'Define below')
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?';
setParameterInputByName('text', inputMessage); clickExecuteNode()
runMockWorkflowExcution({ runMockWorkflowExcution({
trigger: () => clickExecuteNode(), trigger: () => sendManualChatMessage(inputMessage),
runData: [ runData: [
createMockNodeExecutionData(BASIC_LLM_CHAIN_NODE_NAME, { createMockNodeExecutionData(BASIC_LLM_CHAIN_NODE_NAME, {
jsonData: { jsonData: {
@ -120,8 +114,7 @@ describe('Langchain Integration', () => {
lastNodeExecuted: BASIC_LLM_CHAIN_NODE_NAME, lastNodeExecuted: BASIC_LLM_CHAIN_NODE_NAME,
}); });
getOutputPanelTable().should('contain', 'output'); getManualChatDialog().should('contain', outputMessage);
getOutputPanelTable().should('contain', outputMessage);
}); });
it('should be able to open and execute Agent node', () => { it('should be able to open and execute Agent node', () => {
@ -141,11 +134,9 @@ describe('Langchain Integration', () => {
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?';
setParameterSelectByContent('promptType', 'Define below') clickExecuteNode()
setParameterInputByName('text', inputMessage);
runMockWorkflowExcution({ runMockWorkflowExcution({
trigger: () => clickExecuteNode(), trigger: () => sendManualChatMessage(inputMessage),
runData: [ runData: [
createMockNodeExecutionData(AGENT_NODE_NAME, { createMockNodeExecutionData(AGENT_NODE_NAME, {
jsonData: { jsonData: {
@ -159,8 +150,7 @@ describe('Langchain Integration', () => {
lastNodeExecuted: AGENT_NODE_NAME, lastNodeExecuted: AGENT_NODE_NAME,
}); });
getOutputPanelTable().should('contain', 'output'); getManualChatDialog().should('contain', outputMessage);
getOutputPanelTable().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', () => {

View file

@ -126,6 +126,9 @@ export default defineComponent({
isChatNode(): boolean { isChatNode(): boolean {
return Boolean(this.nodeType && this.nodeType.name === CHAT_TRIGGER_NODE_TYPE); return Boolean(this.nodeType && this.nodeType.name === CHAT_TRIGGER_NODE_TYPE);
}, },
isChatChild(): boolean {
return this.workflowsStore.checkIfNodeHasChatParent(this.nodeName);
},
isFormTriggerNode(): boolean { isFormTriggerNode(): boolean {
return Boolean(this.nodeType && this.nodeType.name === FORM_TRIGGER_NODE_TYPE); return Boolean(this.nodeType && this.nodeType.name === FORM_TRIGGER_NODE_TYPE);
}, },
@ -226,7 +229,8 @@ export default defineComponent({
}, },
async onClick() { async onClick() {
if (this.isChatNode) { // Show chat if it's a chat node or a child of a chat node with no input data
if (this.isChatNode || (this.isChatChild && this.ndvStore.isDNVDataEmpty('input'))) {
this.ndvStore.setActiveNodeName(null); this.ndvStore.setActiveNodeName(null);
nodeViewEventBus.emit('openChat'); nodeViewEventBus.emit('openChat');
} else if (this.isListeningForEvents) { } else if (this.isListeningForEvents) {

View file

@ -122,6 +122,7 @@ import { defineAsyncComponent, defineComponent } from 'vue';
import { mapStores } from 'pinia'; import { mapStores } from 'pinia';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useMessage } from '@/composables/useMessage';
import Modal from '@/components/Modal.vue'; import Modal from '@/components/Modal.vue';
import { import {
AI_CATEGORY_AGENTS, AI_CATEGORY_AGENTS,
@ -132,6 +133,7 @@ import {
CHAT_EMBED_MODAL_KEY, CHAT_EMBED_MODAL_KEY,
CHAT_TRIGGER_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE,
MANUAL_CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE,
MODAL_CONFIRM,
VIEWS, VIEWS,
WORKFLOW_LM_CHAT_MODAL_KEY, WORKFLOW_LM_CHAT_MODAL_KEY,
} from '@/constants'; } from '@/constants';
@ -153,6 +155,7 @@ import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useRunWorkflow } from '@/composables/useRunWorkflow'; import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { usePinnedData } from '@/composables/usePinnedData';
const RunDataAi = defineAsyncComponent( const RunDataAi = defineAsyncComponent(
async () => await import('@/components/RunDataAi/RunDataAi.vue'), async () => await import('@/components/RunDataAi/RunDataAi.vue'),
@ -197,6 +200,7 @@ export default defineComponent({
externalHooks, externalHooks,
workflowHelpers, workflowHelpers,
...useToast(), ...useToast(),
...useMessage(),
}; };
}, },
data() { data() {
@ -273,6 +277,23 @@ export default defineComponent({
); );
return; return;
} }
const pinnedChatData = usePinnedData(this.getTriggerNode());
if (pinnedChatData.hasData.value) {
const confirmResult = await this.confirm(
this.$locale.baseText('chat.window.chat.unpinAndExecute.description'),
this.$locale.baseText('chat.window.chat.unpinAndExecute.title'),
{
confirmButtonText: this.$locale.baseText('chat.window.chat.unpinAndExecute.confirm'),
cancelButtonText: this.$locale.baseText('chat.window.chat.unpinAndExecute.cancel'),
},
);
if (!(confirmResult === MODAL_CONFIRM)) return;
pinnedChatData.unsetData('unpin-and-send-chat-message-modal');
}
this.messages.push({ this.messages.push({
text: message, text: message,
sender: 'user', sender: 'user',

View file

@ -139,7 +139,10 @@ describe('WorkflowLMChatModal', () => {
it('should send and display chat message', async () => { it('should send and display chat message', async () => {
const wrapper = renderComponent({ const wrapper = renderComponent({
pinia: await createPiniaWithAINodes(), pinia: await createPiniaWithAINodes({
withConnections: true,
withAgentNode: true,
}),
}); });
await waitFor(() => await waitFor(() =>

View file

@ -28,7 +28,11 @@ export type PinDataSource =
| 'context-menu' | 'context-menu'
| 'keyboard-shortcut'; | 'keyboard-shortcut';
export type UnpinDataSource = 'unpin-and-execute-modal' | 'context-menu' | 'keyboard-shortcut'; export type UnpinDataSource =
| 'unpin-and-execute-modal'
| 'context-menu'
| 'keyboard-shortcut'
| 'unpin-and-send-chat-message-modal';
export function usePinnedData( export function usePinnedData(
node: MaybeRef<INodeUi | null>, node: MaybeRef<INodeUi | null>,

View file

@ -24,7 +24,12 @@ import {
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useNodeHelpers } from '@/composables/useNodeHelpers'; import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { FORM_TRIGGER_NODE_TYPE, WAIT_NODE_TYPE } from '@/constants'; import {
CHAT_TRIGGER_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
WAIT_NODE_TYPE,
WORKFLOW_LM_CHAT_MODAL_KEY,
} from '@/constants';
import { useTitleChange } from '@/composables/useTitleChange'; import { useTitleChange } from '@/composables/useTitleChange';
import { useRootStore } from '@/stores/n8nRoot.store'; import { useRootStore } from '@/stores/n8nRoot.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
@ -198,6 +203,10 @@ export function useRunWorkflow(options: { router: ReturnType<typeof useRouter> }
); );
const { startNodeNames } = consolidatedData; const { startNodeNames } = consolidatedData;
const destinationNodeType = options.destinationNode
? workflowsStore.getNodeByName(options.destinationNode)?.type
: '';
let { runData: newRunData } = consolidatedData; let { runData: newRunData } = consolidatedData;
let executedNode: string | undefined; let executedNode: string | undefined;
if ( if (
@ -217,6 +226,27 @@ export function useRunWorkflow(options: { router: ReturnType<typeof useRouter> }
executedNode = options.triggerNode; executedNode = options.triggerNode;
} }
// If the destination node is specified, check if it is a chat node or has a chat parent
if (
options.destinationNode &&
(workflowsStore.checkIfNodeHasChatParent(options.destinationNode) ||
destinationNodeType === CHAT_TRIGGER_NODE_TYPE)
) {
const startNode = workflow.getStartNode(options.destinationNode);
if (startNode && startNode.type === CHAT_TRIGGER_NODE_TYPE) {
// Check if the chat node has input data or pin data
const chatHasInputData =
nodeHelpers.getNodeInputData(startNode, 0, 0, 'input')?.length > 0;
const chatHasPinData = !!workflowData.pinData?.[startNode.name];
// If the chat node has no input data or pin data, open the chat modal
// and halt the execution
if (!chatHasInputData && !chatHasPinData) {
uiStore.openModal(WORKFLOW_LM_CHAT_MODAL_KEY);
return;
}
}
}
const startNodes: StartNodeData[] = startNodeNames.map((name) => { const startNodes: StartNodeData[] = startNodeNames.map((name) => {
// Find for each start node the source data // Find for each start node the source data
let sourceData = get(runData, [name, 0, 'source', 0], null); let sourceData = get(runData, [name, 0, 'source', 0], null);

View file

@ -34,10 +34,6 @@ $badge-warning-color: var(--color-text-dark);
// Warning tooltip // Warning tooltip
$warning-tooltip-color: var(--color-danger); $warning-tooltip-color: var(--color-danger);
:root {
// Using native css variable enables us to use this value in JS
--header-height: 65;
}
// sass variable is used for scss files // sass variable is used for scss files
$header-height: calc(var(--header-height) * 1px); $header-height: calc(var(--header-height) * 1px);

View file

@ -173,6 +173,9 @@
--node-error-output-color: #991818; --node-error-output-color: #991818;
--chat--spacing: var(--spacing-s); --chat--spacing: var(--spacing-s);
// Using native css variable enables us to use this value in JS
--header-height: 65;
} }
.clickable { .clickable {

View file

@ -148,6 +148,10 @@
"chat.window.chat.chatMessageOptions.reuseMessage": "Reuse Message", "chat.window.chat.chatMessageOptions.reuseMessage": "Reuse Message",
"chat.window.chat.chatMessageOptions.repostMessage": "Repost Message", "chat.window.chat.chatMessageOptions.repostMessage": "Repost Message",
"chat.window.chat.chatMessageOptions.executionId": "Execution ID", "chat.window.chat.chatMessageOptions.executionId": "Execution ID",
"chat.window.chat.unpinAndExecute.description": "Sending the message overwrites the pinned chat node data.",
"chat.window.chat.unpinAndExecute.title": "Unpin chat output data?",
"chat.window.chat.unpinAndExecute.confirm": "Unpin and send",
"chat.window.chat.unpinAndExecute.cancel": "Cancel",
"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",

View file

@ -1,4 +1,5 @@
import { import {
CHAT_TRIGGER_NODE_TYPE,
DEFAULT_NEW_WORKFLOW_NAME, DEFAULT_NEW_WORKFLOW_NAME,
DUPLICATE_POSTFFIX, DUPLICATE_POSTFFIX,
EnterpriseEditionFeature, EnterpriseEditionFeature,
@ -1460,5 +1461,18 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
appendChatMessage(message: string): void { appendChatMessage(message: string): void {
this.chatMessages.push(message); this.chatMessages.push(message);
}, },
checkIfNodeHasChatParent(nodeName: string): boolean {
const workflow = this.getCurrentWorkflow();
const parents = workflow.getParentNodes(nodeName, 'main');
const matchedChatNode = parents.find((parent) => {
const parentNodeType = this.getNodeByName(parent)?.type;
return parentNodeType === CHAT_TRIGGER_NODE_TYPE;
});
return !!matchedChatNode;
},
}, },
}); });