feat: File attachment support in chat (no-changelog) (#21437)

Co-authored-by: Jaakko Husso <jaakko@n8n.io>
Co-authored-by: Alex Grozav <alex@grozav.com>
This commit is contained in:
Suguru Inoue 2025-11-20 14:11:14 +01:00 committed by GitHub
parent 92779ed761
commit 5c58bf919f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 1290 additions and 441 deletions

View File

@ -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<typeof chatAttachmentSchema>;
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[];

View File

@ -27,6 +27,8 @@ export {
emptyChatModelsResponse,
type ChatModelsRequest,
type ChatModelsResponse,
chatAttachmentSchema,
type ChatAttachment,
ChatHubSendMessageRequest,
ChatHubRegenerateMessageRequest,
ChatHubEditMessageRequest,

View File

@ -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']);
}
}

View File

@ -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,
];

View File

@ -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,
];

View File

@ -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 };

View File

@ -408,7 +408,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
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<ExecutionEntity> {
}
const ids = executions.map(({ id, workflowId }) => ({
type: 'execution' as const,
executionId: id,
workflowId,
}));
@ -605,7 +606,11 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
*/
withDeleted: true,
})
).map(({ id: executionId, workflowId }) => ({ workflowId, executionId }));
).map(({ id: executionId, workflowId }) => ({
type: 'execution' as const,
workflowId,
executionId,
}));
return workflowIdsAndExecutionIds;
}

View File

@ -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', () => {

View File

@ -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;
};

View File

@ -7,3 +7,4 @@ export * from './search/reRankSearchResults';
export * from './search/sublimeSearch';
export * from './sort/sortByProperty';
export * from './string/truncate';
export * from './files/sanitize';

View File

@ -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",

View File

@ -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`,
];
}

View File

@ -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 },
]);
});
});

View File

@ -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();

View File

@ -32,6 +32,7 @@ export class ChatHubAgentService {
},
createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(),
allowFileUploads: true,
})),
};
}

View File

@ -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<IBinaryData> | null;
}

View File

@ -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>): 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: {

View File

@ -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<IBinaryData[]> {
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<ArrayBufferLike>; 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<void> {
const messages = await this.messageRepository.getManyBySessionId(sessionId);
await this.deleteAttachments(messages.flatMap((message) => message.attachments ?? []));
}
/**
* Deletes all chat attachment files.
*/
async deleteAll(): Promise<void> {
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<void> {
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<IBinaryData> {
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,
);
}
}

View File

@ -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 */

View File

@ -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<void>((resolve, reject) => {
attachmentAsStreamOrBuffer.stream.on('end', resolve);
attachmentAsStreamOrBuffer.stream.on('error', reject);
attachmentAsStreamOrBuffer.stream.pipe(res);
});
}
@GlobalScope('chatHub:message')
@Post('/conversations/send')
async sendMessage(

View File

@ -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);
}
}

View File

@ -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(),
});

View File

@ -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<ChatHubMessage> {
@ -33,7 +34,7 @@ export class ChatHubMessageRepository extends Repository<ChatHubMessage> {
async updateChatMessage(
id: ChatMessageId,
fields: { status?: ChatHubMessageStatus; content?: string },
fields: { status?: ChatHubMessageStatus; content?: string; attachments?: IBinaryData[] },
trx?: EntityManager,
) {
return await withTransaction(

View File

@ -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);

View File

@ -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<ErrorReporter>();
const binaryDataService = new BinaryDataService(config, errorReporter);
await binaryDataService.init();
Container.set(BinaryDataService, binaryDataService);
}

View File

@ -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<BinaryDataConfig>({ signingSecret });
const errorReporter = mock<ErrorReporter>();
const binaryData = mock<IBinaryData>({ 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', () => {

View File

@ -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<ErrorReporter>();
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);

View File

@ -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,
);

View File

@ -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<string, BinaryData.Manager> = {};
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<INodeExecutionData[] | null>,
) {
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,
}));

View File

@ -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<void> {
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[]) {

View File

@ -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';

View File

@ -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()}`;
}
}
}

View File

