mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 00:37:10 +02:00
310 lines
10 KiB
TypeScript
310 lines
10 KiB
TypeScript
import type { BaseMessage } from '@langchain/core/messages';
|
|
import { type DynamicStructuredTool, type StructuredTool, Tool } from '@langchain/core/tools';
|
|
import type { JSONSchema7 } from 'json-schema';
|
|
import { StructuredToolkit, type SupplyDataToolResponse } from 'n8n-core';
|
|
import type {
|
|
ICredentialDataDecryptedObject,
|
|
IExecuteFunctions,
|
|
ISupplyDataFunctions,
|
|
IWebhookFunctions,
|
|
} from 'n8n-workflow';
|
|
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
|
import { ZodType } from 'zod';
|
|
|
|
import { N8nTool } from './N8nTool';
|
|
import { convertJsonSchemaToZod } from './schemaParsing';
|
|
|
|
export function getPromptInputByType(options: {
|
|
ctx: IExecuteFunctions | ISupplyDataFunctions;
|
|
i: number;
|
|
promptTypeKey: string;
|
|
inputKey: string;
|
|
}) {
|
|
const { ctx, i, promptTypeKey, inputKey } = options;
|
|
const promptType = ctx.getNodeParameter(promptTypeKey, i, 'define') as string;
|
|
|
|
let input;
|
|
if (promptType === 'auto') {
|
|
input = ctx.evaluateExpression('{{ $json["chatInput"] }}', i) as string;
|
|
} else if (promptType === 'guardrails') {
|
|
input = ctx.evaluateExpression('{{ $json["guardrailsInput"] }}', i) as string;
|
|
} else {
|
|
input = ctx.getNodeParameter(inputKey, i) as string;
|
|
}
|
|
|
|
if (input === undefined) {
|
|
if (promptType === 'auto' || promptType === 'guardrails') {
|
|
const key = promptType === 'auto' ? 'chatInput' : 'guardrailsInput';
|
|
throw new NodeOperationError(ctx.getNode(), 'No prompt specified', {
|
|
description: `Expected to find the prompt in an input field called '${key}' (this is what the ${promptType === 'auto' ? 'chat trigger node' : 'guardrails node'} node outputs). To use something else, change the 'Prompt' parameter`,
|
|
});
|
|
} else {
|
|
throw new NodeOperationError(ctx.getNode(), 'No prompt specified', {
|
|
description:
|
|
'The prompt field is empty or the expression used could not be resolved. Please check the configured prompt value.',
|
|
});
|
|
}
|
|
}
|
|
|
|
return input;
|
|
}
|
|
|
|
// Minimum version at which a memory node scopes its session id
|
|
// to the node's own name
|
|
const SESSION_KEY_SCOPING_MIN_VERSION: Record<string, number> = {
|
|
'@n8n/n8n-nodes-langchain.memoryBufferWindow': 1.4,
|
|
'@n8n/n8n-nodes-langchain.memoryPostgresChat': 1.4,
|
|
'@n8n/n8n-nodes-langchain.memoryRedisChat': 1.6,
|
|
'@n8n/n8n-nodes-langchain.memoryMongoDbChat': 1.1,
|
|
'@n8n/n8n-nodes-langchain.memoryMotorhead': 1.4,
|
|
'@n8n/n8n-nodes-langchain.memoryXata': 1.5,
|
|
'@n8n/n8n-nodes-langchain.memoryZep': 1.4,
|
|
};
|
|
|
|
function shouldScopeSessionKey(ctx: ISupplyDataFunctions | IWebhookFunctions): boolean {
|
|
const node = ctx.getNode();
|
|
if (!node) return false;
|
|
const minVersion = SESSION_KEY_SCOPING_MIN_VERSION[node.type];
|
|
// if the node is not in SESSION_KEY_SCOPING_MIN_VERSION, it should
|
|
// scope by default the session key with no retrocompatibility issues
|
|
if (minVersion === undefined) return true;
|
|
return (node.typeVersion ?? 0) >= minVersion;
|
|
}
|
|
|
|
// Some memory backends (Motorhead URL paths, Xata/Zep record IDs) reject
|
|
// characters outside [A-Za-z0-9_-]. Node names in n8n allow spaces, emoji,
|
|
// and punctuation, so sanitize before using the name as part of a session key.
|
|
function sanitizeForSessionKey(name: string): string {
|
|
return name.replace(/[^A-Za-z0-9_-]/g, '_');
|
|
}
|
|
|
|
export function getSessionId(
|
|
ctx: ISupplyDataFunctions | IWebhookFunctions,
|
|
itemIndex: number,
|
|
selectorKey = 'sessionIdType',
|
|
autoSelect = 'fromInput',
|
|
customKey = 'sessionKey',
|
|
) {
|
|
let sessionId = '';
|
|
const selectorType = ctx.getNodeParameter(selectorKey, itemIndex) as string;
|
|
|
|
if (selectorType === autoSelect) {
|
|
// 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;
|
|
|
|
// try to get sessionId from chat trigger
|
|
if (!sessionId || sessionId === undefined) {
|
|
try {
|
|
const chatTrigger = ctx.getChatTrigger();
|
|
|
|
if (chatTrigger) {
|
|
sessionId = ctx.evaluateExpression(
|
|
`{{ $('${chatTrigger.name}').first().json.sessionId }}`,
|
|
itemIndex,
|
|
) as string;
|
|
}
|
|
} catch (error) {}
|
|
}
|
|
}
|
|
|
|
if (sessionId === '' || sessionId === undefined) {
|
|
throw new NodeOperationError(ctx.getNode(), 'No session ID found', {
|
|
description:
|
|
"Expected to find the session ID in an input field called 'sessionId' (this is what the chat trigger node outputs). To use something else, change the 'Session ID' parameter",
|
|
itemIndex,
|
|
});
|
|
}
|
|
} else {
|
|
sessionId = ctx.getNodeParameter(customKey, itemIndex, '') as string;
|
|
if (sessionId === '' || sessionId === undefined) {
|
|
throw new NodeOperationError(ctx.getNode(), 'Key parameter is empty', {
|
|
description:
|
|
"Provide a key to use as session ID in the 'Key' parameter or use the 'Connected Chat Trigger Node' option to use the session ID from your Chat Trigger",
|
|
itemIndex,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Scoping uses the memory node's own name, so connecting a single memory
|
|
// node to multiple callers (e.g. an agent and a sub-agent) will still
|
|
// produce the same session key for all of them and they will share the
|
|
// same memory bucket. To isolate memory per caller, duplicate the memory
|
|
// node instead of reusing one.
|
|
if (selectorType === autoSelect && shouldScopeSessionKey(ctx)) {
|
|
sessionId = `${sessionId}__${sanitizeForSessionKey(ctx.getNode().name)}`;
|
|
}
|
|
|
|
return sessionId;
|
|
}
|
|
|
|
export function serializeChatHistory(chatHistory: BaseMessage[]): string {
|
|
return chatHistory
|
|
.map((chatMessage) => {
|
|
if (chatMessage._getType() === 'human') {
|
|
return `Human: ${chatMessage.content}`;
|
|
} else if (chatMessage._getType() === 'ai') {
|
|
return `Assistant: ${chatMessage.content}`;
|
|
} else {
|
|
return `${chatMessage.content}`;
|
|
}
|
|
})
|
|
.join('\n');
|
|
}
|
|
|
|
export function escapeSingleCurlyBrackets(text?: string): string | undefined {
|
|
if (text === undefined) return undefined;
|
|
|
|
let result = text;
|
|
|
|
result = result
|
|
// First handle triple brackets to avoid interference with double brackets
|
|
.replace(/(?<!{){{{(?!{)/g, '{{{{')
|
|
.replace(/(?<!})}}}(?!})/g, '}}}}')
|
|
// Then handle single brackets, but only if they're not part of double brackets
|
|
// Convert single { to {{ if it's not already part of {{ or {{{
|
|
.replace(/(?<!{){(?!{)/g, '{{')
|
|
// Convert single } to }} if it's not already part of }} or }}}
|
|
.replace(/(?<!})}(?!})/g, '}}');
|
|
|
|
return result;
|
|
}
|
|
|
|
/* Convert tools with json schema to tools with zod schema and type Tool
|
|
* Most nodes expect tools to have a Zod schema and have Tool type, do this conversion to make sure all tools are compatible
|
|
*/
|
|
const normalizeToolSchema = (tool: Tool | DynamicStructuredTool | StructuredTool) => {
|
|
if (tool instanceof Tool) {
|
|
return tool;
|
|
}
|
|
const isZodObject = tool.schema instanceof ZodType;
|
|
if (tool.schema && !isZodObject) {
|
|
tool.schema = convertJsonSchemaToZod(tool.schema as JSONSchema7);
|
|
}
|
|
|
|
return tool as Tool;
|
|
};
|
|
|
|
export const getConnectedTools = async (
|
|
ctx: IExecuteFunctions | IWebhookFunctions | ISupplyDataFunctions,
|
|
enforceUniqueNames: boolean,
|
|
convertStructuredTool: boolean = true,
|
|
escapeCurlyBrackets: boolean = false,
|
|
): Promise<Tool[]> => {
|
|
const toolkitConnections = (await ctx.getInputConnectionData(
|
|
NodeConnectionTypes.AiTool,
|
|
0,
|
|
)) as SupplyDataToolResponse[];
|
|
|
|
// Get parent nodes to map toolkits to their source nodes.
|
|
// getInputConnectionData filters out disabled nodes, so parents must be filtered
|
|
// the same way to keep the index alignment between toolkitConnections and parentNodes.
|
|
const parentNodes =
|
|
'getParentNodes' in ctx
|
|
? ctx
|
|
.getParentNodes(ctx.getNode().name, {
|
|
connectionType: NodeConnectionTypes.AiTool,
|
|
depth: 1,
|
|
})
|
|
.filter((node) => !node.disabled)
|
|
: [];
|
|
|
|
const connectedTools = (toolkitConnections ?? [])
|
|
.flatMap((toolOrToolkit, index) => {
|
|
if (toolOrToolkit instanceof StructuredToolkit) {
|
|
const tools = toolOrToolkit.tools;
|
|
// Add metadata to each tool from the toolkit
|
|
return tools.map((tool) => {
|
|
const sourceNode = parentNodes[index] ?? tool.name;
|
|
|
|
tool.metadata ??= {};
|
|
tool.metadata.isFromToolkit = true;
|
|
tool.metadata.sourceNodeName = sourceNode?.name;
|
|
return tool;
|
|
});
|
|
} else {
|
|
const sourceNode = parentNodes[index] ?? toolOrToolkit.name;
|
|
toolOrToolkit.metadata ??= {};
|
|
toolOrToolkit.metadata.isFromToolkit = false;
|
|
toolOrToolkit.metadata.sourceNodeName = sourceNode?.name;
|
|
}
|
|
|
|
return toolOrToolkit;
|
|
})
|
|
.map(normalizeToolSchema);
|
|
|
|
if (!enforceUniqueNames) return connectedTools;
|
|
|
|
const seenNames = new Set<string>();
|
|
|
|
const finalTools: Tool[] = [];
|
|
|
|
for (const tool of connectedTools) {
|
|
const { name } = tool;
|
|
if (seenNames.has(name)) {
|
|
throw new NodeOperationError(
|
|
ctx.getNode(),
|
|
`You have multiple tools with the same name: '${name}', please rename them to avoid conflicts`,
|
|
);
|
|
}
|
|
seenNames.add(name);
|
|
|
|
if (escapeCurlyBrackets) {
|
|
tool.description = escapeSingleCurlyBrackets(tool.description) ?? tool.description;
|
|
}
|
|
|
|
if (convertStructuredTool && tool instanceof N8nTool) {
|
|
finalTools.push(tool.asDynamicTool());
|
|
} else {
|
|
finalTools.push(tool);
|
|
}
|
|
}
|
|
|
|
return finalTools;
|
|
};
|
|
|
|
/**
|
|
* Merges custom credential headers into an existing defaultHeaders object.
|
|
* Used by OpenAI and other LangChain nodes that pass `configuration.defaultHeaders`.
|
|
*/
|
|
export function mergeCustomHeaders(
|
|
credentials: ICredentialDataDecryptedObject,
|
|
defaultHeaders: Record<string, string>,
|
|
): Record<string, string> {
|
|
if (
|
|
credentials.header &&
|
|
typeof credentials.headerName === 'string' &&
|
|
credentials.headerName &&
|
|
typeof credentials.headerValue === 'string'
|
|
) {
|
|
return {
|
|
...defaultHeaders,
|
|
[credentials.headerName]: credentials.headerValue,
|
|
};
|
|
}
|
|
return defaultHeaders;
|
|
}
|
|
|
|
/**
|
|
* Sometimes model output is wrapped in an additional object property.
|
|
* This function unwraps the output if it is in the format { output: { output: { ... } } }
|
|
*/
|
|
export function unwrapNestedOutput(output: Record<string, unknown>): Record<string, unknown> {
|
|
if (
|
|
'output' in output &&
|
|
Object.keys(output).length === 1 &&
|
|
typeof output.output === 'object' &&
|
|
output.output !== null &&
|
|
'output' in output.output &&
|
|
Object.keys(output.output).length === 1
|
|
) {
|
|
return output.output as Record<string, unknown>;
|
|
}
|
|
|
|
return output;
|
|
}
|