refactor(editor): Clean up feature flag for the log view (#15606)

This commit is contained in:
Suguru Inoue
2025-06-10 10:15:22 +02:00
committed by GitHub
parent 25567f6f0e
commit d68a776e5c
69 changed files with 2402 additions and 3323 deletions

View File

@@ -1,588 +0,0 @@
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { waitFor } from '@testing-library/vue';
import { userEvent } from '@testing-library/user-event';
import { createRouter, createWebHistory } from 'vue-router';
import { computed, ref } from 'vue';
import type { INodeTypeDescription } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import CanvasChat from './CanvasChat.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestWorkflowObject } from '@/__tests__/mocks';
import { mockedStore } from '@/__tests__/utils';
import { STORES } from '@n8n/stores';
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
import { chatEventBus } from '@n8n/chat/event-buses';
import { useWorkflowsStore } from '@/stores/workflows.store';
import * as useChatMessaging from './composables/useChatMessaging';
import * as useChatTrigger from './composables/useChatTrigger';
import { useToast } from '@/composables/useToast';
import type { IExecutionResponse, INodeUi } from '@/Interface';
import type { ChatMessage } from '@n8n/chat/types';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { LOGS_PANEL_STATE } from './types/logs';
import { useLogsStore } from '@/stores/logs.store';
vi.mock('@/composables/useToast', () => {
const showMessage = vi.fn();
const showError = vi.fn();
return {
useToast: () => {
return {
showMessage,
showError,
clearAllStickyNotifications: vi.fn(),
};
},
};
});
vi.mock('@/stores/pushConnection.store', () => ({
usePushConnectionStore: vi.fn().mockReturnValue({
isConnected: true,
}),
}));
// Test data
const mockNodes: INodeUi[] = [
{
parameters: {
options: {
allowFileUploads: true,
},
},
id: 'chat-trigger-id',
name: 'When chat message received',
type: '@n8n/n8n-nodes-langchain.chatTrigger',
typeVersion: 1.1,
position: [740, 860],
webhookId: 'webhook-id',
},
{
parameters: {},
id: 'agent-id',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 1.7,
position: [960, 860],
},
];
const mockNodeTypes: INodeTypeDescription[] = [
{
displayName: 'AI Agent',
name: '@n8n/n8n-nodes-langchain.agent',
properties: [],
defaults: {
name: 'AI Agent',
},
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
version: 0,
group: [],
description: '',
codex: {
subcategories: {
AI: ['Agents'],
},
},
},
];
const mockConnections = {
'When chat message received': {
main: [
[
{
node: 'AI Agent',
type: NodeConnectionTypes.Main,
index: 0,
},
],
],
},
};
const mockWorkflowExecution = {
data: {
resultData: {
runData: {
'AI Agent': [
{
data: {
main: [[{ json: { output: 'AI response message' } }]],
},
},
],
},
lastNodeExecuted: 'AI Agent',
},
},
};
const router = createRouter({
history: createWebHistory(),
routes: [],
});
describe('CanvasChat', () => {
const renderComponent = createComponentRenderer(CanvasChat, {
global: {
provide: {
[ChatSymbol as symbol]: {},
[ChatOptionsSymbol as symbol]: {},
},
plugins: [router],
},
});
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let logsStore: ReturnType<typeof mockedStore<typeof useLogsStore>>;
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
beforeEach(() => {
const pinia = createTestingPinia({
initialState: {
[STORES.WORKFLOWS]: {
workflow: {
nodes: mockNodes,
connections: mockConnections,
},
},
[STORES.UI]: {
chatPanelOpen: true,
},
},
});
setActivePinia(pinia);
workflowsStore = mockedStore(useWorkflowsStore);
logsStore = mockedStore(useLogsStore);
nodeTypeStore = mockedStore(useNodeTypesStore);
// Setup default mocks
workflowsStore.getCurrentWorkflow.mockReturnValue(
createTestWorkflowObject({
nodes: mockNodes,
connections: mockConnections,
}),
);
workflowsStore.getNodeByName.mockImplementation((name) => {
const matchedNode = mockNodes.find((node) => node.name === name) ?? null;
return matchedNode;
});
logsStore.isOpen = true;
workflowsStore.getWorkflowExecution = mockWorkflowExecution as unknown as IExecutionResponse;
workflowsStore.getPastChatMessages = ['Previous message 1', 'Previous message 2'];
logsStore.state = LOGS_PANEL_STATE.ATTACHED;
nodeTypeStore.getNodeType = vi.fn().mockImplementation((nodeTypeName) => {
return mockNodeTypes.find((node) => node.name === nodeTypeName) ?? null;
});
workflowsStore.runWorkflow.mockResolvedValue({ executionId: 'test-execution-issd' });
});
afterEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
it('should render chat when panel is open', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('canvas-chat')).toBeInTheDocument();
});
it('should not render chat when panel is closed', async () => {
logsStore.state = LOGS_PANEL_STATE.CLOSED;
const { queryByTestId } = renderComponent();
await waitFor(() => {
expect(queryByTestId('canvas-chat')).not.toBeInTheDocument();
});
});
it('should show correct input placeholder', async () => {
const { findByTestId } = renderComponent();
expect(await findByTestId('chat-input')).toBeInTheDocument();
});
});
describe('message handling', () => {
beforeEach(() => {
vi.spyOn(chatEventBus, 'emit');
workflowsStore.runWorkflow.mockResolvedValue({ executionId: 'test-execution-id' });
});
it('should send message and show response', async () => {
const { findByTestId, findByText } = renderComponent();
// Send message
const input = await findByTestId('chat-input');
await userEvent.type(input, 'Hello AI!');
await userEvent.keyboard('{Enter}');
// Verify message and response
expect(await findByText('Hello AI!')).toBeInTheDocument();
await waitFor(async () => {
workflowsStore.getWorkflowExecution = {
...(mockWorkflowExecution as unknown as IExecutionResponse),
status: 'success',
};
expect(await findByText('AI response message')).toBeInTheDocument();
});
// Verify workflow execution
expect(workflowsStore.runWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
runData: undefined,
triggerToStartFrom: {
name: 'When chat message received',
data: {
data: {
main: [
[
{
json: {
action: 'sendMessage',
chatInput: 'Hello AI!',
sessionId: expect.any(String),
},
},
],
],
},
executionIndex: 0,
executionStatus: 'success',
executionTime: 0,
source: [null],
startTime: expect.any(Number),
},
},
}),
);
});
it('should show loading state during message processing', async () => {
const { findByTestId, queryByTestId } = renderComponent();
// Send message
const input = await findByTestId('chat-input');
await userEvent.type(input, 'Test message');
// Since runWorkflow resolve is mocked, the isWorkflowRunning will be false from the first run.
// This means that the loading state never gets a chance to appear.
// We're forcing isWorkflowRunning to be true for the first run.
workflowsStore.isWorkflowRunning = true;
await userEvent.keyboard('{Enter}');
await waitFor(() => expect(queryByTestId('chat-message-typing')).toBeInTheDocument());
workflowsStore.isWorkflowRunning = false;
workflowsStore.getWorkflowExecution = {
...(mockWorkflowExecution as unknown as IExecutionResponse),
status: 'success',
};
await waitFor(() => expect(queryByTestId('chat-message-typing')).not.toBeInTheDocument());
});
it('should handle workflow execution errors', async () => {
workflowsStore.runWorkflow.mockRejectedValueOnce(new Error());
const { findByTestId } = renderComponent();
const input = await findByTestId('chat-input');
await userEvent.type(input, 'Hello AI!');
await userEvent.keyboard('{Enter}');
const toast = useToast();
expect(toast.showError).toHaveBeenCalledWith(new Error(), 'Problem running workflow');
});
});
describe('session management', () => {
const mockMessages: ChatMessage[] = [
{
id: '1',
text: 'Existing message',
sender: 'user',
},
];
beforeEach(() => {
vi.spyOn(useChatMessaging, 'useChatMessaging').mockImplementation(({ messages }) => {
messages.value.push(...mockMessages);
return {
sendMessage: vi.fn(),
previousMessageIndex: ref(0),
isLoading: computed(() => false),
};
});
});
it('should allow copying session ID', async () => {
const clipboardSpy = vi.fn();
document.execCommand = clipboardSpy;
const { getByTestId } = renderComponent();
await userEvent.click(getByTestId('chat-session-id'));
const toast = useToast();
expect(clipboardSpy).toHaveBeenCalledWith('copy');
expect(toast.showMessage).toHaveBeenCalledWith({
message: '',
title: 'Copied to clipboard',
type: 'success',
});
});
it('should refresh session when messages exist', async () => {
const { getByTestId } = renderComponent();
const originalSessionId = getByTestId('chat-session-id').textContent;
await userEvent.click(getByTestId('refresh-session-button'));
expect(getByTestId('chat-session-id').textContent).not.toEqual(originalSessionId);
});
});
describe('resize functionality', () => {
it('should handle panel resizing', async () => {
const { container } = renderComponent();
const resizeWrapper = container.querySelector('.resizeWrapper');
if (!resizeWrapper) throw new Error('Resize wrapper not found');
await userEvent.pointer([
{ target: resizeWrapper, coords: { clientX: 0, clientY: 0 } },
{ coords: { clientX: 0, clientY: 100 } },
]);
expect(logsStore.setHeight).toHaveBeenCalled();
});
it('should persist resize dimensions', () => {
const mockStorage = {
getItem: vi.fn(),
setItem: vi.fn(),
};
Object.defineProperty(window, 'localStorage', { value: mockStorage });
renderComponent();
expect(mockStorage.getItem).toHaveBeenCalledWith('N8N_CANVAS_CHAT_HEIGHT');
expect(mockStorage.getItem).toHaveBeenCalledWith('N8N_CANVAS_CHAT_WIDTH');
});
});
describe('file handling', () => {
beforeEach(() => {
vi.spyOn(useChatMessaging, 'useChatMessaging').mockReturnValue({
sendMessage: vi.fn(),
previousMessageIndex: ref(0),
isLoading: computed(() => false),
});
logsStore.state = LOGS_PANEL_STATE.ATTACHED;
workflowsStore.allowFileUploads = true;
});
it('should enable file uploads when allowed by chat trigger node', async () => {
const allowFileUploads = ref(true);
const original = useChatTrigger.useChatTrigger;
vi.spyOn(useChatTrigger, 'useChatTrigger').mockImplementation((...args) => ({
...original(...args),
allowFileUploads: computed(() => allowFileUploads.value),
}));
const { getByTestId } = renderComponent();
const chatPanel = getByTestId('canvas-chat');
expect(chatPanel).toBeInTheDocument();
const fileInput = getByTestId('chat-attach-file-button');
expect(fileInput).toBeInTheDocument();
allowFileUploads.value = false;
await waitFor(() => {
expect(fileInput).not.toBeInTheDocument();
});
});
});
describe('message history handling', () => {
it('should properly navigate through message history with wrap-around', async () => {
const messages = ['Message 1', 'Message 2', 'Message 3'];
workflowsStore.getPastChatMessages = messages;
const { findByTestId } = renderComponent();
const input = await findByTestId('chat-input');
// First up should show most recent message
await userEvent.keyboard('{ArrowUp}');
expect(input).toHaveValue('Message 3');
// Second up should show second most recent
await userEvent.keyboard('{ArrowUp}');
expect(input).toHaveValue('Message 2');
// Third up should show oldest message
await userEvent.keyboard('{ArrowUp}');
expect(input).toHaveValue('Message 1');
// Fourth up should wrap around to most recent
await userEvent.keyboard('{ArrowUp}');
expect(input).toHaveValue('Message 3');
// Down arrow should go in reverse
await userEvent.keyboard('{ArrowDown}');
expect(input).toHaveValue('Message 1');
});
it('should reset message history navigation on new input', async () => {
workflowsStore.getPastChatMessages = ['Message 1', 'Message 2'];
const { findByTestId } = renderComponent();
const input = await findByTestId('chat-input');
// Navigate to oldest message
await userEvent.keyboard('{ArrowUp}'); // Most recent
await userEvent.keyboard('{ArrowUp}'); // Oldest
expect(input).toHaveValue('Message 1');
await userEvent.type(input, 'New message');
await userEvent.keyboard('{Enter}');
await userEvent.keyboard('{ArrowUp}');
expect(input).toHaveValue('Message 2');
});
});
describe('message reuse and repost', () => {
const sendMessageSpy = vi.fn();
beforeEach(() => {
const mockMessages: ChatMessage[] = [
{
id: '1',
text: 'Original message',
sender: 'user',
},
{
id: '2',
text: 'AI response',
sender: 'bot',
},
];
vi.spyOn(useChatMessaging, 'useChatMessaging').mockImplementation(({ messages }) => {
messages.value.push(...mockMessages);
return {
sendMessage: sendMessageSpy,
previousMessageIndex: ref(0),
isLoading: computed(() => false),
};
});
workflowsStore.messages = mockMessages;
});
it('should repost user message with new execution', async () => {
const { findByTestId } = renderComponent();
const repostButton = await findByTestId('repost-message-button');
await userEvent.click(repostButton);
expect(sendMessageSpy).toHaveBeenCalledWith('Original message');
expect.objectContaining({
runData: expect.objectContaining({
'When chat message received': expect.arrayContaining([
expect.objectContaining({
data: expect.objectContaining({
main: expect.arrayContaining([
expect.arrayContaining([
expect.objectContaining({
json: expect.objectContaining({
chatInput: 'Original message',
}),
}),
]),
]),
}),
}),
]),
}),
});
});
it('should show message options only for appropriate messages', async () => {
const { findByText, container } = renderComponent();
await findByText('Original message');
const userMessage = container.querySelector('.chat-message-from-user');
expect(
userMessage?.querySelector('[data-test-id="repost-message-button"]'),
).toBeInTheDocument();
expect(
userMessage?.querySelector('[data-test-id="reuse-message-button"]'),
).toBeInTheDocument();
await findByText('AI response');
const botMessage = container.querySelector('.chat-message-from-bot');
expect(
botMessage?.querySelector('[data-test-id="repost-message-button"]'),
).not.toBeInTheDocument();
expect(
botMessage?.querySelector('[data-test-id="reuse-message-button"]'),
).not.toBeInTheDocument();
});
});
describe('panel state synchronization', () => {
it('should update canvas height when chat or logs panel state changes', async () => {
renderComponent();
// Toggle logs panel
logsStore.isOpen = true;
await waitFor(() => {
expect(logsStore.setHeight).toHaveBeenCalled();
});
// Close chat panel
logsStore.state = LOGS_PANEL_STATE.CLOSED;
await waitFor(() => {
expect(logsStore.setHeight).toHaveBeenCalledWith(0);
});
});
it('should preserve panel state across component remounts', async () => {
const { unmount, rerender } = renderComponent();
// Set initial state
logsStore.state = LOGS_PANEL_STATE.ATTACHED;
logsStore.isOpen = true;
// Unmount and remount
unmount();
await rerender({});
expect(logsStore.state).toBe(LOGS_PANEL_STATE.ATTACHED);
expect(logsStore.isOpen).toBe(true);
});
});
describe('keyboard shortcuts', () => {
it('should handle Enter key with modifier to start new line', async () => {
const { findByTestId } = renderComponent();
const input = await findByTestId('chat-input');
await userEvent.type(input, 'Line 1');
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
await userEvent.type(input, 'Line 2');
expect(input).toHaveValue('Line 1\nLine 2');
});
});
});

View File

