mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
refactor(editor): Clean up feature flag for the log view (#15606)
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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]] });
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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' })),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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] : [])];
|
||||
}
|
||||
@@ -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 }>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user