@ -32,14 +32,15 @@ export namespace BinaryData {
export type PreWriteMetadata = Omit<Metadata, 'fileSize'>;
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<void>;
store(
workflowId: string,
executionId: string,
location: FileLocation,
bufferOrStream: Buffer | Readable,
metadata: PreWriteMetadata,
): Promise<WriteResult>;
@ -52,12 +53,12 @@ export namespace BinaryData {
/**
* Present for `FileSystem`, absent for `ObjectStore` (delegated to S3 lifecycle config)
*/
deleteMany?(ids: IdsForDeletion): Promise<void>;
deleteMany?(locations: FileLocation[]): Promise<void>;
deleteManyByFileId?(ids: string[]): Promise<void>;
copyByFileId(workflowId: string, executionId: string, sourceFileId: string): Promise<string>;
copyByFileId(targetLocation: FileLocation, sourceFileId: string): Promise<string>;
copyByFilePath(
workflowId: string,
executionId: string,
targetLocation: FileLocation,
sourcePath: string,
metadata: PreWriteMetadata,
): Promise<WriteResult>;

View File

@ -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,
}),
};

View File

@ -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,
);
});

View File

@ -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 };

View File

@ -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<ErrorReporter>();
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 () => {

View File

@ -159,8 +159,7 @@ export async function setBinaryDataBuffer(
executionId: string,
): Promise<IBinaryData> {
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,
);

View File

@ -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() {
<template>
<div class="chat-file" @click="onClick">
<TypeIcon />
<TypeIcon class="chat-icon" />
<p class="chat-file-name">{{ file.name }}</p>
<span v-if="isRemovable" class="chat-file-delete" @click.stop="onDelete">
<IconDelete />
</span>
<IconPreview v-else-if="isPreviewable" class="chat-file-preview" />
<IconPreview v-else-if="isPreviewable || href" class="chat-file-preview" />
</div>
</template>
@ -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 {

View File

@ -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';

View File

@ -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<IBinaryData> {
const reader = new FileReader();
return await new Promise((resolve, reject) => {
reader.onload = () => {
const binaryData: IBinaryData = {
data: (reader.result as string).split('base64,')?.[1] ?? '',
mimeType: file.type,
fileName: file.name,
fileSize: `${file.size} bytes`,
fileExtension: file.name.split('.').pop() ?? '',
fileType: file.type.split('/')[0] as BinaryFileType,
};
resolve(binaryData);
};
reader.onerror = () => {
reject(new Error('Failed to convert file to binary data'));
};
reader.readAsDataURL(file);
});
}
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<ChatAttachment> {
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);
});
}

View File

@ -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<string>();
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);
}
</script>
<template>
<ChatLayout
:class="{
[$style.chatLayout]: true,
[$style.isNewSession]: isNewSession,
[$style.isExistingSession]: !isNewSession,
[$style.isMobileDevice]: isMobileDevice,
[$style.isDraggingFile]: fileDrop.isDragging.value,
}"
@dragenter="fileDrop.handleDragEnter"
@dragleave="fileDrop.handleDragLeave"
@dragover="fileDrop.handleDragOver"
@drop="fileDrop.handleDrop"
@paste="fileDrop.handlePaste"
>
<div v-if="fileDrop.isDragging.value" :class="$style.dropOverlay">
<N8nText size="large" color="text-dark">Drop files here to attach</N8nText>
</div>
<ChatConversationHeader
ref="headerRef"
:selected-model="selectedModel ?? null"
@ -556,6 +582,10 @@ function handleOpenWorkflow(workflowId: string) {
</template>
<style lang="scss" module>
.chatLayout {
position: relative;
}
.scrollArea {
flex-grow: 1;
flex-shrink: 1;
@ -637,4 +667,22 @@ function handleOpenWorkflow(workflowId: string) {
box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.15);
border-radius: 50%;
}
.isDraggingFile {
border-color: var(--color--secondary);
}
.dropOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background-color: color-mix(in srgb, var(--color--background--light-2) 95%, transparent);
pointer-events: none;
}
</style>

View File

@ -174,3 +174,12 @@ export const deleteAgentApi = async (context: IRestApiContext, agentId: string):
const apiEndpoint = `/chat/agents/${agentId}`;
await makeRestApiRequest(context, 'DELETE', apiEndpoint);
};
export function buildChatAttachmentUrl(
context: IRestApiContext,
sessionId: string,
messageId: string,
attachmentIndex: number,
): string {
return `${context.baseUrl}/chat/conversations/${sessionId}/messages/${messageId}/attachments/${attachmentIndex}`;
}

View File

