mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
refactor(editor): Clean up feature flag for the log view (#15606)
This commit is contained in:
@@ -9,10 +9,6 @@ import Workflow_loop from '../fixtures/Workflow_loop.json';
|
|||||||
import Workflow_wait_for_webhook from '../fixtures/Workflow_wait_for_webhook.json';
|
import Workflow_wait_for_webhook from '../fixtures/Workflow_wait_for_webhook.json';
|
||||||
|
|
||||||
describe('Logs', () => {
|
describe('Logs', () => {
|
||||||
beforeEach(() => {
|
|
||||||
cy.overrideSettings({ logsView: { enabled: true } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should populate logs as manual execution progresses', () => {
|
it('should populate logs as manual execution progresses', () => {
|
||||||
workflow.navigateToNewWorkflowPage();
|
workflow.navigateToNewWorkflowPage();
|
||||||
workflow.pasteWorkflow(Workflow_loop);
|
workflow.pasteWorkflow(Workflow_loop);
|
||||||
|
|||||||
@@ -163,9 +163,6 @@ export interface FrontendSettings {
|
|||||||
folders: {
|
folders: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
logsView: {
|
|
||||||
enabled: boolean;
|
|
||||||
};
|
|
||||||
banners: {
|
banners: {
|
||||||
dismissed: string[];
|
dismissed: string[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -285,13 +285,4 @@ export const schema = {
|
|||||||
env: 'N8N_PROXY_HOPS',
|
env: 'N8N_PROXY_HOPS',
|
||||||
doc: 'Number of reverse-proxies n8n is running behind',
|
doc: 'Number of reverse-proxies n8n is running behind',
|
||||||
},
|
},
|
||||||
|
|
||||||
logs_view: {
|
|
||||||
enabled: {
|
|
||||||
format: Boolean,
|
|
||||||
default: true,
|
|
||||||
env: 'N8N_ENABLE_LOGS_VIEW',
|
|
||||||
doc: 'Temporary env variable to enable logs view',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -253,9 +253,6 @@ export class FrontendService {
|
|||||||
dashboard: false,
|
dashboard: false,
|
||||||
dateRanges: [],
|
dateRanges: [],
|
||||||
},
|
},
|
||||||
logsView: {
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
evaluation: {
|
evaluation: {
|
||||||
quota: this.licenseState.getMaxWorkflowsWithEvaluations(),
|
quota: this.licenseState.getMaxWorkflowsWithEvaluations(),
|
||||||
},
|
},
|
||||||
@@ -396,8 +393,6 @@ export class FrontendService {
|
|||||||
|
|
||||||
this.settings.folders.enabled = this.license.isFoldersEnabled();
|
this.settings.folders.enabled = this.license.isFoldersEnabled();
|
||||||
|
|
||||||
this.settings.logsView.enabled = config.get('logs_view.enabled');
|
|
||||||
|
|
||||||
// Refresh evaluation settings
|
// Refresh evaluation settings
|
||||||
this.settings.evaluation.quota = this.licenseState.getMaxWorkflowsWithEvaluations();
|
this.settings.evaluation.quota = this.licenseState.getMaxWorkflowsWithEvaluations();
|
||||||
|
|
||||||
|
|||||||
@@ -157,9 +157,6 @@ export const defaultSettings: FrontendSettings = {
|
|||||||
{ key: 'year', licensed: false, granularity: 'week' },
|
{ key: 'year', licensed: false, granularity: 'week' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
logsView: {
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
evaluation: {
|
evaluation: {
|
||||||
quota: 0,
|
quota: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
import type { IExecutionResponse, INodeUi, IWorkflowDb } from '@/Interface';
|
import type { IExecutionResponse, INodeUi, IWorkflowDb } from '@/Interface';
|
||||||
import { CanvasNodeRenderType } from '@/types';
|
import { CanvasNodeRenderType } from '@/types';
|
||||||
import type { FrontendSettings } from '@n8n/api-types';
|
import type { FrontendSettings } from '@n8n/api-types';
|
||||||
import { type LogEntry } from '@/components/RunDataAi/utils';
|
|
||||||
|
|
||||||
export const mockNode = ({
|
export const mockNode = ({
|
||||||
id = uuid(),
|
id = uuid(),
|
||||||
@@ -255,24 +254,6 @@ export function createTestTaskData(partialData: Partial<ITaskData> = {}): ITaskD
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTestLogEntry(data: Partial<LogEntry> = {}): LogEntry {
|
|
||||||
const executionId = data.executionId ?? 'test-execution-id';
|
|
||||||
|
|
||||||
return {
|
|
||||||
node: createTestNode(),
|
|
||||||
runIndex: 0,
|
|
||||||
runData: createTestTaskData({}),
|
|
||||||
id: uuid(),
|
|
||||||
children: [],
|
|
||||||
consumedTokens: { completionTokens: 0, totalTokens: 0, promptTokens: 0, isEstimate: false },
|
|
||||||
depth: 0,
|
|
||||||
workflow: createTestWorkflowObject(),
|
|
||||||
executionId,
|
|
||||||
execution: createTestWorkflowExecutionResponse({ id: executionId }).data!,
|
|
||||||
...data,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTestWorkflowExecutionResponse(
|
export function createTestWorkflowExecutionResponse(
|
||||||
data: Partial<IExecutionResponse> = {},
|
data: Partial<IExecutionResponse> = {},
|
||||||
): IExecutionResponse {
|
): IExecutionResponse {
|
||||||
|
|||||||
@@ -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,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,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,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,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">
|
<script setup lang="ts">
|
||||||
import { formatTokenUsageCount } from '@/components/RunDataAi/utils';
|
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { type LlmTokenUsageData } from '@/Interface';
|
import { type LlmTokenUsageData } from '@/Interface';
|
||||||
|
import { formatTokenUsageCount } from '@/utils/aiUtils';
|
||||||
import { N8nText } from '@n8n/design-system';
|
import { N8nText } from '@n8n/design-system';
|
||||||
|
|
||||||
const { consumedTokens } = defineProps<{ consumedTokens: LlmTokenUsageData }>();
|
const { consumedTokens } = defineProps<{ consumedTokens: LlmTokenUsageData }>();
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import { computed } from 'vue';
|
|||||||
import NodeIcon from '@/components/NodeIcon.vue';
|
import NodeIcon from '@/components/NodeIcon.vue';
|
||||||
import AiRunContentBlock from './AiRunContentBlock.vue';
|
import AiRunContentBlock from './AiRunContentBlock.vue';
|
||||||
import { useI18n } from '@n8n/i18n';
|
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 ConsumedTokensDetails from '@/components/ConsumedTokensDetails.vue';
|
||||||
import ViewSubExecution from '../ViewSubExecution.vue';
|
import ViewSubExecution from '../ViewSubExecution.vue';
|
||||||
|
import { formatTokenUsageCount } from '@/utils/aiUtils';
|
||||||
|
|
||||||
interface RunMeta {
|
interface RunMeta {
|
||||||
startTimeMs: number;
|
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 {
|
import {
|
||||||
type LlmTokenUsageData,
|
|
||||||
type IAiDataContent,
|
|
||||||
type INodeUi,
|
|
||||||
type IExecutionResponse,
|
|
||||||
} from '@/Interface';
|
|
||||||
import {
|
|
||||||
AGENT_LANGCHAIN_NODE_TYPE,
|
|
||||||
type INodeExecutionData,
|
type INodeExecutionData,
|
||||||
type ITaskData,
|
type ITaskData,
|
||||||
type ITaskDataConnections,
|
type ITaskDataConnections,
|
||||||
type NodeConnectionType,
|
type NodeConnectionType,
|
||||||
type Workflow,
|
type Workflow,
|
||||||
type ITaskStartedData,
|
|
||||||
type IRunExecutionData,
|
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { type LogEntrySelection } from '../CanvasChat/types/logs';
|
|
||||||
import { isProxy, isReactive, isRef, toRaw } from 'vue';
|
|
||||||
|
|
||||||
export interface AIResult {
|
export interface AIResult {
|
||||||
node: string;
|
node: string;
|
||||||
@@ -193,22 +184,6 @@ export function getReferencedData(
|
|||||||
return returnData;
|
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 {
|
export function getConsumedTokens(outputRun: IAiDataContent | undefined): LlmTokenUsageData {
|
||||||
if (!outputRun?.data) {
|
if (!outputRun?.data) {
|
||||||
return emptyTokenUsageData;
|
return emptyTokenUsageData;
|
||||||
@@ -230,458 +205,3 @@ export function getConsumedTokens(outputRun: IAiDataContent | undefined): LlmTok
|
|||||||
|
|
||||||
return tokenUsage;
|
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 { useViewportAutoAdjust } from './useViewportAutoAdjust';
|
||||||
import { waitFor } from '@testing-library/vue';
|
import { waitFor } from '@testing-library/vue';
|
||||||
|
|
||||||
vi.mock('@/stores/settings.store', () => ({
|
|
||||||
useSettingsStore: vi.fn(() => ({ isNewLogsEnabled: true })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe(useViewportAutoAdjust, () => {
|
describe(useViewportAutoAdjust, () => {
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useSettingsStore } from '@/stores/settings.store';
|
|
||||||
import type { Rect, SetViewport, ViewportTransform } from '@vue-flow/core';
|
import type { Rect, SetViewport, ViewportTransform } from '@vue-flow/core';
|
||||||
import { type Ref, ref, watch } from 'vue';
|
import { type Ref, ref, watch } from 'vue';
|
||||||
|
|
||||||
@@ -10,9 +9,6 @@ export function useViewportAutoAdjust(
|
|||||||
viewport: Ref<ViewportTransform>,
|
viewport: Ref<ViewportTransform>,
|
||||||
setViewport: SetViewport,
|
setViewport: SetViewport,
|
||||||
) {
|
) {
|
||||||
const settingsStore = useSettingsStore();
|
|
||||||
|
|
||||||
if (settingsStore.isNewLogsEnabled) {
|
|
||||||
const canvasRect = ref<Rect>();
|
const canvasRect = ref<Rect>();
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -53,5 +49,4 @@ export function useViewportAutoAdjust(
|
|||||||
zoom: viewport.value.zoom,
|
zoom: viewport.value.zoom,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { type ActionDropdownItem, N8nActionDropdown, N8nButton, N8nText } from '
|
|||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { type INodeTypeDescription } from 'n8n-workflow';
|
import { type INodeTypeDescription } from 'n8n-workflow';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { isChatNode } from '@/components/CanvasChat/utils';
|
import { isChatNode } from '@/utils/aiUtils';
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
mouseenter: [event: MouseEvent];
|
mouseenter: [event: MouseEvent];
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ import { isPresent } from '../utils/typesUtils';
|
|||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import type { CanvasLayoutEvent } from './useCanvasLayout';
|
import type { CanvasLayoutEvent } from './useCanvasLayout';
|
||||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||||
import { isChatNode } from '@/components/CanvasChat/utils';
|
|
||||||
import { useLogsStore } from '@/stores/logs.store';
|
import { useLogsStore } from '@/stores/logs.store';
|
||||||
|
import { isChatNode } from '@/utils/aiUtils';
|
||||||
import cloneDeep from 'lodash/cloneDeep';
|
import cloneDeep from 'lodash/cloneDeep';
|
||||||
|
|
||||||
type AddNodeData = Partial<INodeUi> & {
|
type AddNodeData = Partial<INodeUi> & {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { computed, type MaybeRef, ref, unref, watch } from 'vue';
|
|||||||
|
|
||||||
type GetSize = number | ((containerSize: number) => number);
|
type GetSize = number | ((containerSize: number) => number);
|
||||||
|
|
||||||
interface UseResizerV2Options {
|
interface UseResizablePanelOptions {
|
||||||
/**
|
/**
|
||||||
* Container element, to which relative size is calculated (doesn't necessarily have to be DOM parent node)
|
* Container element, to which relative size is calculated (doesn't necessarily have to be DOM parent node)
|
||||||
*/
|
*/
|
||||||
@@ -52,7 +52,7 @@ export function useResizablePanel(
|
|||||||
position = 'left',
|
position = 'left',
|
||||||
allowCollapse,
|
allowCollapse,
|
||||||
allowFullSize,
|
allowFullSize,
|
||||||
}: UseResizerV2Options,
|
}: UseResizablePanelOptions,
|
||||||
) {
|
) {
|
||||||
const containerSize = ref(0);
|
const containerSize = ref(0);
|
||||||
const persistedSize = useLocalStorage(localStorageKey, -1, { writeDefaults: false });
|
const persistedSize = useLocalStorage(localStorageKey, -1, { writeDefaults: false });
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { h, defineComponent } from 'vue';
|
|||||||
import { useToast } from './useToast';
|
import { useToast } from './useToast';
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useLogsStore } from '@/stores/logs.store';
|
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { EDITABLE_CANVAS_VIEWS, VIEWS } from '@/constants';
|
import { EDITABLE_CANVAS_VIEWS, VIEWS } from '@/constants';
|
||||||
|
import { useLogsStore } from '@/stores/logs.store';
|
||||||
|
|
||||||
describe('useToast', () => {
|
describe('useToast', () => {
|
||||||
let toast: ReturnType<typeof useToast>;
|
let toast: ReturnType<typeof useToast>;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useStyles } from './useStyles';
|
|||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import { useLogsStore } from '@/stores/logs.store';
|
import { useLogsStore } from '@/stores/logs.store';
|
||||||
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
|
import { LOGS_PANEL_STATE } from '@/features/logs/logs.constants';
|
||||||
|
|
||||||
export interface NotificationErrorWithNodeAndDescription extends ApplicationError {
|
export interface NotificationErrorWithNodeAndDescription extends ApplicationError {
|
||||||
node: {
|
node: {
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
|||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import type { IWorkflowDataUpdate } from '@/Interface';
|
import type { IWorkflowDataUpdate } from '@/Interface';
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
import { createTestNode, createTestWorkflow } from '@/__tests__/mocks';
|
import { createTestNode, createTestWorkflow, mockNodeTypeDescription } from '@/__tests__/mocks';
|
||||||
import { CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
import { CHAT_TRIGGER_NODE_TYPE } from 'n8n-workflow';
|
||||||
import { nodeTypes } from '@/components/CanvasChat/__test__/data';
|
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
|
||||||
const modalConfirmSpy = vi.fn();
|
const modalConfirmSpy = vi.fn();
|
||||||
@@ -82,7 +81,13 @@ describe('useWorkflowSaving', () => {
|
|||||||
workflowsStore = mockedStore(useWorkflowsStore);
|
workflowsStore = mockedStore(useWorkflowsStore);
|
||||||
|
|
||||||
nodeTypesStore = mockedStore(useNodeTypesStore);
|
nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||||
nodeTypesStore.setNodeTypes(nodeTypes);
|
nodeTypesStore.setNodeTypes([
|
||||||
|
mockNodeTypeDescription({
|
||||||
|
name: CHAT_TRIGGER_NODE_TYPE,
|
||||||
|
version: 1,
|
||||||
|
group: ['trigger'],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('promptSaveUnsavedWorkflowChanges', () => {
|
describe('promptSaveUnsavedWorkflowChanges', () => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createTestNode, createTestWorkflow, mockNodeTypeDescription } from '@/__tests__/mocks';
|
import { createTestNode, createTestWorkflow, mockNodeTypeDescription } from '@/__tests__/mocks';
|
||||||
import type { LogTreeCreationContext } from '@/components/RunDataAi/utils';
|
|
||||||
import {
|
import {
|
||||||
AGENT_NODE_TYPE,
|
AGENT_NODE_TYPE,
|
||||||
AI_CATEGORY_AGENTS,
|
AI_CATEGORY_AGENTS,
|
||||||
@@ -9,6 +8,7 @@ import {
|
|||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { type IExecutionResponse } from '@/Interface';
|
import { type IExecutionResponse } from '@/Interface';
|
||||||
import { WorkflowOperationError, type IRunData, type Workflow } from 'n8n-workflow';
|
import { WorkflowOperationError, type IRunData, type Workflow } from 'n8n-workflow';
|
||||||
|
import type { LogTreeCreationContext } from '../logs.types';
|
||||||
|
|
||||||
export function createTestLogTreeCreationContext(
|
export function createTestLogTreeCreationContext(
|
||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
@@ -87,6 +87,7 @@ export const aiChatExecutionResponse: IExecutionResponse = {
|
|||||||
status: 'success',
|
status: 'success',
|
||||||
data: {
|
data: {
|
||||||
resultData: {
|
resultData: {
|
||||||
|
lastNodeExecuted: 'AI Agent',
|
||||||
runData: {
|
runData: {
|
||||||
'AI Agent': [
|
'AI Agent': [
|
||||||
{
|
{
|
||||||
@@ -95,7 +96,9 @@ export const aiChatExecutionResponse: IExecutionResponse = {
|
|||||||
executionIndex: 0,
|
executionIndex: 0,
|
||||||
executionTime: 1778,
|
executionTime: 1778,
|
||||||
source: [],
|
source: [],
|
||||||
data: {},
|
data: {
|
||||||
|
main: [[{ json: { output: 'AI response message' } }]],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'AI Model': [
|
'AI Model': [
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
createTestNode,
|
||||||
|
createTestTaskData,
|
||||||
|
createTestWorkflowExecutionResponse,
|
||||||
|
createTestWorkflowObject,
|
||||||
|
} from '@/__tests__/mocks';
|
||||||
|
import type { LogEntry } from '../logs.types';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
export function createTestLogEntry(data: Partial<LogEntry> = {}): LogEntry {
|
||||||
|
const executionId = data.executionId ?? 'test-execution-id';
|
||||||
|
|
||||||
|
return {
|
||||||
|
node: createTestNode(),
|
||||||
|
runIndex: 0,
|
||||||
|
runData: createTestTaskData({}),
|
||||||
|
id: uuid(),
|
||||||
|
children: [],
|
||||||
|
consumedTokens: { completionTokens: 0, totalTokens: 0, promptTokens: 0, isEstimate: false },
|
||||||
|
depth: 0,
|
||||||
|
workflow: createTestWorkflowObject(),
|
||||||
|
executionId,
|
||||||
|
execution: createTestWorkflowExecutionResponse({ id: executionId }).data!,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,12 +7,11 @@ import MessageOptionAction from './MessageOptionAction.vue';
|
|||||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||||
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
|
import type { ArrowKeyDownPayload } from '@n8n/chat/components/Input.vue';
|
||||||
import ChatInput from '@n8n/chat/components/Input.vue';
|
import ChatInput from '@n8n/chat/components/Input.vue';
|
||||||
import { watch, computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useClipboard } from '@/composables/useClipboard';
|
import { useClipboard } from '@/composables/useClipboard';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import LogsPanelHeader from '@/components/CanvasChat/future/components/LogsPanelHeader.vue';
|
import LogsPanelHeader from '@/features/logs/components/LogsPanelHeader.vue';
|
||||||
import { N8nButton, N8nIconButton, N8nTooltip } from '@n8n/design-system';
|
import { N8nButton, N8nIconButton, N8nTooltip } from '@n8n/design-system';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pastChatMessages: string[];
|
pastChatMessages: string[];
|
||||||
@@ -21,13 +20,11 @@ interface Props {
|
|||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
isOpen?: boolean;
|
isOpen?: boolean;
|
||||||
isReadOnly?: boolean;
|
isReadOnly?: boolean;
|
||||||
isNewLogsEnabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
isNewLogsEnabled: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -41,7 +38,6 @@ const emit = defineEmits<{
|
|||||||
const clipboard = useClipboard();
|
const clipboard = useClipboard();
|
||||||
const locale = useI18n();
|
const locale = useI18n();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const settingsStore = useSettingsStore();
|
|
||||||
|
|
||||||
const previousMessageIndex = ref(0);
|
const previousMessageIndex = ref(0);
|
||||||
|
|
||||||
@@ -138,18 +134,6 @@ async function copySessionId() {
|
|||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.isOpen,
|
|
||||||
(isOpen) => {
|
|
||||||
if (isOpen && !settingsStore.isNewLogsEnabled) {
|
|
||||||
setTimeout(() => {
|
|
||||||
chatEventBus.emit('focusInput');
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -160,7 +144,6 @@ watch(
|
|||||||
tabindex="0"
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<LogsPanelHeader
|
<LogsPanelHeader
|
||||||
v-if="isNewLogsEnabled"
|
|
||||||
data-test-id="chat-header"
|
data-test-id="chat-header"
|
||||||
:title="locale.baseText('chat.window.title')"
|
:title="locale.baseText('chat.window.title')"
|
||||||
@click="emit('clickHeader')"
|
@click="emit('clickHeader')"
|
||||||
@@ -199,49 +182,11 @@ watch(
|
|||||||
</N8nTooltip>
|
</N8nTooltip>
|
||||||
</template>
|
</template>
|
||||||
</LogsPanelHeader>
|
</LogsPanelHeader>
|
||||||
<header v-else :class="$style.chatHeader">
|
<main v-if="isOpen" :class="$style.chatBody" data-test-id="canvas-chat-body">
|
||||||
<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
|
<MessagesList
|
||||||
:messages="messages"
|
:messages="messages"
|
||||||
:class="$style.messages"
|
:class="$style.messages"
|
||||||
:empty-text="
|
:empty-text="locale.baseText('chat.window.chat.emptyChatMessage.v2')"
|
||||||
isNewLogsEnabled ? locale.baseText('chat.window.chat.emptyChatMessage.v2') : undefined
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<template #beforeMessage="{ message }">
|
<template #beforeMessage="{ message }">
|
||||||
<MessageOptionTooltip
|
<MessageOptionTooltip
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import LogsPanel from '@/components/CanvasChat/future/LogsPanel.vue';
|
import LogsPanel from '@/features/logs/components/LogsPanel.vue';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
const { isNewLogsEnabled } = useSettingsStore();
|
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const hasExecutionData = computed(() => workflowsStore.workflowExecutionData);
|
const hasExecutionData = computed(() => workflowsStore.workflowExecutionData);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<LogsPanel v-if="isNewLogsEnabled && hasExecutionData" :is-read-only="true" />
|
<LogsPanel v-if="hasExecutionData" :is-read-only="true" />
|
||||||
</template>
|
</template>
|
||||||
@@ -5,7 +5,6 @@ import { createRouter, createWebHistory } from 'vue-router';
|
|||||||
import { createTestingPinia, type TestingPinia } from '@pinia/testing';
|
import { createTestingPinia, type TestingPinia } from '@pinia/testing';
|
||||||
import { h } from 'vue';
|
import { h } from 'vue';
|
||||||
import {
|
import {
|
||||||
createTestLogEntry,
|
|
||||||
createTestNode,
|
createTestNode,
|
||||||
createTestTaskData,
|
createTestTaskData,
|
||||||
createTestWorkflow,
|
createTestWorkflow,
|
||||||
@@ -14,8 +13,9 @@ import {
|
|||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { type FrontendSettings } from '@n8n/api-types';
|
import { type FrontendSettings } from '@n8n/api-types';
|
||||||
import { LOG_DETAILS_PANEL_STATE } from '../../types/logs';
|
import { LOG_DETAILS_PANEL_STATE } from '@/features/logs/logs.constants';
|
||||||
import type { LogEntry } from '@/components/RunDataAi/utils';
|
import type { LogEntry } from '../logs.types';
|
||||||
|
import { createTestLogEntry } from '../__test__/mocks';
|
||||||
|
|
||||||
describe('LogDetailsPanel', () => {
|
describe('LogDetailsPanel', () => {
|
||||||
let pinia: TestingPinia;
|
let pinia: TestingPinia;
|
||||||
@@ -1,24 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import LogsViewExecutionSummary from '@/components/CanvasChat/future/components/LogsViewExecutionSummary.vue';
|
import LogsViewExecutionSummary from '@/features/logs/components/LogsViewExecutionSummary.vue';
|
||||||
import LogsPanelHeader from '@/components/CanvasChat/future/components/LogsPanelHeader.vue';
|
import LogsPanelHeader from '@/features/logs/components/LogsPanelHeader.vue';
|
||||||
import LogsViewRunData from '@/components/CanvasChat/future/components/LogsViewRunData.vue';
|
import LogsViewRunData from '@/features/logs/components/LogsViewRunData.vue';
|
||||||
import { useResizablePanel } from '@/composables/useResizablePanel';
|
import { useResizablePanel } from '@/composables/useResizablePanel';
|
||||||
import {
|
import {
|
||||||
LOG_DETAILS_PANEL_STATE,
|
type LatestNodeInfo,
|
||||||
|
type LogEntry,
|
||||||
type LogDetailsPanelState,
|
type LogDetailsPanelState,
|
||||||
} from '@/components/CanvasChat/types/logs';
|
} from '@/features/logs/logs.types';
|
||||||
import NodeIcon from '@/components/NodeIcon.vue';
|
import NodeIcon from '@/components/NodeIcon.vue';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import LogsViewNodeName from '@/components/CanvasChat/future/components/LogsViewNodeName.vue';
|
import LogsViewNodeName from '@/features/logs/components/LogsViewNodeName.vue';
|
||||||
import {
|
|
||||||
getSubtreeTotalConsumedTokens,
|
|
||||||
type LogEntry,
|
|
||||||
type LatestNodeInfo,
|
|
||||||
} from '@/components/RunDataAi/utils';
|
|
||||||
import { N8nButton, N8nResizeWrapper } from '@n8n/design-system';
|
import { N8nButton, N8nResizeWrapper } from '@n8n/design-system';
|
||||||
import { computed, useTemplateRef } from 'vue';
|
import { computed, useTemplateRef } from 'vue';
|
||||||
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
import KeyboardShortcutTooltip from '@/components/KeyboardShortcutTooltip.vue';
|
||||||
|
import { getSubtreeTotalConsumedTokens } from '@/features/logs/logs.utils';
|
||||||
|
import { LOG_DETAILS_PANEL_STATE } from '@/features/logs/logs.constants';
|
||||||
|
|
||||||
const MIN_IO_PANEL_WIDTH = 200;
|
const MIN_IO_PANEL_WIDTH = 200;
|
||||||
|
|
||||||
@@ -12,10 +12,10 @@ import {
|
|||||||
aiChatWorkflow,
|
aiChatWorkflow,
|
||||||
aiManualExecutionResponse,
|
aiManualExecutionResponse,
|
||||||
aiManualWorkflow,
|
aiManualWorkflow,
|
||||||
} from '../../__test__/data';
|
} from '../__test__/data';
|
||||||
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||||
import { createTestWorkflowObject } from '@/__tests__/mocks';
|
import { createTestWorkflowObject } from '@/__tests__/mocks';
|
||||||
import { createLogTree, flattenLogEntries } from '@/components/RunDataAi/utils';
|
import { createLogTree, flattenLogEntries } from '../logs.utils';
|
||||||
|
|
||||||
describe('LogsOverviewPanel', () => {
|
describe('LogsOverviewPanel', () => {
|
||||||
let pinia: TestingPinia;
|
let pinia: TestingPinia;
|
||||||
@@ -1,22 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import LogsPanelHeader from '@/components/CanvasChat/future/components/LogsPanelHeader.vue';
|
import LogsPanelHeader from '@/features/logs/components/LogsPanelHeader.vue';
|
||||||
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
|
import { useClearExecutionButtonVisible } from '@/features/logs/composables/useClearExecutionButtonVisible';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { N8nButton, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system';
|
import { N8nButton, N8nRadioButtons, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||||
import { computed, nextTick, toRef, watch } from 'vue';
|
import { computed, nextTick, toRef, watch } from 'vue';
|
||||||
import LogsOverviewRow from '@/components/CanvasChat/future/components/LogsOverviewRow.vue';
|
import LogsOverviewRow from '@/features/logs/components/LogsOverviewRow.vue';
|
||||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import LogsViewExecutionSummary from '@/components/CanvasChat/future/components/LogsViewExecutionSummary.vue';
|
import LogsViewExecutionSummary from '@/features/logs/components/LogsViewExecutionSummary.vue';
|
||||||
import {
|
import {
|
||||||
getSubtreeTotalConsumedTokens,
|
getSubtreeTotalConsumedTokens,
|
||||||
getTotalConsumedTokens,
|
getTotalConsumedTokens,
|
||||||
hasSubExecution,
|
hasSubExecution,
|
||||||
type LatestNodeInfo,
|
} from '@/features/logs/logs.utils';
|
||||||
type LogEntry,
|
|
||||||
} from '@/components/RunDataAi/utils';
|
|
||||||
import { useVirtualList } from '@vueuse/core';
|
import { useVirtualList } from '@vueuse/core';
|
||||||
import { type IExecutionResponse } from '@/Interface';
|
import { type IExecutionResponse } from '@/Interface';
|
||||||
|
import type { LatestNodeInfo, LogEntry } from '@/features/logs/logs.types';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -2,18 +2,15 @@
|
|||||||
import { computed, nextTick, useTemplateRef, watch } from 'vue';
|
import { computed, nextTick, useTemplateRef, watch } from 'vue';
|
||||||
import { N8nButton, N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system';
|
import { N8nButton, N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
import LogsViewConsumedTokenCountText from '@/features/logs/components/LogsViewConsumedTokenCountText.vue';
|
||||||
import upperFirst from 'lodash/upperFirst';
|
import upperFirst from 'lodash/upperFirst';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import LogsViewConsumedTokenCountText from '@/components/CanvasChat/future/components/LogsViewConsumedTokenCountText.vue';
|
|
||||||
import { I18nT } from 'vue-i18n';
|
import { I18nT } from 'vue-i18n';
|
||||||
import { toDayMonth, toTime } from '@/utils/formatters/dateFormatter';
|
import { toDayMonth, toTime } from '@/utils/formatters/dateFormatter';
|
||||||
import LogsViewNodeName from '@/components/CanvasChat/future/components/LogsViewNodeName.vue';
|
import LogsViewNodeName from '@/features/logs/components/LogsViewNodeName.vue';
|
||||||
import {
|
import { getSubtreeTotalConsumedTokens } from '@/features/logs/logs.utils';
|
||||||
getSubtreeTotalConsumedTokens,
|
|
||||||
type LatestNodeInfo,
|
|
||||||
type LogEntry,
|
|
||||||
} from '@/components/RunDataAi/utils';
|
|
||||||
import { useTimestamp } from '@vueuse/core';
|
import { useTimestamp } from '@vueuse/core';
|
||||||
|
import type { LatestNodeInfo, LogEntry } from '@/features/logs/logs.types';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
data: LogEntry;
|
data: LogEntry;
|
||||||
@@ -1,22 +1,21 @@
|
|||||||
import { renderComponent } from '@/__tests__/render';
|
import { renderComponent } from '@/__tests__/render';
|
||||||
import { fireEvent, waitFor, within } from '@testing-library/vue';
|
import { fireEvent, waitFor, within } from '@testing-library/vue';
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
import LogsPanel from '@/components/CanvasChat/future/LogsPanel.vue';
|
import LogsPanel from '@/features/logs/components/LogsPanel.vue';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
|
||||||
import { createTestingPinia, type TestingPinia } from '@pinia/testing';
|
import { createTestingPinia, type TestingPinia } from '@pinia/testing';
|
||||||
import { setActivePinia } from 'pinia';
|
import { setActivePinia } from 'pinia';
|
||||||
import { createRouter, createWebHistory } from 'vue-router';
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { h, nextTick } from 'vue';
|
import { computed, h, nextTick, ref } from 'vue';
|
||||||
import {
|
import {
|
||||||
aiAgentNode,
|
aiAgentNode,
|
||||||
aiChatExecutionResponse,
|
aiChatExecutionResponse,
|
||||||
aiChatWorkflow,
|
aiChatWorkflow,
|
||||||
aiManualWorkflow,
|
aiManualWorkflow,
|
||||||
|
chatTriggerNode,
|
||||||
nodeTypes,
|
nodeTypes,
|
||||||
} from '../__test__/data';
|
} from '../__test__/data';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import { LOGS_PANEL_STATE } from '../types/logs';
|
|
||||||
import { IN_PROGRESS_EXECUTION_ID } from '@/constants';
|
import { IN_PROGRESS_EXECUTION_ID } from '@/constants';
|
||||||
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
@@ -24,12 +23,38 @@ import { deepCopy } from 'n8n-workflow';
|
|||||||
import { createTestTaskData } from '@/__tests__/mocks';
|
import { createTestTaskData } from '@/__tests__/mocks';
|
||||||
import { useLogsStore } from '@/stores/logs.store';
|
import { useLogsStore } from '@/stores/logs.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import { LOGS_PANEL_STATE } from '../logs.constants';
|
||||||
|
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
||||||
|
import { userEvent } from '@testing-library/user-event';
|
||||||
|
import type { ChatMessage } from '@n8n/chat/types';
|
||||||
|
import * as useChatMessaging from '@/features/logs/composables/useChatMessaging';
|
||||||
|
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('LogsPanel', () => {
|
describe('LogsPanel', () => {
|
||||||
const VIEWPORT_HEIGHT = 800;
|
const VIEWPORT_HEIGHT = 800;
|
||||||
|
|
||||||
let pinia: TestingPinia;
|
let pinia: TestingPinia;
|
||||||
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
|
|
||||||
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||||
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
|
let nodeTypeStore: ReturnType<typeof mockedStore<typeof useNodeTypesStore>>;
|
||||||
let logsStore: ReturnType<typeof mockedStore<typeof useLogsStore>>;
|
let logsStore: ReturnType<typeof mockedStore<typeof useLogsStore>>;
|
||||||
@@ -39,6 +64,10 @@ describe('LogsPanel', () => {
|
|||||||
function render() {
|
function render() {
|
||||||
return renderComponent(LogsPanel, {
|
return renderComponent(LogsPanel, {
|
||||||
global: {
|
global: {
|
||||||
|
provide: {
|
||||||
|
[ChatSymbol as symbol]: {},
|
||||||
|
[ChatOptionsSymbol as symbol]: {},
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
createRouter({
|
createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -55,9 +84,6 @@ describe('LogsPanel', () => {
|
|||||||
|
|
||||||
setActivePinia(pinia);
|
setActivePinia(pinia);
|
||||||
|
|
||||||
settingsStore = mockedStore(useSettingsStore);
|
|
||||||
settingsStore.isNewLogsEnabled = true;
|
|
||||||
|
|
||||||
workflowsStore = mockedStore(useWorkflowsStore);
|
workflowsStore = mockedStore(useWorkflowsStore);
|
||||||
workflowsStore.setWorkflowExecutionData(null);
|
workflowsStore.setWorkflowExecutionData(null);
|
||||||
|
|
||||||
@@ -83,6 +109,10 @@ describe('LogsPanel', () => {
|
|||||||
} as DOMRect);
|
} as DOMRect);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('should render collapsed panel by default', async () => {
|
it('should render collapsed panel by default', async () => {
|
||||||
const rendered = render();
|
const rendered = render();
|
||||||
|
|
||||||
@@ -432,4 +462,318 @@ describe('LogsPanel', () => {
|
|||||||
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
|
expect(await findByRole('treeitem', { selected: true })).toHaveTextContent(/AI Model/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('chat', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
logsStore.toggleOpen(true);
|
||||||
|
workflowsStore.setWorkflow(aiChatWorkflow);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render chat when panel is open', () => {
|
||||||
|
const { getByTestId } = render();
|
||||||
|
expect(getByTestId('canvas-chat-body')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render chat when panel is closed', async () => {
|
||||||
|
const { queryByTestId } = render();
|
||||||
|
logsStore.toggleOpen(false);
|
||||||
|
await waitFor(() => expect(queryByTestId('canvas-chat-body')).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show correct input placeholder', async () => {
|
||||||
|
const { findByTestId } = render();
|
||||||
|
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, getByText } = render();
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
workflowsStore.setWorkflowExecutionData({ ...aiChatExecutionResponse, status: 'success' });
|
||||||
|
await waitFor(() => expect(getByText('AI response message')).toBeInTheDocument());
|
||||||
|
|
||||||
|
// Verify workflow execution
|
||||||
|
expect(workflowsStore.runWorkflow).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
runData: undefined,
|
||||||
|
triggerToStartFrom: {
|
||||||
|
name: 'Chat',
|
||||||
|
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 } = render();
|
||||||
|
|
||||||
|
// Send message
|
||||||
|
const input = await findByTestId('chat-input');
|
||||||
|
await userEvent.type(input, 'Test message');
|
||||||
|
await userEvent.keyboard('{Enter}');
|
||||||
|
|
||||||
|
await waitFor(() => expect(queryByTestId('chat-message-typing')).toBeInTheDocument());
|
||||||
|
|
||||||
|
workflowsStore.setActiveExecutionId(undefined);
|
||||||
|
workflowsStore.setWorkflowExecutionData({ ...aiChatExecutionResponse, 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 } = render();
|
||||||
|
|
||||||
|
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 } = render();
|
||||||
|
|
||||||
|
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 } = render();
|
||||||
|
|
||||||
|
const originalSessionId = getByTestId('chat-session-id').textContent;
|
||||||
|
await userEvent.click(getByTestId('refresh-session-button'));
|
||||||
|
|
||||||
|
expect(getByTestId('chat-session-id').textContent).not.toEqual(originalSessionId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
workflowsStore.setNodes(aiChatWorkflow.nodes);
|
||||||
|
workflowsStore.setNodeParameters({
|
||||||
|
name: chatTriggerNode.name,
|
||||||
|
value: { options: { allowFileUploads: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByTestId, queryByTestId } = render();
|
||||||
|
|
||||||
|
expect(getByTestId('canvas-chat')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('chat-attach-file-button')).toBeInTheDocument();
|
||||||
|
|
||||||
|
workflowsStore.setNodeParameters({
|
||||||
|
name: chatTriggerNode.name,
|
||||||
|
value: { options: { allowFileUploads: false } },
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(queryByTestId('chat-attach-file-button')).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('message history handling', () => {
|
||||||
|
it('should properly navigate through message history with wrap-around', async () => {
|
||||||
|
workflowsStore.resetChatMessages();
|
||||||
|
workflowsStore.appendChatMessage('Message 1');
|
||||||
|
workflowsStore.appendChatMessage('Message 2');
|
||||||
|
workflowsStore.appendChatMessage('Message 3');
|
||||||
|
|
||||||
|
const { findByTestId } = render();
|
||||||
|
const input = await findByTestId('chat-input');
|
||||||
|
|
||||||
|
chatEventBus.emit('focusInput');
|
||||||
|
|
||||||
|
// 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.resetChatMessages();
|
||||||
|
workflowsStore.appendChatMessage('Message 1');
|
||||||
|
workflowsStore.appendChatMessage('Message 2');
|
||||||
|
|
||||||
|
const { findByTestId } = render();
|
||||||
|
const input = await findByTestId('chat-input');
|
||||||
|
|
||||||
|
chatEventBus.emit('focusInput');
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should repost user message with new execution', async () => {
|
||||||
|
const { findByTestId } = render();
|
||||||
|
const repostButton = await findByTestId('repost-message-button');
|
||||||
|
|
||||||
|
await userEvent.click(repostButton);
|
||||||
|
|
||||||
|
expect(sendMessageSpy).toHaveBeenCalledWith('Original message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show message options only for appropriate messages', async () => {
|
||||||
|
const { findByText, container } = render();
|
||||||
|
|
||||||
|
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('keyboard shortcuts', () => {
|
||||||
|
it('should handle Enter key with modifier to start new line', async () => {
|
||||||
|
const { findByTestId } = render();
|
||||||
|
|
||||||
|
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,19 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, computed, useTemplateRef } from 'vue';
|
import { nextTick, computed, useTemplateRef } from 'vue';
|
||||||
import { N8nResizeWrapper } from '@n8n/design-system';
|
import { N8nResizeWrapper } from '@n8n/design-system';
|
||||||
import { useChatState } from '@/components/CanvasChat/composables/useChatState';
|
import { useChatState } from '@/features/logs/composables/useChatState';
|
||||||
import LogsOverviewPanel from '@/components/CanvasChat/future/components/LogsOverviewPanel.vue';
|
import LogsOverviewPanel from '@/features/logs/components/LogsOverviewPanel.vue';
|
||||||
import ChatMessagesPanel from '@/components/CanvasChat/components/ChatMessagesPanel.vue';
|
import ChatMessagesPanel from '@/features/logs/components/ChatMessagesPanel.vue';
|
||||||
import LogsDetailsPanel from '@/components/CanvasChat/future/components/LogDetailsPanel.vue';
|
import LogsDetailsPanel from '@/features/logs/components/LogDetailsPanel.vue';
|
||||||
import LogsPanelActions from '@/components/CanvasChat/future/components/LogsPanelActions.vue';
|
import LogsPanelActions from '@/features/logs/components/LogsPanelActions.vue';
|
||||||
import { useLogsPanelLayout } from '@/components/CanvasChat/future/composables/useLogsPanelLayout';
|
import { useLogsExecutionData } from '@/features/logs/composables/useLogsExecutionData';
|
||||||
import { useLogsExecutionData } from '@/components/CanvasChat/future/composables/useLogsExecutionData';
|
|
||||||
import { type LogEntry } from '@/components/RunDataAi/utils';
|
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
import { ndvEventBus } from '@/event-bus';
|
import { ndvEventBus } from '@/event-bus';
|
||||||
import { useLogsSelection } from '@/components/CanvasChat/future/composables/useLogsSelection';
|
import { useLogsSelection } from '@/features/logs/composables/useLogsSelection';
|
||||||
import { useLogsTreeExpand } from '@/components/CanvasChat/future/composables/useLogsTreeExpand';
|
import { useLogsTreeExpand } from '@/features/logs/composables/useLogsTreeExpand';
|
||||||
|
import { type LogEntry } from '@/features/logs/logs.types';
|
||||||
import { useLogsStore } from '@/stores/logs.store';
|
import { useLogsStore } from '@/stores/logs.store';
|
||||||
|
import { useLogsPanelLayout } from '@/features/logs/composables/useLogsPanelLayout';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ isReadOnly?: boolean }>(), { isReadOnly: false });
|
const props = withDefaults(defineProps<{ isReadOnly?: boolean }>(), { isReadOnly: false });
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { formatTokenUsageCount } from '@/components/RunDataAi/utils';
|
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { type LlmTokenUsageData } from '@/Interface';
|
import { type LlmTokenUsageData } from '@/Interface';
|
||||||
|
import { formatTokenUsageCount } from '@/utils/aiUtils';
|
||||||
import { N8nTooltip } from '@n8n/design-system';
|
import { N8nTooltip } from '@n8n/design-system';
|
||||||
|
|
||||||
const { consumedTokens } = defineProps<{ consumedTokens: LlmTokenUsageData }>();
|
const { consumedTokens } = defineProps<{ consumedTokens: LlmTokenUsageData }>();
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import LogsViewConsumedTokenCountText from '@/components/CanvasChat/future/components/LogsViewConsumedTokenCountText.vue';
|
import LogsViewConsumedTokenCountText from '@/features/logs/components/LogsViewConsumedTokenCountText.vue';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { type LlmTokenUsageData } from '@/Interface';
|
import { type LlmTokenUsageData } from '@/Interface';
|
||||||
import { N8nText } from '@n8n/design-system';
|
import { N8nText } from '@n8n/design-system';
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import RunData from '@/components/RunData.vue';
|
import RunData from '@/components/RunData.vue';
|
||||||
import { type LogEntry } from '@/components/RunDataAi/utils';
|
import { type LogEntry } from '@/features/logs/logs.types';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { type IRunDataDisplayMode, type NodePanelType } from '@/Interface';
|
import { type IRunDataDisplayMode, type NodePanelType } from '@/Interface';
|
||||||
import { useNDVStore } from '@/stores/ndv.store';
|
import { useNDVStore } from '@/stores/ndv.store';
|
||||||
@@ -17,7 +17,7 @@ import { usePinnedData } from '@/composables/usePinnedData';
|
|||||||
import { MODAL_CONFIRM } from '@/constants';
|
import { MODAL_CONFIRM } from '@/constants';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import type { IExecutionPushResponse, INodeUi } from '@/Interface';
|
import type { IExecutionPushResponse, INodeUi } from '@/Interface';
|
||||||
import { extractBotResponse, getInputKey } from '@/components/CanvasChat/utils';
|
import { extractBotResponse, getInputKey } from '@/features/logs/logs.utils';
|
||||||
|
|
||||||
export type RunWorkflowChatPayload = {
|
export type RunWorkflowChatPayload = {
|
||||||
triggerNode: string;
|
triggerNode: string;
|
||||||
@@ -1,30 +1,26 @@
|
|||||||
import type { RunWorkflowChatPayload } from '@/components/CanvasChat/composables/useChatMessaging';
|
import type { RunWorkflowChatPayload } from '@/features/logs/composables/useChatMessaging';
|
||||||
import { useChatMessaging } from '@/components/CanvasChat/composables/useChatMessaging';
|
import { useChatMessaging } from '@/features/logs/composables/useChatMessaging';
|
||||||
import { useChatTrigger } from '@/components/CanvasChat/composables/useChatTrigger';
|
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import { type INodeUi } from '@/Interface';
|
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
import { ChatOptionsSymbol, ChatSymbol } from '@n8n/chat/constants';
|
||||||
import { chatEventBus } from '@n8n/chat/event-buses';
|
import { chatEventBus } from '@n8n/chat/event-buses';
|
||||||
import type { Chat, ChatMessage, ChatOptions } from '@n8n/chat/types';
|
import type { Chat, ChatMessage, ChatOptions } from '@n8n/chat/types';
|
||||||
import { type INode } from 'n8n-workflow';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import type { Ref } from 'vue';
|
import type { Ref } from 'vue';
|
||||||
import { computed, provide, ref, watch } from 'vue';
|
import { computed, provide, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { restoreChatHistory } from '@/components/CanvasChat/utils';
|
|
||||||
import { useLogsStore } from '@/stores/logs.store';
|
import { useLogsStore } from '@/stores/logs.store';
|
||||||
|
import { restoreChatHistory } from '@/features/logs/logs.utils';
|
||||||
|
import type { INodeParameters } from 'n8n-workflow';
|
||||||
|
import { isChatNode } from '@/utils/aiUtils';
|
||||||
|
|
||||||
interface ChatState {
|
interface ChatState {
|
||||||
currentSessionId: Ref<string>;
|
currentSessionId: Ref<string>;
|
||||||
messages: Ref<ChatMessage[]>;
|
messages: Ref<ChatMessage[]>;
|
||||||
previousChatMessages: Ref<string[]>;
|
previousChatMessages: Ref<string[]>;
|
||||||
chatTriggerNode: Ref<INodeUi | null>;
|
|
||||||
connectedNode: Ref<INode | null>;
|
|
||||||
sendMessage: (message: string, files?: File[]) => Promise<void>;
|
sendMessage: (message: string, files?: File[]) => Promise<void>;
|
||||||
refreshSession: () => void;
|
refreshSession: () => void;
|
||||||
displayExecution: (executionId: string) => void;
|
displayExecution: (executionId: string) => void;
|
||||||
@@ -33,7 +29,6 @@ interface ChatState {
|
|||||||
export function useChatState(isReadOnly: boolean): ChatState {
|
export function useChatState(isReadOnly: boolean): ChatState {
|
||||||
const locale = useI18n();
|
const locale = useI18n();
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
|
||||||
const logsStore = useLogsStore();
|
const logsStore = useLogsStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const nodeHelpers = useNodeHelpers();
|
const nodeHelpers = useNodeHelpers();
|
||||||
@@ -43,15 +38,19 @@ export function useChatState(isReadOnly: boolean): ChatState {
|
|||||||
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
|
const currentSessionId = ref<string>(uuid().replace(/-/g, ''));
|
||||||
|
|
||||||
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
|
const previousChatMessages = computed(() => workflowsStore.getPastChatMessages);
|
||||||
const workflow = computed(() => workflowsStore.getCurrentWorkflow());
|
const chatTriggerNode = computed(
|
||||||
|
() => Object.values(workflowsStore.allNodes).find(isChatNode) ?? null,
|
||||||
// Initialize features with injected dependencies
|
);
|
||||||
const { chatTriggerNode, connectedNode, allowFileUploads, allowedFilesMimeTypes } =
|
const allowFileUploads = computed(
|
||||||
useChatTrigger({
|
() =>
|
||||||
workflow,
|
(chatTriggerNode.value?.parameters?.options as INodeParameters)?.allowFileUploads === true,
|
||||||
getNodeByName: workflowsStore.getNodeByName,
|
);
|
||||||
getNodeType: nodeTypesStore.getNodeType,
|
const allowedFilesMimeTypes = computed(
|
||||||
});
|
() =>
|
||||||
|
(
|
||||||
|
chatTriggerNode.value?.parameters?.options as INodeParameters
|
||||||
|
)?.allowedFilesMimeTypes?.toString() ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
const { sendMessage, isLoading } = useChatMessaging({
|
const { sendMessage, isLoading } = useChatMessaging({
|
||||||
chatTrigger: chatTriggerNode,
|
chatTrigger: chatTriggerNode,
|
||||||
@@ -176,7 +175,7 @@ export function useChatState(isReadOnly: boolean): ChatState {
|
|||||||
function displayExecution(executionId: string) {
|
function displayExecution(executionId: string) {
|
||||||
const route = router.resolve({
|
const route = router.resolve({
|
||||||
name: VIEWS.EXECUTION_PREVIEW,
|
name: VIEWS.EXECUTION_PREVIEW,
|
||||||
params: { name: workflow.value.id, executionId },
|
params: { name: workflowsStore.workflowId, executionId },
|
||||||
});
|
});
|
||||||
window.open(route.href, '_blank');
|
window.open(route.href, '_blank');
|
||||||
}
|
}
|
||||||
@@ -185,8 +184,6 @@ export function useChatState(isReadOnly: boolean): ChatState {
|
|||||||
currentSessionId,
|
currentSessionId,
|
||||||
messages: computed(() => (isReadOnly ? restoredChatMessages.value : messages.value)),
|
messages: computed(() => (isReadOnly ? restoredChatMessages.value : messages.value)),
|
||||||
previousChatMessages,
|
previousChatMessages,
|
||||||
chatTriggerNode,
|
|
||||||
connectedNode,
|
|
||||||
sendMessage,
|
sendMessage,
|
||||||
refreshSession,
|
refreshSession,
|
||||||
displayExecution,
|
displayExecution,
|
||||||
@@ -2,8 +2,8 @@ import { START_NODE_TYPE } from '@/constants';
|
|||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useCanvasOperations } from './useCanvasOperations';
|
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
|
|
||||||
export function useClearExecutionButtonVisible() {
|
export function useClearExecutionButtonVisible() {
|
||||||
@@ -28,6 +28,6 @@ export function useClearExecutionButtonVisible() {
|
|||||||
!isReadOnlyEnvironment.value &&
|
!isReadOnlyEnvironment.value &&
|
||||||
!isWorkflowRunning.value &&
|
!isWorkflowRunning.value &&
|
||||||
!allTriggerNodesDisabled.value &&
|
!allTriggerNodesDisabled.value &&
|
||||||
workflowExecutionData.value,
|
!!workflowExecutionData.value,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ import { createTestingPinia } from '@pinia/testing';
|
|||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import { nodeTypes } from '../../__test__/data';
|
import { nodeTypes } from '../__test__/data';
|
||||||
import {
|
import {
|
||||||
createTestNode,
|
createTestNode,
|
||||||
createTestTaskData,
|
createTestTaskData,
|
||||||
@@ -1,19 +1,14 @@
|
|||||||
import { watch, computed, ref } from 'vue';
|
import { watch, computed, ref } from 'vue';
|
||||||
import { isChatNode } from '../../utils';
|
|
||||||
import { type IExecutionResponse } from '@/Interface';
|
import { type IExecutionResponse } from '@/Interface';
|
||||||
import { Workflow, type IRunExecutionData } from 'n8n-workflow';
|
import { Workflow, type IRunExecutionData } from 'n8n-workflow';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
import { useNodeHelpers } from '@/composables/useNodeHelpers';
|
||||||
import { useThrottleFn } from '@vueuse/core';
|
import { useThrottleFn } from '@vueuse/core';
|
||||||
import {
|
import { createLogTree, deepToRaw, mergeStartData } from '@/features/logs/logs.utils';
|
||||||
createLogTree,
|
|
||||||
deepToRaw,
|
|
||||||
mergeStartData,
|
|
||||||
type LatestNodeInfo,
|
|
||||||
type LogEntry,
|
|
||||||
} from '@/components/RunDataAi/utils';
|
|
||||||
import { parse } from 'flatted';
|
import { parse } from 'flatted';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import type { LatestNodeInfo, LogEntry } from '../logs.types';
|
||||||
|
import { isChatNode } from '@/utils/aiUtils';
|
||||||
|
|
||||||
export function useLogsExecutionData() {
|
export function useLogsExecutionData() {
|
||||||
const nodeHelpers = useNodeHelpers();
|
const nodeHelpers = useNodeHelpers();
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { computed, type ShallowRef } from 'vue';
|
import { computed, type ShallowRef } from 'vue';
|
||||||
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
|
import { watch } from 'vue';
|
||||||
|
import { useLogsStore } from '@/stores/logs.store';
|
||||||
|
import { useResizablePanel } from '@/composables/useResizablePanel';
|
||||||
|
import { usePiPWindow } from '@/features/logs/composables/usePiPWindow';
|
||||||
import {
|
import {
|
||||||
|
LOGS_PANEL_STATE,
|
||||||
LOCAL_STORAGE_OVERVIEW_PANEL_WIDTH,
|
LOCAL_STORAGE_OVERVIEW_PANEL_WIDTH,
|
||||||
LOCAL_STORAGE_PANEL_HEIGHT,
|
LOCAL_STORAGE_PANEL_HEIGHT,
|
||||||
LOCAL_STORAGE_PANEL_WIDTH,
|
LOCAL_STORAGE_PANEL_WIDTH,
|
||||||
} from '../../composables/useResize';
|
} from '@/features/logs/logs.constants';
|
||||||
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(
|
export function useLogsPanelLayout(
|
||||||
pipContainer: Readonly<ShallowRef<HTMLElement | null>>,
|
pipContainer: Readonly<ShallowRef<HTMLElement | null>>,
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { LogEntrySelection } from '@/components/CanvasChat/types/logs';
|
import type { LogEntry, LogEntrySelection } from '@/features/logs/logs.types';
|
||||||
import {
|
import {
|
||||||
findLogEntryRec,
|
findLogEntryRec,
|
||||||
findSelectedLogEntry,
|
findSelectedLogEntry,
|
||||||
getDepth,
|
getDepth,
|
||||||
getEntryAtRelativeIndex,
|
getEntryAtRelativeIndex,
|
||||||
type LogEntry,
|
} from '@/features/logs/logs.utils';
|
||||||
} from '@/components/RunDataAi/utils';
|
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { canvasEventBus } from '@/event-bus/canvas';
|
import { canvasEventBus } from '@/event-bus/canvas';
|
||||||
import type { IExecutionResponse } from '@/Interface';
|
import type { IExecutionResponse } from '@/Interface';
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { flattenLogEntries, type LogEntry } from '@/components/RunDataAi/utils';
|
import { flattenLogEntries } from '@/features/logs/logs.utils';
|
||||||
import { computed, ref, type ComputedRef } from 'vue';
|
import { computed, ref, type ComputedRef } from 'vue';
|
||||||
|
import type { LogEntry } from '../logs.types';
|
||||||
|
|
||||||
export function useLogsTreeExpand(entries: ComputedRef<LogEntry[]>) {
|
export function useLogsTreeExpand(entries: ComputedRef<LogEntry[]>) {
|
||||||
const collapsedEntries = ref<Record<string, boolean>>({});
|
const collapsedEntries = ref<Record<string, boolean>>({});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
export const LOGS_PANEL_STATE = {
|
||||||
|
CLOSED: 'closed',
|
||||||
|
ATTACHED: 'attached',
|
||||||
|
FLOATING: 'floating',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const LOG_DETAILS_PANEL_STATE = {
|
||||||
|
INPUT: 'input',
|
||||||
|
OUTPUT: 'output',
|
||||||
|
BOTH: 'both',
|
||||||
|
} as const;
|
||||||
43
packages/frontend/editor-ui/src/features/logs/logs.types.ts
Normal file
43
packages/frontend/editor-ui/src/features/logs/logs.types.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { LOG_DETAILS_PANEL_STATE, LOGS_PANEL_STATE } from '@/features/logs/logs.constants';
|
||||||
|
import type { INodeUi, LlmTokenUsageData } from '@/Interface';
|
||||||
|
import type { IRunExecutionData, ITaskData, Workflow } from 'n8n-workflow';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LogEntrySelection =
|
||||||
|
| { type: 'initial' }
|
||||||
|
| { type: 'selected'; id: string }
|
||||||
|
| { type: 'none' };
|
||||||
|
|
||||||
|
export type LogsPanelState = (typeof LOGS_PANEL_STATE)[keyof typeof LOGS_PANEL_STATE];
|
||||||
|
|
||||||
|
export type LogDetailsPanelState =
|
||||||
|
(typeof LOG_DETAILS_PANEL_STATE)[keyof typeof LOG_DETAILS_PANEL_STATE];
|
||||||
1179
packages/frontend/editor-ui/src/features/logs/logs.utils.test.ts
Normal file
1179
packages/frontend/editor-ui/src/features/logs/logs.utils.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
541
packages/frontend/editor-ui/src/features/logs/logs.utils.ts
Normal file
541
packages/frontend/editor-ui/src/features/logs/logs.utils.ts
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
import type { IExecutionResponse, INodeUi, LlmTokenUsageData, IWorkflowDb } from '@/Interface';
|
||||||
|
import { addTokenUsageData, emptyTokenUsageData, isChatNode } from '@/utils/aiUtils';
|
||||||
|
import {
|
||||||
|
NodeConnectionTypes,
|
||||||
|
type IDataObject,
|
||||||
|
AGENT_LANGCHAIN_NODE_TYPE,
|
||||||
|
type IRunExecutionData,
|
||||||
|
type ITaskData,
|
||||||
|
type ITaskStartedData,
|
||||||
|
type Workflow,
|
||||||
|
} from 'n8n-workflow';
|
||||||
|
import type { LogEntry, LogEntrySelection, LogTreeCreationContext } from './logs.types';
|
||||||
|
import { isProxy, isReactive, isRef, toRaw } from 'vue';
|
||||||
|
import { CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE } from '@/constants';
|
||||||
|
import { type ChatMessage } from '@n8n/chat/types';
|
||||||
|
import get from 'lodash-es/get';
|
||||||
|
import isEmpty from 'lodash-es/isEmpty';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
|
||||||
|
function getConsumedTokens(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 createNode(
|
||||||
|
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: getConsumedTokens(runData),
|
||||||
|
workflow: context.workflow,
|
||||||
|
executionId: context.executionId,
|
||||||
|
execution: context.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTreeNodeData(
|
||||||
|
nodeName: string,
|
||||||
|
runData: ITaskData,
|
||||||
|
runIndex: number | undefined,
|
||||||
|
context: LogTreeCreationContext,
|
||||||
|
): LogEntry[] {
|
||||||
|
const node = context.workflow.getNode(nodeName);
|
||||||
|
|
||||||
|
return node ? getTreeNodeDataRec(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
|
||||||
|
? getTreeNodeDataRec(
|
||||||
|
subNode,
|
||||||
|
t,
|
||||||
|
{ ...context, depth: context.depth + 1, parent: treeNode },
|
||||||
|
index,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTreeNodeDataRec(
|
||||||
|
node: INodeUi,
|
||||||
|
runData: ITaskData,
|
||||||
|
context: LogTreeCreationContext,
|
||||||
|
runIndex: number | undefined,
|
||||||
|
): LogEntry[] {
|
||||||
|
const treeNode = createNode(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 }) =>
|
||||||
|
getTreeNodeData(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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] : [])];
|
||||||
|
}
|
||||||
@@ -27,9 +27,8 @@ const ErrorView = async () => await import('./views/ErrorView.vue');
|
|||||||
const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordView.vue');
|
const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordView.vue');
|
||||||
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
|
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
|
||||||
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
||||||
const CanvasChatSwitch = async () => await import('@/components/CanvasChat/CanvasChatSwitch.vue');
|
const LogsPanel = async () => await import('@/features/logs/components/LogsPanel.vue');
|
||||||
const DemoFooter = async () =>
|
const DemoFooter = async () => await import('@/features/logs/components/DemoFooter.vue');
|
||||||
await import('@/components/CanvasChat/future/components/DemoFooter.vue');
|
|
||||||
const NodeView = async () => await import('@/views/NodeView.vue');
|
const NodeView = async () => await import('@/views/NodeView.vue');
|
||||||
const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
|
const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
|
||||||
const WorkflowExecutionsLandingPage = async () =>
|
const WorkflowExecutionsLandingPage = async () =>
|
||||||
@@ -343,7 +342,7 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
default: NodeView,
|
default: NodeView,
|
||||||
header: MainHeader,
|
header: MainHeader,
|
||||||
sidebar: MainSidebar,
|
sidebar: MainSidebar,
|
||||||
footer: CanvasChatSwitch,
|
footer: LogsPanel,
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
nodeView: true,
|
nodeView: true,
|
||||||
@@ -377,7 +376,7 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
default: NodeView,
|
default: NodeView,
|
||||||
header: MainHeader,
|
header: MainHeader,
|
||||||
sidebar: MainSidebar,
|
sidebar: MainSidebar,
|
||||||
footer: CanvasChatSwitch,
|
footer: LogsPanel,
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
nodeView: true,
|
nodeView: true,
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import { type LogDetailsPanelState } from '@/features/logs/logs.types';
|
||||||
LOG_DETAILS_PANEL_STATE,
|
|
||||||
LOGS_PANEL_STATE,
|
|
||||||
type LogDetailsPanelState,
|
|
||||||
} from '@/components/CanvasChat/types/logs';
|
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import {
|
import {
|
||||||
LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL,
|
LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL,
|
||||||
@@ -12,6 +8,7 @@ import {
|
|||||||
import { useLocalStorage } from '@vueuse/core';
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
import { LOG_DETAILS_PANEL_STATE, LOGS_PANEL_STATE } from '@/features/logs/logs.constants';
|
||||||
|
|
||||||
export const useLogsStore = defineStore('logs', () => {
|
export const useLogsStore = defineStore('logs', () => {
|
||||||
const isOpen = useLocalStorage(LOCAL_STORAGE_LOGS_PANEL_OPEN, false);
|
const isOpen = useLocalStorage(LOCAL_STORAGE_LOGS_PANEL_OPEN, false);
|
||||||
|
|||||||
@@ -192,8 +192,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
|||||||
|
|
||||||
const isDevRelease = computed(() => settings.value.releaseChannel === 'dev');
|
const isDevRelease = computed(() => settings.value.releaseChannel === 'dev');
|
||||||
|
|
||||||
const isNewLogsEnabled = computed(() => !!settings.value.logsView?.enabled);
|
|
||||||
|
|
||||||
const setSettings = (newSettings: FrontendSettings) => {
|
const setSettings = (newSettings: FrontendSettings) => {
|
||||||
settings.value = newSettings;
|
settings.value = newSettings;
|
||||||
userManagement.value = newSettings.userManagement;
|
userManagement.value = newSettings.userManagement;
|
||||||
@@ -456,7 +454,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
|||||||
isAskAiEnabled,
|
isAskAiEnabled,
|
||||||
isAiCreditsEnabled,
|
isAiCreditsEnabled,
|
||||||
aiCreditsQuota,
|
aiCreditsQuota,
|
||||||
isNewLogsEnabled,
|
|
||||||
experimental__minZoomNodeSettingsInCanvas,
|
experimental__minZoomNodeSettingsInCanvas,
|
||||||
reset,
|
reset,
|
||||||
testLdapConnection,
|
testLdapConnection,
|
||||||
|
|||||||
@@ -737,11 +737,6 @@ describe('useWorkflowsStore', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
it('should replace existing placeholder task data in new log view', () => {
|
it('should replace existing placeholder task data in new log view', () => {
|
||||||
settingsStore.settings = {
|
|
||||||
logsView: {
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
} as FrontendSettings;
|
|
||||||
const successEventWithExecutionIndex = deepCopy(successEvent);
|
const successEventWithExecutionIndex = deepCopy(successEvent);
|
||||||
successEventWithExecutionIndex.data.executionIndex = 1;
|
successEventWithExecutionIndex.data.executionIndex = 1;
|
||||||
|
|
||||||
|
|||||||
@@ -99,8 +99,7 @@ import { useUsersStore } from '@/stores/users.store';
|
|||||||
import { updateCurrentUserSettings } from '@/api/users';
|
import { updateCurrentUserSettings } from '@/api/users';
|
||||||
import { useExecutingNode } from '@/composables/useExecutingNode';
|
import { useExecutingNode } from '@/composables/useExecutingNode';
|
||||||
import type { NodeExecuteBefore } from '@n8n/api-types/push/execution';
|
import type { NodeExecuteBefore } from '@n8n/api-types/push/execution';
|
||||||
import { useLogsStore } from './logs.store';
|
import { isChatNode } from '@/utils/aiUtils';
|
||||||
import { isChatNode } from '@/components/CanvasChat/utils';
|
|
||||||
|
|
||||||
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
|
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -135,7 +134,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
const nodeHelpers = useNodeHelpers();
|
const nodeHelpers = useNodeHelpers();
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
const logsStore = useLogsStore();
|
|
||||||
const nodeTypesStore = useNodeTypesStore();
|
const nodeTypesStore = useNodeTypesStore();
|
||||||
|
|
||||||
const version = computed(() => settingsStore.partialExecutionVersion);
|
const version = computed(() => settingsStore.partialExecutionVersion);
|
||||||
@@ -1373,11 +1371,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
const { [node.name]: removedNodeMetadata, ...remainingNodeMetadata } = nodeMetadata.value;
|
const { [node.name]: removedNodeMetadata, ...remainingNodeMetadata } = nodeMetadata.value;
|
||||||
nodeMetadata.value = remainingNodeMetadata;
|
nodeMetadata.value = remainingNodeMetadata;
|
||||||
|
|
||||||
// If chat trigger node is removed, close chat
|
|
||||||
if (node.type === CHAT_TRIGGER_NODE_TYPE && !settingsStore.isNewLogsEnabled) {
|
|
||||||
logsStore.toggleOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workflow.value.pinData && workflow.value.pinData.hasOwnProperty(node.name)) {
|
if (workflow.value.pinData && workflow.value.pinData.hasOwnProperty(node.name)) {
|
||||||
const { [node.name]: removedPinData, ...remainingPinData } = workflow.value.pinData;
|
const { [node.name]: removedPinData, ...remainingPinData } = workflow.value.pinData;
|
||||||
workflow.value = {
|
workflow.value = {
|
||||||
@@ -1611,7 +1604,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
|||||||
existingRunIndex > -1 && !hasWaitingItems ? existingRunIndex : tasksData.length - 1;
|
existingRunIndex > -1 && !hasWaitingItems ? existingRunIndex : tasksData.length - 1;
|
||||||
const status = tasksData[index]?.executionStatus ?? 'unknown';
|
const status = tasksData[index]?.executionStatus ?? 'unknown';
|
||||||
|
|
||||||
if ('waiting' === status || (settingsStore.isNewLogsEnabled && 'running' === status)) {
|
if ('waiting' === status || 'running' === status) {
|
||||||
tasksData.splice(index, 1, data);
|
tasksData.splice(index, 1, data);
|
||||||
} else {
|
} else {
|
||||||
tasksData.push(data);
|
tasksData.push(data);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { parseAiContent } from '@/utils/aiUtils';
|
import type { LlmTokenUsageData } from '@/Interface';
|
||||||
|
import { addTokenUsageData, formatTokenUsageCount, parseAiContent } from '@/utils/aiUtils';
|
||||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||||
|
|
||||||
describe(parseAiContent, () => {
|
describe(parseAiContent, () => {
|
||||||
@@ -79,3 +80,56 @@ describe(parseAiContent, () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe(addTokenUsageData, () => {
|
||||||
|
it('should return sum of consumed tokens', () => {
|
||||||
|
expect(
|
||||||
|
addTokenUsageData(
|
||||||
|
{ completionTokens: 1, promptTokens: 100, totalTokens: 1000, isEstimate: false },
|
||||||
|
{ completionTokens: 0, promptTokens: 1, totalTokens: 2, isEstimate: false },
|
||||||
|
),
|
||||||
|
).toEqual({ completionTokens: 1, promptTokens: 101, totalTokens: 1002, isEstimate: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set isEstimate to true if either of the arguments is an estimation', () => {
|
||||||
|
const usageData = { completionTokens: 0, promptTokens: 0, totalTokens: 0, isEstimate: false };
|
||||||
|
|
||||||
|
expect(addTokenUsageData(usageData, usageData)).toEqual({
|
||||||
|
...usageData,
|
||||||
|
isEstimate: false,
|
||||||
|
});
|
||||||
|
expect(addTokenUsageData({ ...usageData, isEstimate: true }, usageData)).toEqual({
|
||||||
|
...usageData,
|
||||||
|
isEstimate: true,
|
||||||
|
});
|
||||||
|
expect(addTokenUsageData(usageData, { ...usageData, isEstimate: true })).toEqual({
|
||||||
|
...usageData,
|
||||||
|
isEstimate: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
addTokenUsageData({ ...usageData, isEstimate: true }, { ...usageData, isEstimate: true }),
|
||||||
|
).toEqual({
|
||||||
|
...usageData,
|
||||||
|
isEstimate: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe(formatTokenUsageCount, () => {
|
||||||
|
const usageData: LlmTokenUsageData = {
|
||||||
|
completionTokens: 11,
|
||||||
|
promptTokens: 22,
|
||||||
|
totalTokens: 33,
|
||||||
|
isEstimate: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should return the number of specified field', () => {
|
||||||
|
expect(formatTokenUsageCount(usageData, 'completion')).toBe('11');
|
||||||
|
expect(formatTokenUsageCount(usageData, 'prompt')).toBe('22');
|
||||||
|
expect(formatTokenUsageCount(usageData, 'total')).toBe('33');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prepend "~" if the usage data is an estimation', () => {
|
||||||
|
expect(formatTokenUsageCount({ ...usageData, isEstimate: true }, 'total')).toBe('~33');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE } from '@/constants';
|
||||||
|
import type { INodeUi, LlmTokenUsageData } from '@/Interface';
|
||||||
import type { IDataObject, INodeExecutionData, NodeConnectionType } from 'n8n-workflow';
|
import type { IDataObject, INodeExecutionData, NodeConnectionType } from 'n8n-workflow';
|
||||||
import { isObjectEmpty, NodeConnectionTypes } from 'n8n-workflow';
|
import { isObjectEmpty, NodeConnectionTypes } from 'n8n-workflow';
|
||||||
|
|
||||||
@@ -240,3 +242,40 @@ export function parseAiContent(
|
|||||||
.filter((c): c is IDataObject => c !== undefined)
|
.filter((c): c is IDataObject => c !== undefined)
|
||||||
.map((c) => ({ raw: c, parsedContent: parser(c) }));
|
.map((c) => ({ raw: c, parsedContent: parser(c) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const emptyTokenUsageData: LlmTokenUsageData = {
|
||||||
|
completionTokens: 0,
|
||||||
|
promptTokens: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
isEstimate: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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 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 function isChatNode(node: INodeUi) {
|
||||||
|
return [CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE].includes(node.type);
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ import { useNDVStore } from '@/stores/ndv.store';
|
|||||||
import { getBounds, getNodesWithNormalizedPosition, getNodeViewTab } from '@/utils/nodeViewUtils';
|
import { getBounds, getNodesWithNormalizedPosition, getNodeViewTab } from '@/utils/nodeViewUtils';
|
||||||
import CanvasStopCurrentExecutionButton from '@/components/canvas/elements/buttons/CanvasStopCurrentExecutionButton.vue';
|
import CanvasStopCurrentExecutionButton from '@/components/canvas/elements/buttons/CanvasStopCurrentExecutionButton.vue';
|
||||||
import CanvasStopWaitingForWebhookButton from '@/components/canvas/elements/buttons/CanvasStopWaitingForWebhookButton.vue';
|
import CanvasStopWaitingForWebhookButton from '@/components/canvas/elements/buttons/CanvasStopWaitingForWebhookButton.vue';
|
||||||
import CanvasClearExecutionDataButton from '@/components/canvas/elements/buttons/CanvasClearExecutionDataButton.vue';
|
|
||||||
import { nodeViewEventBus } from '@/event-bus';
|
import { nodeViewEventBus } from '@/event-bus';
|
||||||
import { tryToParseNumber } from '@/utils/typesUtils';
|
import { tryToParseNumber } from '@/utils/typesUtils';
|
||||||
import { useTemplatesStore } from '@/stores/templates.store';
|
import { useTemplatesStore } from '@/stores/templates.store';
|
||||||
@@ -124,7 +123,6 @@ import { createCanvasConnectionHandleString } from '@/utils/canvasUtils';
|
|||||||
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
import { isValidNodeConnectionType } from '@/utils/typeGuards';
|
||||||
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
|
||||||
import type { CanvasLayoutEvent } from '@/composables/useCanvasLayout';
|
import type { CanvasLayoutEvent } from '@/composables/useCanvasLayout';
|
||||||
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
|
|
||||||
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
|
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
|
||||||
import { useBuilderStore } from '@/stores/builder.store';
|
import { useBuilderStore } from '@/stores/builder.store';
|
||||||
import { useFoldersStore } from '@/stores/folders.store';
|
import { useFoldersStore } from '@/stores/folders.store';
|
||||||
@@ -134,6 +132,7 @@ import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore';
|
|||||||
import { needsAgentInput } from '@/utils/nodes/nodeTransforms';
|
import { needsAgentInput } from '@/utils/nodes/nodeTransforms';
|
||||||
import { useLogsStore } from '@/stores/logs.store';
|
import { useLogsStore } from '@/stores/logs.store';
|
||||||
import { canvasEventBus } from '@/event-bus/canvas';
|
import { canvasEventBus } from '@/event-bus/canvas';
|
||||||
|
import CanvasChatButton from '@/components/canvas/elements/buttons/CanvasChatButton.vue';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'NodeView',
|
name: 'NodeView',
|
||||||
@@ -1239,8 +1238,6 @@ const isStopWaitingForWebhookButtonVisible = computed(
|
|||||||
() => isWorkflowRunning.value && isExecutionWaitingForWebhook.value,
|
() => isWorkflowRunning.value && isExecutionWaitingForWebhook.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isClearExecutionButtonVisible = useClearExecutionButtonVisible();
|
|
||||||
|
|
||||||
async function onRunWorkflowToNode(id: string) {
|
async function onRunWorkflowToNode(id: string) {
|
||||||
const node = workflowsStore.getNodeById(id);
|
const node = workflowsStore.getNodeById(id);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
@@ -1369,11 +1366,6 @@ async function onStopWaitingForWebhook() {
|
|||||||
await stopWaitingForWebhook();
|
await stopWaitingForWebhook();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onClearExecutionData() {
|
|
||||||
workflowsStore.workflowExecutionData = null;
|
|
||||||
nodeHelpers.updateNodesExecutionIssues();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRunWorkflowButtonMouseEnter() {
|
function onRunWorkflowButtonMouseEnter() {
|
||||||
nodeViewEventBus.emit('runWorkflowButton:mouseenter');
|
nodeViewEventBus.emit('runWorkflowButton:mouseenter');
|
||||||
}
|
}
|
||||||
@@ -2062,10 +2054,6 @@ onBeforeUnmount(() => {
|
|||||||
v-if="isStopWaitingForWebhookButtonVisible"
|
v-if="isStopWaitingForWebhookButtonVisible"
|
||||||
@click="onStopWaitingForWebhook"
|
@click="onStopWaitingForWebhook"
|
||||||
/>
|
/>
|
||||||
<CanvasClearExecutionDataButton
|
|
||||||
v-if="isClearExecutionButtonVisible && !settingsStore.isNewLogsEnabled"
|
|
||||||
@click="onClearExecutionData"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<N8nCallout
|
<N8nCallout
|
||||||
|
|||||||
Reference in New Issue
Block a user