@@ -1,251 +0,0 @@
<script setup lang="ts">
import { computed, ref, watchEffect, useTemplateRef, watch } from 'vue';
// Components
import ChatMessagesPanel from './components/ChatMessagesPanel.vue';
import ChatLogsPanel from './components/ChatLogsPanel.vue';
// Composables
import { useResize } from './composables/useResize';
// Types
import { useWorkflowsStore } from '@/stores/workflows.store';
import { usePiPWindow } from '@/components/CanvasChat/composables/usePiPWindow';
import { N8nResizeWrapper } from '@n8n/design-system';
import { useTelemetry } from '@/composables/useTelemetry';
import { useChatState } from '@/components/CanvasChat/composables/useChatState';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
import { useLogsStore } from '@/stores/logs.store';
const workflowsStore = useWorkflowsStore();
const logsStore = useLogsStore();
// Component state
const container = ref<HTMLElement>();
const pipContainer = useTemplateRef('pipContainer');
const pipContent = useTemplateRef('pipContent');
// Computed properties
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
const chatPanelState = computed(() => logsStore.state);
const resultData = computed(() => workflowsStore.getWorkflowRunData);
const telemetry = useTelemetry();
const {
height,
chatWidth,
rootStyles,
logsWidth,
onResizeDebounced,
onResizeChatDebounced,
onWindowResize,
} = useResize(container);
const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
initialHeight: 400,
initialWidth: window.document.body.offsetWidth * 0.8,
container: pipContainer,
content: pipContent,
shouldPopOut: computed(() => chatPanelState.value === LOGS_PANEL_STATE.FLOATING),
onRequestClose: () => {
if (chatPanelState.value === LOGS_PANEL_STATE.CLOSED) {
return;
}
telemetry.track('User toggled log view', { new_state: 'attached' });
logsStore.setPreferPoppedOut(false);
},
});
const {
currentSessionId,
messages,
chatTriggerNode,
connectedNode,
previousChatMessages,
sendMessage,
refreshSession,
displayExecution,
} = useChatState(false);
// Expose internal state for testing
defineExpose({
messages,
currentSessionId,
workflow,
});
const closePanel = () => {
logsStore.toggleOpen(false);
};
function onPopOut() {
telemetry.track('User toggled log view', { new_state: 'floating' });
logsStore.toggleOpen(true);
logsStore.setPreferPoppedOut(true);
}
// Watchers
watchEffect(() => {
logsStore.setHeight(chatPanelState.value === LOGS_PANEL_STATE.ATTACHED ? height.value : 0);
});
watch(
chatPanelState,
(state) => {
if (state !== LOGS_PANEL_STATE.CLOSED) {
setTimeout(() => {
onWindowResize?.();
}, 0);
}
},
{ immediate: true },
);
</script>
<template>
<div ref="pipContainer">
<div ref="pipContent" :class="$style.pipContent">
<N8nResizeWrapper
v-if="chatTriggerNode"
:is-resizing-enabled="!isPoppedOut && chatPanelState === LOGS_PANEL_STATE.ATTACHED"
:supported-directions="['top']"
:class="[$style.resizeWrapper, chatPanelState === LOGS_PANEL_STATE.CLOSED && $style.empty]"
:height="height"
:style="rootStyles"
@resize="onResizeDebounced"
>
<div ref="container" :class="[$style.container, 'ignore-key-press-canvas']" tabindex="0">
<div v-if="chatPanelState !== LOGS_PANEL_STATE.CLOSED" :class="$style.chatResizer">
<N8nResizeWrapper
:supported-directions="['right']"
:width="chatWidth"
:class="$style.chat"
:window="pipWindow"
@resize="onResizeChatDebounced"
>
<div :class="$style.inner">
<ChatMessagesPanel
data-test-id="canvas-chat"
:messages="messages"
:session-id="currentSessionId"
:past-chat-messages="previousChatMessages"
:show-close-button="!isPoppedOut && !connectedNode"
@close="closePanel"
@refresh-session="refreshSession"
@display-execution="displayExecution"
@send-message="sendMessage"
/>
</div>
</N8nResizeWrapper>
<div v-if="connectedNode" :class="$style.logs">
<ChatLogsPanel
:key="`${resultData?.length ?? messages?.length}`"
:workflow="workflow"
data-test-id="canvas-chat-logs"
:node="connectedNode"
:slim="logsWidth < 700"
>
<template #actions>
<n8n-icon-button
v-if="canPopOut && !isPoppedOut"
icon="pop-out"
type="secondary"
size="medium"
@click="onPopOut"
/>
<n8n-icon-button
v-if="!isPoppedOut"
outline
icon="times"
type="secondary"
size="medium"
@click="closePanel"
/>
</template>
</ChatLogsPanel>
</div>
</div>
</div>
</N8nResizeWrapper>
</div>
</div>
</template>
<style lang="scss" module>
@media all and (display-mode: picture-in-picture) {
.resizeWrapper {
height: 100% !important;
max-height: 100vh !important;
}
}
.pipContent {
height: 100%;
}
.resizeWrapper {
height: var(--panel-height);
min-height: 4rem;
max-height: 90vh;
flex-basis: content;
border-top: 1px solid var(--color-foreground-base);
&.empty {
height: auto;
min-height: 0;
flex-basis: 0;
}
}
.container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chatResizer {
display: flex;
width: 100%;
height: 100%;
max-width: 100%;
}
.footer {
border-top: 1px solid var(--color-foreground-base);
width: 100%;
background-color: var(--color-background-light);
display: flex;
padding: var(--spacing-2xs);
gap: var(--spacing-2xs);
}
.chat {
width: var(--chat-width);
flex-shrink: 0;
border-right: 1px solid var(--color-foreground-base);
max-width: 100%;
&:only-child {
width: 100%;
}
}
.inner {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
width: 100%;
}
.logs {
flex-grow: 1;
flex-shrink: 1;
background-color: var(--color-background-light);
}
</style>

View File

@@ -1,11 +0,0 @@
<script setup lang="ts">
import LogsPanel from '@/components/CanvasChat/future/LogsPanel.vue';
import { useSettingsStore } from '@/stores/settings.store';
const { isNewLogsEnabled } = useSettingsStore();
</script>
<template>
<LogsPanel v-if="isNewLogsEnabled" />
<CanvasChat v-else />
</template>

View File

@@ -1,184 +0,0 @@
import { createTestNode, createTestWorkflow, mockNodeTypeDescription } from '@/__tests__/mocks';
import type { LogTreeCreationContext } from '@/components/RunDataAi/utils';
import {
AGENT_NODE_TYPE,
AI_CATEGORY_AGENTS,
AI_SUBCATEGORY,
CHAT_TRIGGER_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
} from '@/constants';
import { type IExecutionResponse } from '@/Interface';
import { WorkflowOperationError, type IRunData, type Workflow } from 'n8n-workflow';
export function createTestLogTreeCreationContext(
workflow: Workflow,
runData: IRunData,
): LogTreeCreationContext {
return {
parent: undefined,
workflow,
workflows: {},
subWorkflowData: {},
executionId: 'test-execution-id',
depth: 0,
data: {
resultData: {
runData,
},
},
};
}
export const nodeTypes = [
mockNodeTypeDescription({
name: CHAT_TRIGGER_NODE_TYPE,
version: 1,
group: ['trigger'],
}),
mockNodeTypeDescription({
name: MANUAL_TRIGGER_NODE_TYPE,
version: 1,
group: ['trigger'],
}),
mockNodeTypeDescription({
name: AGENT_NODE_TYPE,
codex: {
subcategories: {
[AI_SUBCATEGORY]: [AI_CATEGORY_AGENTS],
},
},
version: 1,
}),
];
export const chatTriggerNode = createTestNode({ name: 'Chat', type: CHAT_TRIGGER_NODE_TYPE });
export const manualTriggerNode = createTestNode({ name: 'Manual' });
export const aiAgentNode = createTestNode({ name: 'AI Agent', type: AGENT_NODE_TYPE });
export const aiModelNode = createTestNode({ name: 'AI Model' });
export const aiManualWorkflow = createTestWorkflow({
nodes: [manualTriggerNode, aiAgentNode, aiModelNode],
connections: {
Manual: {
main: [[{ node: 'AI Agent', index: 0, type: 'main' }]],
},
'AI Model': {
ai_languageModel: [[{ node: 'AI Agent', index: 0, type: 'ai_languageModel' }]],
},
},
});
export const aiChatWorkflow = createTestWorkflow({
nodes: [chatTriggerNode, aiAgentNode, aiModelNode],
connections: {
Chat: {
main: [[{ node: 'AI Agent', index: 0, type: 'main' }]],
},
'AI Model': {
ai_languageModel: [[{ node: 'AI Agent', index: 0, type: 'ai_languageModel' }]],
},
},
});
export const aiChatExecutionResponse: IExecutionResponse = {
id: 'test-exec-id',
finished: true,
mode: 'manual',
status: 'success',
data: {
resultData: {
runData: {
'AI Agent': [
{
executionStatus: 'success',
startTime: Date.parse('2025-03-26T00:00:00.002Z'),
executionIndex: 0,
executionTime: 1778,
source: [],
data: {},
},
],
'AI Model': [
{
executionStatus: 'error',
startTime: Date.parse('2025-03-26T00:00:00.003Z'),
executionIndex: 1,
executionTime: 1777,
source: [],
error: new WorkflowOperationError('Test error', aiModelNode, 'Test error description'),
data: {
ai_languageModel: [
[
{
json: {
tokenUsage: {
completionTokens: 222,
promptTokens: 333,
totalTokens: 555,
},
},
},
],
],
},
},
],
},
},
},
workflowData: aiChatWorkflow,
createdAt: new Date('2025-03-26T00:00:00.000Z'),
startedAt: new Date('2025-03-26T00:00:00.001Z'),
stoppedAt: new Date('2025-03-26T00:00:02.000Z'),
};
export const aiManualExecutionResponse: IExecutionResponse = {
id: 'test-exec-id-2',
finished: true,
mode: 'manual',
status: 'success',
data: {
resultData: {
runData: {
'AI Agent': [
{
executionStatus: 'success',
startTime: Date.parse('2025-03-30T00:00:00.002Z'),
executionIndex: 0,
executionTime: 12,
source: [],
data: {},
},
],
'AI Model': [
{
executionStatus: 'success',
startTime: Date.parse('2025-03-30T00:00:00.003Z'),
executionIndex: 1,
executionTime: 3456,
source: [],
data: {
ai_languageModel: [
[
{
json: {
tokenUsage: {
completionTokens: 4,
promptTokens: 5,
totalTokens: 6,
},
},
},
],
],
},
},
],
},
},
},
workflowData: aiManualWorkflow,
createdAt: new Date('2025-03-30T00:00:00.000Z'),
startedAt: new Date('2025-03-30T00:00:00.001Z'),
stoppedAt: new Date('2025-03-30T00:00:02.000Z'),
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,204 +0,0 @@
import type { ComputedRef, Ref } from 'vue';
import { computed, ref } from 'vue';
import { v4 as uuid } from 'uuid';
import type { ChatMessage } from '@n8n/chat/types';
import type {
ITaskData,
INodeExecutionData,
IBinaryKeyData,
IDataObject,
IBinaryData,
BinaryFileType,
IRunExecutionData,
} from 'n8n-workflow';
import { useToast } from '@/composables/useToast';
import { useMessage } from '@/composables/useMessage';
import { usePinnedData } from '@/composables/usePinnedData';
import { MODAL_CONFIRM } from '@/constants';
import { useI18n } from '@n8n/i18n';
import type { IExecutionPushResponse, INodeUi } from '@/Interface';
import { extractBotResponse, getInputKey } from '@/components/CanvasChat/utils';
export type RunWorkflowChatPayload = {
triggerNode: string;
nodeData: ITaskData;
source: string;
message: string;
};
export interface ChatMessagingDependencies {
chatTrigger: Ref<INodeUi | null>;
messages: Ref<ChatMessage[]>;
sessionId: Ref<string>;
executionResultData: ComputedRef<IRunExecutionData['resultData'] | undefined>;
onRunChatWorkflow: (
payload: RunWorkflowChatPayload,
) => Promise<IExecutionPushResponse | undefined>;
}
export function useChatMessaging({
chatTrigger,
messages,
sessionId,
executionResultData,
onRunChatWorkflow,
}: ChatMessagingDependencies) {
const locale = useI18n();
const { showError } = useToast();
const previousMessageIndex = ref(0);
const isLoading = ref(false);
/** Converts a file to binary data */
async function convertFileToBinaryData(file: File): Promise<IBinaryData> {
const reader = new FileReader();
return await new Promise((resolve, reject) => {
reader.onload = () => {
const binaryData: IBinaryData = {
data: (reader.result as string).split('base64,')?.[1] ?? '',
mimeType: file.type,
fileName: file.name,
fileSize: `${file.size} bytes`,
fileExtension: file.name.split('.').pop() ?? '',
fileType: file.type.split('/')[0] as BinaryFileType,
};
resolve(binaryData);
};
reader.onerror = () => {
reject(new Error('Failed to convert file to binary data'));
};
reader.readAsDataURL(file);
});
}
/** Gets keyed files for the workflow input */
async function getKeyedFiles(files: File[]): Promise<IBinaryKeyData> {
const binaryData: IBinaryKeyData = {};
await Promise.all(
files.map(async (file, index) => {
const data = await convertFileToBinaryData(file);
const key = `data${index}`;
binaryData[key] = data;
}),
);
return binaryData;
}
/** Extracts file metadata */
function extractFileMeta(file: File): IDataObject {
return {
fileName: file.name,
fileSize: `${file.size} bytes`,
fileExtension: file.name.split('.').pop() ?? '',
fileType: file.type.split('/')[0],
mimeType: file.type,
};
}
/** Starts workflow execution with the message */
async function startWorkflowWithMessage(message: string, files?: File[]): Promise<void> {
const triggerNode = chatTrigger.value;
if (!triggerNode) {
showError(new Error('Chat Trigger Node could not be found!'), 'Trigger Node not found');
return;
}
const inputKey = getInputKey(triggerNode);
const inputPayload: INodeExecutionData = {
json: {
sessionId: sessionId.value,
action: 'sendMessage',
[inputKey]: message,
},
};
if (files && files.length > 0) {
const filesMeta = files.map((file) => extractFileMeta(file));
const binaryData = await getKeyedFiles(files);
inputPayload.json.files = filesMeta;
inputPayload.binary = binaryData;
}
const nodeData: ITaskData = {
startTime: Date.now(),
executionTime: 0,
executionIndex: 0,
executionStatus: 'success',
data: {
main: [[inputPayload]],
},
source: [null],
};
isLoading.value = true;
const response = await onRunChatWorkflow({
triggerNode: triggerNode.name,
nodeData,
source: 'RunData.ManualChatMessage',
message,
});
isLoading.value = false;
if (!response?.executionId) {
return;
}
const chatMessage = executionResultData.value
? extractBotResponse(
executionResultData.value,
response.executionId,
locale.baseText('chat.window.chat.response.empty'),
)
: undefined;
if (chatMessage !== undefined) {
messages.value.push(chatMessage);
}
}
/** Sends a message to the chat */
async function sendMessage(message: string, files?: File[]) {
previousMessageIndex.value = 0;
if (message.trim() === '' && (!files || files.length === 0)) {
showError(
new Error(locale.baseText('chat.window.chat.provideMessage')),
locale.baseText('chat.window.chat.emptyChatMessage'),
);
return;
}
const pinnedChatData = usePinnedData(chatTrigger.value);
if (pinnedChatData.hasData.value) {
const confirmResult = await useMessage().confirm(
locale.baseText('chat.window.chat.unpinAndExecute.description'),
locale.baseText('chat.window.chat.unpinAndExecute.title'),
{
confirmButtonText: locale.baseText('chat.window.chat.unpinAndExecute.confirm'),
cancelButtonText: locale.baseText('chat.window.chat.unpinAndExecute.cancel'),
},
);
if (!(confirmResult === MODAL_CONFIRM)) return;
pinnedChatData.unsetData('unpin-and-send-chat-message-modal');
}
const newMessage: ChatMessage & { sessionId: string } = {
text: message,
sender: 'user',
sessionId: sessionId.value,
id: uuid(),
files,
};
messages.value.push(newMessage);
await startWorkflowWithMessage(newMessage.text, files);
}
return {
previousMessageIndex,
isLoading: computed(() => isLoading.value),
sendMessage,
};
}

View File

@@ -1,194 +0,0 @@
import type { RunWorkflowChatPayload } from '@/components/CanvasChat/composables/useChatMessaging';
import { useChatMessaging } from '@/components/CanvasChat/composables/useChatMessaging';
import { useChatTrigger } from '@/components/CanvasChat/composables/useChatTrigger';
import { useI18n } from '@n8n/i18n';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { VIEWS } from '@/constants';
import { type INodeUi } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
import { chatEventBus } from '@n8n/chat/event-buses';
import type { Chat, ChatMessage, ChatOptions } from '@n8n/chat/types';
import { type INode } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import type { Ref } from 'vue';
import { computed, provide, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { restoreChatHistory } from '@/components/CanvasChat/utils';
import { useLogsStore } from '@/stores/logs.store';
interface ChatState {
currentSessionId: Ref<string>;
messages: Ref<ChatMessage[]>;
previousChatMessages: Ref<string[]>;
chatTriggerNode: Ref<INodeUi | null>;
connectedNode: Ref<INode | null>;
sendMessage: (message: string, files?: File[]) => Promise<void>;
refreshSession: () => void;
displayExecution: (executionId: string) => void;
}
export function useChatState(isReadOnly: boolean): ChatState {
const locale = useI18n();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const logsStore = useLogsStore();
const router = useRouter();
const nodeHelpers = useNodeHelpers();
const { runWorkflow } = useRunWorkflow({ router });
const messages = ref<ChatMessage[]>([]);
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
// Initialize features with injected dependencies
const { chatTriggerNode, connectedNode, allowFileUploads, allowedFilesMimeTypes } =
useChatTrigger({
workflow,
getNodeByName: workflowsStore.getNodeByName,
getNodeType: nodeTypesStore.getNodeType,
});
const { sendMessage, isLoading } = useChatMessaging({
chatTrigger: chatTriggerNode,
messages,
sessionId: currentSessionId,
executionResultData: computed(() => workflowsStore.getWorkflowExecution?.data?.resultData),
onRunChatWorkflow,
});
// Extracted pure functions for better testability
function createChatConfig(params: {
messages: Chat['messages'];
sendMessage: Chat['sendMessage'];
currentSessionId: Chat['currentSessionId'];
isLoading: Ref<boolean>;
isDisabled: Ref<boolean>;
allowFileUploads: Ref<boolean>;
locale: ReturnType<typeof useI18n>;
}): { chatConfig: Chat; chatOptions: ChatOptions } {
const chatConfig: Chat = {
messages: params.messages,
sendMessage: params.sendMessage,
initialMessages: ref([]),
currentSessionId: params.currentSessionId,
waitingForResponse: params.isLoading,
};
const chatOptions: ChatOptions = {
i18n: {
en: {
title: '',
footer: '',
subtitle: '',
inputPlaceholder: params.locale.baseText('chat.window.chat.placeholder'),
getStarted: '',
closeButtonTooltip: '',
},
},
webhookUrl: '',
mode: 'window',
showWindowCloseButton: true,
disabled: params.isDisabled,
allowFileUploads: params.allowFileUploads,
allowedFilesMimeTypes,
};
return { chatConfig, chatOptions };
}
// Initialize chat config
const { chatConfig, chatOptions } = createChatConfig({
messages,
sendMessage,
currentSessionId,
isLoading,
isDisabled: computed(() => isReadOnly),
allowFileUploads,
locale,
});
const restoredChatMessages = computed(() =>
restoreChatHistory(
workflowsStore.workflowExecutionData,
locale.baseText('chat.window.chat.response.empty'),
),
);
// Provide chat context
provide(ChatSymbol, chatConfig);
provide(ChatOptionsSymbol, chatOptions);
// 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() {
return await new Promise<void>((resolve) => {
const resolveIfFinished = (isRunning: boolean) => {
if (!isRunning) {
unwatch();
resolve();
}
};
// Watch for changes in the workflow execution status
const unwatch = watch(() => workflowsStore.isWorkflowRunning, resolveIfFinished);
resolveIfFinished(workflowsStore.isWorkflowRunning);
});
}
async function onRunChatWorkflow(payload: RunWorkflowChatPayload) {
const runWorkflowOptions: Parameters<typeof runWorkflow>[0] = {
triggerNode: payload.triggerNode,
nodeData: payload.nodeData,
source: payload.source,
};
if (workflowsStore.chatPartialExecutionDestinationNode) {
runWorkflowOptions.destinationNode = workflowsStore.chatPartialExecutionDestinationNode;
workflowsStore.chatPartialExecutionDestinationNode = null;
}
const response = await runWorkflow(runWorkflowOptions);
if (response) {
await createExecutionPromise();
workflowsStore.appendChatMessage(payload.message);
return response;
}
return;
}
function refreshSession() {
workflowsStore.setWorkflowExecutionData(null);
nodeHelpers.updateNodesExecutionIssues();
messages.value = [];
currentSessionId.value = uuid().replace(/-/g, '');
if (logsStore.isOpen) {
chatEventBus.emit('focusInput');
}
}
function displayExecution(executionId: string) {
const route = router.resolve({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: workflow.value.id, executionId },
});
window.open(route.href, '_blank');
}
return {
currentSessionId,
messages: computed(() => (isReadOnly ? restoredChatMessages.value : messages.value)),
previousChatMessages,
chatTriggerNode,
connectedNode,
sendMessage,
refreshSession,
displayExecution,
};
}