@ -43,14 +43,18 @@ import type {
ChatStreamingState,
} from './chat.types';
import { retry } from '@n8n/utils/retry';
import { convertFileToChatAttachment } from '@/app/utils/fileUtils';
import { buildUiMessages, isMatchedAgent } from './chat.utils';
import { createAiMessageFromStreamingState, flattenModel } from './chat.utils';
import { useToast } from '@/app/composables/useToast';
import { useTelemetry } from '@/app/composables/useTelemetry';
import { type INode } from 'n8n-workflow';
export const useChatStore = defineStore(CHAT_STORE, () => {
const rootStore = useRootStore();
const toast = useToast();
const telemetry = useTelemetry();
const agents = ref<ChatModelsResponse>();
const sessions = ref<ChatHubSessionDto[]>();
const currentEditingAgent = ref<ChatHubAgentDto | null>(null);
@ -402,11 +406,13 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
await fetchSessions();
}
function onStreamError() {
function onStreamError(error: Error) {
if (!streaming.value) {
return;
}
toast.showError(error, 'Could not send message');
const { sessionId } = streaming.value;
streaming.value = undefined;
@ -425,12 +431,13 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
}
}
function sendMessage(
async function sendMessage(
sessionId: ChatSessionId,
message: string,
model: ChatHubConversationModel,
credentials: ChatHubSendMessageRequest['credentials'],
tools: INode[],
files: File[] = [],
) {
const messageId = uuidv4();
const conversation = ensureConversation(sessionId);
@ -438,6 +445,8 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
? conversation.activeMessageChain[conversation.activeMessageChain.length - 1]
: null;
const attachments = await Promise.all(files.map(convertFileToChatAttachment));
addMessage(sessionId, {
id: messageId,
sessionId,
@ -457,6 +466,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
revisionOfMessageId: null,
responses: [],
alternatives: [],
attachments,
});
streaming.value = {
@ -476,6 +486,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
credentials,
previousMessageId,
tools,
attachments,
},
onStreamMessage,
onStreamDone,
@ -522,6 +533,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
revisionOfMessageId: editId,
responses: [],
alternatives: [],
attachments: message.attachments ?? null,
});
} else if (message?.type === 'ai') {
replaceMessageContent(sessionId, editId, content);
@ -674,6 +686,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
tools: agent.tools,
allowFileUploads: true,
};
agents.value?.['custom-agent'].models.push(agentModel);
@ -736,6 +749,7 @@ export const useChatStore = defineStore(CHAT_STORE, () => {
description: null,
createdAt: null,
updatedAt: null,
allowFileUploads: true,
};
}

View File

