fix(editor): "Trigger node not found" error when chat message is entered (#14954)

This commit is contained in:
Suguru Inoue
2025-04-29 13:45:30 +02:00
committed by GitHub
parent 7f89244304
commit 8981e22dd4
9 changed files with 125 additions and 107 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watchEffect, useTemplateRef } from 'vue';
import { computed, ref, watchEffect, useTemplateRef, watch } from 'vue';
// Components
import ChatMessagesPanel from './components/ChatMessagesPanel.vue';
@@ -68,7 +68,7 @@ const {
sendMessage,
refreshSession,
displayExecution,
} = useChatState(false, onWindowResize);
} = useChatState(false);
// Expose internal state for testing
defineExpose({
@@ -91,6 +91,18 @@ function onPopOut() {
watchEffect(() => {
canvasStore.setPanelHeight(chatPanelState.value === LOGS_PANEL_STATE.ATTACHED ? height.value : 0);
});
watch(
() => workflowsStore.logsPanelState,
(state) => {
if (state !== LOGS_PANEL_STATE.CLOSED) {
setTimeout(() => {
onWindowResize?.();
}, 0);
}
},
{ immediate: true },
);
</script>
<template>

View File

@@ -7,7 +7,7 @@ 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 { computed, ref } from 'vue';
import { watch, computed, ref } from 'vue';
import { useClipboard } from '@/composables/useClipboard';
import { useToast } from '@/composables/useToast';
import PanelHeader from '@/components/CanvasChat/future/components/PanelHeader.vue';
@@ -136,6 +136,18 @@ async function copySessionId() {
type: 'success',
});
}
watch(
() => props.isOpen,
(isOpen) => {
if (isOpen) {
setTimeout(() => {
chatEventBus.emit('focusInput');
}, 0);
}
},
{ immediate: true },
);
</script>
<template>

View File

@@ -6,7 +6,6 @@ import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { VIEWS } from '@/constants';
import { type INodeUi } from '@/Interface';
import { useCanvasStore } from '@/stores/canvas.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
@@ -31,11 +30,10 @@ interface ChatState {
displayExecution: (executionId: string) => void;
}
export function useChatState(isReadOnly: boolean, onWindowResize?: () => void): ChatState {
export function useChatState(isReadOnly: boolean): ChatState {
const locale = useI18n();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const canvasStore = useCanvasStore();
const router = useRouter();
const nodeHelpers = useNodeHelpers();
const { runWorkflow } = useRunWorkflow({ router });
@@ -44,25 +42,16 @@ export function useChatState(isReadOnly: boolean, onWindowResize?: () => void):
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
const canvasNodes = computed(() => workflowsStore.allNodes);
const allConnections = computed(() => workflowsStore.allConnections);
const logsPanelState = computed(() => workflowsStore.logsPanelState);
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
// Initialize features with injected dependencies
const {
chatTriggerNode,
connectedNode,
allowFileUploads,
allowedFilesMimeTypes,
setChatTriggerNode,
setConnectedNode,
} = useChatTrigger({
workflow,
canvasNodes,
getNodeByName: workflowsStore.getNodeByName,
getNodeType: nodeTypesStore.getNodeType,
});
const { chatTriggerNode, connectedNode, allowFileUploads, allowedFilesMimeTypes } =
useChatTrigger({
workflow,
getNodeByName: workflowsStore.getNodeByName,
getNodeType: nodeTypesStore.getNodeType,
});
const { sendMessage, isLoading } = useChatMessaging({
chatTrigger: chatTriggerNode,
@@ -134,37 +123,6 @@ export function useChatState(isReadOnly: boolean, onWindowResize?: () => void):
provide(ChatSymbol, chatConfig);
provide(ChatOptionsSymbol, chatOptions);
// Watchers
watch(
() => logsPanelState.value,
(state) => {
if (state !== LOGS_PANEL_STATE.CLOSED) {
setChatTriggerNode();
setConnectedNode();
setTimeout(() => {
onWindowResize?.();
chatEventBus.emit('focusInput');
}, 0);
}
},
{ immediate: true },
);
watch(
() => allConnections.value,
() => {
if (canvasStore.isLoading) return;
setTimeout(() => {
if (!chatTriggerNode.value) {
setChatTriggerNode();
}
setConnectedNode();
}, 0);
},
{ deep: true },
);
// This function creates a promise that resolves when the workflow execution completes
// It's used to handle the loading state while waiting for the workflow to finish
async function createExecutionPromise() {

View File

@@ -1,11 +1,11 @@
import type { ComputedRef, MaybeRef } from 'vue';
import { ref, computed, unref } from 'vue';
import type { ComputedRef } from 'vue';
import { computed } from 'vue';
import {
CHAIN_SUMMARIZATION_LANGCHAIN_NODE_TYPE,
NodeConnectionTypes,
NodeHelpers,
} from 'n8n-workflow';
import type { INodeTypeDescription, Workflow, INode, INodeParameters } from 'n8n-workflow';
import type { INodeTypeDescription, Workflow, INodeParameters } from 'n8n-workflow';
import {
AI_CATEGORY_AGENTS,
AI_CATEGORY_CHAINS,
@@ -18,21 +18,12 @@ import { isChatNode } from '@/components/CanvasChat/utils';
export interface ChatTriggerDependencies {
getNodeByName: (name: string) => INodeUi | null;
getNodeType: (type: string, version: number) => INodeTypeDescription | null;
canvasNodes: MaybeRef<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,
export function useChatTrigger({ getNodeByName, getNodeType, workflow }: ChatTriggerDependencies) {
const chatTriggerNode = computed(
() => Object.values(workflow.value.nodes).find(isChatNode) ?? null,
);
const allowFileUploads = computed(() => {
@@ -49,22 +40,12 @@ export function useChatTrigger({
);
});
/** Gets the chat trigger node from the workflow */
function setChatTriggerNode() {
const triggerNode = unref(canvasNodes).find(isChatNode);
if (!triggerNode) {
return;
}
chatTriggerName.value = triggerNode.name;
}
/** Sets the connected node after finding the trigger */
function setConnectedNode() {
const connectedNode = computed(() => {
const triggerNode = chatTriggerNode.value;
if (!triggerNode) {
return;
return null;
}
const chatChildren = workflow.value.getChildNodes(triggerNode.name);
@@ -121,15 +102,14 @@ export function useChatTrigger({
const result = Boolean(isChatChild && (isAgent || isChain || isCustomChainOrAgent));
return result;
});
connectedNode.value = chatRootNode ?? null;
}
return chatRootNode ?? null;
});
return {
allowFileUploads,
allowedFilesMimeTypes,
chatTriggerNode,
connectedNode: computed(() => connectedNode.value),
setChatTriggerNode,
setConnectedNode,
connectedNode,
};
}

View File

@@ -146,6 +146,17 @@ function isLastChild(level: number) {
icon="exclamation-triangle"
:class="$style.compactErrorIcon"
/>
<N8nIconButton
v-if="!isCompact || !props.latestInfo?.deleted"
type="secondary"
size="medium"
icon="edit"
style="color: var(--color-text-base)"
:disabled="props.latestInfo?.deleted"
:class="$style.openNdvButton"
:aria-label="locale.baseText('logs.overview.body.open')"
@click.stop="emit('openNdv', props.data)"
/>
<N8nIconButton
v-if="
!isCompact ||
@@ -160,17 +171,6 @@ function isLastChild(level: number) {
:disabled="props.latestInfo?.deleted || props.latestInfo?.disabled"
@click.stop="emit('triggerPartialExecution', props.data)"
/>
<N8nIconButton
v-if="!isCompact || !props.latestInfo?.deleted"
type="secondary"
size="small"
icon="external-link-alt"
style="color: var(--color-text-base)"
:disabled="props.latestInfo?.deleted"
:class="$style.openNdvButton"
:aria-label="locale.baseText('logs.overview.body.open')"
@click.stop="emit('openNdv', props.data)"
/>
<N8nButton
v-if="!isCompact || props.data.children.length > 0"
type="secondary"

View File

@@ -9,6 +9,7 @@ import {
import {
createAiData,
createLogEntries,
deepToRaw,
findSelectedLogEntry,
getTreeNodeData,
getTreeNodeDataV2,
@@ -21,6 +22,7 @@ import {
} from 'n8n-workflow';
import { type LogEntrySelection } from '../CanvasChat/types/logs';
import { type IExecutionResponse } from '@/Interface';
import { isReactive, reactive } from 'vue';
describe(getTreeNodeData, () => {
it('should generate one node per execution', () => {
@@ -608,3 +610,28 @@ describe(createLogEntries, () => {
]);
});
});
describe(deepToRaw, () => {
it('should convert reactive fields to raw in data with circular structure', () => {
const data = reactive({
foo: reactive({ bar: {} }),
bazz: {},
});
data.foo.bar = data;
data.bazz = data;
const raw = deepToRaw(data);
expect(isReactive(data)).toBe(true);
expect(isReactive(data.foo)).toBe(true);
expect(isReactive(data.foo.bar)).toBe(true);
expect(isReactive(data.bazz)).toBe(true);
expect(isReactive(raw)).toBe(false);
expect(isReactive(raw.foo)).toBe(false);
expect(isReactive(raw.foo.bar)).toBe(false);
expect(isReactive(raw.bazz)).toBe(false);
console.log(raw.foo.bar);
});
});

View File

@@ -431,8 +431,18 @@ export function findSelectedLogEntry(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function deepToRaw<T>(sourceObj: T): T {
const seen = new WeakMap();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const objectIterator = (input: any): any => {
if (seen.has(input)) {
return input;
}
if (input !== null && typeof input === 'object') {
seen.set(input, true);
}
if (Array.isArray(input)) {
return input.map((item) => objectIterator(item));
}