View File

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

View File

@@ -1,110 +0,0 @@
/* eslint-disable vue/one-component-per-file */
import { computed, defineComponent, h, ref } from 'vue';
import { usePiPWindow } from './usePiPWindow';
import { waitFor } from '@testing-library/vue';
import { renderComponent } from '@/__tests__/render';
describe(usePiPWindow, () => {
const documentPictureInPicture: NonNullable<Window['documentPictureInPicture']> = {
window: null,
requestWindow: async () =>
({
document: { body: { append: vi.fn(), removeChild: vi.fn() } },
addEventListener: vi.fn(),
close: vi.fn(),
}) as unknown as Window,
};
describe('canPopOut', () => {
it('should return false if window.documentPictureInPicture is not available', () => {
const MyComponent = defineComponent({
setup() {
const container = ref<HTMLDivElement | null>(null);
const content = ref<HTMLDivElement | null>(null);
const pipWindow = usePiPWindow({
container,
content,
shouldPopOut: computed(() => true),
onRequestClose: vi.fn(),
});
return () =>
h(
'div',
{ ref: container },
h('div', { ref: content }, String(pipWindow.canPopOut.value)),
);
},
});
const { queryByText } = renderComponent(MyComponent);
expect(queryByText('false')).toBeInTheDocument();
});
it('should return true if window.documentPictureInPicture is available', () => {
Object.assign(window, { documentPictureInPicture });
const MyComponent = defineComponent({
setup() {
const container = ref<HTMLDivElement | null>(null);
const content = ref<HTMLDivElement | null>(null);
const pipWindow = usePiPWindow({
container,
content,
shouldPopOut: computed(() => true),
onRequestClose: vi.fn(),
});
return () =>
h(
'div',
{ ref: container },
h('div', { ref: content }, String(pipWindow.canPopOut.value)),
);
},
});
const { queryByText } = renderComponent(MyComponent);
expect(queryByText('true')).toBeInTheDocument();
});
});
describe('isPoppedOut', () => {
beforeEach(() => {
Object.assign(window, { documentPictureInPicture });
});
it('should be set to true when popped out', async () => {
const shouldPopOut = ref(false);
const MyComponent = defineComponent({
setup() {
const container = ref<HTMLDivElement | null>(null);
const content = ref<HTMLDivElement | null>(null);
const pipWindow = usePiPWindow({
container,
content,
shouldPopOut: computed(() => shouldPopOut.value),
onRequestClose: vi.fn(),
});
return () =>
h(
'div',
{ ref: container },
h('div', { ref: content }, String(pipWindow.isPoppedOut.value)),
);
},
});
const { queryByText } = renderComponent(MyComponent);
expect(queryByText('false')).toBeInTheDocument();
shouldPopOut.value = true;
await waitFor(() => expect(queryByText('true')).toBeInTheDocument());
});
});
});

View File

@@ -1,113 +0,0 @@
import { IsInPiPWindowSymbol } from '@/constants';
import { useProvideTooltipAppendTo } from '@n8n/design-system/composables/useTooltipAppendTo';
import {
computed,
type ComputedRef,
onBeforeUnmount,
provide,
type Ref,
ref,
type ShallowRef,
watch,
} from 'vue';
interface UsePiPWindowOptions {
initialWidth?: number;
initialHeight?: number;
container: Readonly<ShallowRef<HTMLElement | null>>;
content: Readonly<ShallowRef<HTMLElement | null>>;
shouldPopOut: ComputedRef<boolean>;
onRequestClose: () => void;
}
interface UsePiPWindowReturn {
isPoppedOut: ComputedRef<boolean>;
canPopOut: ComputedRef<boolean>;
pipWindow?: Ref<Window | undefined>;
}
/**
* A composable that allows to pop out given content in document PiP (picture-in-picture) window
*/
export function usePiPWindow({
container,
content,
initialHeight,
initialWidth,
shouldPopOut,
onRequestClose,
}: UsePiPWindowOptions): UsePiPWindowReturn {
const pipWindow = ref<Window>();
const isUnmounting = ref(false);
const canPopOut = computed(
() =>
!!window.documentPictureInPicture /* Browser supports the API */ &&
window.parent === window /* Not in iframe */,
);
const isPoppedOut = computed(() => !!pipWindow.value);
const tooltipContainer = computed(() =>
isPoppedOut.value ? (content.value ?? undefined) : undefined,
);
provide(IsInPiPWindowSymbol, isPoppedOut);
useProvideTooltipAppendTo(tooltipContainer);
async function showPip() {
if (!content.value) {
return;
}
pipWindow.value =
pipWindow.value ??
(await window.documentPictureInPicture?.requestWindow({
width: initialWidth,
height: initialHeight,
disallowReturnToOpener: true,
}));
// Copy style sheets over from the initial document
// so that the content looks the same.
[...document.styleSheets].forEach((styleSheet) => {
try {
const cssRules = [...styleSheet.cssRules].map((rule) => rule.cssText).join('');
const style = document.createElement('style');
style.textContent = cssRules;
pipWindow.value?.document.head.appendChild(style);
} catch (e) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.type = styleSheet.type;
link.media = styleSheet.media as unknown as string;
link.href = styleSheet.href as string;
pipWindow.value?.document.head.appendChild(link);
}
});
// Move the content to the Picture-in-Picture window.
pipWindow.value?.document.body.append(content.value);
pipWindow.value?.addEventListener('pagehide', () => !isUnmounting.value && onRequestClose());
}
function hidePiP() {
pipWindow.value?.close();
pipWindow.value = undefined;
if (content.value) {
container.value?.appendChild(content.value);
}
}
// `requestAnimationFrame()` to make sure the content is already rendered
watch(shouldPopOut, (value) => (value ? requestAnimationFrame(showPip) : hidePiP()), {
immediate: true,
});
onBeforeUnmount(() => {
isUnmounting.value = true;
pipWindow.value?.close();
});
return { canPopOut, isPoppedOut, pipWindow };
}

View File

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

View File

@@ -1,435 +0,0 @@
import { renderComponent } from '@/__tests__/render';
import { fireEvent, waitFor, within } from '@testing-library/vue';
import { mockedStore } from '@/__tests__/utils';
import LogsPanel from '@/components/CanvasChat/future/LogsPanel.vue';
import { useSettingsStore } from '@/stores/settings.store';
import { createTestingPinia, type TestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { createRouter, createWebHistory } from 'vue-router';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { h, nextTick } from 'vue';
import {
aiAgentNode,
aiChatExecutionResponse,
aiChatWorkflow,
aiManualWorkflow,
nodeTypes,
} from '../__test__/data';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { LOGS_PANEL_STATE } from '../types/logs';
import { IN_PROGRESS_EXECUTION_ID } from '@/constants';
import { useCanvasOperations } from '@/composables/useCanvasOperations';
import { useNDVStore } from '@/stores/ndv.store';
import { deepCopy } from 'n8n-workflow';
import { createTestTaskData } from '@/__tests__/mocks';
import { useLogsStore } from '@/stores/logs.store';
import { useUIStore } from '@/stores/ui.store';
describe('LogsPanel', () => {
const VIEWPORT_HEIGHT = 800;
let pinia: TestingPinia;
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
let logsStore: ReturnType<typeof mockedStore<typeof useLogsStore>>;
let ndvStore: ReturnType<typeof mockedStore<typeof useNDVStore>>;
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
function render() {
return renderComponent(LogsPanel, {
global: {
plugins: [
createRouter({
history: createWebHistory(),
routes: [{ path: '/', component: () => h('div') }],
}),
pinia,
],
},
});
}
beforeEach(() => {
pinia = createTestingPinia({ stubActions: false, fakeApp: true });
setActivePinia(pinia);
settingsStore = mockedStore(useSettingsStore);
settingsStore.isNewLogsEnabled = true;
workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.setWorkflowExecutionData(null);
logsStore = mockedStore(useLogsStore);
logsStore.toggleOpen(false);
nodeTypeStore = mockedStore(useNodeTypesStore);
nodeTypeStore.setNodeTypes(nodeTypes);
ndvStore = mockedStore(useNDVStore);
uiStore = mockedStore(useUIStore);
Object.defineProperty(document.body, 'offsetHeight', {
configurable: true,
get() {
return VIEWPORT_HEIGHT;
},
});
vi.spyOn(document.body, 'getBoundingClientRect').mockReturnValue({
y: 0,
height: VIEWPORT_HEIGHT,
} as DOMRect);
});
it('should render collapsed panel by default', async () => {
const rendered = render();
expect(await rendered.findByTestId('logs-overview-header')).toBeInTheDocument();
expect(rendered.queryByTestId('logs-overview-empty')).not.toBeInTheDocument();
});
it('should only render logs panel if the workflow has no chat trigger', async () => {
workflowsStore.setWorkflow(aiManualWorkflow);
const rendered = render();
expect(await rendered.findByTestId('logs-overview-header')).toBeInTheDocument();
expect(rendered.queryByTestId('chat-header')).not.toBeInTheDocument();
});
it('should render chat panel and logs panel if the workflow has chat trigger', async () => {
workflowsStore.setWorkflow(aiChatWorkflow);
const rendered = render();
expect(await rendered.findByTestId('logs-overview-header')).toBeInTheDocument();
expect(await rendered.findByTestId('chat-header')).toBeInTheDocument();
});
it('opens collapsed panel when clicked', async () => {
workflowsStore.setWorkflow(aiChatWorkflow);
const rendered = render();
await fireEvent.click(await rendered.findByTestId('logs-overview-header'));
expect(await rendered.findByTestId('logs-overview-empty')).toBeInTheDocument();
});
it('should toggle panel when chevron icon button in the overview panel is clicked', async () => {
workflowsStore.setWorkflow(aiChatWorkflow);
const rendered = render();
const overviewPanel = await rendered.findByTestId('logs-overview-header');
await fireEvent.click(within(overviewPanel).getByLabelText('Open panel'));
expect(rendered.getByTestId('logs-overview-empty')).toBeInTheDocument();
await fireEvent.click(within(overviewPanel).getByLabelText('Collapse panel'));
expect(rendered.queryByTestId('logs-overview-empty')).not.toBeInTheDocument();
});
it('should open log details panel when a log entry is clicked in the logs overview panel', async () => {
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render();
await fireEvent.click(await rendered.findByTestId('logs-overview-header'));
await fireEvent.click(await rendered.findByText('AI Agent'));
expect(rendered.getByTestId('log-details')).toBeInTheDocument();
// Click again to close the panel
await fireEvent.click(
await within(rendered.getByTestId('logs-overview-body')).findByText('AI Agent'),
);
expect(rendered.queryByTestId('log-details')).not.toBeInTheDocument();
});
it("should show the button to toggle panel in the header of log details panel when it's opened", async () => {
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render();
await fireEvent.click(await rendered.findByTestId('logs-overview-header'));
await fireEvent.click(await rendered.findByText('AI Agent'));
// Click the toggle button to close the panel
await fireEvent.click(
within(rendered.getByTestId('log-details')).getByLabelText('Collapse panel'),
);
expect(rendered.queryByTestId('chat-messages-empty')).not.toBeInTheDocument();
expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument();
// Click again to open the panel
await fireEvent.click(
within(rendered.getByTestId('logs-overview')).getByLabelText('Open panel'),
);
expect(await rendered.findByTestId('chat-messages-empty')).toBeInTheDocument();
expect(await rendered.findByTestId('logs-overview-body')).toBeInTheDocument();
});
it('should open itself by pulling up the resizer', async () => {
logsStore.toggleOpen(false);
const rendered = render();
expect(logsStore.state).toBe(LOGS_PANEL_STATE.CLOSED);
expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument();
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 0, clientY: 0 }));
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 0, clientY: 0 }));
await waitFor(() => {
expect(logsStore.state).toBe(LOGS_PANEL_STATE.ATTACHED);
expect(rendered.queryByTestId('logs-overview-body')).toBeInTheDocument();
});
});
it('should close itself by pulling down the resizer', async () => {
logsStore.toggleOpen(true);
const rendered = render();
expect(logsStore.state).toBe(LOGS_PANEL_STATE.ATTACHED);
expect(rendered.queryByTestId('logs-overview-body')).toBeInTheDocument();
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
window.dispatchEvent(
new MouseEvent('mousemove', { bubbles: true, clientX: 0, clientY: VIEWPORT_HEIGHT }),
);
window.dispatchEvent(
new MouseEvent('mouseup', { bubbles: true, clientX: 0, clientY: VIEWPORT_HEIGHT }),
);
await waitFor(() => {
expect(logsStore.state).toBe(LOGS_PANEL_STATE.CLOSED);
expect(rendered.queryByTestId('logs-overview-body')).not.toBeInTheDocument();
});
});
it('should reflect changes to execution data in workflow store if execution is in progress', async () => {
logsStore.toggleOpen(true);
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData({
...aiChatExecutionResponse,
id: IN_PROGRESS_EXECUTION_ID,
status: 'running',
finished: false,
startedAt: new Date('2025-04-20T12:34:50.000Z'),
stoppedAt: undefined,
data: {
resultData: { runData: { Chat: [createTestTaskData()] } },
},
});
const rendered = render();
await fireEvent.click(rendered.getByText('Overview'));
expect(rendered.getByText(/Running/)).toBeInTheDocument();
expect(rendered.queryByText('AI Agent')).not.toBeInTheDocument();
workflowsStore.addNodeExecutionStartedData({
nodeName: 'AI Agent',
executionId: '567',
data: { executionIndex: 0, startTime: Date.parse('2025-04-20T12:34:51.000Z'), source: [] },
});
const lastTreeItem = await waitFor(() => {
const items = rendered.getAllByRole('treeitem');
expect(items).toHaveLength(2);
return within(items[1]);
});
expect(lastTreeItem.getByText('AI Agent')).toBeInTheDocument();
expect(lastTreeItem.getByText(/Running/)).toBeInTheDocument();
workflowsStore.updateNodeExecutionData({
nodeName: 'AI Agent',
executionId: '567',
data: {
executionIndex: 0,
startTime: Date.parse('2025-04-20T12:34:51.000Z'),
source: [],
executionTime: 33,
executionStatus: 'success',
},
});
expect(await lastTreeItem.findByText('AI Agent')).toBeInTheDocument();
expect(lastTreeItem.getByText('Success in 33ms')).toBeInTheDocument();
workflowsStore.setWorkflowExecutionData({
...workflowsStore.workflowExecutionData!,
id: '1234',
status: 'success',
finished: true,
startedAt: new Date('2025-04-20T12:34:50.000Z'),
stoppedAt: new Date('2025-04-20T12:34:56.000Z'),
});
expect(await rendered.findByText('Success in 6s')).toBeInTheDocument();
expect(rendered.queryByText('AI Agent')).toBeInTheDocument();
});
it('should still show logs for a removed node', async () => {
const operations = useCanvasOperations();
logsStore.toggleOpen(true);
workflowsStore.setWorkflow(deepCopy(aiChatWorkflow));
workflowsStore.setWorkflowExecutionData({
...aiChatExecutionResponse,
id: '2345',
status: 'success',
finished: true,
startedAt: new Date('2025-04-20T12:34:50.000Z'),
stoppedAt: new Date('2025-04-20T12:34:56.000Z'),
});
const rendered = render();
expect(await rendered.findByText('AI Agent')).toBeInTheDocument();
operations.deleteNode(aiAgentNode.id);
await nextTick();
expect(workflowsStore.nodesByName['AI Agent']).toBeUndefined();
expect(rendered.queryByText('AI Agent')).toBeInTheDocument();
});
it('should open NDV if the button is clicked', async () => {
logsStore.toggleOpen(true);
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render();
const aiAgentRow = (await rendered.findAllByRole('treeitem'))[0];
expect(ndvStore.activeNodeName).toBe(null);
expect(ndvStore.output.run).toBe(undefined);
await fireEvent.click(within(aiAgentRow).getAllByLabelText('Open...')[0]);
await waitFor(() => {
expect(ndvStore.activeNodeName).toBe('AI Agent');
expect(ndvStore.output.run).toBe(0);
});
});
it('should toggle subtree when chevron icon button is pressed', async () => {
logsStore.toggleOpen(true);
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render();
const overview = within(rendered.getByTestId('logs-overview'));
await waitFor(() => expect(overview.queryAllByRole('treeitem')).toHaveLength(2));
expect(overview.queryByText('AI Agent')).toBeInTheDocument();
expect(overview.queryByText('AI Model')).toBeInTheDocument();
// Close subtree of AI Agent
await fireEvent.click(overview.getAllByLabelText('Toggle row')[0]);
await waitFor(() => expect(overview.queryAllByRole('treeitem')).toHaveLength(1));
expect(overview.queryByText('AI Agent')).toBeInTheDocument();
expect(overview.queryByText('AI Model')).not.toBeInTheDocument();
// Re-open subtree of AI Agent
await fireEvent.click(overview.getAllByLabelText('Toggle row')[0]);
await waitFor(() => expect(overview.queryAllByRole('treeitem')).toHaveLength(2));
expect(overview.queryByText('AI Agent')).toBeInTheDocument();
expect(overview.queryByText('AI Model')).toBeInTheDocument();
});
it('should toggle input and output panel when the button is clicked', async () => {
logsStore.toggleOpen(true);
logsStore.toggleInputOpen(false);
logsStore.toggleOutputOpen(true);
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
const rendered = render();
const header = within(rendered.getByTestId('log-details-header'));
expect(rendered.queryByTestId('log-details-input')).not.toBeInTheDocument();
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
await fireEvent.click(header.getByText('Input'));
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
expect(rendered.queryByTestId('log-details-output')).toBeInTheDocument();
await fireEvent.click(header.getByText('Output'));
expect(rendered.queryByTestId('log-details-input')).toBeInTheDocument();
expect(rendered.queryByTestId('log-details-output')).not.toBeInTheDocument();
});
describe('selection', () => {
beforeEach(() => {
logsStore.toggleOpen(true);
workflowsStore.setWorkflow(aiChatWorkflow);
workflowsStore.setWorkflowExecutionData(aiChatExecutionResponse);
});
it('should allow to select previous and next row via keyboard shortcut', async () => {
const { getByTestId, findByRole } = render();
const overview = getByTestId('logs-overview');
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
await fireEvent.keyDown(overview, { key: 'K' });
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/);
await fireEvent.keyDown(overview, { key: 'J' });
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
});
it('should not select a log for the selected node on canvas if sync is disabled', async () => {
logsStore.toggleLogSelectionSync(false);
const { findByRole, rerender } = render();
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
uiStore.lastSelectedNode = 'AI Agent';
await rerender({});
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
});
it('should automatically select a log for the selected node on canvas if sync is enabled', async () => {
logsStore.toggleLogSelectionSync(true);
const { rerender, findByRole } = render();
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
uiStore.lastSelectedNode = 'AI Agent';
await rerender({});
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/);
});
it('should automatically expand and select a log for the selected node on canvas if the log entry is collapsed', async () => {
logsStore.toggleLogSelectionSync(true);
const { rerender, findByRole, getByLabelText, findByText, queryByText } = render();
await fireEvent.click(await findByText('AI Agent'));
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Agent/);
await fireEvent.click(getByLabelText('Toggle row'));
await rerender({});
expect(queryByText(/AI Model/)).not.toBeInTheDocument();
uiStore.lastSelectedNode = 'AI Model';
await rerender({});
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
});
});
});

