mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Show logs panel in execution history page (#14477)
This commit is contained in:
@@ -18,7 +18,6 @@ const message: ChatMessage = {
|
|||||||
id: 'typing',
|
id: 'typing',
|
||||||
text: '',
|
text: '',
|
||||||
sender: 'bot',
|
sender: 'bot',
|
||||||
createdAt: '',
|
|
||||||
};
|
};
|
||||||
const messageContainer = ref<InstanceType<typeof Message>>();
|
const messageContainer = ref<InstanceType<typeof Message>>();
|
||||||
const classes = computed(() => {
|
const classes = computed(() => {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export const ChatPlugin: Plugin<ChatOptions> = {
|
|||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
text,
|
text,
|
||||||
sender: 'bot',
|
sender: 'bot',
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -31,7 +30,6 @@ export const ChatPlugin: Plugin<ChatOptions> = {
|
|||||||
text,
|
text,
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
files,
|
files,
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
messages.value.push(sentMessage);
|
messages.value.push(sentMessage);
|
||||||
@@ -62,7 +60,6 @@ export const ChatPlugin: Plugin<ChatOptions> = {
|
|||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
text: textMessage,
|
text: textMessage,
|
||||||
sender: 'bot',
|
sender: 'bot',
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
};
|
||||||
messages.value.push(receivedMessage);
|
messages.value.push(receivedMessage);
|
||||||
|
|
||||||
@@ -80,13 +77,11 @@ export const ChatPlugin: Plugin<ChatOptions> = {
|
|||||||
|
|
||||||
const sessionId = localStorage.getItem(localStorageSessionIdKey) ?? uuidv4();
|
const sessionId = localStorage.getItem(localStorageSessionIdKey) ?? uuidv4();
|
||||||
const previousMessagesResponse = await api.loadPreviousSession(sessionId, options);
|
const previousMessagesResponse = await api.loadPreviousSession(sessionId, options);
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
|
|
||||||
messages.value = (previousMessagesResponse?.data || []).map((message, index) => ({
|
messages.value = (previousMessagesResponse?.data || []).map((message, index) => ({
|
||||||
id: `${index}`,
|
id: `${index}`,
|
||||||
text: message.kwargs.content,
|
text: message.kwargs.content,
|
||||||
sender: message.id.includes('HumanMessage') ? 'user' : 'bot',
|
sender: message.id.includes('HumanMessage') ? 'user' : 'bot',
|
||||||
createdAt: timestamp,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (messages.value.length) {
|
if (messages.value.length) {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export interface ChatMessageText extends ChatMessageBase {
|
|||||||
|
|
||||||
interface ChatMessageBase {
|
interface ChatMessageBase {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
|
||||||
transparent?: boolean;
|
transparent?: boolean;
|
||||||
sender: 'user' | 'bot';
|
sender: 'user' | 'bot';
|
||||||
files?: File[];
|
files?: File[];
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
LoadedClass,
|
LoadedClass,
|
||||||
INodeTypeDescription,
|
INodeTypeDescription,
|
||||||
INodeIssues,
|
INodeIssues,
|
||||||
|
ITaskData,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeConnectionTypes, NodeHelpers, Workflow } from 'n8n-workflow';
|
import { NodeConnectionTypes, NodeHelpers, Workflow } from 'n8n-workflow';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
@@ -203,3 +204,15 @@ export function createTestNode(node: Partial<INode> = {}): INode {
|
|||||||
...node,
|
...node,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createTestTaskData(partialData: Partial<ITaskData>): ITaskData {
|
||||||
|
return {
|
||||||
|
startTime: 0,
|
||||||
|
executionTime: 1,
|
||||||
|
executionIndex: 0,
|
||||||
|
source: [],
|
||||||
|
executionStatus: 'success',
|
||||||
|
data: { main: [[{ json: {} }]] },
|
||||||
|
...partialData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -311,7 +311,6 @@ describe('CanvasChat', () => {
|
|||||||
id: '1',
|
id: '1',
|
||||||
text: 'Existing message',
|
text: 'Existing message',
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -321,7 +320,6 @@ describe('CanvasChat', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
extractResponseMessage: vi.fn(),
|
|
||||||
previousMessageIndex: ref(0),
|
previousMessageIndex: ref(0),
|
||||||
isLoading: computed(() => false),
|
isLoading: computed(() => false),
|
||||||
};
|
};
|
||||||
@@ -386,7 +384,6 @@ describe('CanvasChat', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
|
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
extractResponseMessage: vi.fn(),
|
|
||||||
previousMessageIndex: ref(0),
|
previousMessageIndex: ref(0),
|
||||||
isLoading: computed(() => false),
|
isLoading: computed(() => false),
|
||||||
});
|
});
|
||||||
@@ -472,13 +469,11 @@ describe('CanvasChat', () => {
|
|||||||
id: '1',
|
id: '1',
|
||||||
text: 'Original message',
|
text: 'Original message',
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
text: 'AI response',
|
text: 'AI response',
|
||||||
sender: 'bot',
|
sender: 'bot',
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
vi.spyOn(useChatMessaging, 'useChatMessaging').mockImplementation(({ messages }) => {
|
vi.spyOn(useChatMessaging, 'useChatMessaging').mockImplementation(({ messages }) => {
|
||||||
@@ -486,7 +481,6 @@ describe('CanvasChat', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
sendMessage: sendMessageSpy,
|
sendMessage: sendMessageSpy,
|
||||||
extractResponseMessage: vi.fn(),
|
|
||||||
previousMessageIndex: ref(0),
|
previousMessageIndex: ref(0),
|
||||||
isLoading: computed(() => false),
|
isLoading: computed(() => false),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ const workflowsStore = useWorkflowsStore();
|
|||||||
const canvasStore = useCanvasStore();
|
const canvasStore = useCanvasStore();
|
||||||
|
|
||||||
// Component state
|
// Component state
|
||||||
const isDisabled = ref(false);
|
|
||||||
const container = ref<HTMLElement>();
|
const container = ref<HTMLElement>();
|
||||||
const pipContainer = useTemplateRef('pipContainer');
|
const pipContainer = useTemplateRef('pipContainer');
|
||||||
const pipContent = useTemplateRef('pipContent');
|
const pipContent = useTemplateRef('pipContent');
|
||||||
@@ -69,13 +68,12 @@ const {
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
refreshSession,
|
refreshSession,
|
||||||
displayExecution,
|
displayExecution,
|
||||||
} = useChatState(isDisabled, onWindowResize);
|
} = useChatState(false, onWindowResize);
|
||||||
|
|
||||||
// Expose internal state for testing
|
// Expose internal state for testing
|
||||||
defineExpose({
|
defineExpose({
|
||||||
messages,
|
messages,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
isDisabled,
|
|
||||||
workflow,
|
workflow,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ interface Props {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
|
isReadOnly?: boolean;
|
||||||
isNewLogsEnabled?: boolean;
|
isNewLogsEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
|
isReadOnly: false,
|
||||||
isNewLogsEnabled: false,
|
isNewLogsEnabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -145,7 +147,7 @@ async function copySessionId() {
|
|||||||
@click="emit('clickHeader')"
|
@click="emit('clickHeader')"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<N8nTooltip v-if="clipboard.isSupported.value">
|
<N8nTooltip v-if="clipboard.isSupported.value && !isReadOnly">
|
||||||
<template #content>
|
<template #content>
|
||||||
{{ sessionId }}
|
{{ sessionId }}
|
||||||
<br />
|
<br />
|
||||||
@@ -160,7 +162,7 @@ async function copySessionId() {
|
|||||||
>
|
>
|
||||||
</N8nTooltip>
|
</N8nTooltip>
|
||||||
<N8nTooltip
|
<N8nTooltip
|
||||||
v-if="messages.length > 0"
|
v-if="messages.length > 0 && !isReadOnly"
|
||||||
:content="locale.baseText('chat.window.session.resetSession')"
|
:content="locale.baseText('chat.window.session.resetSession')"
|
||||||
>
|
>
|
||||||
<N8nIconButton
|
<N8nIconButton
|
||||||
@@ -223,7 +225,7 @@ async function copySessionId() {
|
|||||||
>
|
>
|
||||||
<template #beforeMessage="{ message }">
|
<template #beforeMessage="{ message }">
|
||||||
<MessageOptionTooltip
|
<MessageOptionTooltip
|
||||||
v-if="message.sender === 'bot' && !message.id.includes('preload')"
|
v-if="!isReadOnly && message.sender === 'bot' && !message.id.includes('preload')"
|
||||||
placement="right"
|
placement="right"
|
||||||
data-test-id="execution-id-tooltip"
|
data-test-id="execution-id-tooltip"
|
||||||
>
|
>
|
||||||
@@ -232,7 +234,7 @@ async function copySessionId() {
|
|||||||
</MessageOptionTooltip>
|
</MessageOptionTooltip>
|
||||||
|
|
||||||
<MessageOptionAction
|
<MessageOptionAction
|
||||||
v-if="isTextMessage(message) && message.sender === 'user'"
|
v-if="!isReadOnly && isTextMessage(message) && message.sender === 'user'"
|
||||||
data-test-id="repost-message-button"
|
data-test-id="repost-message-button"
|
||||||
icon="redo"
|
icon="redo"
|
||||||
:label="locale.baseText('chat.window.chat.chatMessageOptions.repostMessage')"
|
:label="locale.baseText('chat.window.chat.chatMessageOptions.repostMessage')"
|
||||||
@@ -241,7 +243,7 @@ async function copySessionId() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<MessageOptionAction
|
<MessageOptionAction
|
||||||
v-if="isTextMessage(message) && message.sender === 'user'"
|
v-if="!isReadOnly && isTextMessage(message) && message.sender === 'user'"
|
||||||
data-test-id="reuse-message-button"
|
data-test-id="reuse-message-button"
|
||||||
icon="copy"
|
icon="copy"
|
||||||
:label="locale.baseText('chat.window.chat.chatMessageOptions.reuseMessage')"
|
:label="locale.baseText('chat.window.chat.chatMessageOptions.reuseMessage')"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { ComputedRef, Ref } from 'vue';
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import type { ChatMessage } from '@n8n/chat/types';
|
import type { ChatMessage } from '@n8n/chat/types';
|
||||||
import { CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
|
||||||
import type {
|
import type {
|
||||||
ITaskData,
|
ITaskData,
|
||||||
INodeExecutionData,
|
INodeExecutionData,
|
||||||
@@ -15,10 +14,10 @@ import type {
|
|||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useMessage } from '@/composables/useMessage';
|
import { useMessage } from '@/composables/useMessage';
|
||||||
import { usePinnedData } from '@/composables/usePinnedData';
|
import { usePinnedData } from '@/composables/usePinnedData';
|
||||||
import { get, isEmpty } from 'lodash-es';
|
import { MODAL_CONFIRM } from '@/constants';
|
||||||
import { MANUAL_CHAT_TRIGGER_NODE_TYPE, MODAL_CONFIRM } from '@/constants';
|
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import type { IExecutionPushResponse, INodeUi } from '@/Interface';
|
import type { IExecutionPushResponse, INodeUi } from '@/Interface';
|
||||||
|
import { extractBotResponse, getInputKey } from '@/components/CanvasChat/utils';
|
||||||
|
|
||||||
export type RunWorkflowChatPayload = {
|
export type RunWorkflowChatPayload = {
|
||||||
triggerNode: string;
|
triggerNode: string;
|
||||||
@@ -106,13 +105,7 @@ export function useChatMessaging({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let inputKey = 'chatInput';
|
const inputKey = getInputKey(triggerNode);
|
||||||
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 = {
|
const inputPayload: INodeExecutionData = {
|
||||||
json: {
|
json: {
|
||||||
@@ -151,53 +144,17 @@ export function useChatMessaging({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
processExecutionResultData(response.executionId);
|
const chatMessage = executionResultData.value
|
||||||
|
? extractBotResponse(
|
||||||
|
executionResultData.value,
|
||||||
|
response.executionId,
|
||||||
|
locale.baseText('chat.window.chat.response.empty'),
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (chatMessage !== undefined) {
|
||||||
|
messages.value.push(chatMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
function processExecutionResultData(executionId: string) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
isLoading.value = false;
|
|
||||||
messages.value.push({
|
|
||||||
text: responseMessage,
|
|
||||||
sender: 'bot',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
id: executionId ?? uuid(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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 */
|
/** Sends a message to the chat */
|
||||||
@@ -230,7 +187,6 @@ export function useChatMessaging({
|
|||||||
const newMessage: ChatMessage & { sessionId: string } = {
|
const newMessage: ChatMessage & { sessionId: string } = {
|
||||||
text: message,
|
text: message,
|
||||||
sender: 'user',
|
sender: 'user',
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
sessionId: sessionId.value,
|
sessionId: sessionId.value,
|
||||||
id: uuid(),
|
id: uuid(),
|
||||||
files,
|
files,
|
||||||
@@ -244,6 +200,5 @@ export function useChatMessaging({
|
|||||||
previousMessageIndex,
|
previousMessageIndex,
|
||||||
isLoading: computed(() => isLoading.value),
|
isLoading: computed(() => isLoading.value),
|
||||||
sendMessage,
|
sendMessage,
|
||||||
extractResponseMessage,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { Ref } from 'vue';
|
|||||||
import { computed, provide, ref, watch } from 'vue';
|
import { computed, provide, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { LOGS_PANEL_STATE } from '../types/logs';
|
import { LOGS_PANEL_STATE } from '../types/logs';
|
||||||
|
import { restoreChatHistory } from '@/components/CanvasChat/utils';
|
||||||
|
|
||||||
interface ChatState {
|
interface ChatState {
|
||||||
currentSessionId: Ref<string>;
|
currentSessionId: Ref<string>;
|
||||||
@@ -29,7 +30,8 @@ interface ChatState {
|
|||||||
displayExecution: (executionId: string) => void;
|
displayExecution: (executionId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChatState(isDisabled: Ref<boolean>, onWindowResize: () => void): ChatState {
|
export function useChatState(isReadOnly: boolean, onWindowResize: () => void): ChatState {
|
||||||
|
const locale = useI18n();
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
const canvasStore = useCanvasStore();
|
const canvasStore = useCanvasStore();
|
||||||
@@ -114,11 +116,18 @@ export function useChatState(isDisabled: Ref<boolean>, onWindowResize: () => voi
|
|||||||
sendMessage,
|
sendMessage,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
isLoading,
|
isLoading,
|
||||||
isDisabled,
|
isDisabled: computed(() => isReadOnly),
|
||||||
allowFileUploads,
|
allowFileUploads,
|
||||||
locale: useI18n(),
|
locale,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const restoredChatMessages = computed(() =>
|
||||||
|
restoreChatHistory(
|
||||||
|
workflowsStore.workflowExecutionData,
|
||||||
|
locale.baseText('chat.window.chat.response.empty'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Provide chat context
|
// Provide chat context
|
||||||
provide(ChatSymbol, chatConfig);
|
provide(ChatSymbol, chatConfig);
|
||||||
provide(ChatOptionsSymbol, chatOptions);
|
provide(ChatOptionsSymbol, chatOptions);
|
||||||
@@ -210,7 +219,7 @@ export function useChatState(isDisabled: Ref<boolean>, onWindowResize: () => voi
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
messages,
|
messages: computed(() => (isReadOnly ? restoredChatMessages.value : messages.value)),
|
||||||
chatTriggerNode,
|
chatTriggerNode,
|
||||||
connectedNode,
|
connectedNode,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
|||||||
@@ -11,10 +11,9 @@ import {
|
|||||||
AI_CATEGORY_CHAINS,
|
AI_CATEGORY_CHAINS,
|
||||||
AI_CODE_NODE_TYPE,
|
AI_CODE_NODE_TYPE,
|
||||||
AI_SUBCATEGORY,
|
AI_SUBCATEGORY,
|
||||||
CHAT_TRIGGER_NODE_TYPE,
|
|
||||||
MANUAL_CHAT_TRIGGER_NODE_TYPE,
|
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import type { INodeUi } from '@/Interface';
|
import type { INodeUi } from '@/Interface';
|
||||||
|
import { isChatNode } from '@/components/CanvasChat/utils';
|
||||||
|
|
||||||
export interface ChatTriggerDependencies {
|
export interface ChatTriggerDependencies {
|
||||||
getNodeByName: (name: string) => INodeUi | null;
|
getNodeByName: (name: string) => INodeUi | null;
|
||||||
@@ -52,9 +51,7 @@ export function useChatTrigger({
|
|||||||
|
|
||||||
/** Gets the chat trigger node from the workflow */
|
/** Gets the chat trigger node from the workflow */
|
||||||
function setChatTriggerNode() {
|
function setChatTriggerNode() {
|
||||||
const triggerNode = unref(canvasNodes).find((node) =>
|
const triggerNode = unref(canvasNodes).find(isChatNode);
|
||||||
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(node.type),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!triggerNode) {
|
if (!triggerNode) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ export function usePiPWindow({
|
|||||||
}: UsePiPWindowOptions): UsePiPWindowReturn {
|
}: UsePiPWindowOptions): UsePiPWindowReturn {
|
||||||
const pipWindow = ref<Window>();
|
const pipWindow = ref<Window>();
|
||||||
const isUnmounting = ref(false);
|
const isUnmounting = ref(false);
|
||||||
const canPopOut = computed(() => !!window.documentPictureInPicture);
|
const canPopOut = computed(
|
||||||
|
() =>
|
||||||
|
!!window.documentPictureInPicture /* Browser supports the API */ &&
|
||||||
|
window.parent === window /* Not in iframe */,
|
||||||
|
);
|
||||||
const isPoppedOut = computed(() => !!pipWindow.value);
|
const isPoppedOut = computed(() => !!pipWindow.value);
|
||||||
const tooltipContainer = computed(() =>
|
const tooltipContainer = computed(() =>
|
||||||
isPoppedOut.value ? (content.value ?? undefined) : undefined,
|
isPoppedOut.value ? (content.value ?? undefined) : undefined,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ describe('LogsPanel', () => {
|
|||||||
|
|
||||||
workflowsStore = mockedStore(useWorkflowsStore);
|
workflowsStore = mockedStore(useWorkflowsStore);
|
||||||
workflowsStore.setWorkflowExecutionData(null);
|
workflowsStore.setWorkflowExecutionData(null);
|
||||||
|
workflowsStore.toggleLogsPanelOpen(false);
|
||||||
|
|
||||||
nodeTypeStore = mockedStore(useNodeTypesStore);
|
nodeTypeStore = mockedStore(useNodeTypesStore);
|
||||||
nodeTypeStore.setNodeTypes(nodeTypes);
|
nodeTypeStore.setNodeTypes(nodeTypes);
|
||||||
|
|||||||
@@ -6,37 +6,66 @@ import { useChatState } from '@/components/CanvasChat/composables/useChatState';
|
|||||||
import { useResize } from '@/components/CanvasChat/composables/useResize';
|
import { useResize } from '@/components/CanvasChat/composables/useResize';
|
||||||
import { usePiPWindow } from '@/components/CanvasChat/composables/usePiPWindow';
|
import { usePiPWindow } from '@/components/CanvasChat/composables/usePiPWindow';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE } from '@/constants';
|
|
||||||
import LogsOverviewPanel from '@/components/CanvasChat/future/components/LogsOverviewPanel.vue';
|
import LogsOverviewPanel from '@/components/CanvasChat/future/components/LogsOverviewPanel.vue';
|
||||||
import { useCanvasStore } from '@/stores/canvas.store';
|
import { useCanvasStore } from '@/stores/canvas.store';
|
||||||
import ChatMessagesPanel from '@/components/CanvasChat/components/ChatMessagesPanel.vue';
|
import ChatMessagesPanel from '@/components/CanvasChat/components/ChatMessagesPanel.vue';
|
||||||
import LogsDetailsPanel from '@/components/CanvasChat/future/components/LogDetailsPanel.vue';
|
import LogsDetailsPanel from '@/components/CanvasChat/future/components/LogDetailsPanel.vue';
|
||||||
import { LOGS_PANEL_STATE, type LogEntryIdentity } from '@/components/CanvasChat/types/logs';
|
import { LOGS_PANEL_STATE, type LogEntrySelection } from '@/components/CanvasChat/types/logs';
|
||||||
import LogsPanelActions from '@/components/CanvasChat/future/components/LogsPanelActions.vue';
|
import LogsPanelActions from '@/components/CanvasChat/future/components/LogsPanelActions.vue';
|
||||||
|
import {
|
||||||
|
createLogEntries,
|
||||||
|
findLogEntryToAutoSelect,
|
||||||
|
type TreeNode,
|
||||||
|
} from '@/components/RunDataAi/utils';
|
||||||
|
import { isChatNode } from '@/components/CanvasChat/utils';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ isReadOnly?: boolean }>(), { isReadOnly: false });
|
||||||
|
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const canvasStore = useCanvasStore();
|
const canvasStore = useCanvasStore();
|
||||||
const panelState = computed(() => workflowsStore.logsPanelState);
|
const panelState = computed(() => workflowsStore.logsPanelState);
|
||||||
const container = ref<HTMLElement>();
|
const container = ref<HTMLElement>();
|
||||||
const selectedLogEntry = ref<LogEntryIdentity | undefined>(undefined);
|
|
||||||
const pipContainer = useTemplateRef('pipContainer');
|
const pipContainer = useTemplateRef('pipContainer');
|
||||||
const pipContent = useTemplateRef('pipContent');
|
const pipContent = useTemplateRef('pipContent');
|
||||||
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
|
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
|
||||||
const hasChat = computed(() =>
|
|
||||||
workflowsStore.workflowTriggerNodes.some((node) =>
|
|
||||||
[CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(node.type),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
const { rootStyles, height, chatWidth, onWindowResize, onResizeDebounced, onResizeChatDebounced } =
|
const { rootStyles, height, chatWidth, onWindowResize, onResizeDebounced, onResizeChatDebounced } =
|
||||||
useResize(container);
|
useResize(container);
|
||||||
|
|
||||||
const { currentSessionId, messages, sendMessage, refreshSession, displayExecution } = useChatState(
|
const { currentSessionId, messages, sendMessage, refreshSession, displayExecution } = useChatState(
|
||||||
ref(false),
|
props.isReadOnly,
|
||||||
onWindowResize,
|
onWindowResize,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasChat = computed(
|
||||||
|
() =>
|
||||||
|
workflowsStore.workflowTriggerNodes.some(isChatNode) &&
|
||||||
|
(!props.isReadOnly || messages.value.length > 0),
|
||||||
|
);
|
||||||
|
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
||||||
|
const executionTree = computed<TreeNode[]>(() =>
|
||||||
|
createLogEntries(
|
||||||
|
workflow.value,
|
||||||
|
workflowsStore.workflowExecutionData?.data?.resultData.runData ?? {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const manualLogEntrySelection = ref<LogEntrySelection>({ type: 'initial' });
|
||||||
|
const autoSelectedLogEntry = computed(() =>
|
||||||
|
findLogEntryToAutoSelect(
|
||||||
|
executionTree.value,
|
||||||
|
workflowsStore.nodesByName,
|
||||||
|
workflowsStore.workflowExecutionData?.data?.resultData.runData ?? {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const selectedLogEntry = computed(() =>
|
||||||
|
manualLogEntrySelection.value.type === 'initial' ||
|
||||||
|
manualLogEntrySelection.value.workflowId !== workflowsStore.workflow.id
|
||||||
|
? autoSelectedLogEntry.value
|
||||||
|
: manualLogEntrySelection.value.type === 'none'
|
||||||
|
? undefined
|
||||||
|
: manualLogEntrySelection.value.data,
|
||||||
|
);
|
||||||
const isLogDetailsOpen = computed(() => selectedLogEntry.value !== undefined);
|
const isLogDetailsOpen = computed(() => selectedLogEntry.value !== undefined);
|
||||||
|
|
||||||
const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
|
const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
|
||||||
@@ -76,8 +105,11 @@ function handleClickHeader() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectLogEntry(selected: LogEntryIdentity | undefined) {
|
function handleSelectLogEntry(selected: TreeNode | undefined) {
|
||||||
selectedLogEntry.value = selected;
|
manualLogEntrySelection.value =
|
||||||
|
selected === undefined
|
||||||
|
? { type: 'none', workflowId: workflowsStore.workflow.id }
|
||||||
|
: { type: 'selected', workflowId: workflowsStore.workflow.id, data: selected };
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPopOut() {
|
function onPopOut() {
|
||||||
@@ -121,6 +153,7 @@ watch([panelState, height], ([state, h]) => {
|
|||||||
<ChatMessagesPanel
|
<ChatMessagesPanel
|
||||||
data-test-id="canvas-chat"
|
data-test-id="canvas-chat"
|
||||||
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
|
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
|
||||||
|
:is-read-only="isReadOnly"
|
||||||
:messages="messages"
|
:messages="messages"
|
||||||
:session-id="currentSessionId"
|
:session-id="currentSessionId"
|
||||||
:past-chat-messages="previousChatMessages"
|
:past-chat-messages="previousChatMessages"
|
||||||
@@ -136,7 +169,9 @@ watch([panelState, height], ([state, h]) => {
|
|||||||
<LogsOverviewPanel
|
<LogsOverviewPanel
|
||||||
:class="$style.logsOverview"
|
:class="$style.logsOverview"
|
||||||
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
|
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
|
||||||
|
:is-read-only="isReadOnly"
|
||||||
:selected="selectedLogEntry"
|
:selected="selectedLogEntry"
|
||||||
|
:execution-tree="executionTree"
|
||||||
@click-header="handleClickHeader"
|
@click-header="handleClickHeader"
|
||||||
@select="handleSelectLogEntry"
|
@select="handleSelectLogEntry"
|
||||||
>
|
>
|
||||||
@@ -145,7 +180,7 @@ watch([panelState, height], ([state, h]) => {
|
|||||||
</template>
|
</template>
|
||||||
</LogsOverviewPanel>
|
</LogsOverviewPanel>
|
||||||
<LogsDetailsPanel
|
<LogsDetailsPanel
|
||||||
v-if="selectedLogEntry"
|
v-if="selectedLogEntry !== undefined"
|
||||||
:class="$style.logDetails"
|
:class="$style.logDetails"
|
||||||
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
|
:is-open="panelState !== LOGS_PANEL_STATE.CLOSED"
|
||||||
@click-header="handleClickHeader"
|
@click-header="handleClickHeader"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import LogsPanel from '@/components/CanvasChat/future/LogsPanel.vue';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const { isNewLogsEnabled } = useSettingsStore();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const hasExecutionData = computed(() => workflowsStore.workflowExecutionData);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LogsPanel v-if="isNewLogsEnabled && hasExecutionData" :is-read-only="true" />
|
||||||
|
</template>
|
||||||
@@ -5,10 +5,9 @@ import { createTestingPinia, type TestingPinia } from '@pinia/testing';
|
|||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import { h, type ExtractPropTypes } from 'vue';
|
import { h } from 'vue';
|
||||||
import { fireEvent, waitFor, within } from '@testing-library/vue';
|
import { fireEvent, waitFor, within } from '@testing-library/vue';
|
||||||
import {
|
import {
|
||||||
aiAgentNode,
|
|
||||||
aiChatExecutionResponse,
|
aiChatExecutionResponse,
|
||||||
aiChatWorkflow,
|
aiChatWorkflow,
|
||||||
aiManualExecutionResponse,
|
aiManualExecutionResponse,
|
||||||
@@ -16,6 +15,7 @@ import {
|
|||||||
} from '../../__test__/data';
|
} from '../../__test__/data';
|
||||||
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
|
import { createLogEntries } from '@/components/RunDataAi/utils';
|
||||||
|
|
||||||
describe('LogsOverviewPanel', () => {
|
describe('LogsOverviewPanel', () => {
|
||||||
let pinia: TestingPinia;
|
let pinia: TestingPinia;
|
||||||
@@ -23,9 +23,19 @@ describe('LogsOverviewPanel', () => {
|
|||||||
let pushConnectionStore: ReturnType<typeof mockedStore<typeof usePushConnectionStore>>;
|
let pushConnectionStore: ReturnType<typeof mockedStore<typeof usePushConnectionStore>>;
|
||||||
let ndvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
|
let ndvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
|
||||||
|
|
||||||
function render(props: ExtractPropTypes<typeof LogsOverviewPanel>) {
|
function render(props: Partial<InstanceType<typeof LogsOverviewPanel>['$props']>) {
|
||||||
|
const mergedProps: InstanceType<typeof LogsOverviewPanel>['$props'] = {
|
||||||
|
isOpen: false,
|
||||||
|
isReadOnly: false,
|
||||||
|
executionTree: createLogEntries(
|
||||||
|
workflowsStore.getCurrentWorkflow(),
|
||||||
|
workflowsStore.workflowExecutionData?.data?.resultData.runData ?? {},
|
||||||
|
),
|
||||||
|
...props,
|
||||||
|
};
|
||||||
|
|
||||||
return renderComponent(LogsOverviewPanel, {
|
return renderComponent(LogsOverviewPanel, {
|
||||||
props,
|
props: mergedProps,
|
||||||
global: {
|
global: {
|
||||||
plugins: [
|
plugins: [
|
||||||
createRouter({
|
createRouter({
|
||||||
@@ -54,13 +64,13 @@ describe('LogsOverviewPanel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not render body if the panel is not open', () => {
|
it('should not render body if the panel is not open', () => {
|
||||||
const rendered = render({ isOpen: false, node: null });
|
const rendered = render({ isOpen: false });
|
||||||
|
|
||||||
expect(rendered.queryByTestId('logs-overview-empty')).not.toBeInTheDocument();
|
expect(rendered.queryByTestId('logs-overview-empty')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render empty text if there is no execution', () => {
|
it('should render empty text if there is no execution', () => {
|
||||||
const rendered = render({ isOpen: true, node: null });
|
const rendered = render({ isOpen: true });
|
||||||
|
|
||||||
expect(rendered.queryByTestId('logs-overview-empty')).toBeInTheDocument();
|
expect(rendered.queryByTestId('logs-overview-empty')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -68,7 +78,7 @@ describe('LogsOverviewPanel', () => {
|
|||||||
it('should render summary text and executed nodes if there is an execution', async () => {
|
it('should render summary text and executed nodes if there is an execution', async () => {
|
||||||
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
|
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
|
||||||
|
|
||||||
const rendered = render({ isOpen: true, node: aiAgentNode });
|
const rendered = render({ isOpen: true });
|
||||||
const summary = within(rendered.container.querySelector('.summary')!);
|
const summary = within(rendered.container.querySelector('.summary')!);
|
||||||
|
|
||||||
expect(summary.queryByText('Success in 1.999s')).toBeInTheDocument();
|
expect(summary.queryByText('Success in 1.999s')).toBeInTheDocument();
|
||||||
@@ -101,7 +111,7 @@ describe('LogsOverviewPanel', () => {
|
|||||||
it('should open NDV if the button is clicked', async () => {
|
it('should open NDV if the button is clicked', async () => {
|
||||||
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
|
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
|
||||||
|
|
||||||
const rendered = render({ isOpen: true, node: aiAgentNode });
|
const rendered = render({ isOpen: true });
|
||||||
const aiAgentRow = rendered.getAllByRole('treeitem')[0];
|
const aiAgentRow = rendered.getAllByRole('treeitem')[0];
|
||||||
|
|
||||||
await fireEvent.click(within(aiAgentRow).getAllByLabelText('Open...')[0]);
|
await fireEvent.click(within(aiAgentRow).getAllByLabelText('Open...')[0]);
|
||||||
@@ -117,7 +127,7 @@ describe('LogsOverviewPanel', () => {
|
|||||||
|
|
||||||
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
|
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
|
||||||
|
|
||||||
const rendered = render({ isOpen: true, node: aiAgentNode });
|
const rendered = render({ isOpen: true });
|
||||||
const aiAgentRow = rendered.getAllByRole('treeitem')[0];
|
const aiAgentRow = rendered.getAllByRole('treeitem')[0];
|
||||||
await fireEvent.click(within(aiAgentRow).getAllByLabelText('Test step')[0]);
|
await fireEvent.click(within(aiAgentRow).getAllByLabelText('Test step')[0]);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { N8nButton, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-sys
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { ElTree, type TreeNode as ElTreeNode } from 'element-plus';
|
import { ElTree, type TreeNode as ElTreeNode } from 'element-plus';
|
||||||
import {
|
import {
|
||||||
createLogEntries,
|
|
||||||
getSubtreeTotalConsumedTokens,
|
getSubtreeTotalConsumedTokens,
|
||||||
getTotalConsumedTokens,
|
getTotalConsumedTokens,
|
||||||
type TreeNode,
|
type TreeNode,
|
||||||
@@ -16,18 +15,18 @@ import {
|
|||||||
import { upperFirst } from 'lodash-es';
|
import { upperFirst } from 'lodash-es';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue';
|
import ConsumedTokenCountText from '@/components/CanvasChat/future/components/ConsumedTokenCountText.vue';
|
||||||
import { type LogEntryIdentity } from '@/components/CanvasChat/types/logs';
|
|
||||||
import LogsOverviewRow from '@/components/CanvasChat/future/components/LogsOverviewRow.vue';
|
|
||||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
const { isOpen, selected } = defineProps<{
|
const { isOpen, isReadOnly, selected, executionTree } = defineProps<{
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
selected?: LogEntryIdentity;
|
isReadOnly: boolean;
|
||||||
|
selected?: TreeNode;
|
||||||
|
executionTree: TreeNode[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{ clickHeader: []; select: [LogEntryIdentity | undefined] }>();
|
const emit = defineEmits<{ clickHeader: []; select: [TreeNode | undefined] }>();
|
||||||
|
|
||||||
defineSlots<{ actions: {} }>();
|
defineSlots<{ actions: {} }>();
|
||||||
|
|
||||||
@@ -40,12 +39,6 @@ const ndvStore = useNDVStore();
|
|||||||
const nodeHelpers = useNodeHelpers();
|
const nodeHelpers = useNodeHelpers();
|
||||||
const isClearExecutionButtonVisible = useClearExecutionButtonVisible();
|
const isClearExecutionButtonVisible = useClearExecutionButtonVisible();
|
||||||
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
||||||
const executionTree = computed<TreeNode[]>(() =>
|
|
||||||
createLogEntries(
|
|
||||||
workflow.value,
|
|
||||||
workflowsStore.workflowExecutionData?.data?.resultData.runData ?? {},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const isEmpty = computed(() => workflowsStore.workflowExecutionData === null);
|
const isEmpty = computed(() => workflowsStore.workflowExecutionData === null);
|
||||||
const switchViewOptions = computed(() => [
|
const switchViewOptions = computed(() => [
|
||||||
{ label: locale.baseText('logs.overview.header.switch.details'), value: 'details' as const },
|
{ label: locale.baseText('logs.overview.header.switch.details'), value: 'details' as const },
|
||||||
@@ -73,7 +66,7 @@ const executionStatusText = computed(() => {
|
|||||||
return upperFirst(execution.status);
|
return upperFirst(execution.status);
|
||||||
});
|
});
|
||||||
const consumedTokens = computed(() =>
|
const consumedTokens = computed(() =>
|
||||||
getTotalConsumedTokens(...executionTree.value.map(getSubtreeTotalConsumedTokens)),
|
getTotalConsumedTokens(...executionTree.map(getSubtreeTotalConsumedTokens)),
|
||||||
);
|
);
|
||||||
|
|
||||||
function onClearExecutionData() {
|
function onClearExecutionData() {
|
||||||
@@ -82,12 +75,12 @@ function onClearExecutionData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleClickNode(clicked: TreeNode) {
|
function handleClickNode(clicked: TreeNode) {
|
||||||
if (selected?.node === clicked.node && selected.runIndex === clicked.runIndex) {
|
if (selected?.node === clicked.node && selected?.runIndex === clicked.runIndex) {
|
||||||
emit('select', undefined);
|
emit('select', undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('select', { node: clicked.node, runIndex: clicked.runIndex });
|
emit('select', clicked);
|
||||||
telemetry.track('User selected node in log view', {
|
telemetry.track('User selected node in log view', {
|
||||||
node_type: workflowsStore.nodesByName[clicked.node].type,
|
node_type: workflowsStore.nodesByName[clicked.node].type,
|
||||||
node_id: workflowsStore.nodesByName[clicked.node].id,
|
node_id: workflowsStore.nodesByName[clicked.node].id,
|
||||||
@@ -97,10 +90,7 @@ function handleClickNode(clicked: TreeNode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSwitchView(value: 'overview' | 'details') {
|
function handleSwitchView(value: 'overview' | 'details') {
|
||||||
emit(
|
emit('select', value === 'overview' || executionTree.length === 0 ? undefined : executionTree[0]);
|
||||||
'select',
|
|
||||||
value === 'overview' || executionTree.value.length === 0 ? undefined : executionTree.value[0],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleToggleExpanded(treeNode: ElTreeNode) {
|
function handleToggleExpanded(treeNode: ElTreeNode) {
|
||||||
@@ -183,6 +173,7 @@ async function handleTriggerPartialExecution(treeNode: TreeNode) {
|
|||||||
<LogsOverviewRow
|
<LogsOverviewRow
|
||||||
:data="data"
|
:data="data"
|
||||||
:node="elTreeNode"
|
:node="elTreeNode"
|
||||||
|
:is-read-only="isReadOnly"
|
||||||
:is-selected="data.node === selected?.node && data.runIndex === selected?.runIndex"
|
:is-selected="data.node === selected?.node && data.runIndex === selected?.runIndex"
|
||||||
:is-compact="selected !== undefined"
|
:is-compact="selected !== undefined"
|
||||||
:should-show-consumed-tokens="consumedTokens.totalTokens > 0"
|
:should-show-consumed-tokens="consumedTokens.totalTokens > 0"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { type TreeNode as ElTreeNode } from 'element-plus';
|
import { type TreeNode as ElTreeNode } from 'element-plus';
|
||||||
import { getSubtreeTotalConsumedTokens, type TreeNode } from '@/components/RunDataAi/utils';
|
import { getSubtreeTotalConsumedTokens, type TreeNode } from '@/components/RunDataAi/utils';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { computed } from 'vue';
|
import { computed, useTemplateRef, watch } from 'vue';
|
||||||
import { type INodeUi } from '@/Interface';
|
import { type INodeUi } from '@/Interface';
|
||||||
import { N8nButton, N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system';
|
import { N8nButton, N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system';
|
||||||
import { type ITaskData } from 'n8n-workflow';
|
import { type ITaskData } from 'n8n-workflow';
|
||||||
@@ -17,6 +17,7 @@ const props = defineProps<{
|
|||||||
data: TreeNode;
|
data: TreeNode;
|
||||||
node: ElTreeNode;
|
node: ElTreeNode;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
isReadOnly: boolean;
|
||||||
shouldShowConsumedTokens: boolean;
|
shouldShowConsumedTokens: boolean;
|
||||||
isCompact: boolean;
|
isCompact: boolean;
|
||||||
}>();
|
}>();
|
||||||
@@ -28,6 +29,7 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const locale = useI18n();
|
const locale = useI18n();
|
||||||
|
const containerRef = useTemplateRef('containerRef');
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const nodeTypeStore = useNodeTypesStore();
|
const nodeTypeStore = useNodeTypesStore();
|
||||||
const node = computed<INodeUi | undefined>(() => workflowsStore.nodesByName[props.data.node]);
|
const node = computed<INodeUi | undefined>(() => workflowsStore.nodesByName[props.data.node]);
|
||||||
@@ -77,11 +79,23 @@ function isLastChild(level: number) {
|
|||||||
(data?.node === lastSibling?.node && data?.runIndex === lastSibling?.runIndex)
|
(data?.node === lastSibling?.node && data?.runIndex === lastSibling?.runIndex)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When selected, scroll into view
|
||||||
|
watch(
|
||||||
|
[() => props.isSelected, containerRef],
|
||||||
|
([isSelected, ref]) => {
|
||||||
|
if (isSelected && ref) {
|
||||||
|
ref.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="node !== undefined"
|
v-if="node !== undefined"
|
||||||
|
ref="containerRef"
|
||||||
:class="{
|
:class="{
|
||||||
[$style.container]: true,
|
[$style.container]: true,
|
||||||
[$style.compact]: props.isCompact,
|
[$style.compact]: props.isCompact,
|
||||||
@@ -106,8 +120,8 @@ function isLastChild(level: number) {
|
|||||||
size="small"
|
size="small"
|
||||||
:class="$style.name"
|
:class="$style.name"
|
||||||
:color="isError ? 'danger' : undefined"
|
:color="isError ? 'danger' : undefined"
|
||||||
>{{ node.name }}</N8nText
|
>{{ node.name }}
|
||||||
>
|
</N8nText>
|
||||||
<N8nText tag="div" color="text-light" size="small" :class="$style.timeTook">
|
<N8nText tag="div" color="text-light" size="small" :class="$style.timeTook">
|
||||||
<I18nT v-if="isSettled && runData" keypath="logs.overview.body.summaryText">
|
<I18nT v-if="isSettled && runData" keypath="logs.overview.body.summaryText">
|
||||||
<template #status>
|
<template #status>
|
||||||
@@ -148,6 +162,7 @@ function isLastChild(level: number) {
|
|||||||
:class="$style.compactErrorIcon"
|
:class="$style.compactErrorIcon"
|
||||||
/>
|
/>
|
||||||
<N8nIconButton
|
<N8nIconButton
|
||||||
|
v-if="!props.isReadOnly"
|
||||||
type="secondary"
|
type="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
icon="play"
|
icon="play"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
export interface LogEntryIdentity {
|
import { type TreeNode } from '@/components/RunDataAi/utils';
|
||||||
node: string;
|
|
||||||
runIndex: number;
|
export type LogEntrySelection =
|
||||||
}
|
| { type: 'initial' }
|
||||||
|
| { type: 'selected'; workflowId: string; data: TreeNode }
|
||||||
|
| { type: 'none'; workflowId: string };
|
||||||
|
|
||||||
export const LOGS_PANEL_STATE = {
|
export const LOGS_PANEL_STATE = {
|
||||||
CLOSED: 'closed',
|
CLOSED: 'closed',
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { createTestNode, createTestTaskData, createTestWorkflow } from '@/__tests__/mocks';
|
||||||
|
import { restoreChatHistory } from '@/components/CanvasChat/utils';
|
||||||
|
import { AGENT_NODE_TYPE, CHAT_TRIGGER_NODE_TYPE } from '@/constants';
|
||||||
|
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||||
|
|
||||||
|
describe(restoreChatHistory, () => {
|
||||||
|
it('should return extracted chat input and bot message from workflow execution data', () => {
|
||||||
|
expect(
|
||||||
|
restoreChatHistory({
|
||||||
|
id: 'test-exec-id',
|
||||||
|
workflowData: createTestWorkflow({
|
||||||
|
nodes: [
|
||||||
|
createTestNode({ name: 'A', type: CHAT_TRIGGER_NODE_TYPE }),
|
||||||
|
createTestNode({ name: 'B', type: AGENT_NODE_TYPE }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
data: {
|
||||||
|
resultData: {
|
||||||
|
lastNodeExecuted: 'B',
|
||||||
|
runData: {
|
||||||
|
A: [
|
||||||
|
createTestTaskData({
|
||||||
|
startTime: Date.parse('2025-04-20T00:00:01.000Z'),
|
||||||
|
data: { [NodeConnectionTypes.Main]: [[{ json: { chatInput: 'test input' } }]] },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
B: [
|
||||||
|
createTestTaskData({
|
||||||
|
startTime: Date.parse('2025-04-20T00:00:02.000Z'),
|
||||||
|
executionTime: 999,
|
||||||
|
data: { [NodeConnectionTypes.Main]: [[{ json: { output: 'test output' } }]] },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
finished: true,
|
||||||
|
mode: 'manual',
|
||||||
|
status: 'success',
|
||||||
|
startedAt: '2025-04-20T00:00:00.000Z',
|
||||||
|
createdAt: '2025-04-20T00:00:00.000Z',
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
{ id: expect.any(String), sender: 'user', text: 'test input' },
|
||||||
|
{ id: 'test-exec-id', sender: 'bot', text: 'test output' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
122
packages/frontend/editor-ui/src/components/CanvasChat/utils.ts
Normal file
122
packages/frontend/editor-ui/src/components/CanvasChat/utils.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE } from '@/constants';
|
||||||
|
import { type IExecutionResponse, type INodeUi, type IWorkflowDb } from '@/Interface';
|
||||||
|
import { type ChatMessage } from '@n8n/chat/types';
|
||||||
|
import { get, isEmpty } from 'lodash-es';
|
||||||
|
import { NodeConnectionTypes, type IDataObject, type IRunExecutionData } from 'n8n-workflow';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
export function isChatNode(node: INodeUi) {
|
||||||
|
return [CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(node.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInputKey(node: INodeUi): string {
|
||||||
|
if (node.type === MANUAL_CHAT_TRIGGER_NODE_TYPE && node.typeVersion < 1.1) {
|
||||||
|
return 'input';
|
||||||
|
}
|
||||||
|
if (node.type === CHAT_TRIGGER_NODE_TYPE) {
|
||||||
|
return 'chatInput';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'chatInput';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractChatInput(
|
||||||
|
workflow: IWorkflowDb,
|
||||||
|
resultData: IRunExecutionData['resultData'],
|
||||||
|
): ChatMessage | undefined {
|
||||||
|
const chatTrigger = workflow.nodes.find(isChatNode);
|
||||||
|
|
||||||
|
if (chatTrigger === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputKey = getInputKey(chatTrigger);
|
||||||
|
const runData = (resultData.runData[chatTrigger.name] ?? [])[0];
|
||||||
|
const message = runData?.data?.[NodeConnectionTypes.Main]?.[0]?.[0]?.json?.[inputKey];
|
||||||
|
|
||||||
|
if (runData === undefined || typeof message !== 'string') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: message,
|
||||||
|
sender: 'user',
|
||||||
|
id: uuid(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractBotResponse(
|
||||||
|
resultData: IRunExecutionData['resultData'],
|
||||||
|
executionId: string,
|
||||||
|
emptyText?: string,
|
||||||
|
): ChatMessage | undefined {
|
||||||
|
const lastNodeExecuted = resultData.lastNodeExecuted;
|
||||||
|
|
||||||
|
if (!lastNodeExecuted) return undefined;
|
||||||
|
|
||||||
|
const nodeResponseDataArray = get(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');
|
||||||
|
const text = extractResponseText(responseData) ?? emptyText;
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
responseMessage = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: responseMessage,
|
||||||
|
sender: 'bot',
|
||||||
|
id: executionId ?? uuid(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts response message from workflow output */
|
||||||
|
function extractResponseText(responseData?: IDataObject): string | undefined {
|
||||||
|
if (!responseData || isEmpty(responseData)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreChatHistory(
|
||||||
|
workflowExecutionData: IExecutionResponse | null,
|
||||||
|
emptyText?: string,
|
||||||
|
): ChatMessage[] {
|
||||||
|
if (!workflowExecutionData?.data) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMessage = extractChatInput(
|
||||||
|
workflowExecutionData.workflowData,
|
||||||
|
workflowExecutionData.data.resultData,
|
||||||
|
);
|
||||||
|
const botMessage = extractBotResponse(
|
||||||
|
workflowExecutionData.data.resultData,
|
||||||
|
workflowExecutionData.id,
|
||||||
|
emptyText,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...(userMessage ? [userMessage] : []), ...(botMessage ? [botMessage] : [])];
|
||||||
|
}
|
||||||
@@ -1,16 +1,28 @@
|
|||||||
import { createTestNode, createTestWorkflowObject } from '@/__tests__/mocks';
|
import { createTestNode, createTestTaskData, createTestWorkflowObject } from '@/__tests__/mocks';
|
||||||
import { createAiData, createLogEntries, getTreeNodeData } from '@/components/RunDataAi/utils';
|
import {
|
||||||
import { type ITaskData, NodeConnectionTypes } from 'n8n-workflow';
|
createAiData,
|
||||||
|
findLogEntryToAutoSelect,
|
||||||
|
getTreeNodeData,
|
||||||
|
createLogEntries,
|
||||||
|
type TreeNode,
|
||||||
|
} from '@/components/RunDataAi/utils';
|
||||||
|
import {
|
||||||
|
AGENT_LANGCHAIN_NODE_TYPE,
|
||||||
|
type ExecutionError,
|
||||||
|
type ITaskData,
|
||||||
|
NodeConnectionTypes,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
function createTaskData(partialData: Partial<ITaskData>): ITaskData {
|
function createTestLogEntry(data: Partial<TreeNode>): TreeNode {
|
||||||
return {
|
return {
|
||||||
|
node: 'test node',
|
||||||
|
runIndex: 0,
|
||||||
|
id: String(Math.random()),
|
||||||
|
children: [],
|
||||||
|
consumedTokens: { completionTokens: 0, totalTokens: 0, promptTokens: 0, isEstimate: false },
|
||||||
|
depth: 0,
|
||||||
startTime: 0,
|
startTime: 0,
|
||||||
executionIndex: 0,
|
...data,
|
||||||
executionTime: 1,
|
|
||||||
source: [],
|
|
||||||
executionStatus: 'success',
|
|
||||||
data: { main: [[{ json: {} }]] },
|
|
||||||
...partialData,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,9 +42,9 @@ describe(getTreeNodeData, () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const taskDataByNodeName: Record<string, ITaskData[]> = {
|
const taskDataByNodeName: Record<string, ITaskData[]> = {
|
||||||
A: [createTaskData({ startTime: Date.parse('2025-02-26T00:00:00.000Z') })],
|
A: [createTestTaskData({ startTime: Date.parse('2025-02-26T00:00:00.000Z') })],
|
||||||
B: [
|
B: [
|
||||||
createTaskData({
|
createTestTaskData({
|
||||||
startTime: Date.parse('2025-02-26T00:00:01.000Z'),
|
startTime: Date.parse('2025-02-26T00:00:01.000Z'),
|
||||||
data: {
|
data: {
|
||||||
main: [
|
main: [
|
||||||
@@ -50,7 +62,7 @@ describe(getTreeNodeData, () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
createTaskData({
|
createTestTaskData({
|
||||||
startTime: Date.parse('2025-02-26T00:00:03.000Z'),
|
startTime: Date.parse('2025-02-26T00:00:03.000Z'),
|
||||||
data: {
|
data: {
|
||||||
main: [
|
main: [
|
||||||
@@ -70,7 +82,7 @@ describe(getTreeNodeData, () => {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
C: [
|
C: [
|
||||||
createTaskData({
|
createTestTaskData({
|
||||||
startTime: Date.parse('2025-02-26T00:00:02.000Z'),
|
startTime: Date.parse('2025-02-26T00:00:02.000Z'),
|
||||||
data: {
|
data: {
|
||||||
main: [
|
main: [
|
||||||
@@ -88,7 +100,7 @@ describe(getTreeNodeData, () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
createTaskData({ startTime: Date.parse('2025-02-26T00:00:04.000Z') }),
|
createTestTaskData({ startTime: Date.parse('2025-02-26T00:00:04.000Z') }),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -181,6 +193,143 @@ describe(getTreeNodeData, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe(findLogEntryToAutoSelect, () => {
|
||||||
|
it('should return undefined if no log entry is provided', () => {
|
||||||
|
expect(
|
||||||
|
findLogEntryToAutoSelect(
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
A: createTestNode({ name: 'A' }),
|
||||||
|
B: createTestNode({ name: 'B' }),
|
||||||
|
C: createTestNode({ name: 'C' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
A: [],
|
||||||
|
B: [],
|
||||||
|
C: [],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return first log entry with error', () => {
|
||||||
|
expect(
|
||||||
|
findLogEntryToAutoSelect(
|
||||||
|
[
|
||||||
|
createTestLogEntry({ node: 'A', runIndex: 0 }),
|
||||||
|
createTestLogEntry({ node: 'B', runIndex: 0 }),
|
||||||
|
createTestLogEntry({ node: 'C', runIndex: 0 }),
|
||||||
|
createTestLogEntry({ node: 'C', runIndex: 1 }),
|
||||||
|
createTestLogEntry({ node: 'C', runIndex: 2 }),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
A: createTestNode({ name: 'A' }),
|
||||||
|
B: createTestNode({ name: 'B' }),
|
||||||
|
C: createTestNode({ name: 'C' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
A: [createTestTaskData({ executionStatus: 'success' })],
|
||||||
|
B: [createTestTaskData({ executionStatus: 'success' })],
|
||||||
|
C: [
|
||||||
|
createTestTaskData({ executionStatus: 'success' }),
|
||||||
|
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
|
||||||
|
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toEqual(expect.objectContaining({ node: 'C', runIndex: 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return first log entry with error even if it's on a sub node", () => {
|
||||||
|
expect(
|
||||||
|
findLogEntryToAutoSelect(
|
||||||
|
[
|
||||||
|
createTestLogEntry({ node: 'A', runIndex: 0 }),
|
||||||
|
createTestLogEntry({
|
||||||
|
node: 'B',
|
||||||
|
runIndex: 0,
|
||||||
|
children: [
|
||||||
|
createTestLogEntry({ node: 'C', runIndex: 0 }),
|
||||||
|
createTestLogEntry({ node: 'C', runIndex: 1 }),
|
||||||
|
createTestLogEntry({ node: 'C', runIndex: 2 }),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
A: createTestNode({ name: 'A' }),
|
||||||
|
B: createTestNode({ name: 'B' }),
|
||||||
|
C: createTestNode({ name: 'C' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
A: [createTestTaskData({ executionStatus: 'success' })],
|
||||||
|
B: [createTestTaskData({ executionStatus: 'success' })],
|
||||||
|
C: [
|
||||||
|
createTestTaskData({ executionStatus: 'success' }),
|
||||||
|
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
|
||||||
|
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toEqual(expect.objectContaining({ node: 'C', runIndex: 1 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return first log entry for AI agent node if there is no log entry with error', () => {
|
||||||
|
expect(
|
||||||
|
findLogEntryToAutoSelect(
|
||||||
|
[
|
||||||
|
createTestLogEntry({ node: 'A', runIndex: 0 }),
|
||||||
|
createTestLogEntry({ node: 'B', runIndex: 0 }),
|
||||||
|
createTestLogEntry({ node: 'C', runIndex: 0 }),
|
||||||
|
createTestLogEntry({ node: 'C', runIndex: 1 }),
|
||||||
|
createTestLogEntry({ node: 'C', runIndex: 2 }),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
A: createTestNode({ name: 'A' }),
|
||||||
|
B: createTestNode({ name: 'B', type: AGENT_LANGCHAIN_NODE_TYPE }),
|
||||||
|
C: createTestNode({ name: 'C' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
A: [createTestTaskData({ executionStatus: 'success' })],
|
||||||
|
B: [createTestTaskData({ executionStatus: 'success' })],
|
||||||
|
C: [
|
||||||
|
createTestTaskData({ executionStatus: 'success' }),
|
||||||
|
createTestTaskData({ executionStatus: 'success' }),
|
||||||
|
createTestTaskData({ executionStatus: 'success' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toEqual(expect.objectContaining({ node: 'B', runIndex: 0 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return first log entry if there is no log entry with error nor executed AI agent node', () => {
|
||||||
|
expect(
|
||||||
|
findLogEntryToAutoSelect(
|
||||||
|
[
|
||||||
|
createTestLogEntry({ node: 'A', runIndex: 0 }),
|
||||||
|
createTestLogEntry({ node: 'B', runIndex: 0 }),
|
||||||
|
createTestLogEntry({ node: 'C', runIndex: 0 }),
|
||||||
|
createTestLogEntry({ node: 'C', runIndex: 1 }),
|
||||||
|
createTestLogEntry({ node: 'C', runIndex: 2 }),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
A: createTestNode({ name: 'A' }),
|
||||||
|
B: createTestNode({ name: 'B' }),
|
||||||
|
C: createTestNode({ name: 'C' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
A: [createTestTaskData({ executionStatus: 'success' })],
|
||||||
|
B: [createTestTaskData({ executionStatus: 'success' })],
|
||||||
|
C: [
|
||||||
|
createTestTaskData({ executionStatus: 'success' }),
|
||||||
|
createTestTaskData({ executionStatus: 'success' }),
|
||||||
|
createTestTaskData({ executionStatus: 'success' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toEqual(expect.objectContaining({ node: 'A', runIndex: 0 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe(createLogEntries, () => {
|
describe(createLogEntries, () => {
|
||||||
it('should return root node log entries in ascending order of executionIndex', () => {
|
it('should return root node log entries in ascending order of executionIndex', () => {
|
||||||
const workflow = createTestWorkflowObject({
|
const workflow = createTestWorkflowObject({
|
||||||
@@ -198,14 +347,26 @@ describe(createLogEntries, () => {
|
|||||||
expect(
|
expect(
|
||||||
createLogEntries(workflow, {
|
createLogEntries(workflow, {
|
||||||
A: [
|
A: [
|
||||||
createTaskData({ startTime: Date.parse('2025-04-04T00:00:00.000Z'), executionIndex: 0 }),
|
createTestTaskData({
|
||||||
|
startTime: Date.parse('2025-04-04T00:00:00.000Z'),
|
||||||
|
executionIndex: 0,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
B: [
|
B: [
|
||||||
createTaskData({ startTime: Date.parse('2025-04-04T00:00:01.000Z'), executionIndex: 1 }),
|
createTestTaskData({
|
||||||
|
startTime: Date.parse('2025-04-04T00:00:01.000Z'),
|
||||||
|
executionIndex: 1,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
C: [
|
C: [
|
||||||
createTaskData({ startTime: Date.parse('2025-04-04T00:00:02.000Z'), executionIndex: 3 }),
|
createTestTaskData({
|
||||||
createTaskData({ startTime: Date.parse('2025-04-04T00:00:03.000Z'), executionIndex: 2 }),
|
startTime: Date.parse('2025-04-04T00:00:02.000Z'),
|
||||||
|
executionIndex: 3,
|
||||||
|
}),
|
||||||
|
createTestTaskData({
|
||||||
|
startTime: Date.parse('2025-04-04T00:00:03.000Z'),
|
||||||
|
executionIndex: 2,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
@@ -236,14 +397,26 @@ describe(createLogEntries, () => {
|
|||||||
expect(
|
expect(
|
||||||
createLogEntries(workflow, {
|
createLogEntries(workflow, {
|
||||||
A: [
|
A: [
|
||||||
createTaskData({ startTime: Date.parse('2025-04-04T00:00:00.000Z'), executionIndex: 0 }),
|
createTestTaskData({
|
||||||
|
startTime: Date.parse('2025-04-04T00:00:00.000Z'),
|
||||||
|
executionIndex: 0,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
B: [
|
B: [
|
||||||
createTaskData({ startTime: Date.parse('2025-04-04T00:00:01.000Z'), executionIndex: 1 }),
|
createTestTaskData({
|
||||||
|
startTime: Date.parse('2025-04-04T00:00:01.000Z'),
|
||||||
|
executionIndex: 1,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
C: [
|
C: [
|
||||||
createTaskData({ startTime: Date.parse('2025-04-04T00:00:02.000Z'), executionIndex: 3 }),
|
createTestTaskData({
|
||||||
createTaskData({ startTime: Date.parse('2025-04-04T00:00:03.000Z'), executionIndex: 2 }),
|
startTime: Date.parse('2025-04-04T00:00:02.000Z'),
|
||||||
|
executionIndex: 3,
|
||||||
|
}),
|
||||||
|
createTestTaskData({
|
||||||
|
startTime: Date.parse('2025-04-04T00:00:03.000Z'),
|
||||||
|
executionIndex: 2,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { type LlmTokenUsageData, type IAiDataContent } from '@/Interface';
|
import { type LlmTokenUsageData, type IAiDataContent, type INodeUi } from '@/Interface';
|
||||||
import {
|
import {
|
||||||
|
AGENT_LANGCHAIN_NODE_TYPE,
|
||||||
type IRunData,
|
type IRunData,
|
||||||
type INodeExecutionData,
|
type INodeExecutionData,
|
||||||
type ITaskData,
|
type ITaskData,
|
||||||
@@ -226,6 +227,46 @@ export function formatTokenUsageCount(
|
|||||||
return usage.isEstimate ? `~${count}` : count.toLocaleString();
|
return usage.isEstimate ? `~${count}` : count.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findLogEntryToAutoSelect(
|
||||||
|
tree: TreeNode[],
|
||||||
|
nodesByName: Record<string, INodeUi>,
|
||||||
|
runData: IRunData,
|
||||||
|
): TreeNode | undefined {
|
||||||
|
return findLogEntryToAutoSelectRec(tree, nodesByName, runData, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLogEntryToAutoSelectRec(
|
||||||
|
tree: TreeNode[],
|
||||||
|
nodesByName: Record<string, INodeUi>,
|
||||||
|
runData: IRunData,
|
||||||
|
depth: number,
|
||||||
|
): TreeNode | undefined {
|
||||||
|
for (const entry of tree) {
|
||||||
|
const taskData = runData[entry.node]?.[entry.runIndex];
|
||||||
|
|
||||||
|
if (taskData?.error) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childAutoSelect = findLogEntryToAutoSelectRec(
|
||||||
|
entry.children,
|
||||||
|
nodesByName,
|
||||||
|
runData,
|
||||||
|
depth + 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (childAutoSelect) {
|
||||||
|
return childAutoSelect;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodesByName[entry.node]?.type === AGENT_LANGCHAIN_NODE_TYPE) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return depth === 0 ? tree[0] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function createLogEntries(workflow: Workflow, runData: IRunData) {
|
export function createLogEntries(workflow: Workflow, runData: IRunData) {
|
||||||
const runs = Object.entries(runData)
|
const runs = Object.entries(runData)
|
||||||
.filter(([nodeName]) => workflow.getChildNodes(nodeName, 'ALL_NON_MAIN').length === 0)
|
.filter(([nodeName]) => workflow.getChildNodes(nodeName, 'ALL_NON_MAIN').length === 0)
|
||||||
|
|||||||
@@ -232,7 +232,9 @@ async function onSaveWorkflowClick(): Promise<void> {
|
|||||||
& > div:nth-child(1) {
|
& > div:nth-child(1) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
padding: var(--spacing-xs);
|
justify-content: space-between;
|
||||||
|
padding-block: var(--spacing-2xs);
|
||||||
|
padding-inline: var(--spacing-s);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
color: var(--color-text-base) !important;
|
color: var(--color-text-base) !important;
|
||||||
@@ -243,7 +245,7 @@ async function onSaveWorkflowClick(): Promise<void> {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 var(--spacing-l) var(--spacing-s) !important;
|
padding: 0 var(--spacing-s) var(--spacing-2xs) !important;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ import { NodeConnectionTypes, TelemetryHelpers } from 'n8n-workflow';
|
|||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
|
|
||||||
import { CHAT_TRIGGER_NODE_TYPE, SINGLE_WEBHOOK_TRIGGERS } from '@/constants';
|
import {
|
||||||
|
CHAT_TRIGGER_NODE_TYPE,
|
||||||
|
IN_PROGRESS_EXECUTION_ID,
|
||||||
|
SINGLE_WEBHOOK_TRIGGERS,
|
||||||
|
} 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';
|
||||||
@@ -292,7 +296,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
|||||||
// that data which gets reused is already set and data of newly executed
|
// that data which gets reused is already set and data of newly executed
|
||||||
// nodes can be added as it gets pushed in
|
// nodes can be added as it gets pushed in
|
||||||
const executionData: IExecutionResponse = {
|
const executionData: IExecutionResponse = {
|
||||||
id: '__IN_PROGRESS__',
|
id: IN_PROGRESS_EXECUTION_ID,
|
||||||
finished: false,
|
finished: false,
|
||||||
mode: 'manual',
|
mode: 'manual',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export const MAX_DISPLAY_ITEMS_AUTO_ALL = 250;
|
|||||||
|
|
||||||
export const PLACEHOLDER_FILLED_AT_EXECUTION_TIME = '[filled at execution time]';
|
export const PLACEHOLDER_FILLED_AT_EXECUTION_TIME = '[filled at execution time]';
|
||||||
|
|
||||||
|
export const IN_PROGRESS_EXECUTION_ID = '__IN_PROGRESS__';
|
||||||
|
|
||||||
// parameter input
|
// parameter input
|
||||||
export const CUSTOM_API_CALL_KEY = '__CUSTOM_API_CALL__';
|
export const CUSTOM_API_CALL_KEY = '__CUSTOM_API_CALL__';
|
||||||
export const CUSTOM_API_CALL_NAME = 'Custom API Call';
|
export const CUSTOM_API_CALL_NAME = 'Custom API Call';
|
||||||
@@ -474,6 +476,7 @@ export const LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON = 'N8N_HIDE_HIDE_GITHUB_STAR_
|
|||||||
export const LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_INPUT_PANEL_DISPLAY_MODE';
|
export const LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_INPUT_PANEL_DISPLAY_MODE';
|
||||||
export const LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_OUTPUT_PANEL_DISPLAY_MODE';
|
export const LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_OUTPUT_PANEL_DISPLAY_MODE';
|
||||||
export const LOCAL_STORAGE_LOGS_2025_SPRING = 'N8N_LOGS_2025_SPRING';
|
export const LOCAL_STORAGE_LOGS_2025_SPRING = 'N8N_LOGS_2025_SPRING';
|
||||||
|
export const LOCAL_STORAGE_LOGS_PANEL_OPEN = 'N8N_LOGS_PANEL_OPEN';
|
||||||
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
export const BASE_NODE_SURVEY_URL = 'https://n8n-community.typeform.com/to/BvmzxqYv#nodename=';
|
||||||
export const COMMUNITY_PLUS_DOCS_URL =
|
export const COMMUNITY_PLUS_DOCS_URL =
|
||||||
'https://docs.n8n.io/hosting/community-edition-features/#registered-community-edition';
|
'https://docs.n8n.io/hosting/community-edition-features/#registered-community-edition';
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordV
|
|||||||
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 CanvasChatSwitch = async () => await import('@/components/CanvasChat/CanvasChatSwitch.vue');
|
const CanvasChatSwitch = async () => await import('@/components/CanvasChat/CanvasChatSwitch.vue');
|
||||||
|
const DemoFooter = async () =>
|
||||||
|
await import('@/components/CanvasChat/future/components/DemoFooter.vue');
|
||||||
const NodeView = async () => await import('@/views/NodeView.vue');
|
const NodeView = async () => await import('@/views/NodeView.vue');
|
||||||
const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
|
const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
|
||||||
const WorkflowExecutionsLandingPage = async () =>
|
const WorkflowExecutionsLandingPage = async () =>
|
||||||
@@ -372,6 +374,7 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
name: VIEWS.DEMO,
|
name: VIEWS.DEMO,
|
||||||
components: {
|
components: {
|
||||||
default: NodeView,
|
default: NodeView,
|
||||||
|
footer: DemoFooter,
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
middleware: ['authenticated'],
|
middleware: ['authenticated'],
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
DUPLICATE_POSTFFIX,
|
DUPLICATE_POSTFFIX,
|
||||||
ERROR_TRIGGER_NODE_TYPE,
|
ERROR_TRIGGER_NODE_TYPE,
|
||||||
FORM_NODE_TYPE,
|
FORM_NODE_TYPE,
|
||||||
|
LOCAL_STORAGE_LOGS_PANEL_OPEN,
|
||||||
MAX_WORKFLOW_NAME_LENGTH,
|
MAX_WORKFLOW_NAME_LENGTH,
|
||||||
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||||
START_NODE_TYPE,
|
START_NODE_TYPE,
|
||||||
@@ -92,6 +93,7 @@ import { useUsersStore } from '@/stores/users.store';
|
|||||||
import { updateCurrentUserSettings } from '@/api/users';
|
import { updateCurrentUserSettings } from '@/api/users';
|
||||||
import { useExecutingNode } from '@/composables/useExecutingNode';
|
import { useExecutingNode } from '@/composables/useExecutingNode';
|
||||||
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
|
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
|
||||||
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
|
|
||||||
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
|
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -147,7 +149,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
const isInDebugMode = ref(false);
|
const isInDebugMode = ref(false);
|
||||||
const chatMessages = ref<string[]>([]);
|
const chatMessages = ref<string[]>([]);
|
||||||
const chatPartialExecutionDestinationNode = ref<string | null>(null);
|
const chatPartialExecutionDestinationNode = ref<string | null>(null);
|
||||||
const isLogsPanelOpen = ref(false);
|
const isLogsPanelOpen = useLocalStorage(LOCAL_STORAGE_LOGS_PANEL_OPEN, false);
|
||||||
const preferPopOutLogsView = ref(false);
|
const preferPopOutLogsView = ref(false);
|
||||||
const logsPanelState = computed(() =>
|
const logsPanelState = computed(() =>
|
||||||
isLogsPanelOpen.value
|
isLogsPanelOpen.value
|
||||||
|
|||||||
Reference in New Issue
Block a user