Skip to content

Commit 3549887

Browse files
autologieCadiacalexgrozav
authored andcommitted
feat: File attachment support in chat (no-changelog) (#21437)
Co-authored-by: Jaakko Husso <[email protected]> Co-authored-by: Alex Grozav <[email protected]>
1 parent ab44ec0 commit 3549887

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+1292
-443
lines changed

packages/@n8n/api-types/src/chat-hub.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ export interface ChatModelDto {
134134
description: string | null;
135135
updatedAt: string | null;
136136
createdAt: string | null;
137+
allowFileUploads?: boolean;
137138
}
138139

139140
/**
@@ -159,6 +160,18 @@ export const emptyChatModelsResponse: ChatModelsResponse = {
159160
'custom-agent': { models: [] },
160161
};
161162

163+
/**
164+
* Chat attachment schema for incoming requests.
165+
* Requires base64 data and fileName.
166+
* MimeType, fileType, fileExtension, and fileSize are populated server-side.
167+
*/
168+
export const chatAttachmentSchema = z.object({
169+
data: z.string(),
170+
fileName: z.string(),
171+
});
172+
173+
export type ChatAttachment = z.infer<typeof chatAttachmentSchema>;
174+
162175
export class ChatHubSendMessageRequest extends Z.class({
163176
messageId: z.string().uuid(),
164177
sessionId: z.string().uuid(),
@@ -172,6 +185,7 @@ export class ChatHubSendMessageRequest extends Z.class({
172185
}),
173186
),
174187
tools: z.array(INodeSchema),
188+
attachments: z.array(chatAttachmentSchema),
175189
}) {}
176190

177191
export class ChatHubRegenerateMessageRequest extends Z.class({
@@ -246,6 +260,8 @@ export interface ChatHubMessageDto {
246260
previousMessageId: ChatMessageId | null;
247261
retryOfMessageId: ChatMessageId | null;
248262
revisionOfMessageId: ChatMessageId | null;
263+
264+
attachments: Array<{ fileName?: string; mimeType?: string }>;
249265
}
250266

251267
export type ChatHubConversationsResponse = ChatHubSessionDto[];

packages/@n8n/api-types/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export {
2727
emptyChatModelsResponse,
2828
type ChatModelsRequest,
2929
type ChatModelsResponse,
30+
chatAttachmentSchema,
31+
type ChatAttachment,
3032
ChatHubSendMessageRequest,
3133
ChatHubRegenerateMessageRequest,
3234
ChatHubEditMessageRequest,
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { MigrationContext, ReversibleMigration } from '../migration-types';
2+
3+
const table = {
4+
messages: 'chat_hub_messages',
5+
} as const;
6+
7+
export class AddAttachmentsToChatHubMessages1761773155024 implements ReversibleMigration {
8+
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
9+
await addColumns(table.messages, [
10+
column('attachments').json.comment(
11+
'File attachments for the message (if any), stored as JSON. Files are stored as base64-encoded data URLs.',
12+
),
13+
]);
14+
}
15+
16+
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
17+
await dropColumns(table.messages, ['attachments']);
18+
}
19+
}

packages/@n8n/db/src/migrations/mysqldb/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ import { CreateChatHubAgentTable1760020000000 } from '../common/1760020000000-Cr
111111
import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRoleNames';
112112
import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities';
113113
import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable';
114+
import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/1761773155024-AddAttachmentsToChatHubMessages';
114115
import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn';
115116
import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords';
116117
import { ChangeOAuthStateColumnToUnboundedVarchar1763572724000 } from '../common/1763572724000-ChangeOAuthStateColumnToUnboundedVarchar';
@@ -233,4 +234,5 @@ export const mysqlMigrations: Migration[] = [
233234
AddWorkflowHistoryAutoSaveFields1762847206508,
234235
AddToolsColumnToChatHubTables1761830340990,
235236
ChangeOAuthStateColumnToUnboundedVarchar1763572724000,
237+
AddAttachmentsToChatHubMessages1761773155024,
236238
];

packages/@n8n/db/src/migrations/postgresdb/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRole
109109
import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities';
110110
import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable';
111111
import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns';
112+
import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/1761773155024-AddAttachmentsToChatHubMessages';
112113
import { AddToolsColumnToChatHubTables1761830340990 } from '../common/1761830340990-AddToolsColumnToChatHubTables';
113114
import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn';
114115
import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords';
@@ -233,4 +234,5 @@ export const postgresMigrations: Migration[] = [
233234
AddWorkflowHistoryAutoSaveFields1762847206508,
234235
AddToolsColumnToChatHubTables1761830340990,
235236
ChangeOAuthStateColumnToUnboundedVarchar1763572724000,
237+
AddAttachmentsToChatHubMessages1761773155024,
236238
];

packages/@n8n/db/src/migrations/sqlite/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ import { UniqueRoleNames1760020838000 } from '../common/1760020838000-UniqueRole
105105
import { CreateOAuthEntities1760116750277 } from '../common/1760116750277-CreateOAuthEntities';
106106
import { CreateWorkflowDependencyTable1760314000000 } from '../common/1760314000000-CreateWorkflowDependencyTable';
107107
import { DropUnusedChatHubColumns1760965142113 } from '../common/1760965142113-DropUnusedChatHubColumns';
108+
import { AddAttachmentsToChatHubMessages1761773155024 } from '../common/1761773155024-AddAttachmentsToChatHubMessages';
108109
import { AddToolsColumnToChatHubTables1761830340990 } from '../common/1761830340990-AddToolsColumnToChatHubTables';
109110
import { AddWorkflowDescriptionColumn1762177736257 } from '../common/1762177736257-AddWorkflowDescriptionColumn';
110111
import { BackfillMissingWorkflowHistoryRecords1762763704614 } from '../common/1762763704614-BackfillMissingWorkflowHistoryRecords';
@@ -225,6 +226,7 @@ const sqliteMigrations: Migration[] = [
225226
AddWorkflowHistoryAutoSaveFields1762847206508,
226227
AddToolsColumnToChatHubTables1761830340990,
227228
ChangeOAuthStateColumnToUnboundedVarchar1763572724000,
229+
AddAttachmentsToChatHubMessages1761773155024,
228230
];
229231

230232
export { sqliteMigrations };

packages/@n8n/db/src/repositories/execution.repository.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
408408
async hardDelete(ids: { workflowId: string; executionId: string }) {
409409
return await Promise.all([
410410
this.delete(ids.executionId),
411-
this.binaryDataService.deleteMany([ids]),
411+
this.binaryDataService.deleteMany([{ type: 'execution', ...ids }]),
412412
]);
413413
}
414414

@@ -509,6 +509,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
509509
}
510510