View File

@@ -1,278 +0,0 @@
<script setup lang="ts">
import { nextTick, computed, useTemplateRef } from 'vue';
import { N8nResizeWrapper } from '@n8n/design-system';
import { useChatState } from '@/components/CanvasChat/composables/useChatState';
import LogsOverviewPanel from '@/components/CanvasChat/future/components/LogsOverviewPanel.vue';
import ChatMessagesPanel from '@/components/CanvasChat/components/ChatMessagesPanel.vue';
import LogsDetailsPanel from '@/components/CanvasChat/future/components/LogDetailsPanel.vue';
import LogsPanelActions from '@/components/CanvasChat/future/components/LogsPanelActions.vue';
import { useLogsPanelLayout } from '@/components/CanvasChat/future/composables/useLogsPanelLayout';
import { useLogsExecutionData } from '@/components/CanvasChat/future/composables/useLogsExecutionData';
import { type LogEntry } from '@/components/RunDataAi/utils';
import { useNDVStore } from '@/stores/ndv.store';
import { ndvEventBus } from '@/event-bus';
import { useLogsSelection } from '@/components/CanvasChat/future/composables/useLogsSelection';
import { useLogsTreeExpand } from '@/components/CanvasChat/future/composables/useLogsTreeExpand';
import { useLogsStore } from '@/stores/logs.store';
const props = withDefaults(defineProps<{ isReadOnly?: boolean }>(), { isReadOnly: false });
const container = useTemplateRef('container');
const logsContainer = useTemplateRef('logsContainer');
const pipContainer = useTemplateRef('pipContainer');
const pipContent = useTemplateRef('pipContent');
const logsStore = useLogsStore();
const ndvStore = useNDVStore();
const {
height,
chatPanelWidth,
overviewPanelWidth,
canPopOut,
isOpen,
isPoppedOut,
isCollapsingDetailsPanel,
isOverviewPanelFullWidth,
pipWindow,
onResize,
onResizeEnd,
onToggleOpen,
onPopOut,
onChatPanelResize,
onChatPanelResizeEnd,
onOverviewPanelResize,
onOverviewPanelResizeEnd,
} = useLogsPanelLayout(pipContainer, pipContent, container, logsContainer);
const {
currentSessionId,
messages,
previousChatMessages,
sendMessage,
refreshSession,
displayExecution,
} = useChatState(props.isReadOnly);
const { entries, execution, hasChat, latestNodeNameById, resetExecutionData, loadSubExecution } =
useLogsExecutionData();
const { flatLogEntries, toggleExpanded } = useLogsTreeExpand(entries);
const { selected, select, selectNext, selectPrev } = useLogsSelection(
execution,
entries,
flatLogEntries,
toggleExpanded,
);
const isLogDetailsOpen = computed(() => isOpen.value && selected.value !== undefined);
const isLogDetailsVisuallyOpen = computed(
() => isLogDetailsOpen.value && !isCollapsingDetailsPanel.value,
);
const logsPanelActionsProps = computed<InstanceType<typeof LogsPanelActions>['$props']>(() => ({
isOpen: isOpen.value,
isSyncSelectionEnabled: logsStore.isLogSelectionSyncedWithCanvas,
showToggleButton: !isPoppedOut.value,
showPopOutButton: canPopOut.value && !isPoppedOut.value,
onPopOut,
onToggleOpen,
onToggleSyncSelection: logsStore.toggleLogSelectionSync,
}));
function handleResizeOverviewPanelEnd() {
if (isOverviewPanelFullWidth.value) {
select(undefined);
}
onOverviewPanelResizeEnd();
}
async function handleOpenNdv(treeNode: LogEntry) {
ndvStore.setActiveNodeName(treeNode.node.name);
await nextTick(() => {
const source = treeNode.runData.source[0];
const inputBranch = source?.previousNodeOutput ?? 0;
ndvEventBus.emit('updateInputNodeName', source?.previousNode);
ndvEventBus.emit('setInputBranchIndex', inputBranch);
ndvStore.setOutputRunIndex(treeNode.runIndex);
});
}
</script>
<template>
<div ref="pipContainer">
<div ref="pipContent" :class="$style.pipContent">
<N8nResizeWrapper
:height="height"
:supported-directions="['top']"
:is-resizing-enabled="!isPoppedOut"
:class="$style.resizeWrapper"
:style="{ height: isOpen ? `${height}px` : 'auto' }"
@resize="onResize"
@resizeend="onResizeEnd"
>
<div
ref="container"
:class="$style.container"
tabindex="-1"
@keydown.esc.exact.stop="select(undefined)"
@keydown.j.exact.stop="selectNext"
@keydown.down.exact.stop.prevent="selectNext"
@keydown.k.exact.stop="selectPrev"
@keydown.up.exact.stop.prevent="selectPrev"
@keydown.space.exact.stop="selected && toggleExpanded(selected)"
@keydown.enter.exact.stop="selected && handleOpenNdv(selected)"
>
<N8nResizeWrapper
v-if="hasChat && (!props.isReadOnly || messages.length > 0)"
:supported-directions="['right']"
:is-resizing-enabled="isOpen"
:width="chatPanelWidth"
:style="{ width: `${chatPanelWidth}px` }"
:class="$style.chat"
:window="pipWindow"
@resize="onChatPanelResize"
@resizeend="onChatPanelResizeEnd"
>
<ChatMessagesPanel
data-test-id="canvas-chat"
:is-open="isOpen"
:is-read-only="isReadOnly"
:messages="messages"
:session-id="currentSessionId"
:past-chat-messages="previousChatMessages"
:show-close-button="false"
:is-new-logs-enabled="true"
@close="onToggleOpen"
@refresh-session="refreshSession"
@display-execution="displayExecution"
@send-message="sendMessage"
@click-header="onToggleOpen(true)"
/>
</N8nResizeWrapper>
<div ref="logsContainer" :class="$style.logsContainer">
<N8nResizeWrapper
:class="$style.overviewResizer"
:width="overviewPanelWidth"
:style="{ width: isLogDetailsVisuallyOpen ? `${overviewPanelWidth}px` : '' }"
:supported-directions="['right']"
:is-resizing-enabled="isLogDetailsOpen"
:window="pipWindow"
@resize="onOverviewPanelResize"
@resizeend="handleResizeOverviewPanelEnd"
>
<LogsOverviewPanel
:key="execution?.id ?? ''"
:class="$style.logsOverview"
:is-open="isOpen"
:is-read-only="isReadOnly"
:is-compact="isLogDetailsVisuallyOpen"
:selected="selected"
:execution="execution"
:entries="entries"
:latest-node-info="latestNodeNameById"
:flat-log-entries="flatLogEntries"
@click-header="onToggleOpen(true)"
@select="select"
@clear-execution-data="resetExecutionData"
@toggle-expanded="toggleExpanded"
@open-ndv="handleOpenNdv"
@load-sub-execution="loadSubExecution"
>
<template #actions>
<LogsPanelActions
v-if="!isLogDetailsVisuallyOpen"
v-bind="logsPanelActionsProps"
/>
</template>
</LogsOverviewPanel>
</N8nResizeWrapper>
<LogsDetailsPanel
v-if="isLogDetailsVisuallyOpen && selected"
:class="$style.logDetails"
:is-open="isOpen"
:log-entry="selected"
:window="pipWindow"
:latest-info="latestNodeNameById[selected.id]"
:panels="logsStore.detailsState"
@click-header="onToggleOpen(true)"
@toggle-input-open="logsStore.toggleInputOpen"
@toggle-output-open="logsStore.toggleOutputOpen"
>
<template #actions>
<LogsPanelActions v-if="isLogDetailsVisuallyOpen" v-bind="logsPanelActionsProps" />
</template>
</LogsDetailsPanel>
</div>
</div>
</N8nResizeWrapper>
</div>
</div>
</template>
<style lang="scss" module>
@media all and (display-mode: picture-in-picture) {
.resizeWrapper {
height: 100% !important;
max-height: 100vh !important;
}
}
.pipContent {
height: 100%;
position: relative;
overflow: hidden;
}
.resizeWrapper {
height: 100%;
min-height: 0;
flex-basis: 0;
border-top: var(--border-base);
background-color: var(--color-background-light);
}
.container {
height: 100%;
display: flex;
flex-grow: 1;
& > *:not(:last-child) {
border-right: var(--border-base);
}
}
.chat {
flex-shrink: 0;
}
.logsContainer {
width: 0;
flex-grow: 1;
display: flex;
align-items: stretch;
& > *:not(:last-child) {
border-right: var(--border-base);
}
}
.overviewResizer {
flex-grow: 0;
flex-shrink: 0;
&:last-child {
flex-grow: 1;
}
}
.logsOverview {
height: 100%;
}
.logsDetails {
width: 0;
flex-grow: 1;
}
</style>

View File

@@ -1,14 +0,0 @@
<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>

View File

@@ -1,158 +0,0 @@
import { fireEvent, within } from '@testing-library/vue';
import { renderComponent } from '@/__tests__/render';
import LogDetailsPanel from './LogDetailsPanel.vue';
import { createRouter, createWebHistory } from 'vue-router';
import { createTestingPinia, type TestingPinia } from '@pinia/testing';
import { h } from 'vue';
import {
createTestLogEntry,
createTestNode,
createTestTaskData,
createTestWorkflow,
createTestWorkflowObject,
} from '@/__tests__/mocks';
import { mockedStore } from '@/__tests__/utils';
import { useSettingsStore } from '@/stores/settings.store';
import { type FrontendSettings } from '@n8n/api-types';
import { LOG_DETAILS_PANEL_STATE } from '../../types/logs';
import type { LogEntry } from '@/components/RunDataAi/utils';
describe('LogDetailsPanel', () => {
let pinia: TestingPinia;
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
const aiNode = createTestNode({ name: 'AI Agent' });
const workflowData = createTestWorkflow({
nodes: [createTestNode({ name: 'Chat Trigger' }), aiNode],
connections: { 'Chat Trigger': { main: [[{ node: 'AI Agent', type: 'main', index: 0 }]] } },
});
const chatNodeRunData = createTestTaskData({
executionStatus: 'success',
executionTime: 0,
data: { main: [[{ json: { response: 'hey' } }]] },
});
const aiNodeRunData = createTestTaskData({
executionStatus: 'success',
executionTime: 10,
data: { main: [[{ json: { response: 'Hello!' } }]] },
source: [{ previousNode: 'Chat Trigger' }],
});
function createLogEntry(data: Partial<LogEntry> = {}) {
return createTestLogEntry({
workflow: createTestWorkflowObject(workflowData),
execution: {
resultData: {
runData: {
'Chat Trigger': [chatNodeRunData],
'AI Agent': [aiNodeRunData],
},
},
},
...data,
});
}
function render(props: InstanceType<typeof LogDetailsPanel>['$props']) {
const rendered = renderComponent(LogDetailsPanel, {
props,
global: {
plugins: [
createRouter({
history: createWebHistory(),
routes: [{ path: '/', component: () => h('div') }],
}),
pinia,
],
},
});
const container = rendered.getByTestId('log-details');
Object.defineProperty(container, 'offsetWidth', {
configurable: true,
get() {
return 1000;
},
});
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
x: 0,
width: 1000,
} as DOMRect);
return rendered;
}
beforeEach(() => {
pinia = createTestingPinia({ stubActions: false, fakeApp: true });
settingsStore = mockedStore(useSettingsStore);
settingsStore.isEnterpriseFeatureEnabled = {} as FrontendSettings['enterprise'];
localStorage.clear();
});
it('should show name, run status, input, and output of the node', async () => {
const rendered = render({
isOpen: true,
logEntry: createLogEntry({ node: aiNode, runIndex: 0, runData: aiNodeRunData }),
panels: LOG_DETAILS_PANEL_STATE.BOTH,
});
const header = within(rendered.getByTestId('log-details-header'));
const inputPanel = within(rendered.getByTestId('log-details-input'));
const outputPanel = within(rendered.getByTestId('log-details-output'));
expect(header.getByText('AI Agent')).toBeInTheDocument();
expect(header.getByText('Success in 10ms')).toBeInTheDocument();
expect(await inputPanel.findByText('hey')).toBeInTheDocument();
expect(await outputPanel.findByText('Hello!')).toBeInTheDocument();
});
it('should show a message in the output panel and data in the input panel when node is running', async () => {
const rendered = render({
isOpen: true,
logEntry: createLogEntry({
node: aiNode,
runIndex: 0,
runData: { ...aiNodeRunData, executionStatus: 'running' },
}),
panels: LOG_DETAILS_PANEL_STATE.BOTH,
});
const inputPanel = within(rendered.getByTestId('log-details-input'));
const outputPanel = within(rendered.getByTestId('log-details-output'));
expect(await inputPanel.findByText('hey')).toBeInTheDocument();
expect(await outputPanel.findByText('Executing node...')).toBeInTheDocument();
});
it('should close input panel by dragging the divider to the left end', async () => {
const rendered = render({
isOpen: true,
logEntry: createLogEntry({ node: aiNode, runIndex: 0, runData: aiNodeRunData }),
panels: LOG_DETAILS_PANEL_STATE.BOTH,
});
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 0, clientY: 0 }));
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 0, clientY: 0 }));
expect(rendered.emitted()).toEqual({ toggleInputOpen: [[false]] });
});
it('should close output panel by dragging the divider to the right end', async () => {
const rendered = render({
isOpen: true,
logEntry: createLogEntry({ node: aiNode, runIndex: 0, runData: aiNodeRunData }),
panels: LOG_DETAILS_PANEL_STATE.BOTH,
});
await fireEvent.mouseDown(rendered.getByTestId('resize-handle'));
window.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 1000, clientY: 0 }));
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: 1000, clientY: 0 }));
expect(rendered.emitted()).toEqual({ toggleOutputOpen: [[false]] });
});
});

View File

