mirror of
https://github.com/n8n-io/n8n.git
synced 2025-11-20 17:46:34 +00:00
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:
parent
92779ed761
commit
5c58bf919f
@ -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[];
|
||||
|
||||
@ -27,6 +27,8 @@ export {
|
||||
emptyChatModelsResponse,
|
||||
type ChatModelsRequest,
|
||||
type ChatModelsResponse,
|
||||
chatAttachmentSchema,
|
||||
type ChatAttachment,
|
||||
ChatHubSendMessageRequest,
|
||||
ChatHubRegenerateMessageRequest,
|
||||
ChatHubEditMessageRequest,
|
||||
|
||||
@ -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']);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
87
packages/@n8n/utils/src/files/sanitize.ts
Normal file
87
packages/@n8n/utils/src/files/sanitize.ts
Normal 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;
|
||||
};
|
||||
@ -7,3 +7,4 @@ export * from './search/reRankSearchResults';
|
||||
export * from './search/sublimeSearch';
|
||||
export * from './sort/sortByProperty';
|
||||
export * from './string/truncate';
|
||||
export * from './files/sanitize';
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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`,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -32,6 +32,7 @@ export class ChatHubAgentService {
|
||||
},
|
||||
createdAt: agent.createdAt.toISOString(),
|
||||
updatedAt: agent.updatedAt.toISOString(),
|
||||
allowFileUploads: true,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
160
packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts
Normal file
160
packages/cli/src/modules/chat-hub/chat-hub.attachment.service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 */
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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,
|
||||
}));
|
||||
|
||||
@ -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[]) {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -251,6 +251,7 @@ export function createAiMessageFromStreamingState(
|
||||
revisionOfMessageId: null,
|
||||
responses: [],
|
||||
alternatives: [],
|
||||
attachments: [],
|
||||
...(streaming?.model
|
||||
? flattenModel(streaming.model)
|
||||
: {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -68,6 +68,7 @@ function handleSelectModelById(provider: ChatHubLLMProvider, modelId: string) {
|
||||
description: null,
|
||||
updatedAt: null,
|
||||
createdAt: null,
|
||||
allowFileUploads: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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
3
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user