From 5c58bf919fcc7a97c1cd8f123d1790c66c53e804 Mon Sep 17 00:00:00 2001 From: Suguru Inoue Date: Thu, 20 Nov 2025 14:11:14 +0100 Subject: [PATCH] feat: File attachment support in chat (no-changelog) (#21437) Co-authored-by: Jaakko Husso Co-authored-by: Alex Grozav --- packages/@n8n/api-types/src/chat-hub.ts | 16 + packages/@n8n/api-types/src/index.ts | 2 + ...3155024-AddAttachmentsToChatHubMessages.ts | 19 ++ .../@n8n/db/src/migrations/mysqldb/index.ts | 2 + .../db/src/migrations/postgresdb/index.ts | 2 + .../@n8n/db/src/migrations/sqlite/index.ts | 2 + .../src/repositories/execution.repository.ts | 9 +- .../utils/src/files/sanitize.test.ts} | 3 +- packages/@n8n/utils/src/files/sanitize.ts | 87 ++++++ packages/@n8n/utils/src/index.ts | 1 + packages/cli/package.json | 1 + packages/cli/src/auth/auth.service.ts | 3 + .../__tests__/execution.repository.test.ts | 4 +- .../chat-hub.service.integration.test.ts | 5 +- .../chat-hub/chat-hub-agent.service.ts | 1 + .../chat-hub/chat-hub-message.entity.ts | 10 + .../chat-hub/chat-hub-workflow.service.ts | 92 ++++-- .../chat-hub/chat-hub.attachment.service.ts | 160 ++++++++++ .../modules/chat-hub/chat-hub.constants.ts | 1 + .../modules/chat-hub/chat-hub.controller.ts | 47 +++ .../src/modules/chat-hub/chat-hub.service.ts | 260 ++++++++++------- .../src/modules/chat-hub/chat-hub.types.ts | 30 +- .../chat-hub/chat-message.repository.ts | 3 +- .../cli/src/workflows/workflow.service.ts | 6 +- .../test/integration/shared/utils/index.ts | 4 +- .../__tests__/binary-data-service.test.ts | 5 +- .../__tests__/file-system.manager.test.ts | 31 +- .../__tests__/object-store.manager.test.ts | 14 +- .../src/binary-data/binary-data.service.ts | 59 ++-- .../src/binary-data/file-system.manager.ts | 87 ++++-- packages/core/src/binary-data/index.ts | 2 +- .../src/binary-data/object-store.manager.ts | 27 +- packages/core/src/binary-data/types.ts | 15 +- packages/core/src/binary-data/utils.ts | 13 + .../__tests__/shared-tests.ts | 3 +- .../base-execute-context.ts | 4 +- .../__tests__/binary-helper-functions.test.ts | 30 +- .../utils/binary-helper-functions.ts | 6 +- .../@n8n/chat/src/components/ChatFile.vue | 19 +- .../components/MainHeader/WorkflowDetails.vue | 2 +- .../editor-ui/src/app/utils/fileUtils.ts | 125 +++----- .../src/features/ai/chatHub/ChatView.vue | 54 +++- .../src/features/ai/chatHub/chat.api.ts | 9 + .../src/features/ai/chatHub/chat.store.ts | 18 +- .../src/features/ai/chatHub/chat.utils.ts | 1 + .../ai/chatHub/components/ChatMessage.vue | 36 ++- .../ai/chatHub/components/ChatPrompt.vue | 274 +++++++++++++----- .../ai/chatHub/components/ModelSelector.vue | 1 + .../ai/chatHub/composables/useFileDrop.ts | 97 +++++++ .../logs/composables/useChatMessaging.ts | 26 +- pnpm-lock.yaml | 3 + 51 files changed, 1290 insertions(+), 441 deletions(-) create mode 100644 packages/@n8n/db/src/migrations/common/1761773155024-AddAttachmentsToChatHubMessages.ts rename packages/{frontend/editor-ui/src/app/utils/fileUtils.test.ts => @n8n/utils/src/files/sanitize.test.ts} (98%) create mode 100644 packages/@n8n/utils/src/files/sanitize.ts create mode 100644 packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts create mode 100644 packages/frontend/editor-ui/src/features/ai/chatHub/composables/useFileDrop.ts diff --git a/packages/@n8n/api-types/src/chat-hub.ts b/packages/@n8n/api-types/src/chat-hub.ts index 85eae52f065..c7cb645db9f 100644 --- a/packages/@n8n/api-types/src/chat-hub.ts +++ b/packages/@n8n/api-types/src/chat-hub.ts @@ -134,6 +134,7 @@ export interface ChatModelDto { description: string | null; updatedAt: string | null; createdAt: string | null; + allowFileUploads?: boolean; } /** @@ -159,6 +160,18 @@ export const emptyChatModelsResponse: ChatModelsResponse = { 'custom-agent': { models: [] }, }; +/** + * Chat attachment schema for incoming requests. + * Requires base64 data and fileName. + * MimeType, fileType, fileExtension, and fileSize are populated server-side. + */ +export const chatAttachmentSchema = z.object({ + data: z.string(), + fileName: z.string(), +}); + +export type ChatAttachment = z.infer; + export class ChatHubSendMessageRequest extends Z.class({ messageId: z.string().uuid(), sessionId: z.string().uuid(), @@ -172,6 +185,7 @@ export class ChatHubSendMessageRequest extends Z.class({ }), ), tools: z.array(INodeSchema), + attachments: z.array(chatAttachmentSchema), }) {} export class ChatHubRegenerateMessageRequest extends Z.class({ @@ -246,6 +260,8 @@ export interface ChatHubMessageDto { previousMessageId: ChatMessageId | null; retryOfMessageId: ChatMessageId | null; revisionOfMessageId: ChatMessageId | null; + + attachments: Array<{ fileName?: string; mimeType?: string }>; } export type ChatHubConversationsResponse = ChatHubSessionDto[]; diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 75d8a3079fa..22f31ddc50a 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -27,6 +27,8 @@ export { emptyChatModelsResponse, type ChatModelsRequest, type ChatModelsResponse, + chatAttachmentSchema, + type ChatAttachment, ChatHubSendMessageRequest, ChatHubRegenerateMessageRequest, ChatHubEditMessageRequest, diff --git a/packages/@n8n/db/src/migrations/common/1761773155024-AddAttachmentsToChatHubMessages.ts b/packages/@n8n/db/src/migrations/common/1761773155024-AddAttachmentsToChatHubMessages.ts new file mode 100644 index 00000000000..732f569d31b --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1761773155024-AddAttachmentsToChatHubMessages.ts @@ -0,0 +1,19 @@ +import type { MigrationContext, ReversibleMigration } from '../migration-types'; + +const table = { + messages: 'chat_hub_messages', +} as const; + +export class AddAttachmentsToChatHubMessages1761773155024 implements ReversibleMigration { + async up({ schemaBuilder: { addColumns, column } }: MigrationContext) { + await addColumns(table.messages, [ + column('attachments').json.comment( + 'File attachments for the message (if any), stored as JSON. Files are stored as base64-encoded data URLs.', + ), + ]); + } + + async down({ schemaBuilder: { dropColumns } }: MigrationContext) { + await dropColumns(table.messages, ['attachments']); + } +} diff --git a/packages/@n8n/db/src/migrations/mysqldb/index.ts b/packages/@n8n/db/src/migrations/mysqldb/index.ts index 31ce8bf32ef..b93ba09ecf3 100644 --- a/packages/@n8n/db/src/migrations/mysqldb/index.ts +++ b/packages/@n8n/db/src/migrations/mysqldb/index.ts @@ -111,6 +111,7 @@ import { CreateChatHubAgentTable1760020000000 } from '../common/1760020000000-Cr import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRoleNames'; import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities'; import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable'; +import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/1761773155024-AddAttachmentsToChatHubMessages'; import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar'; @@ -233,4 +234,5 @@ export const mysqlMigrations: Migration[] = [ AddWorkflowHistoryAutoSaveFields1762847206508, AddToolsColumnToChatHubTables1761830340990, ChangeOAuthStateColumnToUnboundedVarchar1763572724000, + AddAttachmentsToChatHubMessages1761773155024, ]; diff --git a/packages/@n8n/db/src/migrations/postgresdb/index.ts b/packages/@n8n/db/src/migrations/postgresdb/index.ts index e6be75d5df5..234ca067ccc 100644 --- a/packages/@n8n/db/src/migrations/postgresdb/index.ts +++ b/packages/@n8n/db/src/migrations/postgresdb/index.ts @@ -109,6 +109,7 @@ import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRole import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities'; import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable'; import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns'; +import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/1761773155024-AddAttachmentsToChatHubMessages'; import { AddToolsColumnToChatHubTables1761830340990 } from '../common/1761830340990-AddToolsColumnToChatHubTables'; import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; @@ -233,4 +234,5 @@ export const postgresMigrations: Migration[] = [ AddWorkflowHistoryAutoSaveFields1762847206508, AddToolsColumnToChatHubTables1761830340990, ChangeOAuthStateColumnToUnboundedVarchar1763572724000, + AddAttachmentsToChatHubMessages1761773155024, ]; diff --git a/packages/@n8n/db/src/migrations/sqlite/index.ts b/packages/@n8n/db/src/migrations/sqlite/index.ts index bf9dffa0f9c..55ea03ee6be 100644 --- a/packages/@n8n/db/src/migrations/sqlite/index.ts +++ b/packages/@n8n/db/src/migrations/sqlite/index.ts @@ -105,6 +105,7 @@ import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRole import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities'; import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable'; import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns'; +import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/1761773155024-AddAttachmentsToChatHubMessages'; import { AddToolsColumnToChatHubTables1761830340990 } from '../common/1761830340990-AddToolsColumnToChatHubTables'; import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn'; import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords'; @@ -225,6 +226,7 @@ const sqliteMigrations: Migration[] = [ AddWorkflowHistoryAutoSaveFields1762847206508, AddToolsColumnToChatHubTables1761830340990, ChangeOAuthStateColumnToUnboundedVarchar1763572724000, + AddAttachmentsToChatHubMessages1761773155024, ]; export { sqliteMigrations }; diff --git a/packages/@n8n/db/src/repositories/execution.repository.ts b/packages/@n8n/db/src/repositories/execution.repository.ts index 7a9b181560c..df16215e7ba 100644 --- a/packages/@n8n/db/src/repositories/execution.repository.ts +++ b/packages/@n8n/db/src/repositories/execution.repository.ts @@ -408,7 +408,7 @@ export class ExecutionRepository extends Repository { async hardDelete(ids: { workflowId: string; executionId: string }) { return await Promise.all([ this.delete(ids.executionId), - this.binaryDataService.deleteMany([ids]), + this.binaryDataService.deleteMany([{ type: 'execution', ...ids }]), ]); } @@ -509,6 +509,7 @@ export class ExecutionRepository extends Repository { } const ids = executions.map(({ id, workflowId }) => ({ + type: 'execution' as const, executionId: id, workflowId, })); @@ -605,7 +606,11 @@ export class ExecutionRepository extends Repository { */ withDeleted: true, }) - ).map(({ id: executionId, workflowId }) => ({ workflowId, executionId })); + ).map(({ id: executionId, workflowId }) => ({ + type: 'execution' as const, + workflowId, + executionId, + })); return workflowIdsAndExecutionIds; } diff --git a/packages/frontend/editor-ui/src/app/utils/fileUtils.test.ts b/packages/@n8n/utils/src/files/sanitize.test.ts similarity index 98% rename from packages/frontend/editor-ui/src/app/utils/fileUtils.test.ts rename to packages/@n8n/utils/src/files/sanitize.test.ts index 52bef112379..dd7ac3f26cc 100644 --- a/packages/frontend/editor-ui/src/app/utils/fileUtils.test.ts +++ b/packages/@n8n/utils/src/files/sanitize.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { sanitizeFilename } from './fileUtils'; + +import { sanitizeFilename } from './sanitize'; describe('sanitizeFilename', () => { it('should return normal filenames unchanged', () => { diff --git a/packages/@n8n/utils/src/files/sanitize.ts b/packages/@n8n/utils/src/files/sanitize.ts new file mode 100644 index 00000000000..29aae08960e --- /dev/null +++ b/packages/@n8n/utils/src/files/sanitize.ts @@ -0,0 +1,87 @@ +// Constants definition +/* eslint-disable no-control-regex */ +const INVALID_CHARS_REGEX = /[<>:"/\\|?*\u0000-\u001F\u007F-\u009F]/g; +const ZERO_WIDTH_CHARS_REGEX = /[\u200B-\u200D\u2060\uFEFF]/g; +const UNICODE_SPACES_REGEX = /[\u00A0\u2000-\u200A]/g; +const LEADING_TRAILING_DOTS_SPACES_REGEX = /^[\s.]+|[\s.]+$/g; +/* eslint-enable no-control-regex */ + +const WINDOWS_RESERVED_NAMES = new Set([ + 'CON', + 'PRN', + 'AUX', + 'NUL', + 'COM1', + 'COM2', + 'COM3', + 'COM4', + 'COM5', + 'COM6', + 'COM7', + 'COM8', + 'COM9', + 'LPT1', + 'LPT2', + 'LPT3', + 'LPT4', + 'LPT5', + 'LPT6', + 'LPT7', + 'LPT8', + 'LPT9', +]); + +const DEFAULT_FALLBACK_NAME = 'untitled'; +const MAX_FILENAME_LENGTH = 200; + +/** + * Sanitizes a filename to be compatible with Mac, Linux, and Windows file systems + * + * Main features: + * - Replace invalid characters (e.g. ":" in hello:world) + * - Handle Windows reserved names + * - Limit filename length + * - Normalize Unicode characters + * + * @param filename - The filename to sanitize (without extension) + * @param maxLength - Maximum filename length (default: 200) + * @returns A sanitized filename (without extension) + * + * @example + * sanitizeFilename('hello:world') // returns 'hello_world' + * sanitizeFilename('CON') // returns '_CON' + * sanitizeFilename('') // returns 'untitled' + */ +export const sanitizeFilename = ( + filename: string, + maxLength: number = MAX_FILENAME_LENGTH, +): string => { + // Input validation + if (!filename) { + return DEFAULT_FALLBACK_NAME; + } + + let baseName = filename + .trim() + .replace(INVALID_CHARS_REGEX, '_') + .replace(ZERO_WIDTH_CHARS_REGEX, '') + .replace(UNICODE_SPACES_REGEX, ' ') + .replace(LEADING_TRAILING_DOTS_SPACES_REGEX, ''); + + // Handle empty or invalid filenames after cleaning + if (!baseName) { + baseName = DEFAULT_FALLBACK_NAME; + } + + // Handle Windows reserved names + if (WINDOWS_RESERVED_NAMES.has(baseName.toUpperCase())) { + baseName = `_${baseName}`; + } + + // Truncate if too long + if (baseName.length > maxLength) { + baseName = baseName.slice(0, maxLength); + } + + return baseName; +}; diff --git a/packages/@n8n/utils/src/index.ts b/packages/@n8n/utils/src/index.ts index 395a5ff5fd2..e4aaf36e01f 100644 --- a/packages/@n8n/utils/src/index.ts +++ b/packages/@n8n/utils/src/index.ts @@ -7,3 +7,4 @@ export * from './search/reRankSearchResults'; export * from './search/sublimeSearch'; export * from './sort/sortByProperty'; export * from './string/truncate'; +export * from './files/sanitize'; diff --git a/packages/cli/package.json b/packages/cli/package.json index b8da4cc67b9..2705fd255e5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -110,6 +110,7 @@ "@n8n/permissions": "workspace:*", "@n8n/task-runner": "workspace:*", "@n8n/typeorm": "catalog:", + "@n8n/utils": "workspace:*", "@n8n_io/ai-assistant-sdk": "catalog:", "@n8n_io/license-sdk": "2.24.1", "@rudderstack/rudder-sdk-node": "2.1.4", diff --git a/packages/cli/src/auth/auth.service.ts b/packages/cli/src/auth/auth.service.ts index 77457a096ec..ee139c40167 100644 --- a/packages/cli/src/auth/auth.service.ts +++ b/packages/cli/src/auth/auth.service.ts @@ -87,6 +87,9 @@ export class AuthService { '/types/nodes.json', '/types/credentials.json', '/mcp-oauth/authorize/', + + // Skip browser ID check for chat hub attachments + `/${restEndpoint}/chat/conversations/:sessionId/messages/:messageId/attachments/:index`, ]; } diff --git a/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts b/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts index ebd9c26c7c0..1ae237632f0 100644 --- a/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts +++ b/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts @@ -68,7 +68,9 @@ describe('ExecutionRepository', () => { await executionRepository.deleteExecutionsByFilter({ id: '1' }, ['1'], { ids: ['1'] }); - expect(binaryDataService.deleteMany).toHaveBeenCalledWith([{ executionId: '1', workflowId }]); + expect(binaryDataService.deleteMany).toHaveBeenCalledWith([ + { type: 'execution', executionId: '1', workflowId }, + ]); }); }); diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts index 59099868312..8b9d3d47491 100644 --- a/packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts @@ -1,12 +1,15 @@ -import { testDb, testModules } from '@n8n/backend-test-utils'; +import { mockInstance, testDb, testModules } from '@n8n/backend-test-utils'; import type { User } from '@n8n/db'; import { Container } from '@n8n/di'; +import { BinaryDataService } from 'n8n-core'; import { createAdmin, createMember } from '@test-integration/db/users'; import { ChatHubService } from '../chat-hub.service'; import { ChatHubMessageRepository } from '../chat-message.repository'; import { ChatHubSessionRepository } from '../chat-session.repository'; +mockInstance(BinaryDataService); + beforeAll(async () => { await testModules.loadModules(['chat-hub']); await testDb.init(); diff --git a/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts index ddcfcfecf6b..00e0f7c0c89 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-agent.service.ts @@ -32,6 +32,7 @@ export class ChatHubAgentService { }, createdAt: agent.createdAt.toISOString(), updatedAt: agent.updatedAt.toISOString(), + allowFileUploads: true, })), }; } diff --git a/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts b/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts index e66830d6988..b433c937f72 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-message.entity.ts @@ -11,6 +11,7 @@ import { } from '@n8n/typeorm'; import type { ChatHubSession } from './chat-hub-session.entity'; +import type { IBinaryData } from 'n8n-workflow'; @Entity({ name: 'chat_hub_messages' }) export class ChatHubMessage extends WithTimestamps { @@ -167,4 +168,13 @@ export class ChatHubMessage extends WithTimestamps { */ @Column({ type: 'varchar', length: 16, default: 'success' }) status: ChatHubMessageStatus; + + /** + * File attachments for the message (if any), stored as JSON. + * Storage strategy depends on the binary data mode configuration: + * - When using external storage (e.g., filesystem-v2): Only metadata is stored, with 'id' referencing the external location + * - When using default mode: Base64-encoded data is stored directly in the 'data' field + */ + @Column({ type: 'json', nullable: true }) + attachments: Array | null; } diff --git a/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts b/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts index feffc3b60a8..48b43e4774d 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub-workflow.service.ts @@ -21,8 +21,10 @@ import { IWorkflowBase, MEMORY_BUFFER_WINDOW_NODE_TYPE, MEMORY_MANAGER_NODE_TYPE, + MERGE_NODE_TYPE, NodeConnectionTypes, OperationalError, + type IBinaryData, } from 'n8n-workflow'; import { v4 as uuidv4 } from 'uuid'; @@ -49,6 +51,7 @@ export class ChatHubWorkflowService { projectId: string, history: ChatHubMessage[], humanMessage: string, + attachments: IBinaryData[], credentials: INodeCredentials, model: ChatHubConversationModel, systemMessage: string | undefined, @@ -65,6 +68,7 @@ export class ChatHubWorkflowService { sessionId, history, humanMessage, + attachments, credentials, model, systemMessage, @@ -72,6 +76,7 @@ export class ChatHubWorkflowService { }); const newWorkflow = new WorkflowEntity(); + newWorkflow.versionId = uuidv4(); newWorkflow.name = `Chat ${sessionId}`; newWorkflow.active = false; @@ -147,6 +152,38 @@ export class ChatHubWorkflowService { }); } + prepareExecutionData( + triggerNode: INode, + sessionId: string, + message: string, + attachments: IBinaryData[], + ): IExecuteData[] { + // Attachments are already processed (id field populated) by the caller + return [ + { + node: triggerNode, + data: { + main: [ + [ + { + json: { + sessionId, + action: 'sendMessage', + chatInput: message, + files: attachments.map(({ data, ...metadata }) => metadata), + }, + binary: Object.fromEntries( + attachments.map((attachment, index) => [`data${index}`, attachment]), + ), + }, + ], + ], + }, + source: null, + }, + ]; + } + private getUniqueNodeName(originalName: string, existingNames: Set): string { if (!existingNames.has(originalName)) { return originalName; @@ -168,6 +205,7 @@ export class ChatHubWorkflowService { sessionId, history, humanMessage, + attachments, credentials, model, systemMessage, @@ -177,6 +215,7 @@ export class ChatHubWorkflowService { sessionId: ChatSessionId; history: ChatHubMessage[]; humanMessage: string; + attachments: IBinaryData[]; credentials: INodeCredentials; model: ChatHubConversationModel; systemMessage?: string; @@ -188,6 +227,7 @@ export class ChatHubWorkflowService { const memoryNode = this.buildMemoryNode(20); const restoreMemoryNode = this.buildRestoreMemoryNode(history); const clearMemoryNode = this.buildClearMemoryNode(); + const mergeNode = this.buildMergeNode(); const nodes: INode[] = [ chatTriggerNode, @@ -196,6 +236,7 @@ export class ChatHubWorkflowService { memoryNode, restoreMemoryNode, clearMemoryNode, + mergeNode, ]; const nodeNames = new Set(nodes.map((node) => node.name)); @@ -221,10 +262,18 @@ export class ChatHubWorkflowService { const connections: IConnections = { [NODE_NAMES.CHAT_TRIGGER]: { [NodeConnectionTypes.Main]: [ - [{ node: NODE_NAMES.RESTORE_CHAT_MEMORY, type: NodeConnectionTypes.Main, index: 0 }], + [ + { node: NODE_NAMES.RESTORE_CHAT_MEMORY, type: NodeConnectionTypes.Main, index: 0 }, + { node: NODE_NAMES.MERGE, type: NodeConnectionTypes.Main, index: 0 }, + ], ], }, [NODE_NAMES.RESTORE_CHAT_MEMORY]: { + [NodeConnectionTypes.Main]: [ + [{ node: NODE_NAMES.MERGE, type: NodeConnectionTypes.Main, index: 1 }], + ], + }, + [NODE_NAMES.MERGE]: { [NodeConnectionTypes.Main]: [ [{ node: NODE_NAMES.REPLY_AGENT, type: NodeConnectionTypes.Main, index: 0 }], ], @@ -271,25 +320,12 @@ export class ChatHubWorkflowService { }, {}), }; - const nodeExecutionStack: IExecuteData[] = [ - { - node: chatTriggerNode, - data: { - main: [ - [ - { - json: { - sessionId, - action: 'sendMessage', - chatInput: humanMessage, - }, - }, - ], - ], - }, - source: null, - }, - ]; + const nodeExecutionStack = this.prepareExecutionData( + chatTriggerNode, + sessionId, + humanMessage, + attachments, + ); const executionData = createRunExecutionData({ executionData: { @@ -541,6 +577,22 @@ export class ChatHubWorkflowService { }; } + private buildMergeNode(): INode { + return { + parameters: { + mode: 'combine', + fieldsToMatchString: 'chatInput', + joinMode: 'enrichInput1', + options: {}, + }, + type: MERGE_NODE_TYPE, + typeVersion: 3.2, + position: [224, -100], + id: uuidv4(), + name: NODE_NAMES.MERGE, + }; + } + private buildTitleGeneratorAgentNode(): INode { return { parameters: { diff --git a/packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts new file mode 100644 index 00000000000..9a4d724786e --- /dev/null +++ b/packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts @@ -0,0 +1,160 @@ +import { Service } from '@n8n/di'; +import { BINARY_ENCODING, type IBinaryData } from 'n8n-workflow'; +import { sanitizeFilename } from '@n8n/utils'; +import { BinaryDataService, FileLocation } from 'n8n-core'; +import { Not, IsNull } from '@n8n/typeorm'; +import { ChatHubMessageRepository } from './chat-message.repository'; +import type { ChatMessageId, ChatSessionId, ChatAttachment } from '@n8n/api-types'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import type Stream from 'node:stream'; +import FileType from 'file-type'; + +@Service() +export class ChatHubAttachmentService { + private readonly maxTotalSizeBytes = 200 * 1024 * 1024; // 200 MB + + constructor( + private readonly binaryDataService: BinaryDataService, + private readonly messageRepository: ChatHubMessageRepository, + ) {} + + /** + * Stores attachments through BinaryDataService. + * This populates the 'id' and other metadata for attachments. When external storage is used, + * BinaryDataService replaces base64 data with the storage mode string (e.g., "filesystem-v2"). + */ + async store( + sessionId: ChatSessionId, + messageId: ChatMessageId, + attachments: ChatAttachment[], + ): Promise { + let totalSize = 0; + const storedAttachments: IBinaryData[] = []; + + for (const attachment of attachments) { + const buffer = Buffer.from(attachment.data, BINARY_ENCODING); + totalSize += buffer.length; + + if (totalSize > this.maxTotalSizeBytes) { + const maxSizeMB = Math.floor(this.maxTotalSizeBytes / (1024 * 1024)); + + throw new BadRequestError( + `Total size of attachments exceeds maximum size of ${maxSizeMB} MB`, + ); + } + + const stored = await this.processAttachment(sessionId, messageId, attachment, buffer); + storedAttachments.push(stored); + } + + return storedAttachments; + } + + /* + * Gets a specific attachment from a message by index and returns it as either buffer or stream + */ + async getAttachment( + sessionId: ChatSessionId, + messageId: ChatMessageId, + attachmentIndex: number, + ): Promise< + [ + IBinaryData, + ( + | { type: 'buffer'; buffer: Buffer; fileSize: number } + | { type: 'stream'; stream: Stream.Readable; fileSize: number } + ), + ] + > { + const message = await this.messageRepository.getOneById(messageId, sessionId, []); + + if (!message) { + throw new NotFoundError('Message not found'); + } + + const attachment = message.attachments?.[attachmentIndex]; + + if (!attachment) { + throw new NotFoundError('Attachment not found'); + } + + if (attachment.id) { + const metadata = await this.binaryDataService.getMetadata(attachment.id); + const stream = await this.binaryDataService.getAsStream(attachment.id); + + return [attachment, { type: 'stream', stream, fileSize: metadata.fileSize }]; + } + + if (attachment.data) { + const buffer = await this.binaryDataService.getAsBuffer(attachment); + + return [attachment, { type: 'buffer', buffer, fileSize: buffer.length }]; + } + + throw new NotFoundError('Attachment has no stored file'); + } + + /** + * Deletes all files attached to messages in the session + */ + async deleteAllBySessionId(sessionId: string): Promise { + const messages = await this.messageRepository.getManyBySessionId(sessionId); + + await this.deleteAttachments(messages.flatMap((message) => message.attachments ?? [])); + } + + /** + * Deletes all chat attachment files. + */ + async deleteAll(): Promise { + const messages = await this.messageRepository.find({ + where: { + attachments: Not(IsNull()), + }, + select: ['attachments'], + }); + + await this.deleteAttachments(messages.flatMap((message) => message.attachments ?? [])); + } + + /** + * Deletes attachments by their binary data directly (used for rollback when message wasn't saved) + */ + async deleteAttachments(attachments: IBinaryData[]): Promise { + await this.binaryDataService.deleteManyByBinaryDataId( + attachments.flatMap((attachment) => (attachment.id ? [attachment.id] : [])), + ); + } + + /** + * Processes a single attachment by populating metadata and storing it. + */ + private async processAttachment( + sessionId: ChatSessionId, + messageId: ChatMessageId, + attachment: ChatAttachment, + buffer: Buffer, + ): Promise { + const sanitizedFileName = sanitizeFilename(attachment.fileName); + const fileTypeData = await FileType.fromBuffer(buffer); + + // Only trust content-based detection for security + const mimeType = fileTypeData?.mime ?? 'application/octet-stream'; + + // Construct IBinaryData with all required fields + const binaryData: IBinaryData = { + data: attachment.data, + mimeType, + fileName: sanitizedFileName, + fileSize: `${buffer.length}`, + fileExtension: fileTypeData?.ext, + }; + + return await this.binaryDataService.store( + FileLocation.ofChatHubMessageAttachment(sessionId, messageId), + buffer, + binaryData, + ); + } +} diff --git a/packages/cli/src/modules/chat-hub/chat-hub.constants.ts b/packages/cli/src/modules/chat-hub/chat-hub.constants.ts index 33d02af6611..f33cdb712c0 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.constants.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.constants.ts @@ -46,6 +46,7 @@ export const NODE_NAMES = { MEMORY: 'Memory', RESTORE_CHAT_MEMORY: 'Restore Chat Memory', CLEAR_CHAT_MEMORY: 'Clear Chat Memory', + MERGE: 'Merge', } as const; /* eslint-disable @typescript-eslint/naming-convention */ diff --git a/packages/cli/src/modules/chat-hub/chat-hub.controller.ts b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts index bfd44b46f52..5037c96fd85 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.controller.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.controller.ts @@ -27,8 +27,11 @@ import type { Response } from 'express'; import { strict as assert } from 'node:assert'; import { ChatHubAgentService } from './chat-hub-agent.service'; +import { ChatHubAttachmentService } from './chat-hub.attachment.service'; import { ChatHubService } from './chat-hub.service'; import { ChatModelsRequestDto } from './dto/chat-models-request.dto'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { sanitizeFilename } from '@n8n/utils'; import { ResponseError } from '@/errors/response-errors/abstract/response.error'; @@ -37,6 +40,7 @@ export class ChatHubController { constructor( private readonly chatService: ChatHubService, private readonly chatAgentService: ChatHubAgentService, + private readonly chatAttachmentService: ChatHubAttachmentService, private readonly logger: Logger, ) {} @@ -69,6 +73,49 @@ export class ChatHubController { return await this.chatService.getConversation(req.user.id, sessionId); } + @Get('/conversations/:sessionId/messages/:messageId/attachments/:index') + @GlobalScope('chatHub:message') + async getMessageAttachment( + req: AuthenticatedRequest, + res: Response, + @Param('sessionId') sessionId: ChatSessionId, + @Param('messageId') messageId: ChatMessageId, + @Param('index') index: string, + ) { + const attachmentIndex = Number.parseInt(index, 10); + + if (isNaN(attachmentIndex)) { + throw new BadRequestError('Invalid attachment index'); + } + + // Verify user has access to this session + await this.chatService.getConversation(req.user.id, sessionId); + + const [{ mimeType, fileName }, attachmentAsStreamOrBuffer] = + await this.chatAttachmentService.getAttachment(sessionId, messageId, attachmentIndex); + + res.setHeader('Content-Type', mimeType ?? 'application/octet-stream'); + + if (attachmentAsStreamOrBuffer.fileSize) { + res.setHeader('Content-Length', attachmentAsStreamOrBuffer.fileSize); + } + + if (fileName) { + res.setHeader('Content-Disposition', `attachment; filename="${sanitizeFilename(fileName)}"`); + } + + if (attachmentAsStreamOrBuffer.type === 'buffer') { + res.send(attachmentAsStreamOrBuffer.buffer); + return; + } + + return await new Promise((resolve, reject) => { + attachmentAsStreamOrBuffer.stream.on('end', resolve); + attachmentAsStreamOrBuffer.stream.on('error', reject); + attachmentAsStreamOrBuffer.stream.pipe(res); + }); + } + @GlobalScope('chatHub:message') @Post('/conversations/send') async sendMessage( diff --git a/packages/cli/src/modules/chat-hub/chat-hub.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.service.ts index 5222c8f9511..002299b1dca 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.service.ts @@ -33,10 +33,10 @@ import { jsonParse, StructuredChunk, RESPOND_TO_CHAT_NODE_TYPE, - IExecuteData, IRunExecutionData, INodeParameters, INode, + type IBinaryData, createRunExecutionData, } from 'n8n-workflow'; @@ -45,10 +45,11 @@ import { ChatHubCredentialsService, CredentialWithProjectId } from './chat-hub-c import type { ChatHubMessage } from './chat-hub-message.entity'; import { ChatHubWorkflowService } from './chat-hub-workflow.service'; import { JSONL_STREAM_HEADERS, NODE_NAMES, PROVIDER_NODE_TYPE_MAP } from './chat-hub.constants'; -import type { +import { HumanMessagePayload, RegenerateMessagePayload, EditMessagePayload, + validChatTriggerParamsShape, } from './chat-hub.types'; import { ChatHubMessageRepository } from './chat-message.repository'; import { ChatHubSessionRepository } from './chat-session.repository'; @@ -64,6 +65,7 @@ import { getBase } from '@/workflow-execute-additional-data'; import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; import { WorkflowService } from '@/workflows/workflow.service'; +import { ChatHubAttachmentService } from './chat-hub.attachment.service'; @Service() export class ChatHubService { @@ -83,6 +85,7 @@ export class ChatHubService { private readonly chatHubAgentService: ChatHubAgentService, private readonly chatHubCredentialsService: ChatHubCredentialsService, private readonly chatHubWorkflowService: ChatHubWorkflowService, + private readonly chatHubAttachmentService: ChatHubAttachmentService, ) {} async getModels( @@ -191,6 +194,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, + allowFileUploads: true, })), }; } @@ -218,6 +222,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, + allowFileUploads: true, })), }; } @@ -283,6 +288,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, + allowFileUploads: true, })), }; } @@ -341,6 +347,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, + allowFileUploads: true, })), }; } @@ -458,6 +465,7 @@ export class ChatHubService { }, createdAt: null, updatedAt: null, + allowFileUploads: true, })), }; } @@ -481,30 +489,25 @@ export class ChatHubService { return []; } - if (chatTrigger.parameters.availableInChat !== true) { + const chatTriggerParams = validChatTriggerParamsShape.safeParse( + chatTrigger.parameters, + ).data; + + if (!chatTriggerParams) { return []; } - const name = - typeof chatTrigger.parameters.agentName === 'string' && - chatTrigger.parameters.agentName.length > 0 - ? chatTrigger.parameters.agentName - : workflow.name; - return [ { - name: name ?? 'Unknown Agent', - description: - typeof chatTrigger.parameters.agentDescription === 'string' && - chatTrigger.parameters.agentDescription.length > 0 - ? chatTrigger.parameters.agentDescription - : null, + name: chatTriggerParams.agentName ?? workflow.name ?? 'Unknown Agent', + description: chatTriggerParams.agentDescription ?? null, model: { provider: 'n8n', workflowId: workflow.id, }, createdAt: workflow.createdAt ? workflow.createdAt.toISOString() : null, updatedAt: workflow.updatedAt ? workflow.updatedAt.toISOString() : null, + allowFileUploads: chatTriggerParams.options?.allowFileUploads ?? false, }, ]; }), @@ -556,12 +559,31 @@ export class ChatHubService { } async sendHumanMessage(res: Response, user: User, payload: HumanMessagePayload) { - const { sessionId, messageId, message, model, credentials, previousMessageId, tools } = payload; + const { + sessionId, + messageId, + message, + model, + credentials, + previousMessageId, + tools, + attachments, + } = payload; const credentialId = this.getModelCredential(model, credentials); - const { executionData, workflowData } = await this.messageRepository.manager.transaction( - async (trx) => { + // Store attachments early to populate 'id' field via BinaryDataService + const processedAttachments = await this.chatHubAttachmentService.store( + sessionId, + messageId, + attachments, + ); + + let executionData: IRunExecutionData; + let workflowData: IWorkflowBase; + + try { + const result = await this.messageRepository.manager.transaction(async (trx) => { let session = await this.getChatSession(user, sessionId, trx); session ??= await this.createChatSession(user, sessionId, model, credentialId, tools, trx); @@ -569,36 +591,36 @@ export class ChatHubService { const messages = Object.fromEntries((session.messages ?? []).map((m) => [m.id, m])); const history = this.buildMessageHistory(messages, previousMessageId); - await this.saveHumanMessage(payload, user, previousMessageId, model, undefined, trx); + await this.saveHumanMessage( + payload, + processedAttachments, + user, + previousMessageId, + model, + undefined, + trx, + ); - if (model.provider === 'n8n') { - return await this.prepareCustomAgentWorkflow(user, sessionId, model.workflowId, message); - } - - if (model.provider === 'custom-agent') { - return await this.prepareChatAgentWorkflow( - model.agentId, - user, - sessionId, - history, - message, - trx, - ); - } - - return await this.prepareBaseChatWorkflow( + return await this.prepareReplyWorkflow( user, sessionId, credentials, model, history, message, - undefined, - session.tools, + tools, + processedAttachments, trx, ); - }, - ); + }); + + executionData = result.executionData; + workflowData = result.workflowData; + } catch (error) { + // Rollback stored attachments if transaction fails + await this.chatHubAttachmentService.deleteAttachments(processedAttachments); + throw error; + } await this.executeChatWorkflowWithCleanup( res, @@ -632,10 +654,6 @@ export class ChatHubService { const messageToEdit = await this.getChatMessage(session.id, editId, [], trx); - if (!['ai', 'human'].includes(messageToEdit.type)) { - throw new BadRequestError('Only human and AI messages can be edited'); - } - if (messageToEdit.type === 'ai') { // AI edits just change the original message without revisioning or response generation await this.messageRepository.updateChatMessage(editId, { content: payload.message }, trx); @@ -649,8 +667,12 @@ export class ChatHubService { // If the message to edit isn't the original message, we want to point to the original message const revisionOfMessageId = messageToEdit.revisionOfMessageId ?? messageToEdit.id; + // Attachments are already processed (from the original message) + const attachments = messageToEdit.attachments ?? []; + await this.saveHumanMessage( payload, + attachments, user, messageToEdit.previousMessageId, model, @@ -658,34 +680,20 @@ export class ChatHubService { trx, ); - if (model.provider === 'n8n') { - return await this.prepareCustomAgentWorkflow(user, sessionId, model.workflowId, message); - } - - if (model.provider === 'custom-agent') { - return await this.prepareChatAgentWorkflow( - model.agentId, - user, - sessionId, - history, - message, - trx, - ); - } - - return await this.prepareBaseChatWorkflow( + return await this.prepareReplyWorkflow( user, sessionId, credentials, model, history, message, - undefined, session.tools, + attachments, trx, ); } - return null; + + throw new BadRequestError('Only human and AI messages can be edited'); }); if (!workflow) { @@ -707,7 +715,6 @@ export class ChatHubService { async regenerateAIMessage(res: Response, user: User, payload: RegenerateMessagePayload) { const { sessionId, retryId, model, credentials } = payload; - const { provider } = model; const { workflow: { workflowData, executionData }, @@ -744,37 +751,18 @@ export class ChatHubService { // If the message being retried is itself a retry, we want to point to the original message const retryOfMessageId = messageToRetry.retryOfMessageId ?? messageToRetry.id; const message = lastHumanMessage ? lastHumanMessage.content : ''; - - let workflow; - if (provider === 'n8n') { - workflow = await this.prepareCustomAgentWorkflow( - user, - sessionId, - model.workflowId, - message, - ); - } else if (provider === 'custom-agent') { - workflow = await this.prepareChatAgentWorkflow( - model.agentId, - user, - sessionId, - history, - message, - trx, - ); - } else { - workflow = await this.prepareBaseChatWorkflow( - user, - sessionId, - credentials, - model, - history, - message, - undefined, - session.tools, - trx, - ); - } + const attachments = lastHumanMessage.attachments ?? []; + const workflow = await this.prepareReplyWorkflow( + user, + sessionId, + credentials, + model, + history, + message, + session.tools, + attachments, + trx, + ); return { workflow, @@ -795,6 +783,53 @@ export class ChatHubService { ); } + private async prepareReplyWorkflow( + user: User, + sessionId: ChatSessionId, + credentials: INodeCredentials, + model: ChatHubConversationModel, + history: ChatHubMessage[], + message: string, + tools: INode[], + attachments: IBinaryData[], + trx: EntityManager, + ) { + if (model.provider === 'n8n') { + return await this.prepareCustomAgentWorkflow( + user, + sessionId, + model.workflowId, + message, + attachments, + ); + } + + if (model.provider === 'custom-agent') { + return await this.prepareChatAgentWorkflow( + model.agentId, + user, + sessionId, + history, + message, + attachments, + trx, + ); + } + + return await this.prepareBaseChatWorkflow( + user, + sessionId, + credentials, + model, + history, + message, + undefined, + tools, + attachments, + trx, + ); + } + private async prepareBaseChatWorkflow( user: User, sessionId: ChatSessionId, @@ -804,6 +839,7 @@ export class ChatHubService { message: string, systemMessage: string | undefined, tools: INode[], + attachments: IBinaryData[], trx: EntityManager, ) { const credential = await this.chatHubCredentialsService.ensureCredentials( @@ -819,6 +855,7 @@ export class ChatHubService { credential.projectId, history, message, + attachments, credentials, model, systemMessage, @@ -833,6 +870,7 @@ export class ChatHubService { sessionId: ChatSessionId, history: ChatHubMessage[], message: string, + attachments: IBinaryData[], trx: EntityManager, ) { const agent = await this.chatHubAgentService.getAgentById(agentId, user.id); @@ -879,6 +917,7 @@ export class ChatHubService { message, systemMessage, tools, + attachments, trx, ); } @@ -888,6 +927,7 @@ export class ChatHubService { sessionId: ChatSessionId, workflowId: string, message: string, + attachments: IBinaryData[], ) { const workflowEntity = await this.workflowFinderService.findWorkflowForUser( workflowId, @@ -920,25 +960,12 @@ export class ChatHubService { ); } - const nodeExecutionStack: IExecuteData[] = [ - { - node: chatTriggerNode, - data: { - main: [ - [ - { - json: { - sessionId, - action: 'sendMessage', - chatInput: message, - }, - }, - ], - ], - }, - source: null, - }, - ]; + const nodeExecutionStack = this.chatHubWorkflowService.prepareExecutionData( + chatTriggerNode, + sessionId, + message, + attachments, + ); const executionData = createRunExecutionData({ executionData: { @@ -1448,6 +1475,7 @@ export class ChatHubService { private async saveHumanMessage( payload: HumanMessagePayload | EditMessagePayload, + attachments: IBinaryData[], user: User, previousMessageId: ChatMessageId | null, model: ChatHubConversationModel, @@ -1464,6 +1492,7 @@ export class ChatHubService { previousMessageId, revisionOfMessageId, name: user.firstName || 'User', + attachments, ...model, }, trx, @@ -1662,6 +1691,11 @@ export class ChatHubService { previousMessageId: message.previousMessageId, retryOfMessageId: message.retryOfMessageId, revisionOfMessageId: message.revisionOfMessageId, + + attachments: (message.attachments ?? []).map(({ fileName, mimeType }) => ({ + fileName, + mimeType, + })), }; } @@ -1690,6 +1724,7 @@ export class ChatHubService { } async deleteAllSessions() { + await this.chatHubAttachmentService.deleteAll(); const result = await this.sessionRepository.deleteAll(); return result; } @@ -1797,6 +1832,7 @@ export class ChatHubService { throw new NotFoundError('Session not found'); } + await this.chatHubAttachmentService.deleteAllBySessionId(sessionId); await this.sessionRepository.deleteChatHubSession(sessionId); } } diff --git a/packages/cli/src/modules/chat-hub/chat-hub.types.ts b/packages/cli/src/modules/chat-hub/chat-hub.types.ts index c8c85e68c64..b6b6c5d7152 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.types.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.types.ts @@ -1,5 +1,21 @@ -import type { ChatHubConversationModel, ChatMessageId, ChatSessionId } from '@n8n/api-types'; +import type { + ChatHubConversationModel, + ChatHubProvider, + ChatMessageId, + ChatSessionId, + ChatAttachment, +} from '@n8n/api-types'; import type { INode, INodeCredentials } from 'n8n-workflow'; +import { z } from 'zod'; + +export interface ModelWithCredentials { + provider: ChatHubProvider; + model?: string; + workflowId?: string; + credentialId: string | null; + agentId?: string; + name?: string; +} export interface BaseMessagePayload { userId: string; @@ -12,6 +28,7 @@ export interface HumanMessagePayload extends BaseMessagePayload { messageId: ChatMessageId; message: string; previousMessageId: ChatMessageId | null; + attachments: ChatAttachment[]; tools: INode[]; } export interface RegenerateMessagePayload extends BaseMessagePayload { @@ -31,3 +48,14 @@ export interface MessageRecord { message: string; hideFromUI: boolean; } + +export const validChatTriggerParamsShape = z.object({ + availableInChat: z.literal(true), + agentName: z.string().min(1).optional(), + agentDescription: z.string().min(1).optional(), + options: z + .object({ + allowFileUploads: z.boolean().optional(), + }) + .optional(), +}); diff --git a/packages/cli/src/modules/chat-hub/chat-message.repository.ts b/packages/cli/src/modules/chat-hub/chat-message.repository.ts index 9b86ba01e9b..eab1bd09bd5 100644 --- a/packages/cli/src/modules/chat-hub/chat-message.repository.ts +++ b/packages/cli/src/modules/chat-hub/chat-message.repository.ts @@ -5,6 +5,7 @@ import { DataSource, EntityManager, Repository } from '@n8n/typeorm'; import { ChatHubMessage } from './chat-hub-message.entity'; import { ChatHubSessionRepository } from './chat-session.repository'; +import type { IBinaryData } from 'n8n-workflow'; @Service() export class ChatHubMessageRepository extends Repository { @@ -33,7 +34,7 @@ export class ChatHubMessageRepository extends Repository { async updateChatMessage( id: ChatMessageId, - fields: { status?: ChatHubMessageStatus; content?: string }, + fields: { status?: ChatHubMessageStatus; content?: string; attachments?: IBinaryData[] }, trx?: EntityManager, ) { return await withTransaction( diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 901e444e881..1fb6a3d1515 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -18,7 +18,7 @@ import { In } from '@n8n/typeorm'; import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; import omit from 'lodash/omit'; import pick from 'lodash/pick'; -import { BinaryDataService } from 'n8n-core'; +import { FileLocation, BinaryDataService } from 'n8n-core'; import { NodeApiError, PROJECT_ROOT } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; @@ -456,7 +456,9 @@ export class WorkflowService { select: ['id'], where: { workflowId }, }) - .then((rows) => rows.map(({ id: executionId }) => ({ workflowId, executionId }))); + .then((rows) => + rows.map(({ id: executionId }) => FileLocation.ofExecution(workflowId, executionId)), + ); await this.workflowRepository.delete(workflowId); await this.binaryDataService.deleteMany(idsForDeletion); diff --git a/packages/cli/test/integration/shared/utils/index.ts b/packages/cli/test/integration/shared/utils/index.ts index 4fb3f734990..7667cb5f975 100644 --- a/packages/cli/test/integration/shared/utils/index.ts +++ b/packages/cli/test/integration/shared/utils/index.ts @@ -8,6 +8,7 @@ import { InstanceSettings, UnrecognizedNodeTypeError, type DirectoryLoader, + type ErrorReporter, } from 'n8n-core'; import { Ftp } from 'n8n-nodes-base/credentials/Ftp.credentials'; import { GithubApi } from 'n8n-nodes-base/credentials/GithubApi.credentials'; @@ -115,7 +116,8 @@ export async function initBinaryDataService(mode: 'default' | 'filesystem' = 'de availableModes: [mode], localStoragePath: '', }); - const binaryDataService = new BinaryDataService(config); + const errorReporter = mock(); + const binaryDataService = new BinaryDataService(config, errorReporter); await binaryDataService.init(); Container.set(BinaryDataService, binaryDataService); } diff --git a/packages/core/src/binary-data/__tests__/binary-data-service.test.ts b/packages/core/src/binary-data/__tests__/binary-data-service.test.ts index f5ff6cf4faa..0df6a345c22 100644 --- a/packages/core/src/binary-data/__tests__/binary-data-service.test.ts +++ b/packages/core/src/binary-data/__tests__/binary-data-service.test.ts @@ -2,6 +2,8 @@ import { mock } from 'jest-mock-extended'; import { sign, JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; import type { IBinaryData } from 'n8n-workflow'; +import type { ErrorReporter } from '@/errors'; + import type { BinaryDataConfig } from '../binary-data.config'; import { BinaryDataService } from '../binary-data.service'; @@ -11,6 +13,7 @@ jest.useFakeTimers({ now }); describe('BinaryDataService', () => { const signingSecret = 'test-signing-secret'; const config = mock({ signingSecret }); + const errorReporter = mock(); const binaryData = mock({ id: 'filesystem:id_123' }); const validToken = sign({ id: binaryData.id }, signingSecret, { expiresIn: '1 day' }); @@ -19,7 +22,7 @@ describe('BinaryDataService', () => { jest.resetAllMocks(); config.signingSecret = signingSecret; - service = new BinaryDataService(config); + service = new BinaryDataService(config, errorReporter); }); describe('createSignedToken', () => { diff --git a/packages/core/src/binary-data/__tests__/file-system.manager.test.ts b/packages/core/src/binary-data/__tests__/file-system.manager.test.ts index 8f72dac38fb..2735555d35e 100644 --- a/packages/core/src/binary-data/__tests__/file-system.manager.test.ts +++ b/packages/core/src/binary-data/__tests__/file-system.manager.test.ts @@ -1,3 +1,4 @@ +import { mock } from 'jest-mock-extended'; import fs from 'node:fs'; import fsp from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -5,14 +6,18 @@ import path from 'node:path'; import { Readable } from 'node:stream'; import { FileSystemManager } from '@/binary-data/file-system.manager'; +import type { ErrorReporter } from '@/errors'; import { toFileId, toStream } from '@test/utils'; +import type { BinaryData } from '../types'; + jest.mock('fs'); jest.mock('fs/promises'); const storagePath = tmpdir(); +const errorReporter = mock(); -const fsManager = new FileSystemManager(storagePath); +const fsManager = new FileSystemManager(storagePath, errorReporter); const toFullFilePath = (fileId: string) => path.join(storagePath, fileId); @@ -37,7 +42,11 @@ describe('store()', () => { it('should store a buffer', async () => { const metadata = { mimeType: 'text/plain' }; - const result = await fsManager.store(workflowId, executionId, mockBuffer, metadata); + const result = await fsManager.store( + { type: 'execution', workflowId, executionId }, + mockBuffer, + metadata, + ); expect(result.fileSize).toBe(mockBuffer.length); }); @@ -104,7 +113,10 @@ describe('copyByFileId()', () => { // @ts-expect-error - private method jest.spyOn(fsManager, 'toFileId').mockReturnValue(otherFileId); - const targetFileId = await fsManager.copyByFileId(workflowId, executionId, fileId); + const targetFileId = await fsManager.copyByFileId( + { type: 'execution', workflowId, executionId }, + fileId, + ); const sourcePath = toFullFilePath(fileId); const targetPath = toFullFilePath(targetFileId); @@ -133,8 +145,7 @@ describe('copyByFilePath()', () => { fsp.writeFile = jest.fn().mockResolvedValue(undefined); const result = await fsManager.copyByFilePath( - workflowId, - executionId, + { type: 'execution', workflowId, executionId }, sourceFilePath, metadata, ); @@ -156,9 +167,9 @@ describe('deleteMany()', () => { }; it('should delete many files by workflow ID and execution ID', async () => { - const ids = [ - { workflowId, executionId }, - { workflowId: otherWorkflowId, executionId: otherExecutionId }, + const ids: BinaryData.FileLocation[] = [ + { type: 'execution', workflowId, executionId }, + { type: 'execution', workflowId: otherWorkflowId, executionId: otherExecutionId }, ]; fsp.rm = jest.fn().mockResolvedValue(undefined); @@ -181,7 +192,9 @@ describe('deleteMany()', () => { }); it('should suppress error on non-existing filepath', async () => { - const ids = [{ workflowId: 'does-not-exist', executionId: 'does-not-exist' }]; + const ids: BinaryData.FileLocation[] = [ + { type: 'execution', workflowId: 'does-not-exist', executionId: 'does-not-exist' }, + ]; fsp.rm = jest.fn().mockResolvedValue(undefined); diff --git a/packages/core/src/binary-data/__tests__/object-store.manager.test.ts b/packages/core/src/binary-data/__tests__/object-store.manager.test.ts index 09696f7d374..5a47e724af0 100644 --- a/packages/core/src/binary-data/__tests__/object-store.manager.test.ts +++ b/packages/core/src/binary-data/__tests__/object-store.manager.test.ts @@ -34,7 +34,11 @@ describe('store()', () => { it('should store a buffer', async () => { const metadata = { mimeType: 'text/plain' }; - const result = await objectStoreManager.store(workflowId, executionId, mockBuffer, metadata); + const result = await objectStoreManager.store( + { type: 'execution', workflowId, executionId }, + mockBuffer, + metadata, + ); expect(result.fileId.startsWith(prefix)).toBe(true); expect(result.fileSize).toBe(mockBuffer.length); @@ -94,7 +98,10 @@ describe('getMetadata()', () => { describe('copyByFileId()', () => { it('should copy by file ID and return the file ID', async () => { - const targetFileId = await objectStoreManager.copyByFileId(workflowId, executionId, fileId); + const targetFileId = await objectStoreManager.copyByFileId( + { type: 'execution', workflowId, executionId }, + fileId, + ); expect(targetFileId.startsWith(prefix)).toBe(true); expect(objectStoreService.get).toHaveBeenCalledWith(fileId, { mode: 'buffer' }); @@ -109,8 +116,7 @@ describe('copyByFilePath()', () => { fs.readFile = jest.fn().mockResolvedValue(mockBuffer); const result = await objectStoreManager.copyByFilePath( - workflowId, - executionId, + { type: 'execution', workflowId, executionId }, sourceFilePath, metadata, ); diff --git a/packages/core/src/binary-data/binary-data.service.ts b/packages/core/src/binary-data/binary-data.service.ts index 6085e27f571..229b8a65b04 100644 --- a/packages/core/src/binary-data/binary-data.service.ts +++ b/packages/core/src/binary-data/binary-data.service.ts @@ -7,6 +7,8 @@ import { readFile, stat } from 'node:fs/promises'; import prettyBytes from 'pretty-bytes'; import type { Readable } from 'stream'; +import { ErrorReporter } from '@/errors'; + import { BinaryDataConfig } from './binary-data.config'; import type { BinaryData } from './types'; import { areConfigModes, binaryToBuffer } from './utils'; @@ -19,7 +21,10 @@ export class BinaryDataService { private managers: Record = {}; - constructor(private readonly config: BinaryDataConfig) {} + constructor( + private readonly config: BinaryDataConfig, + private readonly errorReporter: ErrorReporter, + ) {} async init() { const { config } = this; @@ -30,7 +35,7 @@ export class BinaryDataService { if (config.availableModes.includes('filesystem')) { const { FileSystemManager } = await import('./file-system.manager'); - this.managers.filesystem = new FileSystemManager(config.localStoragePath); + this.managers.filesystem = new FileSystemManager(config.localStoragePath, this.errorReporter); this.managers['filesystem-v2'] = this.managers.filesystem; await this.managers.filesystem.init(); @@ -66,8 +71,7 @@ export class BinaryDataService { } async copyBinaryFile( - workflowId: string, - executionId: string, + location: BinaryData.FileLocation, binaryData: IBinaryData, filePath: string, ) { @@ -86,12 +90,7 @@ export class BinaryDataService { mimeType: binaryData.mimeType, }; - const { fileId, fileSize } = await manager.copyByFilePath( - workflowId, - executionId, - filePath, - metadata, - ); + const { fileId, fileSize } = await manager.copyByFilePath(location, filePath, metadata); binaryData.id = this.createBinaryDataId(fileId); binaryData.fileSize = prettyBytes(fileSize); @@ -101,8 +100,7 @@ export class BinaryDataService { } async store( - workflowId: string, - executionId: string, + location: BinaryData.FileLocation, bufferOrStream: Buffer | Readable, binaryData: IBinaryData, ) { @@ -121,12 +119,7 @@ export class BinaryDataService { mimeType: binaryData.mimeType, }; - const { fileId, fileSize } = await manager.store( - workflowId, - executionId, - bufferOrStream, - metadata, - ); + const { fileId, fileSize } = await manager.store(location, bufferOrStream, metadata); binaryData.id = this.createBinaryDataId(fileId); binaryData.fileSize = prettyBytes(fileSize); @@ -163,17 +156,28 @@ export class BinaryDataService { return await this.getManager(mode).getMetadata(fileId); } - async deleteMany(ids: BinaryData.IdsForDeletion) { + async deleteMany(locations: BinaryData.FileLocation[]) { const manager = this.managers[this.mode]; if (!manager) return; - if (manager.deleteMany) await manager.deleteMany(ids); + if (manager.deleteMany) await manager.deleteMany(locations); + } + + async deleteManyByBinaryDataId(ids: string[]) { + const manager = this.managers[this.mode]; + + const fileIds = ids.flatMap((attachmentId) => { + const [, fileId] = attachmentId.split(':'); // remove mode + + return fileId ? [fileId] : []; + }); + + await manager.deleteManyByFileId?.(fileIds); } async duplicateBinaryData( - workflowId: string, - executionId: string, + location: BinaryData.FileLocation, inputData: Array, ) { if (inputData && this.managers[this.mode]) { @@ -183,11 +187,7 @@ export class BinaryDataService { return await Promise.all( executionDataArray.map(async (executionData) => { if (executionData.binary) { - return await this.duplicateBinaryDataInExecData( - workflowId, - executionId, - executionData, - ); + return await this.duplicateBinaryDataInExecData(location, executionData); } return executionData; @@ -222,8 +222,7 @@ export class BinaryDataService { } private async duplicateBinaryDataInExecData( - workflowId: string, - executionId: string, + location: BinaryData.FileLocation, executionData: INodeExecutionData, ) { const manager = this.managers[this.mode]; @@ -242,7 +241,7 @@ export class BinaryDataService { const [_mode, fileId] = binaryDataId.split(':'); - return await manager?.copyByFileId(workflowId, executionId, fileId).then((newFileId) => ({ + return await manager?.copyByFileId(location, fileId).then((newFileId) => ({ newId: this.createBinaryDataId(newFileId), key, })); diff --git a/packages/core/src/binary-data/file-system.manager.ts b/packages/core/src/binary-data/file-system.manager.ts index a951e98cd42..6f6bde136bd 100644 --- a/packages/core/src/binary-data/file-system.manager.ts +++ b/packages/core/src/binary-data/file-system.manager.ts @@ -1,32 +1,40 @@ -import { jsonParse } from 'n8n-workflow'; +import { jsonParse, UnexpectedError } from 'n8n-workflow'; import { createReadStream } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; import type { Readable } from 'stream'; import { v4 as uuid } from 'uuid'; +import type { ErrorReporter } from '@/errors'; + import type { BinaryData } from './types'; -import { assertDir, doesNotExist } from './utils'; +import { assertDir, doesNotExist, FileLocation } from './utils'; import { DisallowedFilepathError } from '../errors/disallowed-filepath.error'; import { FileNotFoundError } from '../errors/file-not-found.error'; const EXECUTION_ID_EXTRACTOR = /^(\w+)(?:[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12})$/; +const EXECUTION_PATH_MATCHER = /^workflows\/([^/]+)\/executions\/([^/]+)\//; + +const CHAT_HUB_ATTACHMENT_PATH_MATCHER = /^chat-hub\/sessions\/([^/]+)\/messages\/([^/]+)\//; + export class FileSystemManager implements BinaryData.Manager { - constructor(private storagePath: string) {} + constructor( + private storagePath: string, + private readonly errorReporter: ErrorReporter, + ) {} async init() { await assertDir(this.storagePath); } async store( - workflowId: string, - executionId: string, + location: BinaryData.FileLocation, bufferOrStream: Buffer | Readable, { mimeType, fileName }: BinaryData.PreWriteMetadata, ) { - const fileId = this.toFileId(workflowId, executionId); + const fileId = this.toFileId(location); const filePath = this.resolvePath(fileId); await assertDir(path.dirname(filePath)); @@ -70,12 +78,14 @@ export class FileSystemManager implements BinaryData.Manager { return await jsonParse(await fs.readFile(filePath, { encoding: 'utf-8' })); } - async deleteMany(ids: BinaryData.IdsForDeletion) { - if (ids.length === 0) return; + async deleteMany(locations: BinaryData.FileLocation[]) { + if (locations.length === 0) return; // binary files stored in single dir - `filesystem` - const executionIds = ids.map((o) => o.executionId); + const executionIds = locations.flatMap((location) => + location.type === 'execution' ? [location.executionId] : [], + ); const set = new Set(executionIds); const fileNames = await fs.readdir(this.storagePath); @@ -92,8 +102,8 @@ export class FileSystemManager implements BinaryData.Manager { // binary files stored in nested dirs - `filesystem-v2` - const binaryDataDirs = ids.map(({ workflowId, executionId }) => - this.resolvePath(`workflows/${workflowId}/executions/${executionId}`), + const binaryDataDirs = locations.map((location) => + this.resolvePath(this.toRelativePath(location)), ); await Promise.all( @@ -104,12 +114,11 @@ export class FileSystemManager implements BinaryData.Manager { } async copyByFilePath( - workflowId: string, - executionId: string, + targetLocation: BinaryData.FileLocation, sourcePath: string, { mimeType, fileName }: BinaryData.PreWriteMetadata, ) { - const targetFileId = this.toFileId(workflowId, executionId); + const targetFileId = this.toFileId(targetLocation); const targetPath = this.resolvePath(targetFileId); await assertDir(path.dirname(targetPath)); @@ -123,8 +132,8 @@ export class FileSystemManager implements BinaryData.Manager { return { fileId: targetFileId, fileSize }; } - async copyByFileId(workflowId: string, executionId: string, sourceFileId: string) { - const targetFileId = this.toFileId(workflowId, executionId); + async copyByFileId(targetLocation: BinaryData.FileLocation, sourceFileId: string) { + const targetFileId = this.toFileId(targetLocation); const sourcePath = this.resolvePath(sourceFileId); const targetPath = this.resolvePath(targetFileId); const sourceMetadata = await this.getMetadata(sourceFileId); @@ -155,6 +164,21 @@ export class FileSystemManager implements BinaryData.Manager { await fs.rm(tempDir, { recursive: true }); } + async deleteManyByFileId(ids: string[]): Promise { + const parsedIds = ids.flatMap((id) => { + try { + const parsed = this.parseFileId(id); + + return [parsed]; + } catch (e) { + this.errorReporter.warn(`Could not parse file ID ${id}. Skip deletion`); + return []; + } + }); + + await this.deleteMany(parsedIds); + } + // ---------------------------------- // private methods // ---------------------------------- @@ -165,10 +189,35 @@ export class FileSystemManager implements BinaryData.Manager { * The legacy ID format `{executionId}{uuid}` for `filesystem` mode is * no longer used on write, only when reading old stored execution data. */ - private toFileId(workflowId: string, executionId: string) { - if (!executionId) executionId = 'temp'; // missing only in edge case, see PR #7244 + private toFileId(location: BinaryData.FileLocation) { + return `${this.toRelativePath(location)}/binary_data/${uuid()}`; + } - return `workflows/${workflowId}/executions/${executionId}/binary_data/${uuid()}`; + private toRelativePath(location: BinaryData.FileLocation) { + switch (location.type) { + case 'execution': { + const executionId = location.executionId || 'temp'; // missing only in edge case, see PR #7244 + return `workflows/${location.workflowId}/executions/${executionId}`; + } + case 'chat-hub-message-attachment': + return `chat-hub/sessions/${location.sessionId}/messages/${location.messageId}`; + } + } + + private parseFileId(fileId: string): BinaryData.FileLocation { + const executionMatch = fileId.match(EXECUTION_PATH_MATCHER); + + if (executionMatch) { + return FileLocation.ofExecution(executionMatch[1], executionMatch[2]); + } + + const chatHubMatch = fileId.match(CHAT_HUB_ATTACHMENT_PATH_MATCHER); + + if (chatHubMatch) { + return FileLocation.ofChatHubMessageAttachment(chatHubMatch[1], chatHubMatch[2]); + } + + throw new UnexpectedError(`File ID ${fileId} has invalid format.`); } private resolvePath(...args: string[]) { diff --git a/packages/core/src/binary-data/index.ts b/packages/core/src/binary-data/index.ts index 975988700ae..9a0f0f46b41 100644 --- a/packages/core/src/binary-data/index.ts +++ b/packages/core/src/binary-data/index.ts @@ -2,4 +2,4 @@ export * from './binary-data.service'; export { BinaryDataConfig } from './binary-data.config'; export type * from './types'; export { ObjectStoreService } from './object-store/object-store.service.ee'; -export { isStoredMode as isValidNonDefaultMode } from './utils'; +export { isStoredMode as isValidNonDefaultMode, FileLocation } from './utils'; diff --git a/packages/core/src/binary-data/object-store.manager.ts b/packages/core/src/binary-data/object-store.manager.ts index cc0fa564cef..79156a35a33 100644 --- a/packages/core/src/binary-data/object-store.manager.ts +++ b/packages/core/src/binary-data/object-store.manager.ts @@ -16,12 +16,11 @@ export class ObjectStoreManager implements BinaryData.Manager { } async store( - workflowId: string, - executionId: string, + location: BinaryData.FileLocation, bufferOrStream: Buffer | Readable, metadata: BinaryData.PreWriteMetadata, ) { - const fileId = this.toFileId(workflowId, executionId); + const fileId = this.toFileId(location); const buffer = await binaryToBuffer(bufferOrStream); await this.objectStoreService.put(fileId, buffer, metadata); @@ -56,8 +55,8 @@ export class ObjectStoreManager implements BinaryData.Manager { return metadata; } - async copyByFileId(workflowId: string, executionId: string, sourceFileId: string) { - const targetFileId = this.toFileId(workflowId, executionId); + async copyByFileId(targetLocation: BinaryData.FileLocation, sourceFileId: string) { + const targetFileId = this.toFileId(targetLocation); const sourceFile = await this.objectStoreService.get(sourceFileId, { mode: 'buffer' }); @@ -70,12 +69,11 @@ export class ObjectStoreManager implements BinaryData.Manager { * Copy to object store the temp file written by nodes like Webhook, FTP, and SSH. */ async copyByFilePath( - workflowId: string, - executionId: string, + targetLocation: BinaryData.FileLocation, sourcePath: string, metadata: BinaryData.PreWriteMetadata, ) { - const targetFileId = this.toFileId(workflowId, executionId); + const targetFileId = this.toFileId(targetLocation); const sourceFile = await fs.readFile(sourcePath); await this.objectStoreService.put(targetFileId, sourceFile, metadata); @@ -95,9 +93,14 @@ export class ObjectStoreManager implements BinaryData.Manager { // private methods // ---------------------------------- - private toFileId(workflowId: string, executionId: string) { - if (!executionId) executionId = 'temp'; // missing only in edge case, see PR #7244 - - return `workflows/${workflowId}/executions/${executionId}/binary_data/${uuid()}`; + private toFileId(location: BinaryData.FileLocation) { + switch (location.type) { + case 'execution': { + const executionId = location.executionId || 'temp'; // missing only in edge case, see PR #7244 + return `workflows/${location.workflowId}/executions/${executionId}/binary_data/${uuid()}`; + } + case 'chat-hub-message-attachment': + return `chat-hub/sessions/${location.sessionId}/messages/${location.messageId}/binary_data/${uuid()}`; + } } } diff --git a/packages/core/src/binary-data/types.ts b/packages/core/src/binary-data/types.ts index 9ee54792773..88657dc5d55 100644 --- a/packages/core/src/binary-data/types.ts +++ b/packages/core/src/binary-data/types.ts @@ -32,14 +32,15 @@ export namespace BinaryData { export type PreWriteMetadata = Omit; - export type IdsForDeletion = Array<{ workflowId: string; executionId: string }>; + export type FileLocation = + | { type: 'execution'; workflowId: string; executionId: string } + | { type: 'chat-hub-message-attachment'; sessionId: string; messageId: string }; export interface Manager { init(): Promise; store( - workflowId: string, - executionId: string, + location: FileLocation, bufferOrStream: Buffer | Readable, metadata: PreWriteMetadata, ): Promise; @@ -52,12 +53,12 @@ export namespace BinaryData { /** * Present for `FileSystem`, absent for `ObjectStore` (delegated to S3 lifecycle config) */ - deleteMany?(ids: IdsForDeletion): Promise; + deleteMany?(locations: FileLocation[]): Promise; + deleteManyByFileId?(ids: string[]): Promise; - copyByFileId(workflowId: string, executionId: string, sourceFileId: string): Promise; + copyByFileId(targetLocation: FileLocation, sourceFileId: string): Promise; copyByFilePath( - workflowId: string, - executionId: string, + targetLocation: FileLocation, sourcePath: string, metadata: PreWriteMetadata, ): Promise; diff --git a/packages/core/src/binary-data/utils.ts b/packages/core/src/binary-data/utils.ts index 13f46fef617..a7852e1fca1 100644 --- a/packages/core/src/binary-data/utils.ts +++ b/packages/core/src/binary-data/utils.ts @@ -52,3 +52,16 @@ export async function binaryToBuffer(body: Buffer | Readable) { if (Buffer.isBuffer(body)) return body; return await streamToBuffer(body); } + +export const FileLocation = { + ofExecution: (workflowId: string, executionId: string): BinaryData.FileLocation => ({ + type: 'execution', + workflowId, + executionId, + }), + ofChatHubMessageAttachment: (sessionId: string, messageId: string): BinaryData.FileLocation => ({ + type: 'chat-hub-message-attachment', + sessionId, + messageId, + }), +}; diff --git a/packages/core/src/execution-engine/node-execution-context/__tests__/shared-tests.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/shared-tests.ts index e7e2ab439d6..3692d3fd257 100644 --- a/packages/core/src/execution-engine/node-execution-context/__tests__/shared-tests.ts +++ b/packages/core/src/execution-engine/node-execution-context/__tests__/shared-tests.ts @@ -286,8 +286,7 @@ export const describeCommonTests = ( expect(result.data).toEqual(data); expect(binaryDataService.duplicateBinaryData).toHaveBeenCalledWith( - workflow.id, - additionalData.executionId, + { type: 'execution', workflowId: workflow.id, executionId: additionalData.executionId }, executeWorkflowData.data, ); }); diff --git a/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts index 063e3e8f572..354a798145e 100644 --- a/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts @@ -34,6 +34,7 @@ import { } from 'n8n-workflow'; import { BinaryDataService } from '@/binary-data/binary-data.service'; +import { FileLocation } from '@/binary-data/utils'; import { NodeExecutionContext } from './node-execution-context'; @@ -155,8 +156,7 @@ export class BaseExecuteContext extends NodeExecutionContext { } const data = await this.binaryDataService.duplicateBinaryData( - this.workflow.id, - this.additionalData.executionId!, + FileLocation.ofExecution(this.workflow.id, this.additionalData.executionId!), result.data, ); return { ...result, data }; diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/binary-helper-functions.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/binary-helper-functions.test.ts index 4e24ebd4735..98420d4c6d6 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/binary-helper-functions.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/binary-helper-functions.test.ts @@ -14,6 +14,7 @@ import { Readable } from 'stream'; import type { BinaryDataConfig } from '@/binary-data'; import { BinaryDataService } from '@/binary-data/binary-data.service'; +import type { ErrorReporter } from '@/errors'; import { assertBinaryData, @@ -44,11 +45,12 @@ describe('test binary data helper methods', () => { availableModes: ['default', 'filesystem'], localStoragePath: temporaryDir, }); + const errorReporter = mock(); let binaryDataService: BinaryDataService; beforeEach(() => { jest.resetAllMocks(); - binaryDataService = new BinaryDataService(binaryDataConfig); + binaryDataService = new BinaryDataService(binaryDataConfig, errorReporter); Container.set(BinaryDataService, binaryDataService); }); @@ -592,8 +594,7 @@ describe('copyBinaryFile', () => { expect(result.fileName).toBe(fileName); expect(binaryDataService.copyBinaryFile).toHaveBeenCalledWith( - workflowId, - executionId, + { type: 'execution', workflowId, executionId }, { ...binaryData, fileExtension: 'txt', @@ -614,8 +615,7 @@ describe('copyBinaryFile', () => { expect(result.fileName).toBe(fileName); expect(binaryDataService.copyBinaryFile).toHaveBeenCalledWith( - workflowId, - executionId, + { type: 'execution', workflowId, executionId }, { ...binaryData, fileExtension: 'bin', @@ -635,7 +635,7 @@ describe('prepareBinaryData', () => { jest.resetAllMocks(); Container.set(BinaryDataService, binaryDataService); - binaryDataService.store.mockImplementation(async (_w, _e, _b, binaryData) => binaryData); + binaryDataService.store.mockImplementation(async (_l, _b, binaryData) => binaryData); }); it('parses filenames correctly', async () => { @@ -644,13 +644,17 @@ describe('prepareBinaryData', () => { const result = await prepareBinaryData(buffer, executionId, workflowId, fileName); expect(result.fileName).toEqual(fileName); - expect(binaryDataService.store).toHaveBeenCalledWith(workflowId, executionId, buffer, { - data: '', - fileExtension: undefined, - fileName, - fileType: 'text', - mimeType: 'text/plain', - }); + expect(binaryDataService.store).toHaveBeenCalledWith( + { type: 'execution', executionId, workflowId }, + buffer, + { + data: '', + fileExtension: undefined, + fileName, + fileType: 'text', + mimeType: 'text/plain', + }, + ); }); it('handles IncomingMessage with responseUrl', async () => { diff --git a/packages/core/src/execution-engine/node-execution-context/utils/binary-helper-functions.ts b/packages/core/src/execution-engine/node-execution-context/utils/binary-helper-functions.ts index 01329df51bd..50bef3a970a 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/binary-helper-functions.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/binary-helper-functions.ts @@ -159,8 +159,7 @@ export async function setBinaryDataBuffer( executionId: string, ): Promise { return await Container.get(BinaryDataService).store( - workflowId, - executionId, + { type: 'execution', workflowId, executionId }, bufferOrStream, binaryData, ); @@ -218,8 +217,7 @@ export async function copyBinaryFile( } return await Container.get(BinaryDataService).copyBinaryFile( - workflowId, - executionId, + { type: 'execution', workflowId, executionId }, returnData, filePath, ); diff --git a/packages/frontend/@n8n/chat/src/components/ChatFile.vue b/packages/frontend/@n8n/chat/src/components/ChatFile.vue index 52b4acf789a..890fd0cbf1d 100644 --- a/packages/frontend/@n8n/chat/src/components/ChatFile.vue +++ b/packages/frontend/@n8n/chat/src/components/ChatFile.vue @@ -11,6 +11,7 @@ const props = defineProps<{ file: File; isRemovable: boolean; isPreviewable?: boolean; + href?: string; }>(); const emit = defineEmits<{ @@ -30,6 +31,11 @@ const TypeIcon = computed(() => { }); function onClick() { + if (props.href) { + window.open(props.href, '_blank', 'noopener noreferrer'); + return; + } + if (props.isPreviewable) { window.open(URL.createObjectURL(props.file)); } @@ -41,12 +47,12 @@ function onDelete() { @@ -64,7 +70,14 @@ function onDelete() { background: white; color: var(--chat--color-dark); border: 1px solid var(--chat--color-dark); - cursor: pointer; + + &:has(.chat-file-preview) { + cursor: pointer; + } +} + +.chat-icon { + flex-shrink: 0; } .chat-file-name-tooltip { diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue index 7e627be65f5..a60ccdc3832 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue @@ -47,7 +47,7 @@ import type { FolderShortInfo } from '@/features/core/folders/folders.types'; import { useFoldersStore } from '@/features/core/folders/folders.store'; import { useNpsSurveyStore } from '@/app/stores/npsSurvey.store'; import { ProjectTypes } from '@/features/collaboration/projects/projects.types'; -import { sanitizeFilename } from '@/app/utils/fileUtils'; +import { sanitizeFilename } from '@n8n/utils/files/sanitize'; import { hasPermission } from '@/app/utils/rbac/permissions'; import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue'; import { type BaseTextKey, useI18n } from '@n8n/i18n'; diff --git a/packages/frontend/editor-ui/src/app/utils/fileUtils.ts b/packages/frontend/editor-ui/src/app/utils/fileUtils.ts index f9fcdd1818c..f823c2ae0ce 100644 --- a/packages/frontend/editor-ui/src/app/utils/fileUtils.ts +++ b/packages/frontend/editor-ui/src/app/utils/fileUtils.ts @@ -1,89 +1,40 @@ -/** - * Filename sanitization utilities - * For handling cross-platform filename compatibility issues - */ +import type { BinaryFileType, IBinaryData } from 'n8n-workflow'; +import type { ChatAttachment } from '@n8n/api-types'; -// Constants definition -const INVALID_CHARS_REGEX = /[<>:"/\\|?*\u0000-\u001F\u007F-\u009F]/g; -const ZERO_WIDTH_CHARS_REGEX = /[\u200B-\u200D\u2060\uFEFF]/g; -const UNICODE_SPACES_REGEX = /[\u00A0\u2000-\u200A]/g; -const LEADING_TRAILING_DOTS_SPACES_REGEX = /^[\s.]+|[\s.]+$/g; -const WINDOWS_RESERVED_NAMES = new Set([ - 'CON', - 'PRN', - 'AUX', - 'NUL', - 'COM1', - 'COM2', - 'COM3', - 'COM4', - 'COM5', - 'COM6', - 'COM7', - 'COM8', - 'COM9', - 'LPT1', - 'LPT2', - 'LPT3', - 'LPT4', - 'LPT5', - 'LPT6', - 'LPT7', - 'LPT8', - 'LPT9', -]); +export async function convertFileToBinaryData(file: File): Promise { + const reader = new FileReader(); + return await new Promise((resolve, reject) => { + reader.onload = () => { + const binaryData: IBinaryData = { + data: (reader.result as string).split('base64,')?.[1] ?? '', + mimeType: file.type, + fileName: file.name, + fileSize: `${file.size} bytes`, + fileExtension: file.name.split('.').pop() ?? '', + fileType: file.type.split('/')[0] as BinaryFileType, + }; + resolve(binaryData); + }; + reader.onerror = () => { + reject(new Error('Failed to convert file to binary data')); + }; + reader.readAsDataURL(file); + }); +} -const DEFAULT_FALLBACK_NAME = 'untitled'; -const MAX_FILENAME_LENGTH = 200; - -/** - * Sanitizes a filename to be compatible with Mac, Linux, and Windows file systems - * - * Main features: - * - Replace invalid characters (e.g. ":" in hello:world) - * - Handle Windows reserved names - * - Limit filename length - * - Normalize Unicode characters - * - * @param filename - The filename to sanitize (without extension) - * @param maxLength - Maximum filename length (default: 200) - * @returns A sanitized filename (without extension) - * - * @example - * sanitizeFilename('hello:world') // returns 'hello_world' - * sanitizeFilename('CON') // returns '_CON' - * sanitizeFilename('') // returns 'untitled' - */ -export const sanitizeFilename = ( - filename: string, - maxLength: number = MAX_FILENAME_LENGTH, -): string => { - // Input validation - if (!filename) { - return DEFAULT_FALLBACK_NAME; - } - - let baseName = filename - .trim() - .replace(INVALID_CHARS_REGEX, '_') - .replace(ZERO_WIDTH_CHARS_REGEX, '') - .replace(UNICODE_SPACES_REGEX, ' ') - .replace(LEADING_TRAILING_DOTS_SPACES_REGEX, ''); - - // Handle empty or invalid filenames after cleaning - if (!baseName) { - baseName = DEFAULT_FALLBACK_NAME; - } - - // Handle Windows reserved names - if (WINDOWS_RESERVED_NAMES.has(baseName.toUpperCase())) { - baseName = `_${baseName}`; - } - - // Truncate if too long - if (baseName.length > maxLength) { - baseName = baseName.slice(0, maxLength); - } - - return baseName; -}; +export async function convertFileToChatAttachment(file: File): Promise { + const reader = new FileReader(); + return await new Promise((resolve, reject) => { + reader.onload = () => { + const attachment: ChatAttachment = { + data: (reader.result as string).split('base64,')?.[1] ?? '', + fileName: file.name, + }; + resolve(attachment); + }; + reader.onerror = () => { + reject(new Error('Failed to convert file to chat attachment')); + }; + reader.readAsDataURL(file); + }); +} diff --git a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue index e66ba320b5f..c1965208d96 100644 --- a/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue +++ b/packages/frontend/editor-ui/src/features/ai/chatHub/ChatView.vue @@ -27,7 +27,7 @@ import { type ChatHubSendMessageRequest, type ChatModelDto, } from '@n8n/api-types'; -import { N8nIconButton, N8nScrollArea } from '@n8n/design-system'; +import { N8nIconButton, N8nScrollArea, N8nText } from '@n8n/design-system'; import { useLocalStorage, useMediaQuery, useScroll } from '@vueuse/core'; import { v4 as uuidv4 } from 'uuid'; import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'; @@ -38,6 +38,7 @@ import { useUIStore } from '@/app/stores/ui.store'; import { useChatCredentials } from '@/features/ai/chatHub/composables/useChatCredentials'; import ChatLayout from '@/features/ai/chatHub/components/ChatLayout.vue'; import { INodesSchema, type INode } from 'n8n-workflow'; +import { useFileDrop } from '@/features/ai/chatHub/composables/useFileDrop'; const router = useRouter(); const route = useRoute(); @@ -208,6 +209,15 @@ const isMissingSelectedCredential = computed(() => !credentialsForSelectedProvid const editingMessageId = ref(); const didSubmitInCurrentSession = ref(false); +const canAcceptFiles = computed( + () => + editingMessageId.value === undefined && + !!selectedModel.value?.allowFileUploads && + !isMissingSelectedCredential.value, +); + +const fileDrop = useFileDrop(canAcceptFiles, onFilesDropped); + function scrollToBottom(smooth: boolean) { scrollContainerRef.value?.scrollTo({ top: scrollableRef.value?.scrollHeight, @@ -310,7 +320,7 @@ watch( { immediate: true }, ); -function onSubmit(message: string) { +function onSubmit(message: string, attachments: File[]) { if ( !message.trim() || isResponding.value || @@ -322,12 +332,13 @@ function onSubmit(message: string) { didSubmitInCurrentSession.value = true; - chatStore.sendMessage( + void chatStore.sendMessage( sessionId.value, message, selectedModel.value.model, credentialsForSelectedProvider.value, canSelectTools.value ? selectedTools.value : [], + attachments, ); inputRef.value?.setText(''); @@ -463,16 +474,31 @@ function handleOpenWorkflow(workflowId: string) { window.open(routeData.href, '_blank'); } + +function onFilesDropped(files: File[]) { + inputRef.value?.addAttachments(files); +}