@@ -1,223 +0,0 @@
<script setup lang="ts">
import LogsViewExecutionSummary from '@/components/CanvasChat/future/components/LogsViewExecutionSummary.vue';
import LogsPanelHeader from '@/components/CanvasChat/future/components/LogsPanelHeader.vue';
import LogsViewRunData from '@/components/CanvasChat/future/components/LogsViewRunData.vue';
import { useResizablePanel } from '@/composables/useResizablePanel';
import {
LOG_DETAILS_PANEL_STATE,
type LogDetailsPanelState,
} from '@/components/CanvasChat/types/logs';
import NodeIcon from '@/components/NodeIcon.vue';
import { useI18n } from '@n8n/i18n';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import LogsViewNodeName from '@/components/CanvasChat/future/components/LogsViewNodeName.vue';
import {
getSubtreeTotalConsumedTokens,
type LogEntry,
type LatestNodeInfo,
} from '@/components/RunDataAi/utils';
import { N8nButton, N8nResizeWrapper } from '@n8n/design-system';
import { computed, useTemplateRef } from 'vue';
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
const MIN_IO_PANEL_WIDTH = 200;
const { isOpen, logEntry, window, latestInfo, panels } = defineProps<{
isOpen: boolean;
logEntry: LogEntry;
window?: Window;
latestInfo?: LatestNodeInfo;
panels: LogDetailsPanelState;
}>();
const emit = defineEmits<{
clickHeader: [];
toggleInputOpen: [] | [boolean];
toggleOutputOpen: [] | [boolean];
}>();
defineSlots<{ actions: {} }>();
const locale = useI18n();
const nodeTypeStore = useNodeTypesStore();
const type = computed(() => nodeTypeStore.getNodeType(logEntry.node.type));
const consumedTokens = computed(() => getSubtreeTotalConsumedTokens(logEntry, false));
const isTriggerNode = computed(() => type.value?.group.includes('trigger'));
const container = useTemplateRef<HTMLElement>('container');
const resizer = useResizablePanel('N8N_LOGS_INPUT_PANEL_WIDTH', {
container,
defaultSize: (size) => size / 2,
minSize: MIN_IO_PANEL_WIDTH,
maxSize: (size) => size - MIN_IO_PANEL_WIDTH,
allowCollapse: true,
allowFullSize: true,
});
const shouldResize = computed(() => panels === LOG_DETAILS_PANEL_STATE.BOTH);
function handleResizeEnd() {
if (resizer.isCollapsed.value) {
emit('toggleInputOpen', false);
}
if (resizer.isFullSize.value) {
emit('toggleOutputOpen', false);
}
resizer.onResizeEnd();
}
</script>
<template>
<div ref="container" :class="$style.container" data-test-id="log-details">
<LogsPanelHeader
data-test-id="log-details-header"
:class="$style.header"
@click="emit('clickHeader')"
>
<template #title>
<div :class="$style.title">
<NodeIcon :node-type="type" :size="16" :class="$style.icon" />
<LogsViewNodeName
:latest-name="latestInfo?.name ?? logEntry.node.name"
:name="logEntry.node.name"
:is-deleted="latestInfo?.deleted ?? false"
/>
<LogsViewExecutionSummary
v-if="isOpen"
:class="$style.executionSummary"
:status="logEntry.runData.executionStatus ?? 'unknown'"
:consumed-tokens="consumedTokens"
:start-time="logEntry.runData.startTime"
:time-took="logEntry.runData.executionTime"
/>
</div>
</template>
<template #actions>
<div v-if="isOpen && !isTriggerNode" :class="$style.actions">
<KeyboardShortcutTooltip
:label="locale.baseText('generic.shortcutHint')"
:shortcut="{ keys: ['i'] }"
>
<N8nButton
size="mini"
type="secondary"
:class="panels === LOG_DETAILS_PANEL_STATE.OUTPUT ? '' : $style.pressed"
@click.stop="emit('toggleInputOpen')"
>
{{ locale.baseText('logs.details.header.actions.input') }}
</N8nButton>
</KeyboardShortcutTooltip>
<KeyboardShortcutTooltip
:label="locale.baseText('generic.shortcutHint')"
:shortcut="{ keys: ['o'] }"
>
<N8nButton
size="mini"
type="secondary"
:class="panels === LOG_DETAILS_PANEL_STATE.INPUT ? '' : $style.pressed"
@click.stop="emit('toggleOutputOpen')"
>
{{ locale.baseText('logs.details.header.actions.output') }}
</N8nButton>
</KeyboardShortcutTooltip>
</div>
<slot name="actions" />
</template>
</LogsPanelHeader>
<div v-if="isOpen" :class="$style.content" data-test-id="logs-details-body">
<N8nResizeWrapper
v-if="!isTriggerNode && panels !== LOG_DETAILS_PANEL_STATE.OUTPUT"
:class="{
[$style.inputResizer]: true,
[$style.collapsed]: resizer.isCollapsed.value,
[$style.full]: resizer.isFullSize.value,
}"
:width="resizer.size.value"
:style="shouldResize ? { width: `${resizer.size.value ?? 0}px` } : undefined"
:supported-directions="['right']"
:is-resizing-enabled="shouldResize"
:window="window"
@resize="resizer.onResize"
@resizeend="handleResizeEnd"
>
<LogsViewRunData
data-test-id="log-details-input"
pane-type="input"
:title="locale.baseText('logs.details.header.actions.input')"
:log-entry="logEntry"
/>
</N8nResizeWrapper>
<LogsViewRunData
v-if="isTriggerNode || panels !== LOG_DETAILS_PANEL_STATE.INPUT"
data-test-id="log-details-output"
pane-type="output"
:class="$style.outputPanel"
:title="locale.baseText('logs.details.header.actions.output')"
:log-entry="logEntry"
/>
</div>
</div>
</template>
<style lang="scss" module>
.container {
flex-grow: 1;
flex-shrink: 1;
display: flex;
flex-direction: column;
align-items: stretch;
overflow: hidden;
}
.header {
padding: var(--spacing-2xs);
}
.actions {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
padding-inline-end: var(--spacing-2xs);
.pressed {
background-color: var(--color-button-secondary-focus-outline);
}
}
.title {
display: flex;
align-items: center;
flex-shrink: 1;
}
.icon {
margin-right: var(--spacing-2xs);
}
.executionSummary {
flex-shrink: 1;
}
.content {
flex-shrink: 1;
flex-grow: 1;
display: flex;
align-items: stretch;
overflow: hidden;
}
.outputPanel {
width: 0;
flex-grow: 1;
}
.inputResizer {
overflow: hidden;
flex-shrink: 0;
&:not(:is(:last-child, .collapsed, .full)) {
border-right: var(--border-base);
}
}
</style>

View File

@@ -1,128 +0,0 @@
import { renderComponent } from '@/__tests__/render';
import LogsOverviewPanel from './LogsOverviewPanel.vue';
import { setActivePinia } from 'pinia';
import { createTestingPinia, type TestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createRouter, createWebHistory } from 'vue-router';
import { h } from 'vue';
import { fireEvent, waitFor, within } from '@testing-library/vue';
import {
aiChatExecutionResponse,
aiChatWorkflow,
aiManualExecutionResponse,
aiManualWorkflow,
} from '../../__test__/data';
import { usePushConnectionStore } from '@/stores/pushConnection.store';
import { createTestWorkflowObject } from '@/__tests__/mocks';
import { createLogTree, flattenLogEntries } from '@/components/RunDataAi/utils';
describe('LogsOverviewPanel', () => {
let pinia: TestingPinia;
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let pushConnectionStore: ReturnType<typeof mockedStore<typeof usePushConnectionStore>>;
function render(props: Partial<InstanceType<typeof LogsOverviewPanel>['$props']>) {
const logs = createLogTree(createTestWorkflowObject(aiChatWorkflow), aiChatExecutionResponse);
const mergedProps: InstanceType<typeof LogsOverviewPanel>['$props'] = {
isOpen: false,
isReadOnly: false,
isCompact: false,
flatLogEntries: flattenLogEntries(logs, {}),
entries: logs,
latestNodeInfo: {},
execution: aiChatExecutionResponse,
...props,
};
return renderComponent(LogsOverviewPanel, {
props: mergedProps,
global: {
plugins: [
createRouter({
history: createWebHistory(),
routes: [{ path: '/', component: () => h('div') }],
}),
pinia,
],
},
});
}
beforeEach(() => {
pinia = createTestingPinia({ stubActions: false, fakeApp: true });
setActivePinia(pinia);
workflowsStore = mockedStore(useWorkflowsStore);
pushConnectionStore = mockedStore(usePushConnectionStore);
pushConnectionStore.isConnected = true;
});
it('should not render body if the panel is not open', () => {
const rendered = render({ isOpen: false });
expect(rendered.queryByTestId('logs-overview-empty')).not.toBeInTheDocument();
});
it('should render empty text if there is no execution', () => {
const rendered = render({
isOpen: true,
flatLogEntries: [],
entries: [],
execution: undefined,
});
expect(rendered.queryByTestId('logs-overview-empty')).toBeInTheDocument();
});
it('should render summary text and executed nodes if there is an execution', async () => {
const rendered = render({ isOpen: true });
const summary = within(rendered.container.querySelector('.summary')!);
expect(summary.queryByText('Success in 1.999s')).toBeInTheDocument();
expect(summary.queryByText('555 Tokens')).toBeInTheDocument();
await fireEvent.click(rendered.getByText('Overview'));
const tree = within(rendered.getByRole('tree'));
await waitFor(() => expect(tree.queryAllByRole('treeitem')).toHaveLength(2));
const row1 = within(tree.queryAllByRole('treeitem')[0]);
expect(row1.queryByText('AI Agent')).toBeInTheDocument();
expect(row1.queryByText('Success in 1.778s')).toBeInTheDocument();
expect(row1.queryByText('Started 00:00:00.002, 26 Mar')).toBeInTheDocument();
const row2 = within(tree.queryAllByRole('treeitem')[1]);
expect(row2.queryByText('AI Model')).toBeInTheDocument();
expect(row2.queryByText('Error')).toBeInTheDocument();
expect(row2.queryByText('in 1.777s')).toBeInTheDocument();
expect(row2.queryByText('Started 00:00:00.003, 26 Mar')).toBeInTheDocument();
expect(row2.queryByText('555 Tokens')).toBeInTheDocument();
});
it('should trigger partial execution if the button is clicked', async () => {
const spyRun = vi.spyOn(workflowsStore, 'runWorkflow');
const logs = createLogTree(
createTestWorkflowObject(aiManualWorkflow),
aiManualExecutionResponse,
);
const rendered = render({
isOpen: true,
execution: aiManualExecutionResponse,
entries: logs,
flatLogEntries: flattenLogEntries(logs, {}),
});
const aiAgentRow = (await rendered.findAllByRole('treeitem'))[0];
await fireEvent.click(within(aiAgentRow).getAllByLabelText('Execute step')[0]);
await waitFor(() =>
expect(spyRun).toHaveBeenCalledWith(expect.objectContaining({ destinationNode: 'AI Agent' })),
);
});
});

View File