@ -251,6 +251,7 @@ export function createAiMessageFromStreamingState(
revisionOfMessageId: null,
responses: [],
alternatives: [],
attachments: [],
...(streaming?.model
? flattenModel(streaming.model)
: {

View File

@ -14,6 +14,9 @@ import type { ChatMessage } from '../chat.types';
import ChatMessageActions from './ChatMessageActions.vue';
import { unflattenModel } from '@/features/ai/chatHub/chat.utils';
import { useAgent } from '@/features/ai/chatHub/composables/useAgent';
import ChatFile from '@n8n/chat/components/ChatFile.vue';
import { buildChatAttachmentUrl } from '@/features/ai/chatHub/chat.api';
import { useRootStore } from '@n8n/stores/useRootStore';
const { message, compact, isEditing, isStreaming, minHeight } = defineProps<{
message: ChatMessage;
@ -35,6 +38,7 @@ const emit = defineEmits<{
}>();
const clipboard = useClipboard();
const rootStore = useRootStore();
const editedText = ref('');
const textareaRef = useTemplateRef('textarea');
@ -51,6 +55,18 @@ const speech = useSpeechSynthesis(messageContent, {
const model = computed(() => unflattenModel(message));
const agent = useAgent(model);
const attachments = computed(() =>
message.attachments.map(({ fileName, mimeType }, index) => ({
file: new File([], fileName ?? 'file', { type: mimeType }), // Placeholder file for display
downloadUrl: buildChatAttachmentUrl(
rootStore.restApiContext,
message.sessionId,
message.id,
index,
),
})),
);
async function handleCopy() {
const text = message.content;
await clipboard.copy(text);
@ -163,6 +179,15 @@ onBeforeMount(() => {
</div>
<template v-else>
<div :class="[$style.chatMessage, { [$style.errorMessage]: message.status === 'error' }]">
<div v-if="attachments.length > 0" :class="$style.attachments">
<ChatFile
v-for="(attachment, index) in attachments"
:key="index"
:file="attachment.file"
:is-removable="false"
:href="attachment.downloadUrl"
/>
</div>
<VueMarkdown
:key="forceReRenderKey"
:class="[$style.chatMessageMarkdown, 'chat-message-markdown']"
@ -225,8 +250,17 @@ onBeforeMount(() => {
flex-direction: column;
}
.attachments {
display: flex;
flex-wrap: wrap;
gap: var(--spacing--2xs);
margin-top: var(--spacing--xs);
}
.chatMessage {
display: block;
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
position: relative;
max-width: fit-content;

View File

@ -2,6 +2,7 @@
import { useToast } from '@/app/composables/useToast';
import { providerDisplayNames } from '@/features/ai/chatHub/constants';
import type { ChatHubLLMProvider, ChatModelDto } from '@n8n/api-types';
import ChatFile from '@n8n/chat/components/ChatFile.vue';
import { N8nIconButton, N8nInput, N8nText } from '@n8n/design-system';
import { useSpeechRecognition } from '@vueuse/core';
import type { INode } from 'n8n-workflow';
@ -18,7 +19,7 @@ const { selectedModel, selectedTools, isMissingCredentials } = defineProps<{
}>();
const emit = defineEmits<{
submit: [string];
submit: [message: string, attachments: File[]];
stop: [];
selectModel: [];
selectTools: [INode[]];
@ -26,7 +27,9 @@ const emit = defineEmits<{
}>();
const inputRef = useTemplateRef<HTMLElement>('inputRef');
const fileInputRef = useTemplateRef<HTMLInputElement>('fileInputRef');
const message = ref('');
const attachments = ref<File[]>([]);
const toast = useToast();
@ -58,12 +61,43 @@ function onStop() {
emit('stop');
}
function onAttach() {
fileInputRef.value?.click();
}
function handleFileSelect(e: Event) {
const target = e.target as HTMLInputElement;
const files = target.files;
if (!files || files.length === 0) {
return;
}
// Store File objects directly instead of converting to base64
for (const file of Array.from(files)) {
attachments.value.push(file);
}
// Reset input
if (target) {
target.value = '';
}
inputRef.value?.focus();
}
function removeAttachment(removed: File) {
attachments.value = attachments.value.filter((attachment) => attachment !== removed);
}
function handleSubmitForm() {
const trimmed = message.value.trim();
if (trimmed) {
speechInput.stop();
emit('submit', trimmed);
emit('submit', trimmed, attachments.value);
message.value = '';
attachments.value = [];
}
}
@ -73,10 +107,16 @@ function handleKeydownTextarea(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && trimmed) {
e.preventDefault();
speechInput.stop();
emit('submit', trimmed);
emit('submit', trimmed, attachments.value);
message.value = '';
attachments.value = [];
}
}
function handleClickInputWrapper() {
inputRef.value?.focus();
}
watch(speechInput.result, (spoken) => {
if (spoken) {
message.value = spoken;
@ -109,6 +149,10 @@ defineExpose({
setText: (text: string) => {
message.value = text;
},
addAttachments: (files: File[]) => {
attachments.value.push(...files);
inputRef.value?.focus();
},
});
</script>
@ -137,66 +181,89 @@ defineExpose({
for {{ providerDisplayNames[llmProvider] }} to continue the conversation
</template>
</N8nText>
<N8nInput
ref="inputRef"
v-model="message"
:class="$style.input"
type="textarea"
:placeholder="placeholder"
autocomplete="off"
:autosize="{ minRows: 1, maxRows: 6 }"
autofocus
:disabled="isMissingCredentials || !selectedModel"
@keydown="handleKeydownTextarea"
<input
ref="fileInputRef"
type="file"
:class="$style.fileInput"
multiple
accept="image/*,.pdf,.doc,.docx,.txt"
@change="handleFileSelect"
/>
<div v-if="isToolsSelectable" :class="$style.tools">
<ToolsSelector
:selected="selectedTools ?? []"
:disabled="isMissingCredentials || !selectedModel || isResponding"
@select="onSelectTools"
/>
</div>
<div :class="$style.inputWrapper" @click="handleClickInputWrapper">
<div v-if="attachments.length > 0" :class="$style.attachments">
<ChatFile
v-for="(file, index) in attachments"
:key="index"
:file="file"
:is-previewable="true"
:is-removable="true"
@remove="removeAttachment"
/>
</div>
<div :class="$style.actions">
<!-- TODO: Implement attachments
<N8nIconButton
native-type="button"
type="secondary"
title="Attach"
:disabled="isMissingCredentials || !selectedModel || isResponding"
icon="paperclip"
icon-size="large"
text
@click="onAttach"
/> -->
<N8nIconButton
v-if="speechInput.isSupported"
native-type="button"
:title="speechInput.isListening.value ? 'Stop recording' : 'Voice input'"
type="secondary"
:disabled="isMissingCredentials || !selectedModel || isResponding"
:icon="speechInput.isListening.value ? 'square' : 'mic'"
:class="{ [$style.recording]: speechInput.isListening.value }"
icon-size="large"
@click="onMic"
/>
<N8nIconButton
v-if="!isResponding"
native-type="submit"
:disabled="isMissingCredentials || !selectedModel || !message.trim()"
title="Send"
icon="arrow-up"
icon-size="large"
/>
<N8nIconButton
v-else
native-type="button"
title="Stop generating"
icon="square"
icon-size="large"
@click="onStop"
<N8nInput
ref="inputRef"
v-model="message"
type="textarea"
:placeholder="placeholder"
autocomplete="off"
:autosize="{ minRows: 1, maxRows: 6 }"
autofocus
:disabled="isMissingCredentials || !selectedModel"
@keydown="handleKeydownTextarea"
/>
<div :class="$style.footer">
<div v-if="isToolsSelectable" :class="$style.tools">
<ToolsSelector
:selected="selectedTools ?? []"
:disabled="isMissingCredentials || !selectedModel || isResponding"
@select="onSelectTools"
/>
</div>
<div :class="$style.actions">
<N8nIconButton
v-if="selectedModel?.allowFileUploads"
native-type="button"
type="secondary"
title="Attach"
:disabled="isMissingCredentials || isResponding"
icon="paperclip"
icon-size="large"
text
@click.stop="onAttach"
/>
<N8nIconButton
v-if="speechInput.isSupported"
native-type="button"
:title="speechInput.isListening.value ? 'Stop recording' : 'Voice input'"
type="secondary"
:disabled="isMissingCredentials || !selectedModel || isResponding"
:icon="speechInput.isListening.value ? 'square' : 'mic'"
:class="{ [$style.recording]: speechInput.isListening.value }"
icon-size="large"
@click.stop="onMic"
/>
<N8nIconButton
v-if="!isResponding"
native-type="submit"
:disabled="isMissingCredentials || !selectedModel || !message.trim()"
title="Send"
icon="arrow-up"
icon-size="large"
@click.stop
/>
<N8nIconButton
v-else
native-type="button"
title="Stop generating"
icon="square"
icon-size="large"
@click.stop="onStop"
/>
</div>
</div>
</div>
</div>
</form>
@ -234,34 +301,89 @@ defineExpose({
}
}
.input {
.fileInput {
display: none;
}
.inputWrapper {
width: 100%;
border-radius: 16px !important;
padding: 16px;
box-shadow: 0 10px 24px 0 #00000010;
background-color: var(--color--background--light-3);
border: var(--border);
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
&:focus-within,
&:hover {
border-color: var(--color--secondary);
}
& textarea {
font: inherit;
line-height: 1.5em;
border-radius: 16px !important;
resize: none;
padding: 16px 16px 64px;
box-shadow: 0 10px 24px 0 #00000010;
background-color: var(--color--background--light-3);
background-color: transparent !important;
border: none !important;
padding: 0 !important;
}
}
.footer {
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: var(--spacing--sm);
}
.tools {
position: absolute;
left: 0;
bottom: 0;
padding: var(--spacing--sm);
flex-grow: 1;
}
.toolsButton {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
padding: var(--spacing--3xs) var(--spacing--xs);
color: var(--color--text);
cursor: pointer;
border-radius: var(--radius);
border: var(--border);
background: var(--color--background--light-3);
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.iconStack {
display: flex;
align-items: center;
position: relative;
}
.icon {
padding: var(--spacing--4xs);
background-color: var(--button--color--background--secondary);
border-radius: 50%;
outline: 2px var(--color--background--light-3) solid;
}
.iconOverlap {
margin-left: -6px;
}
.iconFallback {
display: flex;
align-items: center;
justify-content: center;
}
/* Right-side actions */
.actions {
position: absolute;
right: 0;
bottom: 0;
padding: var(--spacing--sm);
display: flex;
align-items: center;
gap: var(--spacing--2xs);
@ -271,6 +393,12 @@ defineExpose({
}
}
.attachments {
display: flex;
flex-wrap: wrap;
gap: var(--spacing--2xs);
}
.recording {
animation: chatHubPromptRecordingPulse 1.5s ease-in-out infinite;
}

View File

@ -68,6 +68,7 @@ function handleSelectModelById(provider: ChatHubLLMProvider, modelId: string) {
description: null,
updatedAt: null,
createdAt: null,
allowFileUploads: true,
});
}

View File

@ -0,0 +1,97 @@
import { ref, type Ref } from 'vue';
export function useFileDrop(canAcceptFiles: Ref<boolean>, onFilesDropped: (files: File[]) => void) {
const isDragging = ref(false);
function handleDragEnter(e: DragEvent) {
if (!canAcceptFiles.value) {
return;
}
// Check if dragging files (not text or other content)
if (e.dataTransfer?.types.includes('Files')) {
isDragging.value = true;
}
}
function handleDragLeave(e: DragEvent) {
if (!canAcceptFiles.value) {
return;
}
// Only hide overlay if leaving the component
const target = e.currentTarget as HTMLElement;
const relatedTarget = e.relatedTarget as Node | null;
if (relatedTarget && target.contains(relatedTarget)) {
return;
}
isDragging.value = false;
}
function handleDragOver(e: DragEvent) {
if (!canAcceptFiles.value) {
return;
}
e.preventDefault();
e.stopPropagation();
}
function handleDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
isDragging.value = false;
if (!canAcceptFiles.value) {
return;
}
const files = e.dataTransfer?.files;
if (!files || files.length === 0) {
return;
}
onFilesDropped(Array.from(files));
}
function handlePaste(e: ClipboardEvent) {
if (!canAcceptFiles.value) {
return;
}
const items = e.clipboardData?.items;
if (!items) {
return;
}
let hasFiles = false;
const files: File[] = [];
for (const item of Array.from(items)) {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
files.push(file);
hasFiles = true;
}
}
}
// Prevent default paste behavior if files were found
if (hasFiles) {
e.preventDefault();
onFilesDropped(files);
}
}
return {
isDragging,
handleDragEnter,
handleDragLeave,
handleDragOver,
handleDrop,
handlePaste,
};
}

View File

@ -7,8 +7,6 @@ import type {
INodeExecutionData,
IBinaryKeyData,
IDataObject,
IBinaryData,
BinaryFileType,
IRunExecutionData,
} from 'n8n-workflow';
import { useToast } from '@/app/composables/useToast';
@ -18,12 +16,12 @@ import { MODAL_CONFIRM } from '@/app/constants';
import { useI18n } from '@n8n/i18n';
import type { INodeUi } from '@/Interface';
import type { IExecutionPushResponse } from '@/features/execution/executions/executions.types';
import {
extractBotResponse,
getInputKey,
processFiles,
} from '@/features/execution/logs/logs.utils';
import { convertFileToBinaryData } from '@/app/utils/fileUtils';
export type RunWorkflowChatPayload = {
triggerNode: string;
@ -59,28 +57,6 @@ export function useChatMessaging({
isLoading.value = loading;
};
/** Converts a file to binary data */
async function convertFileToBinaryData(file: File): Promise<IBinaryData> {
const reader = new FileReader();
return await new Promise((resolve, reject) => {
reader.onload = () => {
const binaryData: IBinaryData = {
data: (reader.result as string).split('base64,')?.[1] ?? '',
mimeType: file.type,
fileName: file.name,
fileSize: `${file.size} bytes`,
fileExtension: file.name.split('.').pop() ?? '',
fileType: file.type.split('/')[0] as BinaryFileType,
};
resolve(binaryData);
};
reader.onerror = () => {
reject(new Error('Failed to convert file to binary data'));
};
reader.readAsDataURL(file);
});
}
/** Gets keyed files for the workflow input */
async function getKeyedFiles(files: File[]): Promise<IBinaryKeyData> {
const binaryData: IBinaryKeyData = {};

3
pnpm-lock.yaml generated
View File

@ -1523,6 +1523,9 @@ importers:
'@n8n/typeorm':
specifier: 'catalog:'
version: 0.3.20-15(@sentry/node@9.42.1)(mysql2@3.15.0)(pg@8.12.0)(sqlite3@5.1.7)
'@n8n/utils':
specifier: workspace:*
version: link:../@n8n/utils
'@n8n_io/ai-assistant-sdk':
specifier: 'catalog:'
version: 1.17.0