mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat(Chat Trigger Node): Add support for file uploads & harmonize public and development chat (#9802)
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
@@ -38,6 +38,14 @@ export const toolsAgentProperties: INodeProperties[] = [
|
||||
default: false,
|
||||
description: 'Whether or not the output should include intermediate steps the agent took',
|
||||
},
|
||||
{
|
||||
displayName: 'Automatically Passthrough Binary Images',
|
||||
name: 'passthroughBinaryImages',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Whether or not binary images should be automatically passed through to the agent as image type messages',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
||||
import { BINARY_ENCODING, NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import type { AgentAction, AgentFinish, AgentStep } from 'langchain/agents';
|
||||
import { AgentExecutor, createToolCallingAgent } from 'langchain/agents';
|
||||
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
|
||||
import type { BaseMessagePromptTemplateLike } from '@langchain/core/prompts';
|
||||
import { ChatPromptTemplate } from '@langchain/core/prompts';
|
||||
import { omit } from 'lodash';
|
||||
import type { Tool } from '@langchain/core/tools';
|
||||
@@ -13,6 +14,7 @@ import type { ZodObject } from 'zod';
|
||||
import { z } from 'zod';
|
||||
import type { BaseOutputParser, StructuredOutputParser } from '@langchain/core/output_parsers';
|
||||
import { OutputFixingParser } from 'langchain/output_parsers';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import {
|
||||
isChatInstance,
|
||||
getPromptInputByType,
|
||||
@@ -39,6 +41,40 @@ function getOutputParserSchema(outputParser: BaseOutputParser): ZodObject<any, a
|
||||
return schema;
|
||||
}
|
||||
|
||||
async function extractBinaryMessages(ctx: IExecuteFunctions) {
|
||||
const binaryData = ctx.getInputData(0, 'main')?.[0]?.binary ?? {};
|
||||
const binaryMessages = await Promise.all(
|
||||
Object.values(binaryData)
|
||||
.filter((data) => data.mimeType.startsWith('image/'))
|
||||
.map(async (data) => {
|
||||
let binaryUrlString;
|
||||
|
||||
// In filesystem mode we need to get binary stream by id before converting it to buffer
|
||||
if (data.id) {
|
||||
const binaryBuffer = await ctx.helpers.binaryToBuffer(
|
||||
await ctx.helpers.getBinaryStream(data.id),
|
||||
);
|
||||
|
||||
binaryUrlString = `data:${data.mimeType};base64,${Buffer.from(binaryBuffer).toString(BINARY_ENCODING)}`;
|
||||
} else {
|
||||
binaryUrlString = data.data.includes('base64')
|
||||
? data.data
|
||||
: `data:${data.mimeType};base64,${data.data}`;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: binaryUrlString,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
return new HumanMessage({
|
||||
content: [...binaryMessages],
|
||||
});
|
||||
}
|
||||
|
||||
export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
this.logger.verbose('Executing Tools Agent');
|
||||
const model = await this.getInputConnectionData(NodeConnectionType.AiLanguageModel, 0);
|
||||
@@ -113,12 +149,20 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeE
|
||||
returnIntermediateSteps?: boolean;
|
||||
};
|
||||
|
||||
const prompt = ChatPromptTemplate.fromMessages([
|
||||
const passthroughBinaryImages = this.getNodeParameter('options.passthroughBinaryImages', 0, true);
|
||||
const messages: BaseMessagePromptTemplateLike[] = [
|
||||
['system', `{system_message}${outputParser ? '\n\n{formatting_instructions}' : ''}`],
|
||||
['placeholder', '{chat_history}'],
|
||||
['human', '{input}'],
|
||||
['placeholder', '{agent_scratchpad}'],
|
||||
]);
|
||||
];
|
||||
|
||||
const hasBinaryData = this.getInputData(0, 'main')?.[0]?.binary !== undefined;
|
||||
if (hasBinaryData && passthroughBinaryImages) {
|
||||
const binaryMessage = await extractBinaryMessages(this);
|
||||
messages.push(binaryMessage);
|
||||
}
|
||||
const prompt = ChatPromptTemplate.fromMessages(messages);
|
||||
|
||||
const agent = createToolCallingAgent({
|
||||
llm: model,
|
||||
|
||||
@@ -109,6 +109,30 @@ export class DocumentDefaultDataLoader implements INodeType {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Mode',
|
||||
name: 'binaryMode',
|
||||
type: 'options',
|
||||
default: 'allInputData',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
dataType: ['binary'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Load All Input Data',
|
||||
value: 'allInputData',
|
||||
description: 'Use all Binary data that flows into the parent agent or chain',
|
||||
},
|
||||
{
|
||||
name: 'Load Specific Data',
|
||||
value: 'specificField',
|
||||
description: 'Load data from a specific field in the parent agent or chain',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Data Format',
|
||||
name: 'loader',
|
||||
@@ -187,6 +211,9 @@ export class DocumentDefaultDataLoader implements INodeType {
|
||||
show: {
|
||||
dataType: ['binary'],
|
||||
},
|
||||
hide: {
|
||||
binaryMode: ['allInputData'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import {
|
||||
type IDataObject,
|
||||
type IWebhookFunctions,
|
||||
type IWebhookResponseData,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
NodeConnectionType,
|
||||
import { Node, NodeConnectionType } from 'n8n-workflow';
|
||||
import type {
|
||||
IDataObject,
|
||||
IWebhookFunctions,
|
||||
IWebhookResponseData,
|
||||
INodeTypeDescription,
|
||||
MultiPartFormData,
|
||||
INodeExecutionData,
|
||||
IBinaryData,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { pick } from 'lodash';
|
||||
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
|
||||
import { createPage } from './templates';
|
||||
@@ -13,15 +17,31 @@ import { validateAuth } from './GenericFunctions';
|
||||
import type { LoadPreviousSessionChatOption } from './types';
|
||||
|
||||
const CHAT_TRIGGER_PATH_IDENTIFIER = 'chat';
|
||||
const allowFileUploadsOption: INodeProperties = {
|
||||
displayName: 'Allow File Uploads',
|
||||
name: 'allowFileUploads',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to allow file uploads in the chat',
|
||||
};
|
||||
const allowedFileMimeTypeOption: INodeProperties = {
|
||||
displayName: 'Allowed File Mime Types',
|
||||
name: 'allowedFilesMimeTypes',
|
||||
type: 'string',
|
||||
default: '*',
|
||||
placeholder: 'e.g. image/*, text/*, application/pdf',
|
||||
description:
|
||||
'Allowed file types for upload. Comma-separated list of <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types" target="_blank">MIME types</a>.',
|
||||
};
|
||||
|
||||
export class ChatTrigger implements INodeType {
|
||||
export class ChatTrigger extends Node {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Chat Trigger',
|
||||
name: 'chatTrigger',
|
||||
icon: 'fa:comments',
|
||||
iconColor: 'black',
|
||||
group: ['trigger'],
|
||||
version: 1,
|
||||
version: [1, 1.1],
|
||||
description: 'Runs the workflow when an n8n generated webchat is submitted',
|
||||
defaults: {
|
||||
name: 'When chat message received',
|
||||
@@ -194,6 +214,20 @@ export class ChatTrigger implements INodeType {
|
||||
default: 'Hi there! 👋\nMy name is Nathan. How can I assist you today?',
|
||||
description: 'Default messages shown at the start of the chat, one per line',
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
displayOptions: {
|
||||
show: {
|
||||
public: [false],
|
||||
'@version': [{ _cnd: { gte: 1.1 } }],
|
||||
},
|
||||
},
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
options: [allowFileUploadsOption, allowedFileMimeTypeOption],
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
@@ -207,6 +241,22 @@ export class ChatTrigger implements INodeType {
|
||||
placeholder: 'Add Field',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
...allowFileUploadsOption,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/mode': ['hostedChat'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...allowedFileMimeTypeOption,
|
||||
displayOptions: {
|
||||
show: {
|
||||
'/mode': ['hostedChat'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Input Placeholder',
|
||||
name: 'inputPlaceholder',
|
||||
@@ -320,11 +370,73 @@ export class ChatTrigger implements INodeType {
|
||||
],
|
||||
};
|
||||
|
||||
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
|
||||
const res = this.getResponseObject();
|
||||
private async handleFormData(context: IWebhookFunctions) {
|
||||
const req = context.getRequestObject() as MultiPartFormData.Request;
|
||||
const options = context.getNodeParameter('options', {}) as IDataObject;
|
||||
const { data, files } = req.body;
|
||||
|
||||
const isPublic = this.getNodeParameter('public', false) as boolean;
|
||||
const nodeMode = this.getNodeParameter('mode', 'hostedChat') as string;
|
||||
const returnItem: INodeExecutionData = {
|
||||
json: data,
|
||||
};
|
||||
|
||||
if (files && Object.keys(files).length) {
|
||||
returnItem.json.files = [] as Array<Omit<IBinaryData, 'data'>>;
|
||||
returnItem.binary = {};
|
||||
|
||||
const count = 0;
|
||||
for (const fileKey of Object.keys(files)) {
|
||||
const processedFiles: MultiPartFormData.File[] = [];
|
||||
if (Array.isArray(files[fileKey])) {
|
||||
processedFiles.push(...files[fileKey]);
|
||||
} else {
|
||||
processedFiles.push(files[fileKey]);
|
||||
}
|
||||
|
||||
let fileIndex = 0;
|
||||
for (const file of processedFiles) {
|
||||
let binaryPropertyName = 'data';
|
||||
|
||||
// Remove the '[]' suffix from the binaryPropertyName if it exists
|
||||
if (binaryPropertyName.endsWith('[]')) {
|
||||
binaryPropertyName = binaryPropertyName.slice(0, -2);
|
||||
}
|
||||
if (options.binaryPropertyName) {
|
||||
binaryPropertyName = `${options.binaryPropertyName.toString()}${count}`;
|
||||
}
|
||||
|
||||
const binaryFile = await context.nodeHelpers.copyBinaryFile(
|
||||
file.filepath,
|
||||
file.originalFilename ?? file.newFilename,
|
||||
file.mimetype,
|
||||
);
|
||||
|
||||
const binaryKey = `${binaryPropertyName}${fileIndex}`;
|
||||
|
||||
const binaryInfo = {
|
||||
...pick(binaryFile, ['fileName', 'fileSize', 'fileType', 'mimeType', 'fileExtension']),
|
||||
binaryKey,
|
||||
};
|
||||
|
||||
returnItem.binary = Object.assign(returnItem.binary ?? {}, {
|
||||
[`${binaryKey}`]: binaryFile,
|
||||
});
|
||||
returnItem.json.files = [
|
||||
...(returnItem.json.files as Array<Omit<IBinaryData, 'data'>>),
|
||||
binaryInfo,
|
||||
];
|
||||
fileIndex += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return returnItem;
|
||||
}
|
||||
|
||||
async webhook(ctx: IWebhookFunctions): Promise<IWebhookResponseData> {
|
||||
const res = ctx.getResponseObject();
|
||||
|
||||
const isPublic = ctx.getNodeParameter('public', false) as boolean;
|
||||
const nodeMode = ctx.getNodeParameter('mode', 'hostedChat') as string;
|
||||
if (!isPublic) {
|
||||
res.status(404).end();
|
||||
return {
|
||||
@@ -332,22 +444,25 @@ export class ChatTrigger implements INodeType {
|
||||
};
|
||||
}
|
||||
|
||||
const webhookName = this.getWebhookName();
|
||||
const mode = this.getMode() === 'manual' ? 'test' : 'production';
|
||||
const bodyData = this.getBodyData() ?? {};
|
||||
|
||||
const options = this.getNodeParameter('options', {}) as {
|
||||
const options = ctx.getNodeParameter('options', {}) as {
|
||||
getStarted?: string;
|
||||
inputPlaceholder?: string;
|
||||
loadPreviousSession?: LoadPreviousSessionChatOption;
|
||||
showWelcomeScreen?: boolean;
|
||||
subtitle?: string;
|
||||
title?: string;
|
||||
allowFileUploads?: boolean;
|
||||
allowedFilesMimeTypes?: string;
|
||||
};
|
||||
|
||||
const req = ctx.getRequestObject();
|
||||
const webhookName = ctx.getWebhookName();
|
||||
const mode = ctx.getMode() === 'manual' ? 'test' : 'production';
|
||||
const bodyData = ctx.getBodyData() ?? {};
|
||||
|
||||
if (nodeMode === 'hostedChat') {
|
||||
try {
|
||||
await validateAuth(this);
|
||||
await validateAuth(ctx);
|
||||
} catch (error) {
|
||||
if (error) {
|
||||
res.writeHead((error as IDataObject).responseCode as number, {
|
||||
@@ -361,19 +476,19 @@ export class ChatTrigger implements INodeType {
|
||||
|
||||
// Show the chat on GET request
|
||||
if (webhookName === 'setup') {
|
||||
const webhookUrlRaw = this.getNodeWebhookUrl('default') as string;
|
||||
const webhookUrlRaw = ctx.getNodeWebhookUrl('default') as string;
|
||||
const webhookUrl =
|
||||
mode === 'test' ? webhookUrlRaw.replace('/webhook', '/webhook-test') : webhookUrlRaw;
|
||||
const authentication = this.getNodeParameter('authentication') as
|
||||
const authentication = ctx.getNodeParameter('authentication') as
|
||||
| 'none'
|
||||
| 'basicAuth'
|
||||
| 'n8nUserAuth';
|
||||
const initialMessagesRaw = this.getNodeParameter('initialMessages', '') as string;
|
||||
const initialMessagesRaw = ctx.getNodeParameter('initialMessages', '') as string;
|
||||
const initialMessages = initialMessagesRaw
|
||||
.split('\n')
|
||||
.filter((line) => line)
|
||||
.map((line) => line.trim());
|
||||
const instanceId = this.getInstanceId();
|
||||
const instanceId = ctx.getInstanceId();
|
||||
|
||||
const i18nConfig = pick(options, ['getStarted', 'inputPlaceholder', 'subtitle', 'title']);
|
||||
|
||||
@@ -388,6 +503,8 @@ export class ChatTrigger implements INodeType {
|
||||
mode,
|
||||
instanceId,
|
||||
authentication,
|
||||
allowFileUploads: options.allowFileUploads,
|
||||
allowedFilesMimeTypes: options.allowedFilesMimeTypes,
|
||||
});
|
||||
|
||||
res.status(200).send(page).end();
|
||||
@@ -399,7 +516,7 @@ export class ChatTrigger implements INodeType {
|
||||
|
||||
if (bodyData.action === 'loadPreviousSession') {
|
||||
if (options?.loadPreviousSession === 'memory') {
|
||||
const memory = (await this.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as
|
||||
const memory = (await ctx.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as
|
||||
| BaseChatMemory
|
||||
| undefined;
|
||||
const messages = ((await memory?.chatHistory.getMessages()) ?? [])
|
||||
@@ -416,11 +533,21 @@ export class ChatTrigger implements INodeType {
|
||||
}
|
||||
}
|
||||
|
||||
const returnData: IDataObject = { ...bodyData };
|
||||
let returnData: INodeExecutionData[];
|
||||
const webhookResponse: IDataObject = { status: 200 };
|
||||
if (req.contentType === 'multipart/form-data') {
|
||||
returnData = [await this.handleFormData(ctx)];
|
||||
return {
|
||||
webhookResponse,
|
||||
workflowData: [returnData],
|
||||
};
|
||||
} else {
|
||||
returnData = [{ json: bodyData }];
|
||||
}
|
||||
|
||||
return {
|
||||
webhookResponse,
|
||||
workflowData: [this.helpers.returnJsonArray(returnData)],
|
||||
workflowData: [ctx.helpers.returnJsonArray(returnData)],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ export function createPage({
|
||||
i18n: { en },
|
||||
initialMessages,
|
||||
authentication,
|
||||
allowFileUploads,
|
||||
allowedFilesMimeTypes,
|
||||
}: {
|
||||
instanceId: string;
|
||||
webhookUrl?: string;
|
||||
@@ -19,6 +21,8 @@ export function createPage({
|
||||
initialMessages: string[];
|
||||
mode: 'test' | 'production';
|
||||
authentication: AuthenticationChatOption;
|
||||
allowFileUploads?: boolean;
|
||||
allowedFilesMimeTypes?: string;
|
||||
}) {
|
||||
const validAuthenticationOptions: AuthenticationChatOption[] = [
|
||||
'none',
|
||||
@@ -35,6 +39,8 @@ export function createPage({
|
||||
? authentication
|
||||
: 'none';
|
||||
const sanitizedShowWelcomeScreen = !!showWelcomeScreen;
|
||||
const sanitizedAllowFileUploads = !!allowFileUploads;
|
||||
const sanitizedAllowedFilesMimeTypes = allowedFilesMimeTypes?.toString() ?? '';
|
||||
const sanitizedLoadPreviousSession = validLoadPreviousSessionOptions.includes(
|
||||
loadPreviousSession as LoadPreviousSessionChatOption,
|
||||
)
|
||||
@@ -103,6 +109,8 @@ export function createPage({
|
||||
'X-Instance-Id': '${instanceId}',
|
||||
}
|
||||
},
|
||||
allowFileUploads: ${sanitizedAllowFileUploads},
|
||||
allowedFilesMimeTypes: '${sanitizedAllowedFilesMimeTypes}',
|
||||
i18n: {
|
||||
${en ? `en: ${JSON.stringify(en)},` : ''}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { createWriteStream } from 'fs';
|
||||
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import type { IBinaryData, IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
|
||||
import { NodeOperationError, BINARY_ENCODING } from 'n8n-workflow';
|
||||
|
||||
import type { TextSplitter } from '@langchain/textsplitters';
|
||||
@@ -60,21 +60,10 @@ export class N8nBinaryLoader {
|
||||
return docs;
|
||||
}
|
||||
|
||||
async processItem(item: INodeExecutionData, itemIndex: number): Promise<Document[]> {
|
||||
const selectedLoader: keyof typeof SUPPORTED_MIME_TYPES = this.context.getNodeParameter(
|
||||
'loader',
|
||||
itemIndex,
|
||||
'auto',
|
||||
) as keyof typeof SUPPORTED_MIME_TYPES;
|
||||
|
||||
const docs: Document[] = [];
|
||||
const metadata = getMetadataFiltersValues(this.context, itemIndex);
|
||||
|
||||
if (!item) return [];
|
||||
|
||||
const binaryData = this.context.helpers.assertBinaryData(itemIndex, this.binaryDataKey);
|
||||
const { mimeType } = binaryData;
|
||||
|
||||
private async validateMimeType(
|
||||
mimeType: string,
|
||||
selectedLoader: keyof typeof SUPPORTED_MIME_TYPES,
|
||||
): Promise<void> {
|
||||
// Check if loader matches the mime-type of the data
|
||||
if (selectedLoader !== 'auto' && !SUPPORTED_MIME_TYPES[selectedLoader].includes(mimeType)) {
|
||||
const neededLoader = Object.keys(SUPPORTED_MIME_TYPES).find((loader) =>
|
||||
@@ -90,6 +79,7 @@ export class N8nBinaryLoader {
|
||||
if (!Object.values(SUPPORTED_MIME_TYPES).flat().includes(mimeType)) {
|
||||
throw new NodeOperationError(this.context.getNode(), `Unsupported mime type: ${mimeType}`);
|
||||
}
|
||||
|
||||
if (
|
||||
!SUPPORTED_MIME_TYPES[selectedLoader].includes(mimeType) &&
|
||||
selectedLoader !== 'textLoader' &&
|
||||
@@ -100,24 +90,31 @@ export class N8nBinaryLoader {
|
||||
`Unsupported mime type: ${mimeType} for selected loader: ${selectedLoader}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let filePathOrBlob: string | Blob;
|
||||
private async getFilePathOrBlob(
|
||||
binaryData: IBinaryData,
|
||||
mimeType: string,
|
||||
): Promise<string | Blob> {
|
||||
if (binaryData.id) {
|
||||
const binaryBuffer = await this.context.helpers.binaryToBuffer(
|
||||
await this.context.helpers.getBinaryStream(binaryData.id),
|
||||
);
|
||||
filePathOrBlob = new Blob([binaryBuffer], {
|
||||
return new Blob([binaryBuffer], {
|
||||
type: mimeType,
|
||||
});
|
||||
} else {
|
||||
filePathOrBlob = new Blob([Buffer.from(binaryData.data, BINARY_ENCODING)], {
|
||||
return new Blob([Buffer.from(binaryData.data, BINARY_ENCODING)], {
|
||||
type: mimeType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let loader: PDFLoader | CSVLoader | EPubLoader | DocxLoader | TextLoader | JSONLoader;
|
||||
let cleanupTmpFile: DirectoryResult['cleanup'] | undefined = undefined;
|
||||
|
||||
private async getLoader(
|
||||
mimeType: string,
|
||||
filePathOrBlob: string | Blob,
|
||||
itemIndex: number,
|
||||
): Promise<PDFLoader | CSVLoader | EPubLoader | DocxLoader | TextLoader | JSONLoader> {
|
||||
switch (mimeType) {
|
||||
case 'application/pdf':
|
||||
const splitPages = this.context.getNodeParameter(
|
||||
@@ -125,10 +122,7 @@ export class N8nBinaryLoader {
|
||||
itemIndex,
|
||||
false,
|
||||
) as boolean;
|
||||
loader = new PDFLoader(filePathOrBlob, {
|
||||
splitPages,
|
||||
});
|
||||
break;
|
||||
return new PDFLoader(filePathOrBlob, { splitPages });
|
||||
case 'text/csv':
|
||||
const column = this.context.getNodeParameter(
|
||||
`${this.optionsPrefix}column`,
|
||||
@@ -140,38 +134,23 @@ export class N8nBinaryLoader {
|
||||
itemIndex,
|
||||
',',
|
||||
) as string;
|
||||
|
||||
loader = new CSVLoader(filePathOrBlob, {
|
||||
column: column ?? undefined,
|
||||
separator,
|
||||
});
|
||||
break;
|
||||
return new CSVLoader(filePathOrBlob, { column: column ?? undefined, separator });
|
||||
case 'application/epub+zip':
|
||||
// EPubLoader currently does not accept Blobs https://github.com/langchain-ai/langchainjs/issues/1623
|
||||
let filePath: string;
|
||||
if (filePathOrBlob instanceof Blob) {
|
||||
const tmpFileData = await tmpFile({ prefix: 'epub-loader-' });
|
||||
cleanupTmpFile = tmpFileData.cleanup;
|
||||
try {
|
||||
const bufferData = await filePathOrBlob.arrayBuffer();
|
||||
await pipeline([new Uint8Array(bufferData)], createWriteStream(tmpFileData.path));
|
||||
loader = new EPubLoader(tmpFileData.path);
|
||||
break;
|
||||
} catch (error) {
|
||||
await cleanupTmpFile();
|
||||
throw new NodeOperationError(this.context.getNode(), error as Error);
|
||||
}
|
||||
const bufferData = await filePathOrBlob.arrayBuffer();
|
||||
await pipeline([new Uint8Array(bufferData)], createWriteStream(tmpFileData.path));
|
||||
return new EPubLoader(tmpFileData.path);
|
||||
} else {
|
||||
filePath = filePathOrBlob;
|
||||
}
|
||||
loader = new EPubLoader(filePath);
|
||||
break;
|
||||
return new EPubLoader(filePath);
|
||||
case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
|
||||
loader = new DocxLoader(filePathOrBlob);
|
||||
break;
|
||||
return new DocxLoader(filePathOrBlob);
|
||||
case 'text/plain':
|
||||
loader = new TextLoader(filePathOrBlob);
|
||||
break;
|
||||
return new TextLoader(filePathOrBlob);
|
||||
case 'application/json':
|
||||
const pointers = this.context.getNodeParameter(
|
||||
`${this.optionsPrefix}pointers`,
|
||||
@@ -179,15 +158,77 @@ export class N8nBinaryLoader {
|
||||
'',
|
||||
) as string;
|
||||
const pointersArray = pointers.split(',').map((pointer) => pointer.trim());
|
||||
loader = new JSONLoader(filePathOrBlob, pointersArray);
|
||||
break;
|
||||
return new JSONLoader(filePathOrBlob, pointersArray);
|
||||
default:
|
||||
loader = new TextLoader(filePathOrBlob);
|
||||
return new TextLoader(filePathOrBlob);
|
||||
}
|
||||
}
|
||||
|
||||
const loadedDoc = this.textSplitter
|
||||
private async loadDocuments(
|
||||
loader: PDFLoader | CSVLoader | EPubLoader | DocxLoader | TextLoader | JSONLoader,
|
||||
): Promise<Document[]> {
|
||||
return this.textSplitter
|
||||
? await this.textSplitter.splitDocuments(await loader.load())
|
||||
: await loader.load();
|
||||
}
|
||||
|
||||
private async cleanupTmpFileIfNeeded(
|
||||
cleanupTmpFile: DirectoryResult['cleanup'] | undefined,
|
||||
): Promise<void> {
|
||||
if (cleanupTmpFile) {
|
||||
await cleanupTmpFile();
|
||||
}
|
||||
}
|
||||
|
||||
async processItem(item: INodeExecutionData, itemIndex: number): Promise<Document[]> {
|
||||
const docs: Document[] = [];
|
||||
const binaryMode = this.context.getNodeParameter('binaryMode', itemIndex, 'allInputData');
|
||||
if (binaryMode === 'allInputData') {
|
||||
const binaryData = this.context.getInputData();
|
||||
|
||||
for (const data of binaryData) {
|
||||
if (data.binary) {
|
||||
const binaryDataKeys = Object.keys(data.binary);
|
||||
|
||||
for (const fileKey of binaryDataKeys) {
|
||||
const processedDocuments = await this.processItemByKey(item, itemIndex, fileKey);
|
||||
docs.push(...processedDocuments);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const processedDocuments = await this.processItemByKey(item, itemIndex, this.binaryDataKey);
|
||||
docs.push(...processedDocuments);
|
||||
}
|
||||
|
||||
return docs;
|
||||
}
|
||||
|
||||
async processItemByKey(
|
||||
item: INodeExecutionData,
|
||||
itemIndex: number,
|
||||
binaryKey: string,
|
||||
): Promise<Document[]> {
|
||||
const selectedLoader: keyof typeof SUPPORTED_MIME_TYPES = this.context.getNodeParameter(
|
||||
'loader',
|
||||
itemIndex,
|
||||
'auto',
|
||||
) as keyof typeof SUPPORTED_MIME_TYPES;
|
||||
|
||||
const docs: Document[] = [];
|
||||
const metadata = getMetadataFiltersValues(this.context, itemIndex);
|
||||
|
||||
if (!item) return [];
|
||||
|
||||
const binaryData = this.context.helpers.assertBinaryData(itemIndex, binaryKey);
|
||||
const { mimeType } = binaryData;
|
||||
|
||||
await this.validateMimeType(mimeType, selectedLoader);
|
||||
|
||||
const filePathOrBlob = await this.getFilePathOrBlob(binaryData, mimeType);
|
||||
const cleanupTmpFile: DirectoryResult['cleanup'] | undefined = undefined;
|
||||
const loader = await this.getLoader(mimeType, filePathOrBlob, itemIndex);
|
||||
const loadedDoc = await this.loadDocuments(loader);
|
||||
|
||||
docs.push(...loadedDoc);
|
||||
|
||||
@@ -200,9 +241,8 @@ export class N8nBinaryLoader {
|
||||
});
|
||||
}
|
||||
|
||||
if (cleanupTmpFile) {
|
||||
await cleanupTmpFile();
|
||||
}
|
||||
await this.cleanupTmpFileIfNeeded(cleanupTmpFile);
|
||||
|
||||
return docs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { NodeConnectionType, NodeOperationError, jsonStringify } from 'n8n-workflow';
|
||||
import type { EventNamesAiNodesType, IDataObject, IExecuteFunctions } from 'n8n-workflow';
|
||||
import type {
|
||||
EventNamesAiNodesType,
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
IWebhookFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { BaseOutputParser } from '@langchain/core/output_parsers';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
@@ -81,7 +86,7 @@ export function getPromptInputByType(options: {
|
||||
}
|
||||
|
||||
export function getSessionId(
|
||||
ctx: IExecuteFunctions,
|
||||
ctx: IExecuteFunctions | IWebhookFunctions,
|
||||
itemIndex: number,
|
||||
selectorKey = 'sessionIdType',
|
||||
autoSelect = 'fromInput',
|
||||
@@ -91,7 +96,15 @@ export function getSessionId(
|
||||
const selectorType = ctx.getNodeParameter(selectorKey, itemIndex) as string;
|
||||
|
||||
if (selectorType === autoSelect) {
|
||||
sessionId = ctx.evaluateExpression('{{ $json.sessionId }}', itemIndex) as string;
|
||||
// If memory node is used in webhook like node(like chat trigger node), it doesn't have access to evaluateExpression
|
||||
// so we try to extract sessionId from the bodyData
|
||||
if ('getBodyData' in ctx) {
|
||||
const bodyData = ctx.getBodyData() ?? {};
|
||||
sessionId = bodyData.sessionId as string;
|
||||
} else {
|
||||
sessionId = ctx.evaluateExpression('{{ $json.sessionId }}', itemIndex) as string;
|
||||
}
|
||||
|
||||
if (sessionId === '' || sessionId === undefined) {
|
||||
throw new NodeOperationError(ctx.getNode(), 'No session ID found', {
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user