@@ -1,273 +0,0 @@
<script setup lang="ts">
import LogsPanelHeader from '@/components/CanvasChat/future/components/LogsPanelHeader.vue';
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
import { useI18n } from '@n8n/i18n';
import { N8nButton, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system';
import { computed, nextTick, toRef, watch } from 'vue';
import LogsOverviewRow from '@/components/CanvasChat/future/components/LogsOverviewRow.vue';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { useRouter } from 'vue-router';
import LogsViewExecutionSummary from '@/components/CanvasChat/future/components/LogsViewExecutionSummary.vue';
import {
getSubtreeTotalConsumedTokens,
getTotalConsumedTokens,
hasSubExecution,
type LatestNodeInfo,
type LogEntry,
} from '@/components/RunDataAi/utils';
import { useVirtualList } from '@vueuse/core';
import { type IExecutionResponse } from '@/Interface';
const {
isOpen,
isReadOnly,
selected,
isCompact,
execution,
entries,
flatLogEntries,
latestNodeInfo,
} = defineProps<{
isOpen: boolean;
selected?: LogEntry;
isReadOnly: boolean;
isCompact: boolean;
execution?: IExecutionResponse;
entries: LogEntry[];
flatLogEntries: LogEntry[];
latestNodeInfo: Record<string, LatestNodeInfo>;
}>();
const emit = defineEmits<{
clickHeader: [];
select: [LogEntry | undefined];
clearExecutionData: [];
openNdv: [LogEntry];
toggleExpanded: [LogEntry];
loadSubExecution: [LogEntry];
}>();
defineSlots<{ actions: {} }>();
const locale = useI18n();
const router = useRouter();
const runWorkflow = useRunWorkflow({ router });
const isClearExecutionButtonVisible = useClearExecutionButtonVisible();
const isEmpty = computed(() => flatLogEntries.length === 0 || execution === undefined);
const switchViewOptions = computed(() => [
{ label: locale.baseText('logs.overview.header.switch.overview'), value: 'overview' as const },
{ label: locale.baseText('logs.overview.header.switch.details'), value: 'details' as const },
]);
const consumedTokens = computed(() =>
getTotalConsumedTokens(
...entries.map((entry) =>
getSubtreeTotalConsumedTokens(
entry,
false, // Exclude token usages from sub workflow which is loaded only after expanding the row
),
),
),
);
const shouldShowTokenCountColumn = computed(
() =>
consumedTokens.value.totalTokens > 0 ||
entries.some((entry) => getSubtreeTotalConsumedTokens(entry, true).totalTokens > 0),
);
const virtualList = useVirtualList(
toRef(() => flatLogEntries),
{ itemHeight: 32 },
);
function handleSwitchView(value: 'overview' | 'details') {
emit('select', value === 'overview' ? undefined : flatLogEntries[0]);
}
function handleToggleExpanded(treeNode: LogEntry) {
if (hasSubExecution(treeNode) && treeNode.children.length === 0) {
emit('loadSubExecution', treeNode);
return;
}
emit('toggleExpanded', treeNode);
}
async function handleTriggerPartialExecution(treeNode: LogEntry) {
const latestName = latestNodeInfo[treeNode.node.id]?.name ?? treeNode.node.name;
if (latestName) {
await runWorkflow.runWorkflow({ destinationNode: latestName });
}
}
// Scroll selected row into view
watch(
() => selected,
async (selection) => {
if (selection && virtualList.list.value.every((e) => e.data.id !== selection.id)) {
const index = flatLogEntries.findIndex((e) => e.id === selection?.id);
if (index >= 0) {
// Wait for the node to be added to the list, and then scroll
await nextTick(() => virtualList.scrollTo(index));
}
}
},
{ immediate: true },
);
</script>
<template>
<div :class="$style.container" data-test-id="logs-overview">
<LogsPanelHeader
:title="locale.baseText('logs.overview.header.title')"
data-test-id="logs-overview-header"
@click="emit('clickHeader')"
>
<template #actions>
<N8nTooltip
v-if="isClearExecutionButtonVisible"
:content="locale.baseText('logs.overview.header.actions.clearExecution.tooltip')"
>
<N8nButton
size="mini"
type="secondary"
icon="trash"
icon-size="medium"
data-test-id="clear-execution-data-button"
:class="$style.clearButton"
@click.stop="emit('clearExecutionData')"
>{{ locale.baseText('logs.overview.header.actions.clearExecution') }}</N8nButton
>
</N8nTooltip>
<slot name="actions" />
</template>
</LogsPanelHeader>
<div
v-if="isOpen"
:class="[$style.content, isEmpty ? $style.empty : '']"
data-test-id="logs-overview-body"
>
<N8nText
v-if="isEmpty || execution === undefined"
tag="p"
size="medium"
color="text-base"
:class="$style.emptyText"
data-test-id="logs-overview-empty"
>
{{ locale.baseText('logs.overview.body.empty.message') }}
</N8nText>
<template v-else>
<LogsViewExecutionSummary
data-test-id="logs-overview-status"
:class="$style.summary"
:status="execution.status"
:consumed-tokens="consumedTokens"
:start-time="+new Date(execution.startedAt)"
:time-took="
execution.startedAt && execution.stoppedAt
? +new Date(execution.stoppedAt) - +new Date(execution.startedAt)
: undefined
"
/>
<div :class="$style.tree" v-bind="virtualList.containerProps">
<div v-bind="virtualList.wrapperProps.value" role="tree">
<LogsOverviewRow
v-for="{ data, index } of virtualList.list.value"
:key="index"
:data="data"
:is-read-only="isReadOnly"
:is-selected="data.id === selected?.id"
:is-compact="isCompact"
:should-show-token-count-column="shouldShowTokenCountColumn"
:latest-info="latestNodeInfo[data.node.id]"
:expanded="virtualList.list.value[index + 1]?.data.parent?.id === data.id"
:can-open-ndv="data.executionId === execution?.id"
@toggle-expanded="handleToggleExpanded(data)"
@open-ndv="emit('openNdv', data)"
@trigger-partial-execution="handleTriggerPartialExecution(data)"
@toggle-selected="emit('select', selected?.id === data.id ? undefined : data)"
/>
</div>
</div>
<N8nRadioButtons
size="small-medium"
:class="$style.switchViewButtons"
:model-value="selected ? 'details' : 'overview'"
:options="switchViewOptions"
@update:model-value="handleSwitchView"
/>
</template>
</div>
</div>
</template>
<style lang="scss" module>
@import '@/styles/variables';
.container {
flex-grow: 1;
flex-shrink: 1;
display: flex;
flex-direction: column;
align-items: stretch;
overflow: hidden;
background-color: var(--color-foreground-xlight);
}
.clearButton {
border: none;
color: var(--color-text-light);
gap: var(--spacing-5xs);
}
.content {
position: relative;
flex-grow: 1;
overflow: auto;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: stretch;
&.empty {
align-items: center;
justify-content: center;
}
}
.emptyText {
max-width: 20em;
text-align: center;
}
.summary {
padding: var(--spacing-2xs);
}
.tree {
padding: 0 var(--spacing-2xs) var(--spacing-2xs) var(--spacing-2xs);
scroll-padding-block: var(--spacing-3xs);
& :global(.el-icon) {
display: none;
}
}
.switchViewButtons {
position: absolute;
z-index: 10; /* higher than log entry rows background */
right: 0;
top: 0;
margin: var(--spacing-4xs) var(--spacing-2xs);
visibility: hidden;
opacity: 0;
transition: opacity 0.3s $ease-out-expo;
.content:hover & {
visibility: visible;
opacity: 1;
}
}
</style>

View File

@@ -1,394 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, useTemplateRef, watch } from 'vue';
import { N8nButton, N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import upperFirst from 'lodash/upperFirst';
import { useI18n } from '@n8n/i18n';
import LogsViewConsumedTokenCountText from '@/components/CanvasChat/future/components/LogsViewConsumedTokenCountText.vue';
import { I18nT } from 'vue-i18n';
import { toDayMonth, toTime } from '@/utils/formatters/dateFormatter';
import LogsViewNodeName from '@/components/CanvasChat/future/components/LogsViewNodeName.vue';
import {
getSubtreeTotalConsumedTokens,
type LatestNodeInfo,
type LogEntry,
} from '@/components/RunDataAi/utils';
import { useTimestamp } from '@vueuse/core';
const props = defineProps<{
data: LogEntry;
isSelected: boolean;
isReadOnly: boolean;
shouldShowTokenCountColumn: boolean;
isCompact: boolean;
latestInfo?: LatestNodeInfo;
expanded: boolean;
canOpenNdv: boolean;
}>();
const emit = defineEmits<{
toggleExpanded: [];
toggleSelected: [];
triggerPartialExecution: [];
openNdv: [];
}>();
const container = useTemplateRef('containerRef');
const locale = useI18n();
const now = useTimestamp({ interval: 1000 });
const nodeTypeStore = useNodeTypesStore();
const type = computed(() => nodeTypeStore.getNodeType(props.data.node.type));
const isSettled = computed(
() =>
props.data.runData.executionStatus &&
!['running', 'waiting'].includes(props.data.runData.executionStatus),
);
const isError = computed(() => !!props.data.runData.error);
const startedAtText = computed(() => {
const time = new Date(props.data.runData.startTime);
return locale.baseText('logs.overview.body.started', {
interpolate: {
time: `${toTime(time, true)}, ${toDayMonth(time)}`,
},
});
});
const statusText = computed(() => upperFirst(props.data.runData.executionStatus));
const timeText = computed(() =>
locale.displayTimer(
isSettled.value
? props.data.runData.executionTime
: Math.floor((now.value - props.data.runData.startTime) / 1000) * 1000,
true,
),
);
const subtreeConsumedTokens = computed(() =>
props.shouldShowTokenCountColumn ? getSubtreeTotalConsumedTokens(props.data, false) : undefined,
);
const hasChildren = computed(
() => props.data.children.length > 0 || !!props.data.runData.metadata?.subExecution,
);
function isLastChild(level: number) {
let parent = props.data.parent;
let data: LogEntry | undefined = props.data;
for (let i = 0; i < props.data.depth - level; i++) {
data = parent;
parent = parent?.parent;
}
const siblings = parent?.children ?? [];
const lastSibling = siblings[siblings.length - 1];
return (
(data === undefined && lastSibling === undefined) ||
(data?.node === lastSibling?.node && data?.runIndex === lastSibling?.runIndex)
);
}
// Focus when selected: For scrolling into view and for keyboard navigation to work
watch(
() => props.isSelected,
(isSelected) => {
void nextTick(() => {
if (isSelected) {
container.value?.focus();
}
});
},
{ immediate: true },
);
</script>
<template>
<div
ref="containerRef"
role="treeitem"
tabindex="-1"
:aria-expanded="props.data.children.length > 0 && props.expanded"
:aria-selected="props.isSelected"
:class="{
[$style.container]: true,
[$style.compact]: props.isCompact,
[$style.error]: isError,
[$style.selected]: props.isSelected,
}"
@click.stop="emit('toggleSelected')"
>
<template v-for="level in props.data.depth" :key="level">
<div
:class="{
[$style.indent]: true,
[$style.connectorCurved]: level === props.data.depth,
[$style.connectorStraight]: !isLastChild(level),
}"
/>
</template>
<div :class="$style.background" :style="{ '--indent-depth': props.data.depth }" />
<NodeIcon :node-type="type" :size="16" :class="$style.icon" />
<LogsViewNodeName
:class="$style.name"
:latest-name="latestInfo?.name ?? props.data.node.name"
:name="props.data.node.name"
:is-error="isError"
:is-deleted="latestInfo?.deleted ?? false"
/>
<N8nText v-if="!isCompact" tag="div" color="text-light" size="small" :class="$style.timeTook">
<I18nT v-if="isSettled" keypath="logs.overview.body.summaryText.in">
<template #status>
<N8nText v-if="isError" color="danger" :bold="true" size="small">
<N8nIcon icon="exclamation-triangle" :class="$style.errorIcon" />
{{ statusText }}
</N8nText>
<template v-else>{{ statusText }}</template>
</template>
<template #time>{{ timeText }}</template>
</I18nT>
<template v-else>
{{
locale.baseText('logs.overview.body.summaryText.for', {
interpolate: { status: statusText, time: timeText },
})
}}
</template>
</N8nText>
<N8nText
v-if="!isCompact"
tag="div"
color="text-light"
size="small"
:class="$style.startedAt"
>{{ startedAtText }}</N8nText
>
<N8nText
v-if="!isCompact && subtreeConsumedTokens !== undefined"
tag="div"
color="text-light"
size="small"
:class="$style.consumedTokens"
>
<LogsViewConsumedTokenCountText
v-if="
subtreeConsumedTokens.totalTokens > 0 &&
(props.data.children.length === 0 || !props.expanded)
"
:consumed-tokens="subtreeConsumedTokens"
/>
</N8nText>
<N8nIcon
v-if="isError && isCompact"
size="medium"
color="danger"
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)"
:style="{
visibility: props.canOpenNdv ? '' : 'hidden',
color: 'var(--color-text-base)',
}"
:disabled="props.latestInfo?.deleted"
:class="$style.openNdvButton"
:aria-label="locale.baseText('logs.overview.body.open')"
@click.stop="emit('openNdv')"
/>
<N8nIconButton
v-if="
!isCompact ||
(!props.isReadOnly && !props.latestInfo?.deleted && !props.latestInfo?.disabled)
"
type="secondary"
size="small"
icon="play"
style="color: var(--color-text-base)"
:aria-label="locale.baseText('logs.overview.body.run')"
:class="[$style.partialExecutionButton, props.data.depth > 0 ? $style.unavailable : '']"
:disabled="props.latestInfo?.deleted || props.latestInfo?.disabled"
@click.stop="emit('triggerPartialExecution')"
/>
<N8nButton
v-if="!isCompact || hasChildren"
type="secondary"
size="small"
:square="true"
:style="{
visibility: hasChildren ? '' : 'hidden',
color: 'var(--color-text-base)', // give higher specificity than the style from the component itself
}"
:class="$style.toggleButton"
:aria-label="locale.baseText('logs.overview.body.toggleRow')"
@click.stop="emit('toggleExpanded')"
>
<N8nIcon size="medium" :icon="props.expanded ? 'chevron-down' : 'chevron-up'" />
</N8nButton>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
align-items: center;
justify-content: stretch;
overflow: hidden;
position: relative;
z-index: 1;
& > * {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: var(--spacing-2xs);
}
}
.background {
position: absolute;
left: calc(var(--indent-depth) * 32px);
top: 0;
width: calc(100% - var(--indent-depth) * 32px);
height: 100%;
border-radius: var(--border-radius-base);
z-index: -1;
.selected & {
background-color: var(--color-foreground-base);
}
.container:hover:not(.selected) & {
background-color: var(--color-background-light-base);
}
.selected:not(:hover).error & {
background-color: var(--color-danger-tint-2);
}
}
.indent {
flex-grow: 0;
flex-shrink: 0;
width: var(--spacing-xl);
align-self: stretch;
position: relative;
overflow: hidden;
margin-bottom: 0;
&.connectorCurved:before {
content: '';
position: absolute;
left: var(--spacing-s);
bottom: var(--spacing-s);
border: 2px solid var(--color-canvas-dot);
width: var(--spacing-l);
height: var(--spacing-l);
border-radius: var(--border-radius-large);
}
&.connectorStraight:after {
content: '';
position: absolute;
left: var(--spacing-s);
top: 0;
border-left: 2px solid var(--color-canvas-dot);
height: 100%;
}
}
.icon {
margin-left: var(--row-gap-thickness);
flex-grow: 0;
flex-shrink: 0;
}
.name {
flex-basis: 0;
flex-grow: 1;
padding-inline-start: 0;
}
.timeTook {
flex-grow: 0;
flex-shrink: 0;
width: 20%;
.errorIcon {
margin-right: var(--spacing-4xs);
vertical-align: text-bottom;
}
}
.startedAt {
flex-grow: 0;
flex-shrink: 0;
width: 25%;
}
.consumedTokens {
flex-grow: 0;
flex-shrink: 0;
width: 15%;
text-align: right;
}
.compactErrorIcon {
flex-grow: 0;
flex-shrink: 0;
.container:hover & {
display: none;
}
}
.partialExecutionButton,
.openNdvButton {
transition: none;
/* By default, take space but keep invisible */
visibility: hidden;
.container.compact & {
/* When compact, collapse to save space */
display: none;
}
.container:hover &:not(.unavailable) {
visibility: visible;
display: inline-flex;
}
}
.partialExecutionButton,
.openNdvButton,
.toggleButton {
flex-grow: 0;
flex-shrink: 0;
border: none;
background: transparent;
color: var(--color-text-base);
align-items: center;
justify-content: center;
&:last-child {
margin-inline-end: var(--spacing-5xs);
}
&:hover {
background: transparent;
}
&:disabled {
visibility: hidden !important;
}
}
.toggleButton {
display: inline-flex;
}
</style>

View File

@@ -1,104 +0,0 @@
<script setup lang="ts">
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
import { useI18n } from '@n8n/i18n';
import { useStyles } from '@/composables/useStyles';
import { N8nActionDropdown, N8nIconButton } from '@n8n/design-system';
import { computed } from 'vue';
const {
isOpen,
isSyncSelectionEnabled: isSyncEnabled,
showToggleButton,
showPopOutButton,
} = defineProps<{
isOpen: boolean;
isSyncSelectionEnabled: boolean;
showToggleButton: boolean;
showPopOutButton: boolean;
}>();
const emit = defineEmits<{ popOut: []; toggleOpen: []; toggleSyncSelection: [] }>();
const appStyles = useStyles();
const locales = useI18n();
const tooltipZIndex = computed(() => appStyles.APP_Z_INDEXES.ASK_ASSISTANT_FLOATING_BUTTON + 100);
const popOutButtonText = computed(() => locales.baseText('runData.panel.actions.popOut'));
const toggleButtonText = computed(() =>
locales.baseText(isOpen ? 'runData.panel.actions.collapse' : 'runData.panel.actions.open'),
);
const menuItems = computed(() => [
{
id: 'toggleSyncSelection' as const,
label: locales.baseText('runData.panel.actions.sync'),
checked: isSyncEnabled,
},
...(showPopOutButton ? [{ id: 'popOut' as const, label: popOutButtonText.value }] : []),
]);
function handleSelectMenuItem(selected: string) {
// This switch looks redundant, but needed to pass type checker
switch (selected) {
case 'popOut':
emit(selected);
return;
case 'toggleSyncSelection':
emit(selected);
return;
}
}
</script>
<template>
<div :class="$style.container">
<N8nTooltip
v-if="!isOpen && showPopOutButton"
:z-index="tooltipZIndex"
:content="popOutButtonText"
>
<N8nIconButton
icon="pop-out"
type="tertiary"
text
size="small"
icon-size="medium"
:aria-label="popOutButtonText"
@click.stop="emit('popOut')"
/>
</N8nTooltip>
<N8nActionDropdown
v-if="isOpen"
icon-size="small"
activator-icon="ellipsis-h"
activator-size="small"
:items="menuItems"
:teleported="false /* for PiP window */"
@select="handleSelectMenuItem"
/>
<KeyboardShortcutTooltip
v-if="showToggleButton"
:label="locales.baseText('generic.shortcutHint')"
:shortcut="{ keys: ['l'] }"
:z-index="tooltipZIndex"
>
<N8nIconButton
type="tertiary"
text
size="small"
icon-size="medium"
:icon="isOpen ? 'chevron-down' : 'chevron-up'"
:aria-label="toggleButtonText"
@click.stop="emit('toggleOpen')"
/>
</KeyboardShortcutTooltip>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
}
.container button:hover {
background-color: var(--color-background-base);
}
</style>

View File

@@ -1,63 +0,0 @@
<script setup lang="ts">
import { N8nText } from '@n8n/design-system';
const { title } = defineProps<{ title?: string }>();
defineSlots<{ actions: {}; title?: {} }>();
const emit = defineEmits<{ click: [] }>();
</script>
<template>
<header :class="$style.container" @click="emit('click')">
<N8nText :class="$style.title" :bold="true" size="small">
<slot name="title">{{ title }}</slot>
</N8nText>
<div :class="$style.actions">
<slot name="actions" />
</div>
</header>
</template>
<style lang="scss" module>
.container {
font-size: var(--font-size-2xs);
text-align: left;
padding-inline-start: var(--spacing-s);
padding-inline-end: var(--spacing-2xs);
padding-block: var(--spacing-2xs);
background-color: var(--color-foreground-xlight);
display: flex;
justify-content: space-between;
align-items: center;
line-height: var(--font-line-height-compact);
&:last-child {
/** Panel collapsed */
cursor: pointer;
}
&:not(:last-child) {
/** Panel open */
border-bottom: var(--border-base);
}
}
.title {
flex-grow: 1;
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
flex-shrink: 0;
display: flex;
align-items: center;
color: var(--color-text-base);
max-width: 70%;
/* Let button heights not affect the header height */
margin-block: calc(-1 * var(--spacing-s));
}
</style>

View File

@@ -1,24 +0,0 @@
<script setup lang="ts">
import { formatTokenUsageCount } from '@/components/RunDataAi/utils';
import { useI18n } from '@n8n/i18n';
import { type LlmTokenUsageData } from '@/Interface';
import { N8nTooltip } from '@n8n/design-system';
const { consumedTokens } = defineProps<{ consumedTokens: LlmTokenUsageData }>();
const locale = useI18n();
</script>
<template>
<N8nTooltip v-if="consumedTokens !== undefined" :enterable="false">
<span>{{
locale.baseText('runData.aiContentBlock.tokens', {
interpolate: {
count: formatTokenUsageCount(consumedTokens, 'total'),
},
})
}}</span>
<template #content>
<ConsumedTokensDetails :consumed-tokens="consumedTokens" />
</template>
</N8nTooltip>
</template>

View File

@@ -1,66 +0,0 @@
<script setup lang="ts">
import LogsViewConsumedTokenCountText from '@/components/CanvasChat/future/components/LogsViewConsumedTokenCountText.vue';
import { useI18n } from '@n8n/i18n';
import { type LlmTokenUsageData } from '@/Interface';
import { N8nText } from '@n8n/design-system';
import { useTimestamp } from '@vueuse/core';
import upperFirst from 'lodash/upperFirst';
import { type ExecutionStatus } from 'n8n-workflow';
import { computed } from 'vue';
const { status, consumedTokens, startTime, timeTook } = defineProps<{
status: ExecutionStatus;
consumedTokens: LlmTokenUsageData;
startTime: number;
timeTook?: number;
}>();
const locale = useI18n();
const now = useTimestamp({ interval: 1000 });
const executionStatusText = computed(() =>
status === 'running' || status === 'waiting'
? locale.baseText('logs.overview.body.summaryText.for', {
interpolate: {
status: upperFirst(status),
time: locale.displayTimer(Math.floor((now.value - startTime) / 1000) * 1000, true),
},
})
: timeTook === undefined
? upperFirst(status)
: locale.baseText('logs.overview.body.summaryText.in', {
interpolate: {
status: upperFirst(status),
time: locale.displayTimer(timeTook, true),
},
}),
);
</script>
<template>
<N8nText tag="div" color="text-light" size="small" :class="$style.container">
<span>{{ executionStatusText }}</span>
<LogsViewConsumedTokenCountText
v-if="consumedTokens.totalTokens > 0"
:consumed-tokens="consumedTokens"
/>
</N8nText>
</template>
<style lang="scss" module>
.container {
display: flex;
align-items: center;
& > * {
padding-inline: var(--spacing-2xs);
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
& > *:not(:last-child) {
border-right: var(--border-base);
}
}
</style>

View File

@@ -1,39 +0,0 @@
<script setup lang="ts">
import { N8nText } from '@n8n/design-system';
const { name, latestName, isError, isDeleted } = defineProps<{
name: string;
latestName: string;
isError?: boolean;
isDeleted?: boolean;
}>();
</script>
<template>
<N8nText
tag="div"
:bold="true"
size="small"
:class="$style.name"
:color="isError ? 'danger' : undefined"
>
<del v-if="isDeleted || name !== latestName">
{{ name }}
</del>
<span v-if="!isDeleted">
{{ latestName }}
</span>
</N8nText>
</template>
<style lang="scss" module>
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
& del:not(:last-child) {
margin-right: var(--spacing-4xs);
}
}
</style>

View File