511511
const ids = executions.map(({ id, workflowId }) => ({
512+
type: 'execution' as const,
512513
executionId: id,
513514
workflowId,
514515
}));
@@ -605,7 +606,11 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
605606
*/
606607
withDeleted: true,
607608
})
608-
).map(({ id: executionId, workflowId }) => ({ workflowId, executionId }));
609+
).map(({ id: executionId, workflowId }) => ({
610+
type: 'execution' as const,
611+
workflowId,
612+
executionId,
613+
}));
609614

610615
return workflowIdsAndExecutionIds;
611616
}

packages/frontend/editor-ui/src/app/utils/fileUtils.test.ts renamed to packages/@n8n/utils/src/files/sanitize.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from 'vitest';
2-
import { sanitizeFilename } from './fileUtils';
2+
3+
import { sanitizeFilename } from './sanitize';
34

45
describe('sanitizeFilename', () => {
56
it('should return normal filenames unchanged', () => {
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Constants definition
2+
/* eslint-disable no-control-regex */
3+
const INVALID_CHARS_REGEX = /[<>:"/\\|?*\u0000-\u001F\u007F-\u009F]/g;
4+
const ZERO_WIDTH_CHARS_REGEX = /[\u200B-\u200D\u2060\uFEFF]/g;
5+
const UNICODE_SPACES_REGEX = /[\u00A0\u2000-\u200A]/g;
6+
const LEADING_TRAILING_DOTS_SPACES_REGEX = /^[\s.]+|[\s.]+$/g;
7+
/* eslint-enable no-control-regex */
8+
9+
const WINDOWS_RESERVED_NAMES = new Set([
10+
'CON',
11+
'PRN',
12+
'AUX',
13+
'NUL',
14+
'COM1',
15+
'COM2',
16+
'COM3',
17+
'COM4',
18+
'COM5',
19+
'COM6',
20+
'COM7',
21+
'COM8',
22+
'COM9',
23+
'LPT1',
24+
'LPT2',
25+
'LPT3',
26+
'LPT4',
27+
'LPT5',
28+
'LPT6',
29+
'LPT7',
30+
'LPT8',
31+
'LPT9',
32+
]);
33+
34+
const DEFAULT_FALLBACK_NAME = 'untitled';
35+
const MAX_FILENAME_LENGTH = 200;
36+
37+
/**
38+
* Sanitizes a filename to be compatible with Mac, Linux, and Windows file systems
39+
*
40+
* Main features:
41+
* - Replace invalid characters (e.g. ":" in hello:world)
42+
* - Handle Windows reserved names
43+
* - Limit filename length
44+
* - Normalize Unicode characters
45+
*
46+
* @param filename - The filename to sanitize (without extension)
47+
* @param maxLength - Maximum filename length (default: 200)
48+
* @returns A sanitized filename (without extension)
49+
*
50+
* @example
51+
* sanitizeFilename('hello:world') // returns 'hello_world'
52+
* sanitizeFilename('CON') // returns '_CON'
53+
* sanitizeFilename('') // returns 'untitled'
54+
*/
55+
export const sanitizeFilename = (
56+
filename: string,
57+
maxLength: number = MAX_FILENAME_LENGTH,
58+
): string => {
59+
// Input validation
60+
if (!filename) {
61+
return DEFAULT_FALLBACK_NAME;
62+
}
63+
64+
let baseName = filename
65+
.trim()
66+
.replace(INVALID_CHARS_REGEX, '_')
67+
.replace(ZERO_WIDTH_CHARS_REGEX, '')
68+
.replace(UNICODE_SPACES_REGEX, ' ')
69+
.replace(LEADING_TRAILING_DOTS_SPACES_REGEX, '');
70+
71+
// Handle empty or invalid filenames after cleaning
72+
if (!baseName) {
73+
baseName = DEFAULT_FALLBACK_NAME;
74+
}
75+
76+
// Handle Windows reserved names
77+
if (WINDOWS_RESERVED_NAMES.has(baseName.toUpperCase())) {
78+
baseName = `_${baseName}`;
79+
}
80+
81+
// Truncate if too long
82+
if (baseName.length > maxLength) {
83+
baseName = baseName.slice(0, maxLength);
84+
}
85+
86+
return baseName;
87+
};

packages/@n8n/utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ export * from './search/reRankSearchResults';
77
export * from './search/sublimeSearch';
88
export * from './sort/sortByProperty';
99
export * from './string/truncate';
10+
export * from './files/sanitize';

0 commit comments

Comments
 (0)