diff --git a/cypress/e2e/50-logs.cy.ts b/cypress/e2e/50-logs.cy.ts index 2489633d0f..5d20e834cb 100644 --- a/cypress/e2e/50-logs.cy.ts +++ b/cypress/e2e/50-logs.cy.ts @@ -9,10 +9,6 @@ import Workflow_loop from '../fixtures/Workflow_loop.json'; import Workflow_wait_for_webhook from '../fixtures/Workflow_wait_for_webhook.json'; describe('Logs', () => { - beforeEach(() => { - cy.overrideSettings({ logsView: { enabled: true } }); - }); - it('should populate logs as manual execution progresses', () => { workflow.navigateToNewWorkflowPage(); workflow.pasteWorkflow(Workflow_loop); diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index a3a87589af..3bd9400a5c 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -163,9 +163,6 @@ export interface FrontendSettings { folders: { enabled: boolean; }; - logsView: { - enabled: boolean; - }; banners: { dismissed: string[]; }; diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index a060ac55da..ae30e0655e 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -285,13 +285,4 @@ export const schema = { env: 'N8N_PROXY_HOPS', 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', - }, - }, }; diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 118203c4e9..65658e4488 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -253,9 +253,6 @@ export class FrontendService { dashboard: false, dateRanges: [], }, - logsView: { - enabled: false, - }, evaluation: { quota: this.licenseState.getMaxWorkflowsWithEvaluations(), }, @@ -396,8 +393,6 @@ export class FrontendService { this.settings.folders.enabled = this.license.isFoldersEnabled(); - this.settings.logsView.enabled = config.get('logs_view.enabled'); - // Refresh evaluation settings this.settings.evaluation.quota = this.licenseState.getMaxWorkflowsWithEvaluations(); diff --git a/packages/frontend/editor-ui/src/__tests__/defaults.ts b/packages/frontend/editor-ui/src/__tests__/defaults.ts index 41dd1d6b97..65749920fb 100644 --- a/packages/frontend/editor-ui/src/__tests__/defaults.ts +++ b/packages/frontend/editor-ui/src/__tests__/defaults.ts @@ -157,9 +157,6 @@ export const defaultSettings: FrontendSettings = { { key: 'year', licensed: false, granularity: 'week' }, ], }, - logsView: { - enabled: false, - }, evaluation: { quota: 0, }, diff --git a/packages/frontend/editor-ui/src/__tests__/mocks.ts b/packages/frontend/editor-ui/src/__tests__/mocks.ts index f8bf30328e..155352b3a5 100644 --- a/packages/frontend/editor-ui/src/__tests__/mocks.ts +++ b/packages/frontend/editor-ui/src/__tests__/mocks.ts @@ -30,7 +30,6 @@ import { import type { IExecutionResponse, INodeUi, IWorkflowDb } from '@/Interface'; import { CanvasNodeRenderType } from '@/types'; import type { FrontendSettings } from '@n8n/api-types'; -import { type LogEntry } from '@/components/RunDataAi/utils'; export const mockNode = ({ id = uuid(), @@ -255,24 +254,6 @@ export function createTestTaskData(partialData: Partial = {}): ITaskD }; } -export function createTestLogEntry(data: Partial = {}): 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( data: Partial = {}, ): IExecutionResponse { diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/CanvasChat.test.ts b/packages/frontend/editor-ui/src/components/CanvasChat/CanvasChat.test.ts deleted file mode 100644 index aa95ca8694..0000000000 --- a/packages/frontend/editor-ui/src/components/CanvasChat/CanvasChat.test.ts +++ /dev/null @@ -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>; - let logsStore: ReturnType>; - let nodeTypeStore: ReturnType>; - - 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'); - }); - }); -}); diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/CanvasChat.vue b/packages/frontend/editor-ui/src/components/CanvasChat/CanvasChat.vue deleted file mode 100644 index 802c8ab9a1..0000000000 --- a/packages/frontend/editor-ui/src/components/CanvasChat/CanvasChat.vue +++ /dev/null @@ -1,251 +0,0 @@ - - - - - diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/CanvasChatSwitch.vue b/packages/frontend/editor-ui/src/components/CanvasChat/CanvasChatSwitch.vue deleted file mode 100644 index 72a1b8da07..0000000000 --- a/packages/frontend/editor-ui/src/components/CanvasChat/CanvasChatSwitch.vue +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/components/ChatLogsPanel.vue b/packages/frontend/editor-ui/src/components/CanvasChat/components/ChatLogsPanel.vue deleted file mode 100644 index 83eecd238a..0000000000 --- a/packages/frontend/editor-ui/src/components/CanvasChat/components/ChatLogsPanel.vue +++ /dev/null @@ -1,89 +0,0 @@ - - - - - diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/composables/useChatTrigger.ts b/packages/frontend/editor-ui/src/components/CanvasChat/composables/useChatTrigger.ts deleted file mode 100644 index 33e20a8b74..0000000000 --- a/packages/frontend/editor-ui/src/components/CanvasChat/composables/useChatTrigger.ts +++ /dev/null @@ -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; -} - -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, - }; -} diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/composables/useResize.ts b/packages/frontend/editor-ui/src/components/CanvasChat/composables/useResize.ts deleted file mode 100644 index 744cf9697c..0000000000 --- a/packages/frontend/editor-ui/src/components/CanvasChat/composables/useResize.ts +++ /dev/null @@ -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) { - 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(() => ({ - '--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, - }; -} diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/types/chat.ts b/packages/frontend/editor-ui/src/components/CanvasChat/types/chat.ts deleted file mode 100644 index 6eb6e2c2ad..0000000000 --- a/packages/frontend/editor-ui/src/components/CanvasChat/types/chat.ts +++ /dev/null @@ -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; -} diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/types/logs.ts b/packages/frontend/editor-ui/src/components/CanvasChat/types/logs.ts deleted file mode 100644 index ec6dfc518e..0000000000 --- a/packages/frontend/editor-ui/src/components/CanvasChat/types/logs.ts +++ /dev/null @@ -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]; diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/utils.test.ts b/packages/frontend/editor-ui/src/components/CanvasChat/utils.test.ts deleted file mode 100644 index 8776611033..0000000000 --- a/packages/frontend/editor-ui/src/components/CanvasChat/utils.test.ts +++ /dev/null @@ -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' }, - ]); - }); -}); diff --git a/packages/frontend/editor-ui/src/components/CanvasChat/utils.ts b/packages/frontend/editor-ui/src/components/CanvasChat/utils.ts deleted file mode 100644 index 61ecca3925..0000000000 --- a/packages/frontend/editor-ui/src/components/CanvasChat/utils.ts +++ /dev/null @@ -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] : [])]; -} diff --git a/packages/frontend/editor-ui/src/components/ConsumedTokensDetails.vue b/packages/frontend/editor-ui/src/components/ConsumedTokensDetails.vue index d8ebda8acb..29a8844262 100644 --- a/packages/frontend/editor-ui/src/components/ConsumedTokensDetails.vue +++ b/packages/frontend/editor-ui/src/components/ConsumedTokensDetails.vue @@ -1,7 +1,7 @@ -
- {{ locale.baseText('chat.window.title') }} -
- {{ locale.baseText('chat.window.session.title') }} - - - {{ sessionId }} - - - -
-
-
+