@@ -1,123 +0,0 @@
<script setup lang="ts">
import RunData from '@/components/RunData.vue';
import { type LogEntry } from '@/components/RunDataAi/utils';
import { useI18n } from '@n8n/i18n';
import { type IRunDataDisplayMode, type NodePanelType } from '@/Interface';
import { useNDVStore } from '@/stores/ndv.store';
import { waitingNodeTooltip } from '@/utils/executionUtils';
import { N8nLink, N8nText } from '@n8n/design-system';
import { computed, ref } from 'vue';
import { I18nT } from 'vue-i18n';
const { title, logEntry, paneType } = defineProps<{
title: string;
paneType: NodePanelType;
logEntry: LogEntry;
}>();
const locale = useI18n();
const ndvStore = useNDVStore();
const displayMode = ref<IRunDataDisplayMode>(paneType === 'input' ? 'schema' : 'table');
const isMultipleInput = computed(() => paneType === 'input' && logEntry.runData.source.length > 1);
const runDataProps = computed<
Pick<InstanceType<typeof RunData>['$props'], 'node' | 'runIndex' | 'overrideOutputs'> | undefined
>(() => {
if (logEntry.depth > 0 || paneType === 'output') {
return { node: logEntry.node, runIndex: logEntry.runIndex };
}
const source = logEntry.runData.source[0];
const node = source && logEntry.workflow.getNode(source.previousNode);
if (!source || !node) {
return undefined;
}
return {
node: {
...node,
disabled: false, // For RunData component to render data from disabled nodes as well
},
runIndex: source.previousNodeRun ?? 0,
overrideOutputs: [source.previousNodeOutput ?? 0],
};
});
const isExecuting = computed(
() =>
paneType === 'output' &&
(logEntry.runData.executionStatus === 'running' ||
logEntry.runData.executionStatus === 'waiting'),
);
function handleClickOpenNdv() {
ndvStore.setActiveNodeName(logEntry.node.name);
}
function handleChangeDisplayMode(value: IRunDataDisplayMode) {
displayMode.value = value;
}
</script>
<template>
<RunData
v-if="runDataProps"
v-bind="runDataProps"
:workflow="logEntry.workflow"
:workflow-execution="logEntry.execution"
:too-much-data-title="locale.baseText('ndv.output.tooMuchData.title')"
:no-data-in-branch-message="locale.baseText('ndv.output.noOutputDataInBranch')"
:executing-message="locale.baseText('ndv.output.executing')"
:pane-type="paneType"
:disable-run-index-selection="true"
:compact="true"
:disable-pin="true"
:disable-edit="true"
:disable-hover-highlight="true"
:display-mode="displayMode"
:disable-ai-content="logEntry.depth === 0"
:is-executing="isExecuting"
table-header-bg-color="light"
@display-mode-change="handleChangeDisplayMode"
>
<template #header>
<N8nText :class="$style.title" :bold="true" color="text-light" size="small">
{{ title }}
</N8nText>
</template>
<template #no-output-data>
<N8nText :bold="true" color="text-dark" size="large">
{{ locale.baseText('ndv.output.noOutputData.title') }}
</N8nText>
</template>
<template #node-waiting>
<N8nText :bold="true" color="text-dark" size="large">
{{ locale.baseText('ndv.output.waitNodeWaiting.title') }}
</N8nText>
<N8nText v-n8n-html="waitingNodeTooltip(logEntry.node)"></N8nText>
</template>
<template v-if="isMultipleInput" #content>
<!-- leave empty -->
</template>
<template v-if="isMultipleInput" #callout-message>
<I18nT keypath="logs.details.body.multipleInputs">
<template #button>
<N8nLink size="small" @click="handleClickOpenNdv">
{{ locale.baseText('logs.details.body.multipleInputs.openingTheNode') }}
</N8nLink>
</template>
</I18nT>
</template>
</RunData>
</template>
<style lang="scss" module>
.title {
text-transform: uppercase;
letter-spacing: 3px;
}
</style>

View File

@@ -1,110 +0,0 @@
import { setActivePinia } from 'pinia';
import { useLogsExecutionData } from './useLogsExecutionData';
import { waitFor } from '@testing-library/vue';
import { createTestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { nodeTypes } from '../../__test__/data';
import {
createTestNode,
createTestTaskData,
createTestWorkflow,
createTestWorkflowExecutionResponse,
} from '@/__tests__/mocks';
import type { IRunExecutionData } from 'n8n-workflow';
import { stringify } from 'flatted';
import { useToast } from '@/composables/useToast';
vi.mock('@/composables/useToast');
describe(useLogsExecutionData, () => {
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }));
workflowsStore = mockedStore(useWorkflowsStore);
nodeTypeStore = mockedStore(useNodeTypesStore);
nodeTypeStore.setNodeTypes(nodeTypes);
});
describe('loadSubExecution', () => {
beforeEach(() => {
workflowsStore.setWorkflowExecutionData(
createTestWorkflowExecutionResponse({
id: 'e0',
workflowData: createTestWorkflow({
id: 'w0',
nodes: [createTestNode({ name: 'A' }), createTestNode({ name: 'B' })],
connections: {
A: {
main: [[{ type: 'main', node: 'B', index: 0 }]],
},
},
}),
data: {
resultData: {
runData: {
A: [createTestTaskData()],
B: [
createTestTaskData({
metadata: { subExecution: { workflowId: 'w1', executionId: 'e1' } },
}),
],
},
},
},
}),
);
});
it('should add runs from sub execution to the entries', async () => {
workflowsStore.fetchExecutionDataById.mockResolvedValueOnce(
createTestWorkflowExecutionResponse({
id: 'e1',
data: stringify({
resultData: { runData: { C: [createTestTaskData()] } },
}) as unknown as IRunExecutionData, // Data is stringified in actual API response
workflowData: createTestWorkflow({ id: 'w1', nodes: [createTestNode({ name: 'C' })] }),
}),
);
const { loadSubExecution, entries } = useLogsExecutionData();
expect(entries.value).toHaveLength(2);
expect(entries.value[1].children).toHaveLength(0);
await loadSubExecution(entries.value[1]);
await waitFor(() => {
expect(entries.value).toHaveLength(2);
expect(entries.value[1].children).toHaveLength(1);
expect(entries.value[1].children[0].node.name).toBe('C');
expect(entries.value[1].children[0].workflow.id).toBe('w1');
expect(entries.value[1].children[0].executionId).toBe('e1');
});
});
it('should show toast when failed to fetch execution data for sub execution', async () => {
const showErrorSpy = vi.fn();
const useToastMock = vi.mocked(useToast);
useToastMock.mockReturnValue({ showError: showErrorSpy } as unknown as ReturnType<
typeof useToastMock
>);
workflowsStore.fetchWorkflow.mockResolvedValueOnce(createTestWorkflow());
workflowsStore.fetchExecutionDataById.mockRejectedValueOnce(
new Error('test execution fetch fail'),
);
const { loadSubExecution, entries } = useLogsExecutionData();
await loadSubExecution(entries.value[1]);
await waitFor(() => expect(showErrorSpy).toHaveBeenCalled());
});
});
});

View File

@@ -1,145 +0,0 @@
import { watch, computed, ref } from 'vue';
import { isChatNode } from '../../utils';
import { type IExecutionResponse } from '@/Interface';
import { Workflow, type IRunExecutionData } from 'n8n-workflow';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useNodeHelpers } from '@/composables/useNodeHelpers';
import { useThrottleFn } from '@vueuse/core';
import {
createLogTree,
deepToRaw,
mergeStartData,
type LatestNodeInfo,
type LogEntry,
} from '@/components/RunDataAi/utils';
import { parse } from 'flatted';
import { useToast } from '@/composables/useToast';
export function useLogsExecutionData() {
const nodeHelpers = useNodeHelpers();
const workflowsStore = useWorkflowsStore();
const toast = useToast();
const execData = ref<IExecutionResponse | undefined>();
const subWorkflowExecData = ref<Record<string, IRunExecutionData>>({});
const subWorkflows = ref<Record<string, Workflow>>({});
const workflow = computed(() =>
execData.value
? new Workflow({
...execData.value?.workflowData,
nodeTypes: workflowsStore.getNodeTypes(),
})
: undefined,
);
const latestNodeNameById = computed(() =>
Object.values(workflow.value?.nodes ?? {}).reduce<Record<string, LatestNodeInfo>>(
(acc, node) => {
const nodeInStore = workflowsStore.getNodeById(node.id);
acc[node.id] = {
deleted: !nodeInStore,
disabled: nodeInStore?.disabled ?? false,
name: nodeInStore?.name ?? node.name,
};
return acc;
},
{},
),
);
const hasChat = computed(() =>
[Object.values(workflow.value?.nodes ?? {}), workflowsStore.workflow.nodes].some((nodes) =>
nodes.some(isChatNode),
),
);
const entries = computed<LogEntry[]>(() => {
if (!execData.value?.data || !workflow.value) {
return [];
}
return createLogTree(
workflow.value,
execData.value,
subWorkflows.value,
subWorkflowExecData.value,
);
});
const updateInterval = computed(() => ((entries.value?.length ?? 0) > 10 ? 300 : 0));
function resetExecutionData() {
execData.value = undefined;
workflowsStore.setWorkflowExecutionData(null);
nodeHelpers.updateNodesExecutionIssues();
}
async function loadSubExecution(logEntry: LogEntry) {
const executionId = logEntry.runData.metadata?.subExecution?.executionId;
const workflowId = logEntry.runData.metadata?.subExecution?.workflowId;
if (!execData.value?.data || !executionId || !workflowId) {
return;
}
try {
const subExecution = await workflowsStore.fetchExecutionDataById(executionId);
const data = subExecution?.data
? (parse(subExecution.data as unknown as string) as IRunExecutionData)
: undefined;
if (!data || !subExecution) {
throw Error('Data is missing');
}
subWorkflowExecData.value[executionId] = data;
subWorkflows.value[workflowId] = new Workflow({
...subExecution.workflowData,
nodeTypes: workflowsStore.getNodeTypes(),
});
} catch (e) {
toast.showError(e, 'Unable to load sub execution');
}
}
watch(
// Fields that should trigger update
[
() => workflowsStore.workflowExecutionData?.id,
() => workflowsStore.workflowExecutionData?.workflowData.id,
() => workflowsStore.workflowExecutionData?.status,
() => workflowsStore.workflowExecutionResultDataLastUpdate,
() => workflowsStore.workflowExecutionStartedData,
],
useThrottleFn(
([executionId], [previousExecutionId]) => {
execData.value =
workflowsStore.workflowExecutionData === null
? undefined
: deepToRaw(
mergeStartData(
workflowsStore.workflowExecutionStartedData?.[1] ?? {},
workflowsStore.workflowExecutionData,
),
); // Create deep copy to disable reactivity
if (executionId !== previousExecutionId) {
// Reset sub workflow data when top-level execution changes
subWorkflowExecData.value = {};
subWorkflows.value = {};
}
},
updateInterval,
true,
true,
),
{ immediate: true },
);
return {
execution: computed(() => execData.value),
entries,
hasChat,
latestNodeNameById,
resetExecutionData,
loadSubExecution,
};
}

View File

@@ -1,136 +0,0 @@
import { computed, type ShallowRef } from 'vue';
import {
LOCAL_STORAGE_OVERVIEW_PANEL_WIDTH,
LOCAL_STORAGE_PANEL_HEIGHT,
LOCAL_STORAGE_PANEL_WIDTH,
} from '../../composables/useResize';
import { LOGS_PANEL_STATE } from '../../types/logs';
import { usePiPWindow } from '../../composables/usePiPWindow';
import { useTelemetry } from '@/composables/useTelemetry';
import { watch } from 'vue';
import { useResizablePanel } from '../../../../composables/useResizablePanel';
import { useLogsStore } from '@/stores/logs.store';
export function useLogsPanelLayout(
pipContainer: Readonly<ShallowRef<HTMLElement | null>>,
pipContent: Readonly<ShallowRef<HTMLElement | null>>,
container: Readonly<ShallowRef<HTMLElement | null>>,
logsContainer: Readonly<ShallowRef<HTMLElement | null>>,
) {
const logsStore = useLogsStore();
const telemetry = useTelemetry();
const resizer = useResizablePanel(LOCAL_STORAGE_PANEL_HEIGHT, {
container: document.body,
position: 'bottom',
snap: false,
defaultSize: (size) => size * 0.3,
minSize: 160,
maxSize: (size) => size * 0.75,
allowCollapse: true,
});
const chatPanelResizer = useResizablePanel(LOCAL_STORAGE_PANEL_WIDTH, {
container,
defaultSize: (size) => Math.min(800, size * 0.3),
minSize: 240,
maxSize: (size) => size * 0.8,
});
const overviewPanelResizer = useResizablePanel(LOCAL_STORAGE_OVERVIEW_PANEL_WIDTH, {
container: logsContainer,
defaultSize: (size) => Math.min(240, size * 0.2),
minSize: 80,
maxSize: 500,
allowFullSize: true,
});
const isOpen = computed(() =>
logsStore.isOpen
? !resizer.isCollapsed.value
: resizer.isResizing.value && resizer.size.value > 0,
);
const isCollapsingDetailsPanel = computed(() => overviewPanelResizer.isFullSize.value);
const { canPopOut, isPoppedOut, pipWindow } = usePiPWindow({
initialHeight: 400,
initialWidth: window.document.body.offsetWidth * 0.8,
container: pipContainer,
content: pipContent,
shouldPopOut: computed(() => logsStore.state === LOGS_PANEL_STATE.FLOATING),
onRequestClose: () => {
if (!isOpen.value) {
return;
}
telemetry.track('User toggled log view', { new_state: 'attached' });
logsStore.setPreferPoppedOut(false);
},
});
function handleToggleOpen(open?: boolean) {
const wasOpen = logsStore.isOpen;
if (open === wasOpen) {
return;
}
logsStore.toggleOpen(open);
telemetry.track('User toggled log view', {
new_state: wasOpen ? 'collapsed' : 'attached',
});
}
function handlePopOut() {
telemetry.track('User toggled log view', { new_state: 'floating' });
logsStore.toggleOpen(true);
logsStore.setPreferPoppedOut(true);
}
function handleResizeEnd() {
if (!logsStore.isOpen && !resizer.isCollapsed.value) {
handleToggleOpen(true);
}
if (resizer.isCollapsed.value) {
handleToggleOpen(false);
}
resizer.onResizeEnd();
}
watch(
[() => logsStore.state, resizer.size],
([state, height]) => {
logsStore.setHeight(
state === LOGS_PANEL_STATE.FLOATING
? 0
: state === LOGS_PANEL_STATE.ATTACHED
? height
: 32 /* collapsed panel height */,
);
},
{ immediate: true },
);
return {
height: resizer.size,
chatPanelWidth: chatPanelResizer.size,
overviewPanelWidth: overviewPanelResizer.size,
canPopOut,
isOpen,
isCollapsingDetailsPanel,
isPoppedOut,
isOverviewPanelFullWidth: overviewPanelResizer.isFullSize,
pipWindow,
onToggleOpen: handleToggleOpen,
onPopOut: handlePopOut,
onResize: resizer.onResize,
onResizeEnd: handleResizeEnd,
onChatPanelResize: chatPanelResizer.onResize,
onChatPanelResizeEnd: chatPanelResizer.onResizeEnd,
onOverviewPanelResize: overviewPanelResizer.onResize,
onOverviewPanelResizeEnd: overviewPanelResizer.onResizeEnd,
};
}

View File

@@ -1,108 +0,0 @@
import type { LogEntrySelection } from '@/components/CanvasChat/types/logs';
import {
findLogEntryRec,
findSelectedLogEntry,
getDepth,
getEntryAtRelativeIndex,
type LogEntry,
} from '@/components/RunDataAi/utils';
import { useTelemetry } from '@/composables/useTelemetry';
import { canvasEventBus } from '@/event-bus/canvas';
import type { IExecutionResponse } from '@/Interface';
import { useCanvasStore } from '@/stores/canvas.store';
import { useLogsStore } from '@/stores/logs.store';
import { useUIStore } from '@/stores/ui.store';
import { watch } from 'vue';
import { computed, ref, type ComputedRef } from 'vue';
export function useLogsSelection(
execution: ComputedRef<IExecutionResponse | undefined>,
tree: ComputedRef<LogEntry[]>,
flatLogEntries: ComputedRef<LogEntry[]>,
toggleExpand: (entry: LogEntry, expand?: boolean) => void,
) {
const telemetry = useTelemetry();
const manualLogEntrySelection = ref<LogEntrySelection>({ type: 'initial' });
const selected = computed(() => findSelectedLogEntry(manualLogEntrySelection.value, tree.value));
const logsStore = useLogsStore();
const uiStore = useUIStore();
const canvasStore = useCanvasStore();
function syncSelectionToCanvasIfEnabled(value: LogEntry) {
if (!logsStore.isLogSelectionSyncedWithCanvas) {
return;
}
canvasEventBus.emit('nodes:select', { ids: [value.node.id], panIntoView: true });
}
function select(value: LogEntry | undefined) {
manualLogEntrySelection.value =
value === undefined ? { type: 'none' } : { type: 'selected', id: value.id };
if (value) {
syncSelectionToCanvasIfEnabled(value);
telemetry.track('User selected node in log view', {
node_type: value.node.type,
node_id: value.node.id,
execution_id: execution.value?.id,
workflow_id: execution.value?.workflowData.id,
subworkflow_depth: getDepth(value),
});
}
}
function selectPrev() {
const entries = flatLogEntries.value;
const prevEntry = selected.value
? (getEntryAtRelativeIndex(entries, selected.value.id, -1) ?? entries[0])
: entries[entries.length - 1];
manualLogEntrySelection.value = { type: 'selected', id: prevEntry.id };
syncSelectionToCanvasIfEnabled(prevEntry);
}
function selectNext() {
const entries = flatLogEntries.value;
const nextEntry = selected.value
? (getEntryAtRelativeIndex(entries, selected.value.id, 1) ?? entries[entries.length - 1])
: entries[0];
manualLogEntrySelection.value = { type: 'selected', id: nextEntry.id };
syncSelectionToCanvasIfEnabled(nextEntry);
}
// Synchronize selection from canvas
watch(
[() => uiStore.lastSelectedNode, () => logsStore.isLogSelectionSyncedWithCanvas],
([selectedOnCanvas, shouldSync]) => {
if (
!shouldSync ||
!selectedOnCanvas ||
canvasStore.hasRangeSelection ||
selected.value?.node.name === selectedOnCanvas
) {
return;
}
const entry = findLogEntryRec((e) => e.node.name === selectedOnCanvas, tree.value);
if (!entry) {
return;
}
manualLogEntrySelection.value = { type: 'selected', id: entry.id };
let parent = entry.parent;
while (parent !== undefined) {
toggleExpand(parent, true);
parent = parent.parent;
}
},
{ immediate: true },
);
return { selected, select, selectPrev, selectNext };
}

View File

@@ -1,17 +0,0 @@
import { flattenLogEntries, type LogEntry } from '@/components/RunDataAi/utils';
import { computed, ref, type ComputedRef } from 'vue';
export function useLogsTreeExpand(entries: ComputedRef<LogEntry[]>) {
const collapsedEntries = ref<Record<string, boolean>>({});
const flatLogEntries = computed(() => flattenLogEntries(entries.value, collapsedEntries.value));
function toggleExpanded(treeNode: LogEntry, expand?: boolean) {
collapsedEntries.value[treeNode.id] =
expand === undefined ? !collapsedEntries.value[treeNode.id] : !expand;
}
return {
flatLogEntries,
toggleExpanded,
};
}

