- Replace manual template variable processing in multiple components with a new utility function `processTemplateVariables`.

This commit is contained in:
Henry 2025-09-12 13:49:47 +01:00
parent 736c2b11a1
commit 3fa521ab68
9 changed files with 199 additions and 81 deletions

View File

@ -28,7 +28,7 @@ import {
replaceBase64ImagesWithFileReferences, replaceBase64ImagesWithFileReferences,
updateFlowState updateFlowState
} from '../utils' } from '../utils'
import { convertMultiOptionsToStringArray, getCredentialData, getCredentialParam } from '../../../src/utils' import { convertMultiOptionsToStringArray, getCredentialData, getCredentialParam, processTemplateVariables } from '../../../src/utils'
import { addSingleFileToStorage } from '../../../src/storageUtils' import { addSingleFileToStorage } from '../../../src/storageUtils'
import fetch from 'node-fetch' import fetch from 'node-fetch'
@ -1086,13 +1086,7 @@ class Agent_Agentflow implements INode {
} }
// Process template variables in state // Process template variables in state
if (newState && Object.keys(newState).length > 0) { newState = processTemplateVariables(newState, finalResponse)
for (const key in newState) {
if (newState[key].toString().includes('{{ output }}')) {
newState[key] = newState[key].replaceAll('{{ output }}', finalResponse)
}
}
}
// Replace the actual messages array with one that includes the file references for images instead of base64 data // Replace the actual messages array with one that includes the file references for images instead of base64 data
const messagesWithFileReferences = replaceBase64ImagesWithFileReferences( const messagesWithFileReferences = replaceBase64ImagesWithFileReferences(

View File

@ -8,7 +8,7 @@ import {
INodeParams, INodeParams,
IServerSideEventStreamer IServerSideEventStreamer
} from '../../../src/Interface' } from '../../../src/Interface'
import { getVars, executeJavaScriptCode, createCodeExecutionSandbox } from '../../../src/utils' import { getVars, executeJavaScriptCode, createCodeExecutionSandbox, processTemplateVariables } from '../../../src/utils'
import { updateFlowState } from '../utils' import { updateFlowState } from '../utils'
interface ICustomFunctionInputVariables { interface ICustomFunctionInputVariables {
@ -145,19 +145,13 @@ class CustomFunction_Agentflow implements INode {
const appDataSource = options.appDataSource as DataSource const appDataSource = options.appDataSource as DataSource
const databaseEntities = options.databaseEntities as IDatabaseEntity const databaseEntities = options.databaseEntities as IDatabaseEntity
// Update flow state if needed
let newState = { ...state }
if (_customFunctionUpdateState && Array.isArray(_customFunctionUpdateState) && _customFunctionUpdateState.length > 0) {
newState = updateFlowState(state, _customFunctionUpdateState)
}
const variables = await getVars(appDataSource, databaseEntities, nodeData, options) const variables = await getVars(appDataSource, databaseEntities, nodeData, options)
const flow = { const flow = {
chatflowId: options.chatflowid, chatflowId: options.chatflowid,
sessionId: options.sessionId, sessionId: options.sessionId,
chatId: options.chatId, chatId: options.chatId,
input, input,
state: newState state
} }
// Create additional sandbox variables for custom function inputs // Create additional sandbox variables for custom function inputs
@ -190,15 +184,14 @@ class CustomFunction_Agentflow implements INode {
finalOutput = JSON.stringify(response, null, 2) finalOutput = JSON.stringify(response, null, 2)
} }
// Process template variables in state // Update flow state if needed
if (newState && Object.keys(newState).length > 0) { let newState = { ...state }
for (const key in newState) { if (_customFunctionUpdateState && Array.isArray(_customFunctionUpdateState) && _customFunctionUpdateState.length > 0) {
if (newState[key].toString().includes('{{ output }}')) { newState = updateFlowState(state, _customFunctionUpdateState)
newState[key] = newState[key].replaceAll('{{ output }}', finalOutput)
}
}
} }
newState = processTemplateVariables(newState, finalOutput)
const returnOutput = { const returnOutput = {
id: nodeData.id, id: nodeData.id,
name: this.name, name: this.name,

View File

@ -8,7 +8,7 @@ import {
IServerSideEventStreamer IServerSideEventStreamer
} from '../../../src/Interface' } from '../../../src/Interface'
import axios, { AxiosRequestConfig } from 'axios' import axios, { AxiosRequestConfig } from 'axios'
import { getCredentialData, getCredentialParam } from '../../../src/utils' import { getCredentialData, getCredentialParam, processTemplateVariables } from '../../../src/utils'
import { DataSource } from 'typeorm' import { DataSource } from 'typeorm'
import { BaseMessageLike } from '@langchain/core/messages' import { BaseMessageLike } from '@langchain/core/messages'
import { updateFlowState } from '../utils' import { updateFlowState } from '../utils'
@ -222,13 +222,7 @@ class ExecuteFlow_Agentflow implements INode {
} }
// Process template variables in state // Process template variables in state
if (newState && Object.keys(newState).length > 0) { newState = processTemplateVariables(newState, resultText)
for (const key in newState) {
if (newState[key].toString().includes('{{ output }}')) {
newState[key] = newState[key].replaceAll('{{ output }}', resultText)
}
}
}
// Only add to runtime chat history if this is the first node // Only add to runtime chat history if this is the first node
const inputMessages = [] const inputMessages = []

View File

@ -12,7 +12,7 @@ import {
replaceBase64ImagesWithFileReferences, replaceBase64ImagesWithFileReferences,
updateFlowState updateFlowState
} from '../utils' } from '../utils'
import { get } from 'lodash' import { processTemplateVariables } from '../../../src/utils'
class LLM_Agentflow implements INode { class LLM_Agentflow implements INode {
label: string label: string
@ -529,36 +529,7 @@ class LLM_Agentflow implements INode {
} }
// Process template variables in state // Process template variables in state
if (newState && Object.keys(newState).length > 0) { newState = processTemplateVariables(newState, finalResponse)
for (const key in newState) {
const stateValue = newState[key].toString()
if (stateValue.includes('{{ output')) {
// Handle simple output replacement
if (stateValue === '{{ output }}') {
newState[key] = finalResponse
continue
}
// Handle JSON path expressions like {{ output.item1 }}
// eslint-disable-next-line
const match = stateValue.match(/{{[\s]*output\.([\w\.]+)[\s]*}}/)
if (match) {
try {
// Parse the response if it's JSON
const jsonResponse = typeof finalResponse === 'string' ? JSON.parse(finalResponse) : finalResponse
// Get the value using lodash get
const path = match[1]
const value = get(jsonResponse, path)
newState[key] = value ?? stateValue // Fall back to original if path not found
} catch (e) {
// If JSON parsing fails, keep original template
console.warn(`Failed to parse JSON or find path in output: ${e}`)
newState[key] = stateValue
}
}
}
}
}
// Replace the actual messages array with one that includes the file references for images instead of base64 data // Replace the actual messages array with one that includes the file references for images instead of base64 data
const messagesWithFileReferences = replaceBase64ImagesWithFileReferences( const messagesWithFileReferences = replaceBase64ImagesWithFileReferences(

View File

@ -8,6 +8,7 @@ import {
IServerSideEventStreamer IServerSideEventStreamer
} from '../../../src/Interface' } from '../../../src/Interface'
import { updateFlowState } from '../utils' import { updateFlowState } from '../utils'
import { processTemplateVariables } from '../../../src/utils'
import { DataSource } from 'typeorm' import { DataSource } from 'typeorm'
import { BaseRetriever } from '@langchain/core/retrievers' import { BaseRetriever } from '@langchain/core/retrievers'
import { Document } from '@langchain/core/documents' import { Document } from '@langchain/core/documents'
@ -197,14 +198,7 @@ class Retriever_Agentflow implements INode {
sseStreamer.streamTokenEvent(chatId, finalOutput) sseStreamer.streamTokenEvent(chatId, finalOutput)
} }
// Process template variables in state newState = processTemplateVariables(newState, finalOutput)
if (newState && Object.keys(newState).length > 0) {
for (const key in newState) {
if (newState[key].toString().includes('{{ output }}')) {
newState[key] = newState[key].replaceAll('{{ output }}', finalOutput)
}
}
}
const returnOutput = { const returnOutput = {
id: nodeData.id, id: nodeData.id,

View File

@ -1,5 +1,6 @@
import { ICommonObject, INode, INodeData, INodeOptionsValue, INodeParams, IServerSideEventStreamer } from '../../../src/Interface' import { ICommonObject, INode, INodeData, INodeOptionsValue, INodeParams, IServerSideEventStreamer } from '../../../src/Interface'
import { updateFlowState } from '../utils' import { updateFlowState } from '../utils'
import { processTemplateVariables } from '../../../src/utils'
import { Tool } from '@langchain/core/tools' import { Tool } from '@langchain/core/tools'
import { ARTIFACTS_PREFIX, TOOL_ARGS_PREFIX } from '../../../src/agents' import { ARTIFACTS_PREFIX, TOOL_ARGS_PREFIX } from '../../../src/agents'
import zodToJsonSchema from 'zod-to-json-schema' import zodToJsonSchema from 'zod-to-json-schema'
@ -330,14 +331,7 @@ class Tool_Agentflow implements INode {
sseStreamer.streamTokenEvent(chatId, toolOutput) sseStreamer.streamTokenEvent(chatId, toolOutput)
} }
// Process template variables in state newState = processTemplateVariables(newState, toolOutput)
if (newState && Object.keys(newState).length > 0) {
for (const key in newState) {
if (newState[key].toString().includes('{{ output }}')) {
newState[key] = newState[key].replaceAll('{{ output }}', toolOutput)
}
}
}
const returnOutput = { const returnOutput = {
id: nodeData.id, id: nodeData.id,

View File

@ -459,9 +459,9 @@ export const getPastChatHistoryImageMessages = async (
/** /**
* Updates the flow state with new values * Updates the flow state with new values
*/ */
export const updateFlowState = (state: ICommonObject, llmUpdateState: IFlowState[]): ICommonObject => { export const updateFlowState = (state: ICommonObject, updateState: IFlowState[]): ICommonObject => {
let newFlowState: Record<string, any> = {} let newFlowState: Record<string, any> = {}
for (const state of llmUpdateState) { for (const state of updateState) {
newFlowState[state.key] = state.value newFlowState[state.key] = state.value
} }

View File

@ -8,7 +8,7 @@ import TurndownService from 'turndown'
import { DataSource, Equal } from 'typeorm' import { DataSource, Equal } from 'typeorm'
import { ICommonObject, IDatabaseEntity, IFileUpload, IMessage, INodeData, IVariable, MessageContentImageUrl } from './Interface' import { ICommonObject, IDatabaseEntity, IFileUpload, IMessage, INodeData, IVariable, MessageContentImageUrl } from './Interface'
import { AES, enc } from 'crypto-js' import { AES, enc } from 'crypto-js'
import { omit } from 'lodash' import { omit, get } from 'lodash'
import { AIMessage, HumanMessage, BaseMessage } from '@langchain/core/messages' import { AIMessage, HumanMessage, BaseMessage } from '@langchain/core/messages'
import { Document } from '@langchain/core/documents' import { Document } from '@langchain/core/documents'
import { getFileFromStorage } from './storageUtils' import { getFileFromStorage } from './storageUtils'
@ -1609,3 +1609,50 @@ export const createCodeExecutionSandbox = (
return sandbox return sandbox
} }
/**
* Process template variables in state object, replacing {{ output }} and {{ output.property }} patterns
* @param {ICommonObject} state - The state object to process
* @param {any} finalOutput - The output value to substitute
* @returns {ICommonObject} - The processed state object
*/
export const processTemplateVariables = (state: ICommonObject, finalOutput: any): ICommonObject => {
if (!state || Object.keys(state).length === 0) {
return state
}
const newState = { ...state }
for (const key in newState) {
const stateValue = newState[key].toString()
if (stateValue.includes('{{ output') || stateValue.includes('{{output')) {
// Handle simple output replacement (with or without spaces)
if (stateValue === '{{ output }}' || stateValue === '{{output}}') {
newState[key] = finalOutput
continue
}
// Handle JSON path expressions like {{ output.updated }} or {{output.updated}}
// eslint-disable-next-line
const match = stateValue.match(/\{\{\s*output\.([\w\.]+)\s*\}\}/)
if (match) {
try {
// Parse the response if it's JSON
const jsonResponse = typeof finalOutput === 'string' ? JSON.parse(finalOutput) : finalOutput
// Get the value using lodash get
const path = match[1]
const value = get(jsonResponse, path)
newState[key] = value ?? stateValue // Fall back to original if path not found
} catch (e) {
// If JSON parsing fails, keep original template
newState[key] = stateValue
}
} else {
// Handle simple {{ output }} replacement for backward compatibility
newState[key] = newState[key].replaceAll('{{ output }}', finalOutput)
}
}
}
return newState
}

View File

@ -216,6 +216,7 @@ export const resolveVariables = async (
variableOverrides: IVariableOverride[], variableOverrides: IVariableOverride[],
uploadedFilesContent: string, uploadedFilesContent: string,
chatHistory: IMessage[], chatHistory: IMessage[],
componentNodes: IComponentNodes,
agentFlowExecutedData?: IAgentflowExecutedData[], agentFlowExecutedData?: IAgentflowExecutedData[],
iterationContext?: ICommonObject iterationContext?: ICommonObject
): Promise<INodeData> => { ): Promise<INodeData> => {
@ -390,6 +391,135 @@ export const resolveVariables = async (
} }
const getParamValues = async (paramsObj: ICommonObject) => { const getParamValues = async (paramsObj: ICommonObject) => {
/*
* EXAMPLE SCENARIO:
*
* 1. Agent node has inputParam: { name: "agentTools", type: "array", array: [{ name: "agentSelectedTool", loadConfig: true }] }
* 2. Inputs contain: { agentTools: [{ agentSelectedTool: "requestsGet", agentSelectedToolConfig: { requestsGetHeaders: "Bearer {{ $vars.TOKEN }}" } }] }
* 3. We need to resolve the variable in requestsGetHeaders because RequestsGet node defines requestsGetHeaders with acceptVariable: true
*
* STEP 1: Find all parameters with loadConfig=true (e.g., "agentSelectedTool")
* STEP 2: Find their values in inputs (e.g., "requestsGet")
* STEP 3: Look up component node definition for "requestsGet"
* STEP 4: Find which of its parameters have acceptVariable=true (e.g., "requestsGetHeaders")
* STEP 5: Find the config object (e.g., "agentSelectedToolConfig")
* STEP 6: Resolve variables in config parameters that accept variables
*/
// Helper function to find params with loadConfig recursively
// Example: Finds ["agentModel", "agentSelectedTool"] from the inputParams structure
const findParamsWithLoadConfig = (inputParams: any[]): string[] => {
const paramsWithLoadConfig: string[] = []
for (const param of inputParams) {
// Direct loadConfig param (e.g., agentModel with loadConfig: true)
if (param.loadConfig === true) {
paramsWithLoadConfig.push(param.name)
}
// Check nested array parameters (e.g., agentTools.array contains agentSelectedTool with loadConfig: true)
if (param.type === 'array' && param.array && Array.isArray(param.array)) {
const nestedParams = findParamsWithLoadConfig(param.array)
paramsWithLoadConfig.push(...nestedParams)
}
}
return paramsWithLoadConfig
}
// Helper function to find value of a parameter recursively in nested objects/arrays
// Example: Searches for "agentSelectedTool" value in complex nested inputs structure
// Returns "requestsGet" when found in agentTools[0].agentSelectedTool
const findParamValue = (obj: any, paramName: string): any => {
if (typeof obj !== 'object' || obj === null) {
return undefined
}
// Handle arrays (e.g., agentTools array)
if (Array.isArray(obj)) {
for (const item of obj) {
const result = findParamValue(item, paramName)
if (result !== undefined) {
return result
}
}
return undefined
}
// Direct property match
if (Object.prototype.hasOwnProperty.call(obj, paramName)) {
return obj[paramName]
}
// Recursively search nested objects
for (const value of Object.values(obj)) {
const result = findParamValue(value, paramName)
if (result !== undefined) {
return result
}
}
return undefined
}
// Helper function to process config parameters with acceptVariable
// Example: Processes agentSelectedToolConfig object, resolving variables in requestsGetHeaders
const processConfigParams = async (configObj: any, configParamWithAcceptVariables: string[]) => {
if (typeof configObj !== 'object' || configObj === null) {
return
}
for (const [key, value] of Object.entries(configObj)) {
// Only resolve variables for parameters that accept them
// Example: requestsGetHeaders is in configParamWithAcceptVariables, so resolve "Bearer {{ $vars.TOKEN }}"
if (configParamWithAcceptVariables.includes(key)) {
configObj[key] = await resolveNodeReference(value)
}
}
}
// STEP 1: Get all params with loadConfig from inputParams
// Example result: ["agentModel", "agentSelectedTool"]
const paramsWithLoadConfig = findParamsWithLoadConfig(reactFlowNodeData.inputParams)
// STEP 2-6: Process each param with loadConfig
for (const paramWithLoadConfig of paramsWithLoadConfig) {
// STEP 2: Find the value of this parameter in the inputs
// Example: paramWithLoadConfig="agentSelectedTool", paramValue="requestsGet"
const paramValue = findParamValue(paramsObj, paramWithLoadConfig)
if (paramValue && componentNodes[paramValue]) {
// STEP 3: Get the node instance inputs to find params with acceptVariable
// Example: componentNodes["requestsGet"] contains the RequestsGet node definition
const nodeInstance = componentNodes[paramValue]
const configParamWithAcceptVariables: string[] = []
// STEP 4: Find which parameters of the component accept variables
// Example: RequestsGet has inputs like { name: "requestsGetHeaders", acceptVariable: true }
if (nodeInstance.inputs && Array.isArray(nodeInstance.inputs)) {
for (const input of nodeInstance.inputs) {
if (input.acceptVariable === true) {
configParamWithAcceptVariables.push(input.name)
}
}
}
// Example result: configParamWithAcceptVariables = ["requestsGetHeaders", "requestsGetUrl", ...]
// STEP 5: Look for the config object (paramName + "Config")
// Example: Look for "agentSelectedToolConfig" in the inputs
const configParamName = paramWithLoadConfig + 'Config'
const configValue = findParamValue(paramsObj, configParamName)
// STEP 6: Process config object to resolve variables
// Example: Resolve "Bearer {{ $vars.TOKEN }}" in requestsGetHeaders
if (configValue && configParamWithAcceptVariables.length > 0) {
await processConfigParams(configValue, configParamWithAcceptVariables)
}
}
}
// Original logic for direct acceptVariable params (maintains backward compatibility)
// Example: Direct params like agentUserMessage with acceptVariable: true
for (const key in paramsObj) { for (const key in paramsObj) {
const paramValue = paramsObj[key] const paramValue = paramsObj[key]
const isAcceptVariable = reactFlowNodeData.inputParams.find((param) => param.name === key)?.acceptVariable ?? false const isAcceptVariable = reactFlowNodeData.inputParams.find((param) => param.name === key)?.acceptVariable ?? false
@ -912,6 +1042,7 @@ const executeNode = async ({
variableOverrides, variableOverrides,
uploadedFilesContent, uploadedFilesContent,
chatHistory, chatHistory,
componentNodes,
agentFlowExecutedData, agentFlowExecutedData,
iterationContext iterationContext
) )