View File

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

View File

@@ -1,21 +0,0 @@
export type LogEntrySelection =
| { type: 'initial' }
| { type: 'selected'; id: string }
| { type: 'none' };
export const LOGS_PANEL_STATE = {
CLOSED: 'closed',
ATTACHED: 'attached',
FLOATING: 'floating',
} as const;
export type LogsPanelState = (typeof LOGS_PANEL_STATE)[keyof typeof LOGS_PANEL_STATE];
export const LOG_DETAILS_PANEL_STATE = {
INPUT: 'input',
OUTPUT: 'output',
BOTH: 'both',
} as const;
export type LogDetailsPanelState =
(typeof LOG_DETAILS_PANEL_STATE)[keyof typeof LOG_DETAILS_PANEL_STATE];

View File

@@ -1,48 +0,0 @@
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' },
]);
});
});

View File

@@ -1,123 +0,0 @@
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 from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
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] : [])];
}

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { formatTokenUsageCount } from '@/components/RunDataAi/utils';
import { useI18n } from '@n8n/i18n';
import { type LlmTokenUsageData } from '@/Interface';
import { formatTokenUsageCount } from '@/utils/aiUtils';
import { N8nText } from '@n8n/design-system';
const { consumedTokens } = defineProps<{ consumedTokens: LlmTokenUsageData }>();

View File

@@ -7,9 +7,10 @@ import { computed } from 'vue';
import NodeIcon from '@/components/NodeIcon.vue';
import AiRunContentBlock from './AiRunContentBlock.vue';
import { useI18n } from '@n8n/i18n';
import { formatTokenUsageCount, getConsumedTokens } from '@/components/RunDataAi/utils';
import { getConsumedTokens } from '@/components/RunDataAi/utils';
import ConsumedTokensDetails from '@/components/ConsumedTokensDetails.vue';
import ViewSubExecution from '../ViewSubExecution.vue';
import { formatTokenUsageCount } from '@/utils/aiUtils';
interface RunMeta {
startTimeMs: number;

View File

@@ -1,21 +1,12 @@
import { type LlmTokenUsageData, type IAiDataContent } from '@/Interface';
import { addTokenUsageData, emptyTokenUsageData } from '@/utils/aiUtils';
import {
type LlmTokenUsageData,
type IAiDataContent,
type INodeUi,
type IExecutionResponse,
} from '@/Interface';
import {
AGENT_LANGCHAIN_NODE_TYPE,
type INodeExecutionData,
type ITaskData,
type ITaskDataConnections,
type NodeConnectionType,
type Workflow,
type ITaskStartedData,
type IRunExecutionData,
} from 'n8n-workflow';
import { type LogEntrySelection } from '../CanvasChat/types/logs';
import { isProxy, isReactive, isRef, toRaw } from 'vue';
export interface AIResult {
node: string;
@@ -193,22 +184,6 @@ export function getReferencedData(
return returnData;
}
const emptyTokenUsageData: LlmTokenUsageData = {
completionTokens: 0,
promptTokens: 0,
totalTokens: 0,
isEstimate: false,
};
function addTokenUsageData(one: LlmTokenUsageData, another: LlmTokenUsageData): LlmTokenUsageData {
return {
completionTokens: one.completionTokens + another.completionTokens,
promptTokens: one.promptTokens + another.promptTokens,
totalTokens: one.totalTokens + another.totalTokens,
isEstimate: one.isEstimate || another.isEstimate,
};
}
export function getConsumedTokens(outputRun: IAiDataContent | undefined): LlmTokenUsageData {
if (!outputRun?.data) {
return emptyTokenUsageData;
@@ -230,458 +205,3 @@ export function getConsumedTokens(outputRun: IAiDataContent | undefined): LlmTok
return tokenUsage;
}
export function formatTokenUsageCount(
usage: LlmTokenUsageData,
field: 'total' | 'prompt' | 'completion',
) {
const count =
field === 'total'
? usage.totalTokens
: field === 'completion'
? usage.completionTokens
: usage.promptTokens;
return usage.isEstimate ? `~${count}` : count.toLocaleString();
}
export interface LogEntry {
parent?: LogEntry;
node: INodeUi;
id: string;
children: LogEntry[];
depth: number;
runIndex: number;
runData: ITaskData;
consumedTokens: LlmTokenUsageData;
workflow: Workflow;
executionId: string;
execution: IRunExecutionData;
}
export interface LogTreeCreationContext {
parent: LogEntry | undefined;
depth: number;
workflow: Workflow;
executionId: string;
data: IRunExecutionData;
workflows: Record<string, Workflow>;
subWorkflowData: Record<string, IRunExecutionData>;
}
export interface LatestNodeInfo {
disabled: boolean;
deleted: boolean;
name: string;
}
function getConsumedTokensV2(task: ITaskData): LlmTokenUsageData {
if (!task.data) {
return emptyTokenUsageData;
}
const tokenUsage = Object.values(task.data)
.flat()
.flat()
.reduce<LlmTokenUsageData>((acc, curr) => {
const tokenUsageData = curr?.json?.tokenUsage ?? curr?.json?.tokenUsageEstimate;
if (!tokenUsageData) return acc;
return addTokenUsageData(acc, {
...(tokenUsageData as Omit<LlmTokenUsageData, 'isEstimate'>),
isEstimate: !!curr?.json.tokenUsageEstimate,
});
}, emptyTokenUsageData);
return tokenUsage;
}
function createNodeV2(
node: INodeUi,
context: LogTreeCreationContext,
runIndex: number,
runData: ITaskData,
children: LogEntry[] = [],
): LogEntry {
return {
parent: context.parent,
node,
id: `${context.workflow.id}:${node.name}:${context.executionId}:${runIndex}`,
depth: context.depth,
runIndex,
runData,
children,
consumedTokens: getConsumedTokensV2(runData),
workflow: context.workflow,
executionId: context.executionId,
execution: context.data,
};
}
export function getTreeNodeDataV2(
nodeName: string,
runData: ITaskData,
runIndex: number | undefined,
context: LogTreeCreationContext,
): LogEntry[] {
const node = context.workflow.getNode(nodeName);
return node ? getTreeNodeDataRecV2(node, runData, context, runIndex) : [];
}
function getChildNodes(
treeNode: LogEntry,
node: INodeUi,
runIndex: number | undefined,
context: LogTreeCreationContext,
) {
if (hasSubExecution(treeNode)) {
const workflowId = treeNode.runData.metadata?.subExecution?.workflowId;
const executionId = treeNode.runData.metadata?.subExecution?.executionId;
const workflow = workflowId ? context.workflows[workflowId] : undefined;
const subWorkflowRunData = executionId ? context.subWorkflowData[executionId] : undefined;
if (!workflow || !subWorkflowRunData || !executionId) {
return [];
}
return createLogTreeRec({
...context,
parent: treeNode,
depth: context.depth + 1,
workflow,
executionId,
data: subWorkflowRunData,
});
}
// Get the first level of children
const connectedSubNodes = context.workflow.getParentNodes(node.name, 'ALL_NON_MAIN', 1);
const isExecutionRoot =
treeNode.parent === undefined || treeNode.executionId !== treeNode.parent.executionId;
return connectedSubNodes.flatMap((subNodeName) =>
(context.data.resultData.runData[subNodeName] ?? []).flatMap((t, index) => {
// At root depth, filter out node executions that weren't triggered by this node
// This prevents showing duplicate executions when a sub-node is connected to multiple parents
// Only filter nodes that have source information with valid previousNode references
const isMatched =
isExecutionRoot && t.source.some((source) => source !== null)
? t.source.some(
(source) =>
source?.previousNode === node.name &&
(runIndex === undefined || source.previousNodeRun === runIndex),
)
: runIndex === undefined || index === runIndex;
if (!isMatched) {
return [];
}
const subNode = context.workflow.getNode(subNodeName);
return subNode
? getTreeNodeDataRecV2(
subNode,
t,
{ ...context, depth: context.depth + 1, parent: treeNode },
index,
)
: [];
}),
);
}
function getTreeNodeDataRecV2(
node: INodeUi,
runData: ITaskData,
context: LogTreeCreationContext,
runIndex: number | undefined,
): LogEntry[] {
const treeNode = createNodeV2(node, context, runIndex ?? 0, runData);
const children = getChildNodes(treeNode, node, runIndex, context).sort(sortLogEntries);
treeNode.children = children;
return [treeNode];
}
export function getTotalConsumedTokens(...usage: LlmTokenUsageData[]): LlmTokenUsageData {
return usage.reduce(addTokenUsageData, emptyTokenUsageData);
}
export function getSubtreeTotalConsumedTokens(
treeNode: LogEntry,
includeSubWorkflow: boolean,
): LlmTokenUsageData {
const executionId = treeNode.executionId;
function calculate(currentNode: LogEntry): LlmTokenUsageData {
if (!includeSubWorkflow && currentNode.executionId !== executionId) {
return emptyTokenUsageData;
}
return getTotalConsumedTokens(
currentNode.consumedTokens,
...currentNode.children.map(calculate),
);
}
return calculate(treeNode);
}
function findLogEntryToAutoSelectRec(subTree: LogEntry[], depth: number): LogEntry | undefined {
for (const entry of subTree) {
if (entry.runData?.error) {
return entry;
}
const childAutoSelect = findLogEntryToAutoSelectRec(entry.children, depth + 1);
if (childAutoSelect) {
return childAutoSelect;
}
if (entry.node.type === AGENT_LANGCHAIN_NODE_TYPE) {
return entry;
}
}
return depth === 0 ? subTree[0] : undefined;
}
export function createLogTree(
workflow: Workflow,
response: IExecutionResponse,
workflows: Record<string, Workflow> = {},
subWorkflowData: Record<string, IRunExecutionData> = {},
) {
return createLogTreeRec({
parent: undefined,
depth: 0,
executionId: response.id,
workflow,
workflows,
data: response.data ?? { resultData: { runData: {} } },
subWorkflowData,
});
}
function createLogTreeRec(context: LogTreeCreationContext) {
const runs = Object.entries(context.data.resultData.runData)
.flatMap(([nodeName, taskData]) =>
context.workflow.getChildNodes(nodeName, 'ALL_NON_MAIN').length > 0 ||
context.workflow.getNode(nodeName)?.disabled
? [] // skip sub nodes and disabled nodes
: taskData.map((task, runIndex) => ({
nodeName,
runData: task,
runIndex,
nodeHasMultipleRuns: taskData.length > 1,
})),
)
.sort(sortLogEntries);
return runs.flatMap(({ nodeName, runIndex, runData, nodeHasMultipleRuns }) =>
getTreeNodeDataV2(nodeName, runData, nodeHasMultipleRuns ? runIndex : undefined, context),
);
}
export function findLogEntryRec(
isMatched: (entry: LogEntry) => boolean,
entries: LogEntry[],
): LogEntry | undefined {
for (const entry of entries) {
if (isMatched(entry)) {
return entry;
}
const child = findLogEntryRec(isMatched, entry.children);
if (child) {
return child;
}
}
return undefined;
}
export function findSelectedLogEntry(
selection: LogEntrySelection,
entries: LogEntry[],
): LogEntry | undefined {
switch (selection.type) {
case 'initial':
return findLogEntryToAutoSelectRec(entries, 0);
case 'none':
return undefined;
case 'selected': {
const entry = findLogEntryRec((e) => e.id === selection.id, entries);
if (entry) {
return entry;
}
return findLogEntryToAutoSelectRec(entries, 0);
}
}
}
// 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));
}
if (isRef(input) || isReactive(input) || isProxy(input)) {
return objectIterator(toRaw(input));
}
if (
input !== null &&
typeof input === 'object' &&
Object.getPrototypeOf(input) === Object.prototype
) {
return Object.keys(input).reduce((acc, key) => {
acc[key as keyof typeof acc] = objectIterator(input[key]);
return acc;
}, {} as T);
}
return input;
};
return objectIterator(sourceObj);
}
export function flattenLogEntries(
entries: LogEntry[],
collapsedEntryIds: Record<string, boolean>,
ret: LogEntry[] = [],
): LogEntry[] {
for (const entry of entries) {
ret.push(entry);
if (!collapsedEntryIds[entry.id]) {
flattenLogEntries(entry.children, collapsedEntryIds, ret);
}
}
return ret;
}
export function getEntryAtRelativeIndex(
entries: LogEntry[],
id: string,
relativeIndex: number,
): LogEntry | undefined {
const offset = entries.findIndex((e) => e.id === id);
return offset === -1 ? undefined : entries[offset + relativeIndex];
}
function sortLogEntries<T extends { runData: ITaskData }>(a: T, b: T) {
// We rely on execution index only when startTime is different
// Because it is reset to 0 when execution is waited, and therefore not necessarily unique
if (a.runData.startTime === b.runData.startTime) {
return a.runData.executionIndex - b.runData.executionIndex;
}
return a.runData.startTime - b.runData.startTime;
}
export function mergeStartData(
startData: { [nodeName: string]: ITaskStartedData[] },
response: IExecutionResponse,
): IExecutionResponse {
if (!response.data) {
return response;
}
const nodeNames = [
...new Set(
Object.keys(startData).concat(Object.keys(response.data.resultData.runData)),
).values(),
];
const runData = Object.fromEntries(
nodeNames.map<[string, ITaskData[]]>((nodeName) => {
const tasks = response.data?.resultData.runData[nodeName] ?? [];
const mergedTasks = tasks.concat(
(startData[nodeName] ?? [])
.filter((task) =>
// To remove duplicate runs, we check start time in addition to execution index
// because nodes such as Wait and Form emits multiple websocket events with
// different execution index for a single run
tasks.every(
(t) => t.startTime < task.startTime && t.executionIndex !== task.executionIndex,
),
)
.map<ITaskData>((task) => ({
...task,
executionTime: 0,
executionStatus: 'running',
})),
);
return [nodeName, mergedTasks];
}),
);
return {
...response,
data: {
...response.data,
resultData: {
...response.data.resultData,
runData,
},
},
};
}
export function hasSubExecution(entry: LogEntry): boolean {
return !!entry.runData.metadata?.subExecution;
}
export function getDefaultCollapsedEntries(entries: LogEntry[]): Record<string, boolean> {
const ret: Record<string, boolean> = {};
function collect(children: LogEntry[]) {
for (const entry of children) {
if (hasSubExecution(entry) && entry.children.length === 0) {
ret[entry.id] = true;
}
collect(entry.children);
}
}
collect(entries);
return ret;
}
export function getDepth(entry: LogEntry): number {
let depth = 0;
let currentEntry = entry;
while (currentEntry.parent !== undefined) {
currentEntry = currentEntry.parent;
depth++;
}
return depth;
}

View File

@@ -2,10 +2,6 @@ import { ref } from 'vue';
import { useViewportAutoAdjust } from './useViewportAutoAdjust';
import { waitFor } from '@testing-library/vue';
vi.mock('@/stores/settings.store', () => ({
useSettingsStore: vi.fn(() => ({ isNewLogsEnabled: true })),
}));
describe(useViewportAutoAdjust, () => {
afterAll(() => {
vi.clearAllMocks();

View File

@@ -1,4 +1,3 @@
import { useSettingsStore } from '@/stores/settings.store';
import type { Rect, SetViewport, ViewportTransform } from '@vue-flow/core';
import { type Ref, ref, watch } from 'vue';
@@ -10,48 +9,44 @@ export function useViewportAutoAdjust(
viewport: Ref<ViewportTransform>,
setViewport: SetViewport,
) {
const settingsStore = useSettingsStore();
const canvasRect = ref<Rect>();
if (settingsStore.isNewLogsEnabled) {
const canvasRect = ref<Rect>();
watch(
viewportRef,
(vp, _, onCleanUp) => {
if (!vp) {
return;
}
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
canvasRect.value = entry.contentRect;
}
});
canvasRect.value = {
x: vp.offsetLeft,
y: vp.offsetTop,
width: vp.offsetWidth,
height: vp.offsetHeight,
};
resizeObserver.observe(vp);
onCleanUp(() => resizeObserver.disconnect());
},
{ immediate: true },
);
watch(canvasRect, async (newRect, oldRect) => {
if (!newRect || !oldRect) {
watch(
viewportRef,
(vp, _, onCleanUp) => {
if (!vp) {
return;
}
await setViewport({
x: viewport.value.x + (newRect.width - oldRect.width) / 2,
y: viewport.value.y + (newRect.height - oldRect.height) / 2,
zoom: viewport.value.zoom,
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
canvasRect.value = entry.contentRect;
}
});
canvasRect.value = {
x: vp.offsetLeft,
y: vp.offsetTop,
width: vp.offsetWidth,
height: vp.offsetHeight,
};
resizeObserver.observe(vp);
onCleanUp(() => resizeObserver.disconnect());
},
{ immediate: true },
);
watch(canvasRect, async (newRect, oldRect) => {
if (!newRect || !oldRect) {
return;
}
await setViewport({
x: viewport.value.x + (newRect.width - oldRect.width) / 2,
y: viewport.value.y + (newRect.height - oldRect.height) / 2,
zoom: viewport.value.zoom,
});
}
});
}

View File

@@ -6,7 +6,7 @@ import { type ActionDropdownItem, N8nActionDropdown, N8nButton, N8nText } from '
import { useI18n } from '@n8n/i18n';
import { type INodeTypeDescription } from 'n8n-workflow';
import { computed } from 'vue';
import { isChatNode } from '@/components/CanvasChat/utils';
import { isChatNode } from '@/utils/aiUtils';
const emit = defineEmits<{
mouseenter: [event: MouseEvent];