Feature/agentflow v2 (#4298)

* agent flow v2

* chat message background

* conditon agent flow

* add sticky note

* update human input dynamic prompt

* add HTTP node

* add default tool icon

* fix export duplicate agentflow v2

* add agentflow v2 marketplaces

* refractor memoization, add iteration nodes

* add agentflow v2 templates

* add agentflow generator

* add migration scripts for mysql, mariadb, posrgres and fix date filters for executions

* update agentflow chat history config

* fix get all flows error after deletion and rename

* add previous nodes from parent node

* update generator prompt

* update run time state when using iteration nodes

* prevent looping connection, prevent duplication of start node, add executeflow node, add nodes agentflow, chat history variable

* update embed

* convert form input to string

* bump openai version

* add react rewards

* add prompt generator to prediction queue

* add array schema to overrideconfig

* UI touchup

* update embedded chat version

* fix node info dialog

* update start node and loop default iteration

* update UI fixes for agentflow v2

* fix async drop down

* add export import to agentflowsv2, executions, fix UI bugs

* add default empty object to flowlisttable

* add ability to share trace link publicly, allow MCP tool use for Agent and Assistant

* add runtime message length to variable, display conditions on UI

* fix array validation

* add ability to add knowledge from vector store and embeddings for agent

* add agent tool require human input

* add ephemeral memory to start node

* update agent flow node to show vs and embeddings icons

* feat: add import chat data functionality for AgentFlowV2

* feat: set chatMessage.executionId to null if not found in import JSON file or database

* fix: MariaDB execution migration script to utf8mb4_unicode_520_ci

---------

Co-authored-by: Ong Chung Yau <33013947+chungyau97@users.noreply.github.com>
Co-authored-by: chungyau97 <chungyau97@gmail.com>
This commit is contained in:
Henry Heng
2025-05-10 10:21:26 +08:00
committed by GitHub
parent 82e6f43b5c
commit 7924fbce0d
216 changed files with 33304 additions and 5269 deletions
+34 -2
View File
@@ -8,6 +8,7 @@ import { Moderation } from '../nodes/moderation/Moderation'
export type NodeParamsType =
| 'asyncOptions'
| 'asyncMultiOptions'
| 'options'
| 'multiOptions'
| 'datagrid'
@@ -57,12 +58,13 @@ export interface INodeOptionsValue {
label: string
name: string
description?: string
imageSrc?: string
}
export interface INodeOutputsValue {
label: string
name: string
baseClasses: string[]
baseClasses?: string[]
description?: string
hidden?: boolean
isAnchor?: boolean
@@ -83,10 +85,12 @@ export interface INodeParams {
rows?: number
list?: boolean
acceptVariable?: boolean
acceptNodeOutputAsVariable?: boolean
placeholder?: string
fileType?: string
additionalParams?: boolean
loadMethod?: string
loadConfig?: boolean
hidden?: boolean
hideCodeExecute?: boolean
codeExample?: string
@@ -96,6 +100,11 @@ export interface INodeParams {
refresh?: boolean
freeSolo?: boolean
loadPreviousNodes?: boolean
array?: Array<INodeParams>
show?: INodeDisplay
hide?: INodeDisplay
generateDocStoreDescription?: boolean
generateInstruction?: boolean
}
export interface INodeExecutionData {
@@ -103,7 +112,7 @@ export interface INodeExecutionData {
}
export interface INodeDisplay {
[key: string]: string[] | string
[key: string]: string[] | string | boolean | number | ICommonObject
}
export interface INodeProperties {
@@ -120,11 +129,15 @@ export interface INodeProperties {
badge?: string
deprecateMessage?: string
hideOutput?: boolean
hideInput?: boolean
author?: string
documentation?: string
color?: string
hint?: string
}
export interface INode extends INodeProperties {
credential?: INodeParams
inputs?: INodeParams[]
output?: INodeOutputsValue[]
loadMethods?: {
@@ -412,14 +425,19 @@ export interface IServerSideEventStreamer {
streamCustomEvent(chatId: string, eventType: string, data: any): void
streamSourceDocumentsEvent(chatId: string, data: any): void
streamUsedToolsEvent(chatId: string, data: any): void
streamCalledToolsEvent(chatId: string, data: any): void
streamFileAnnotationsEvent(chatId: string, data: any): void
streamToolEvent(chatId: string, data: any): void
streamAgentReasoningEvent(chatId: string, data: any): void
streamAgentFlowExecutedDataEvent(chatId: string, data: any): void
streamAgentFlowEvent(chatId: string, data: any): void
streamNextAgentEvent(chatId: string, data: any): void
streamNextAgentFlowEvent(chatId: string, data: any): void
streamActionEvent(chatId: string, data: any): void
streamArtifactsEvent(chatId: string, data: any): void
streamAbortEvent(chatId: string): void
streamEndEvent(chatId: string): void
streamUsageMetadataEvent(chatId: string, data: any): void
}
export enum FollowUpPromptProvider {
@@ -446,3 +464,17 @@ export type FollowUpPromptConfig = {
status: boolean
selectedProvider: FollowUpPromptProvider
} & FollowUpPromptProviderConfig
export interface ICondition {
type: string
value1: CommonType
operation: string
value2: CommonType
isFulfilled?: boolean
}
export interface IHumanInput {
type: 'proceed' | 'reject'
startNodeId: string
feedback?: string
}
@@ -0,0 +1,655 @@
import { ICommonObject } from './Interface'
import { z } from 'zod'
import { StructuredOutputParser } from '@langchain/core/output_parsers'
import { isEqual, get, cloneDeep } from 'lodash'
import { BaseChatModel } from '@langchain/core/language_models/chat_models'
const ToolType = z.array(z.string()).describe('List of tools')
// Define a more specific NodePosition schema
const NodePositionType = z.object({
x: z.number().describe('X coordinate of the node position'),
y: z.number().describe('Y coordinate of the node position')
})
// Define a more specific EdgeData schema
const EdgeDataType = z.object({
edgeLabel: z.string().optional().describe('Label for the edge')
})
// Define a basic NodeData schema to avoid using .passthrough() which might cause issues
const NodeDataType = z
.object({
label: z.string().optional().describe('Label for the node'),
name: z.string().optional().describe('Name of the node')
})
.optional()
const NodeType = z.object({
id: z.string().describe('Unique identifier for the node'),
type: z.enum(['agentFlow']).describe('Type of the node'),
position: NodePositionType.describe('Position of the node in the UI'),
width: z.number().describe('Width of the node'),
height: z.number().describe('Height of the node'),
selected: z.boolean().optional().describe('Whether the node is selected'),
positionAbsolute: NodePositionType.optional().describe('Absolute position of the node'),
data: NodeDataType
})
const EdgeType = z.object({
id: z.string().describe('Unique identifier for the edge'),
type: z.enum(['agentFlow']).describe('Type of the node'),
source: z.string().describe('ID of the source node'),
sourceHandle: z.string().describe('ID of the source handle'),
target: z.string().describe('ID of the target node'),
targetHandle: z.string().describe('ID of the target handle'),
data: EdgeDataType.optional().describe('Data associated with the edge')
})
const NodesEdgesType = z
.object({
description: z.string().optional().describe('Description of the workflow'),
usecases: z.array(z.string()).optional().describe('Use cases for this workflow'),
nodes: z.array(NodeType).describe('Array of nodes in the workflow'),
edges: z.array(EdgeType).describe('Array of edges connecting the nodes')
})
.describe('Generate Agentflowv2 nodes and edges')
interface NodePosition {
x: number
y: number
}
interface EdgeData {
edgeLabel?: string
sourceColor?: string
targetColor?: string
isHumanInput?: boolean
}
interface AgentToolConfig {
agentSelectedTool: string
agentSelectedToolConfig: {
agentSelectedTool: string
}
}
interface NodeInputs {
agentTools?: AgentToolConfig[]
selectedTool?: string
toolInputArgs?: Record<string, any>[]
selectedToolConfig?: {
selectedTool: string
}
[key: string]: any
}
interface NodeData {
label?: string
name?: string
id?: string
inputs?: NodeInputs
inputAnchors?: InputAnchor[]
inputParams?: InputParam[]
outputs?: Record<string, any>
outputAnchors?: OutputAnchor[]
credential?: string
color?: string
[key: string]: any
}
interface Node {
id: string
type: 'agentFlow' | 'iteration'
position: NodePosition
width: number
height: number
selected?: boolean
positionAbsolute?: NodePosition
data: NodeData
parentNode?: string
extent?: string
}
interface Edge {
id: string
type: 'agentFlow'
source: string
sourceHandle: string
target: string
targetHandle: string
data?: EdgeData
label?: string
}
interface InputAnchor {
id: string
label: string
name: string
type?: string
[key: string]: any
}
interface InputParam {
id: string
name: string
label?: string
type?: string
display?: boolean
show?: Record<string, any>
hide?: Record<string, any>
[key: string]: any
}
interface OutputAnchor {
id: string
label: string
name: string
}
export const generateAgentflowv2 = async (config: Record<string, any>, question: string, options: ICommonObject) => {
try {
const result = await generateNodesEdges(config, question, options)
const { nodes, edges } = generateNodesData(result, config)
const updatedNodes = await generateSelectedTools(nodes, config, question, options)
const updatedEdges = updateEdges(edges, nodes)
return { nodes: updatedNodes, edges: updatedEdges }
} catch (error) {
console.error('Error generating AgentflowV2:', error)
return { error: error.message || 'Unknown error occurred' }
}
}
const updateEdges = (edges: Edge[], nodes: Node[]): Edge[] => {
const isMultiOutput = (source: string) => {
return source.includes('conditionAgentflow') || source.includes('conditionAgentAgentflow') || source.includes('humanInputAgentflow')
}
const findNodeColor = (nodeId: string) => {
const node = nodes.find((node) => node.id === nodeId)
return node?.data?.color
}
// filter out edges that do not exist in nodes
edges = edges.filter((edge) => {
return nodes.some((node) => node.id === edge.source || node.id === edge.target)
})
// filter out the edge that has hideInput/hideOutput on the source/target node
const indexToDelete = []
for (let i = 0; i < edges.length; i += 1) {
const edge = edges[i]
const sourceNode = nodes.find((node) => node.id === edge.source)
if (sourceNode?.data?.hideOutput) {
indexToDelete.push(i)
}
const targetNode = nodes.find((node) => node.id === edge.target)
if (targetNode?.data?.hideInput) {
indexToDelete.push(i)
}
}
// delete the edges at the index in indexToDelete
for (let i = indexToDelete.length - 1; i >= 0; i -= 1) {
edges.splice(indexToDelete[i], 1)
}
const updatedEdges = edges.map((edge) => {
return {
...edge,
data: {
...edge.data,
sourceColor: findNodeColor(edge.source),
targetColor: findNodeColor(edge.target),
edgeLabel: isMultiOutput(edge.source) && edge.label && edge.label.trim() !== '' ? edge.label.trim() : undefined,
isHumanInput: edge.source.includes('humanInputAgentflow') ? true : false
},
type: 'agentFlow',
id: `${edge.source}-${edge.sourceHandle}-${edge.target}-${edge.targetHandle}`
}
}) as Edge[]
if (updatedEdges.length > 0) {
updatedEdges.forEach((edge) => {
if (isMultiOutput(edge.source)) {
if (edge.sourceHandle.includes('true')) {
edge.sourceHandle = edge.sourceHandle.replace('true', '0')
} else if (edge.sourceHandle.includes('false')) {
edge.sourceHandle = edge.sourceHandle.replace('false', '1')
}
}
})
}
return updatedEdges
}
const generateSelectedTools = async (nodes: Node[], config: Record<string, any>, question: string, options: ICommonObject) => {
const selectedTools: string[] = []
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i]
if (!node.data.inputs) {
node.data.inputs = {}
}
if (node.data.name === 'agentAgentflow') {
const sysPrompt = `You are a workflow orchestrator that is designed to make agent coordination and execution easy. Your goal is to select the tools that are needed to achieve the given task.
Here are the tools to choose from:
${config.toolNodes}
Here's the selected tools:
${JSON.stringify(selectedTools, null, 2)}
Output Format should be a list of tool names:
For example:["googleCustomSearch", "slackMCP"]
Now, select the tools that are needed to achieve the given task. You must only select tools that are in the list of tools above. You must NOT select the tools that are already in the list of selected tools.
`
const tools = await _generateSelectedTools({ ...config, prompt: sysPrompt }, question, options)
if (Array.isArray(tools) && tools.length > 0) {
selectedTools.push(...tools)
const existingTools = node.data.inputs.agentTools || []
node.data.inputs.agentTools = [
...existingTools,
...tools.map((tool) => ({
agentSelectedTool: tool,
agentSelectedToolConfig: {
agentSelectedTool: tool
}
}))
]
}
} else if (node.data.name === 'toolAgentflow') {
const sysPrompt = `You are a workflow orchestrator that is designed to make agent coordination and execution easy. Your goal is to select ONE tool that is needed to achieve the given task.
Here are the tools to choose from:
${config.toolNodes}
Here's the selected tools:
${JSON.stringify(selectedTools, null, 2)}
Output Format should ONLY one tool name inside of a list:
For example:["googleCustomSearch"]
Now, select the ONLY tool that is needed to achieve the given task. You must only select tool that is in the list of tools above. You must NOT select the tool that is already in the list of selected tools.
`
const tools = await _generateSelectedTools({ ...config, prompt: sysPrompt }, question, options)
if (Array.isArray(tools) && tools.length > 0) {
selectedTools.push(...tools)
node.data.inputs.selectedTool = tools[0]
node.data.inputs.toolInputArgs = []
node.data.inputs.selectedToolConfig = {
selectedTool: tools[0]
}
}
}
}
return nodes
}
const _generateSelectedTools = async (config: Record<string, any>, question: string, options: ICommonObject) => {
try {
const chatModelComponent = config.componentNodes[config.selectedChatModel?.name]
if (!chatModelComponent) {
throw new Error('Chat model component not found')
}
const nodeInstanceFilePath = chatModelComponent.filePath as string
const nodeModule = await import(nodeInstanceFilePath)
const newToolNodeInstance = new nodeModule.nodeClass()
const model = (await newToolNodeInstance.init(config.selectedChatModel, '', options)) as BaseChatModel
// Create a parser to validate the output
const parser = StructuredOutputParser.fromZodSchema(ToolType)
// Generate JSON schema from our Zod schema
const formatInstructions = parser.getFormatInstructions()
// Full conversation with system prompt and instructions
const messages = [
{
role: 'system',
content: `${config.prompt}\n\n${formatInstructions}\n\nMake sure to follow the exact JSON schema structure.`
},
{
role: 'user',
content: question
}
]
// Standard completion without structured output
const response = await model.invoke(messages)
// Try to extract JSON from the response
const responseContent = response.content.toString()
const jsonMatch = responseContent.match(/```json\n([\s\S]*?)\n```/) || responseContent.match(/{[\s\S]*?}/)
if (jsonMatch) {
const jsonStr = jsonMatch[1] || jsonMatch[0]
try {
const parsedJSON = JSON.parse(jsonStr)
// Validate with our schema
return ToolType.parse(parsedJSON)
} catch (parseError) {
console.error('Error parsing JSON from response:', parseError)
return { error: 'Failed to parse JSON from response', content: responseContent }
}
} else {
console.error('No JSON found in response:', responseContent)
return { error: 'No JSON found in response', content: responseContent }
}
} catch (error) {
console.error('Error generating AgentflowV2:', error)
return { error: error.message || 'Unknown error occurred' }
}
}
const generateNodesEdges = async (config: Record<string, any>, question: string, options?: ICommonObject) => {
try {
const chatModelComponent = config.componentNodes[config.selectedChatModel?.name]
if (!chatModelComponent) {
throw new Error('Chat model component not found')
}
const nodeInstanceFilePath = chatModelComponent.filePath as string
const nodeModule = await import(nodeInstanceFilePath)
const newToolNodeInstance = new nodeModule.nodeClass()
const model = (await newToolNodeInstance.init(config.selectedChatModel, '', options)) as BaseChatModel
// Create a parser to validate the output
const parser = StructuredOutputParser.fromZodSchema(NodesEdgesType)
// Generate JSON schema from our Zod schema
const formatInstructions = parser.getFormatInstructions()
// Full conversation with system prompt and instructions
const messages = [
{
role: 'system',
content: `${config.prompt}\n\n${formatInstructions}\n\nMake sure to follow the exact JSON schema structure.`
},
{
role: 'user',
content: question
}
]
// Standard completion without structured output
const response = await model.invoke(messages)
// Try to extract JSON from the response
const responseContent = response.content.toString()
const jsonMatch = responseContent.match(/```json\n([\s\S]*?)\n```/) || responseContent.match(/{[\s\S]*?}/)
if (jsonMatch) {
const jsonStr = jsonMatch[1] || jsonMatch[0]
try {
const parsedJSON = JSON.parse(jsonStr)
// Validate with our schema
return NodesEdgesType.parse(parsedJSON)
} catch (parseError) {
console.error('Error parsing JSON from response:', parseError)
return { error: 'Failed to parse JSON from response', content: responseContent }
}
} else {
console.error('No JSON found in response:', responseContent)
return { error: 'No JSON found in response', content: responseContent }
}
} catch (error) {
console.error('Error generating AgentflowV2:', error)
return { error: error.message || 'Unknown error occurred' }
}
}
const generateNodesData = (result: Record<string, any>, config: Record<string, any>) => {
try {
if (result.error) {
return result
}
let nodes = result.nodes
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i]
let nodeName = node.data.name
// If nodeName is not found in data.name, try extracting from node.id
if (!nodeName || !config.componentNodes[nodeName]) {
nodeName = node.id.split('_')[0]
}
const componentNode = config.componentNodes[nodeName]
if (!componentNode) {
continue
}
const initializedNodeData = initNode(cloneDeep(componentNode), node.id)
nodes[i].data = {
...initializedNodeData,
label: node.data?.label
}
if (nodes[i].data.name === 'iterationAgentflow') {
nodes[i].type = 'iteration'
}
if (nodes[i].parentNode) {
nodes[i].extent = 'parent'
}
}
return { nodes, edges: result.edges }
} catch (error) {
console.error('Error generating AgentflowV2:', error)
return { error: error.message || 'Unknown error occurred' }
}
}
const initNode = (nodeData: Record<string, any>, newNodeId: string): NodeData => {
const inputParams = []
const incoming = nodeData.inputs ? nodeData.inputs.length : 0
// Inputs
for (let i = 0; i < incoming; i += 1) {
const newInput = {
...nodeData.inputs[i],
id: `${newNodeId}-input-${nodeData.inputs[i].name}-${nodeData.inputs[i].type}`
}
inputParams.push(newInput)
}
// Credential
if (nodeData.credential) {
const newInput = {
...nodeData.credential,
id: `${newNodeId}-input-${nodeData.credential.name}-${nodeData.credential.type}`
}
inputParams.unshift(newInput)
}
// Outputs
let outputAnchors = initializeOutputAnchors(nodeData, newNodeId)
/* Initial
inputs = [
{
label: 'field_label_1',
name: 'string'
},
{
label: 'field_label_2',
name: 'CustomType'
}
]
=> Convert to inputs, inputParams, inputAnchors
=> inputs = { 'field': 'defaultvalue' } // Turn into inputs object with default values
=> // For inputs that are part of whitelistTypes
inputParams = [
{
label: 'field_label_1',
name: 'string'
}
]
=> // For inputs that are not part of whitelistTypes
inputAnchors = [
{
label: 'field_label_2',
name: 'CustomType'
}
]
*/
// Inputs
if (nodeData.inputs) {
const defaultInputs = initializeDefaultNodeData(nodeData.inputs)
nodeData.inputAnchors = showHideInputAnchors({ ...nodeData, inputAnchors: [], inputs: defaultInputs })
nodeData.inputParams = showHideInputParams({ ...nodeData, inputParams, inputs: defaultInputs })
nodeData.inputs = defaultInputs
} else {
nodeData.inputAnchors = []
nodeData.inputParams = []
nodeData.inputs = {}
}
// Outputs
if (nodeData.outputs) {
nodeData.outputs = initializeDefaultNodeData(outputAnchors)
} else {
nodeData.outputs = {}
}
nodeData.outputAnchors = outputAnchors
// Credential
if (nodeData.credential) nodeData.credential = ''
nodeData.id = newNodeId
return nodeData
}
const initializeDefaultNodeData = (nodeParams: Record<string, any>[]) => {
const initialValues: Record<string, any> = {}
for (let i = 0; i < nodeParams.length; i += 1) {
const input = nodeParams[i]
initialValues[input.name] = input.default || ''
}
return initialValues
}
const createAgentFlowOutputs = (nodeData: Record<string, any>, newNodeId: string) => {
if (nodeData.hideOutput) return []
if (nodeData.outputs?.length) {
return nodeData.outputs.map((_: any, index: number) => ({
id: `${newNodeId}-output-${index}`,
label: nodeData.label,
name: nodeData.name
}))
}
return [
{
id: `${newNodeId}-output-${nodeData.name}`,
label: nodeData.label,
name: nodeData.name
}
]
}
const initializeOutputAnchors = (nodeData: Record<string, any>, newNodeId: string): OutputAnchor[] => {
return createAgentFlowOutputs(nodeData, newNodeId)
}
const _showHideOperation = (nodeData: Record<string, any>, inputParam: Record<string, any>, displayType: string, index?: number) => {
const displayOptions = inputParam[displayType]
/* For example:
show: {
enableMemory: true
}
*/
Object.keys(displayOptions).forEach((path) => {
const comparisonValue = displayOptions[path]
if (path.includes('$index') && index) {
path = path.replace('$index', index.toString())
}
const groundValue = get(nodeData.inputs, path, '')
if (Array.isArray(comparisonValue)) {
if (displayType === 'show' && !comparisonValue.includes(groundValue)) {
inputParam.display = false
}
if (displayType === 'hide' && comparisonValue.includes(groundValue)) {
inputParam.display = false
}
} else if (typeof comparisonValue === 'string') {
if (displayType === 'show' && !(comparisonValue === groundValue || new RegExp(comparisonValue).test(groundValue))) {
inputParam.display = false
}
if (displayType === 'hide' && (comparisonValue === groundValue || new RegExp(comparisonValue).test(groundValue))) {
inputParam.display = false
}
} else if (typeof comparisonValue === 'boolean') {
if (displayType === 'show' && comparisonValue !== groundValue) {
inputParam.display = false
}
if (displayType === 'hide' && comparisonValue === groundValue) {
inputParam.display = false
}
} else if (typeof comparisonValue === 'object') {
if (displayType === 'show' && !isEqual(comparisonValue, groundValue)) {
inputParam.display = false
}
if (displayType === 'hide' && isEqual(comparisonValue, groundValue)) {
inputParam.display = false
}
} else if (typeof comparisonValue === 'number') {
if (displayType === 'show' && comparisonValue !== groundValue) {
inputParam.display = false
}
if (displayType === 'hide' && comparisonValue === groundValue) {
inputParam.display = false
}
}
})
}
const showHideInputs = (nodeData: Record<string, any>, inputType: string, overrideParams?: Record<string, any>, arrayIndex?: number) => {
const params = overrideParams ?? nodeData[inputType] ?? []
for (let i = 0; i < params.length; i += 1) {
const inputParam = params[i]
// Reset display flag to false for each inputParam
inputParam.display = true
if (inputParam.show) {
_showHideOperation(nodeData, inputParam, 'show', arrayIndex)
}
if (inputParam.hide) {
_showHideOperation(nodeData, inputParam, 'hide', arrayIndex)
}
}
return params
}
const showHideInputParams = (nodeData: Record<string, any>): InputParam[] => {
return showHideInputs(nodeData, 'inputParams')
}
const showHideInputAnchors = (nodeData: Record<string, any>): InputAnchor[] => {
return showHideInputs(nodeData, 'inputAnchors')
}
+177 -115
View File
@@ -29,7 +29,7 @@ import { ICommonObject, IDatabaseEntity, INodeData, IServerSideEventStreamer } f
import { LangWatch, LangWatchSpan, LangWatchTrace, autoconvertTypedValues } from 'langwatch'
import { DataSource } from 'typeorm'
import { ChatGenerationChunk } from '@langchain/core/outputs'
import { AIMessageChunk } from '@langchain/core/messages'
import { AIMessageChunk, BaseMessageLike } from '@langchain/core/messages'
import { Serialized } from '@langchain/core/load/serializable'
interface AgentRun extends Run {
@@ -635,137 +635,184 @@ export const additionalCallbacks = async (nodeData: INodeData, options: ICommonO
}
export class AnalyticHandler {
nodeData: INodeData
options: ICommonObject = {}
handlers: ICommonObject = {}
private static instances: Map<string, AnalyticHandler> = new Map()
private nodeData: INodeData
private options: ICommonObject
private handlers: ICommonObject = {}
private initialized: boolean = false
private analyticsConfig: string | undefined
private chatId: string
private createdAt: number
constructor(nodeData: INodeData, options: ICommonObject) {
this.options = options
private constructor(nodeData: INodeData, options: ICommonObject) {
this.nodeData = nodeData
this.init()
this.options = options
this.analyticsConfig = options.analytic
this.chatId = options.chatId
this.createdAt = Date.now()
}
static getInstance(nodeData: INodeData, options: ICommonObject): AnalyticHandler {
const chatId = options.chatId
if (!chatId) throw new Error('ChatId is required for analytics')
// Reset instance if analytics config changed for this chat
const instance = AnalyticHandler.instances.get(chatId)
if (instance?.analyticsConfig !== options.analytic) {
AnalyticHandler.resetInstance(chatId)
}
if (!AnalyticHandler.instances.get(chatId)) {
AnalyticHandler.instances.set(chatId, new AnalyticHandler(nodeData, options))
}
return AnalyticHandler.instances.get(chatId)!
}
static resetInstance(chatId: string): void {
AnalyticHandler.instances.delete(chatId)
}
// Keep this as backup for orphaned instances
static cleanup(maxAge: number = 3600000): void {
const now = Date.now()
for (const [chatId, instance] of AnalyticHandler.instances) {
if (now - instance.createdAt > maxAge) {
AnalyticHandler.resetInstance(chatId)
}
}
}
async init() {
if (this.initialized) return
try {
if (!this.options.analytic) return
const analytic = JSON.parse(this.options.analytic)
for (const provider in analytic) {
const providerStatus = analytic[provider].status as boolean
if (providerStatus) {
const credentialId = analytic[provider].credentialId as string
const credentialData = await getCredentialData(credentialId ?? '', this.options)
if (provider === 'langSmith') {
const langSmithProject = analytic[provider].projectName as string
const langSmithApiKey = getCredentialParam('langSmithApiKey', credentialData, this.nodeData)
const langSmithEndpoint = getCredentialParam('langSmithEndpoint', credentialData, this.nodeData)
const client = new LangsmithClient({
apiUrl: langSmithEndpoint ?? 'https://api.smith.langchain.com',
apiKey: langSmithApiKey
})
this.handlers['langSmith'] = { client, langSmithProject }
} else if (provider === 'langFuse') {
const release = analytic[provider].release as string
const langFuseSecretKey = getCredentialParam('langFuseSecretKey', credentialData, this.nodeData)
const langFusePublicKey = getCredentialParam('langFusePublicKey', credentialData, this.nodeData)
const langFuseEndpoint = getCredentialParam('langFuseEndpoint', credentialData, this.nodeData)
const langfuse = new Langfuse({
secretKey: langFuseSecretKey,
publicKey: langFusePublicKey,
baseUrl: langFuseEndpoint ?? 'https://cloud.langfuse.com',
sdkIntegration: 'Flowise',
release
})
this.handlers['langFuse'] = { client: langfuse }
} else if (provider === 'lunary') {
const lunaryPublicKey = getCredentialParam('lunaryAppId', credentialData, this.nodeData)
const lunaryEndpoint = getCredentialParam('lunaryEndpoint', credentialData, this.nodeData)
lunary.init({
publicKey: lunaryPublicKey,
apiUrl: lunaryEndpoint,
runtime: 'flowise'
})
this.handlers['lunary'] = { client: lunary }
} else if (provider === 'langWatch') {
const langWatchApiKey = getCredentialParam('langWatchApiKey', credentialData, this.nodeData)
const langWatchEndpoint = getCredentialParam('langWatchEndpoint', credentialData, this.nodeData)
const langwatch = new LangWatch({
apiKey: langWatchApiKey,
endpoint: langWatchEndpoint
})
this.handlers['langWatch'] = { client: langwatch }
} else if (provider === 'arize') {
const arizeApiKey = getCredentialParam('arizeApiKey', credentialData, this.nodeData)
const arizeSpaceId = getCredentialParam('arizeSpaceId', credentialData, this.nodeData)
const arizeEndpoint = getCredentialParam('arizeEndpoint', credentialData, this.nodeData)
const arizeProject = analytic[provider].projectName as string
let arizeOptions: ArizeTracerOptions = {
apiKey: arizeApiKey,
spaceId: arizeSpaceId,
baseUrl: arizeEndpoint ?? 'https://otlp.arize.com',
projectName: arizeProject ?? 'default',
sdkIntegration: 'Flowise',
enableCallback: false
}
const arize: Tracer | undefined = getArizeTracer(arizeOptions)
const rootSpan: Span | undefined = undefined
this.handlers['arize'] = { client: arize, arizeProject, rootSpan }
} else if (provider === 'phoenix') {
const phoenixApiKey = getCredentialParam('phoenixApiKey', credentialData, this.nodeData)
const phoenixEndpoint = getCredentialParam('phoenixEndpoint', credentialData, this.nodeData)
const phoenixProject = analytic[provider].projectName as string
let phoenixOptions: PhoenixTracerOptions = {
apiKey: phoenixApiKey,
baseUrl: phoenixEndpoint ?? 'https://app.phoenix.arize.com',
projectName: phoenixProject ?? 'default',
sdkIntegration: 'Flowise',
enableCallback: false
}
const phoenix: Tracer | undefined = getPhoenixTracer(phoenixOptions)
const rootSpan: Span | undefined = undefined
this.handlers['phoenix'] = { client: phoenix, phoenixProject, rootSpan }
} else if (provider === 'opik') {
const opikApiKey = getCredentialParam('opikApiKey', credentialData, this.nodeData)
const opikEndpoint = getCredentialParam('opikUrl', credentialData, this.nodeData)
const opikWorkspace = getCredentialParam('opikWorkspace', credentialData, this.nodeData)
const opikProject = analytic[provider].opikProjectName as string
let opikOptions: OpikTracerOptions = {
apiKey: opikApiKey,
baseUrl: opikEndpoint ?? 'https://www.comet.com/opik/api',
projectName: opikProject ?? 'default',
workspace: opikWorkspace ?? 'default',
sdkIntegration: 'Flowise',
enableCallback: false
}
const opik: Tracer | undefined = getOpikTracer(opikOptions)
const rootSpan: Span | undefined = undefined
this.handlers['opik'] = { client: opik, opikProject, rootSpan }
}
await this.initializeProvider(provider, analytic[provider], credentialData)
}
}
this.initialized = true
} catch (e) {
throw new Error(e)
}
}
// Add getter for handlers (useful for debugging)
getHandlers(): ICommonObject {
return this.handlers
}
async initializeProvider(provider: string, providerConfig: any, credentialData: any) {
if (provider === 'langSmith') {
const langSmithProject = providerConfig.projectName as string
const langSmithApiKey = getCredentialParam('langSmithApiKey', credentialData, this.nodeData)
const langSmithEndpoint = getCredentialParam('langSmithEndpoint', credentialData, this.nodeData)
const client = new LangsmithClient({
apiUrl: langSmithEndpoint ?? 'https://api.smith.langchain.com',
apiKey: langSmithApiKey
})
this.handlers['langSmith'] = { client, langSmithProject }
} else if (provider === 'langFuse') {
const release = providerConfig.release as string
const langFuseSecretKey = getCredentialParam('langFuseSecretKey', credentialData, this.nodeData)
const langFusePublicKey = getCredentialParam('langFusePublicKey', credentialData, this.nodeData)
const langFuseEndpoint = getCredentialParam('langFuseEndpoint', credentialData, this.nodeData)
const langfuse = new Langfuse({
secretKey: langFuseSecretKey,
publicKey: langFusePublicKey,
baseUrl: langFuseEndpoint ?? 'https://cloud.langfuse.com',
sdkIntegration: 'Flowise',
release
})
this.handlers['langFuse'] = { client: langfuse }
} else if (provider === 'lunary') {
const lunaryPublicKey = getCredentialParam('lunaryAppId', credentialData, this.nodeData)
const lunaryEndpoint = getCredentialParam('lunaryEndpoint', credentialData, this.nodeData)
lunary.init({
publicKey: lunaryPublicKey,
apiUrl: lunaryEndpoint,
runtime: 'flowise'
})
this.handlers['lunary'] = { client: lunary }
} else if (provider === 'langWatch') {
const langWatchApiKey = getCredentialParam('langWatchApiKey', credentialData, this.nodeData)
const langWatchEndpoint = getCredentialParam('langWatchEndpoint', credentialData, this.nodeData)
const langwatch = new LangWatch({
apiKey: langWatchApiKey,
endpoint: langWatchEndpoint
})
this.handlers['langWatch'] = { client: langwatch }
} else if (provider === 'arize') {
const arizeApiKey = getCredentialParam('arizeApiKey', credentialData, this.nodeData)
const arizeSpaceId = getCredentialParam('arizeSpaceId', credentialData, this.nodeData)
const arizeEndpoint = getCredentialParam('arizeEndpoint', credentialData, this.nodeData)
const arizeProject = providerConfig.projectName as string
let arizeOptions: ArizeTracerOptions = {
apiKey: arizeApiKey,
spaceId: arizeSpaceId,
baseUrl: arizeEndpoint ?? 'https://otlp.arize.com',
projectName: arizeProject ?? 'default',
sdkIntegration: 'Flowise',
enableCallback: false
}
const arize: Tracer | undefined = getArizeTracer(arizeOptions)
const rootSpan: Span | undefined = undefined
this.handlers['arize'] = { client: arize, arizeProject, rootSpan }
} else if (provider === 'phoenix') {
const phoenixApiKey = getCredentialParam('phoenixApiKey', credentialData, this.nodeData)
const phoenixEndpoint = getCredentialParam('phoenixEndpoint', credentialData, this.nodeData)
const phoenixProject = providerConfig.projectName as string
let phoenixOptions: PhoenixTracerOptions = {
apiKey: phoenixApiKey,
baseUrl: phoenixEndpoint ?? 'https://app.phoenix.arize.com',
projectName: phoenixProject ?? 'default',
sdkIntegration: 'Flowise',
enableCallback: false
}
const phoenix: Tracer | undefined = getPhoenixTracer(phoenixOptions)
const rootSpan: Span | undefined = undefined
this.handlers['phoenix'] = { client: phoenix, phoenixProject, rootSpan }
} else if (provider === 'opik') {
const opikApiKey = getCredentialParam('opikApiKey', credentialData, this.nodeData)
const opikEndpoint = getCredentialParam('opikUrl', credentialData, this.nodeData)
const opikWorkspace = getCredentialParam('opikWorkspace', credentialData, this.nodeData)
const opikProject = providerConfig.opikProjectName as string
let opikOptions: OpikTracerOptions = {
apiKey: opikApiKey,
baseUrl: opikEndpoint ?? 'https://www.comet.com/opik/api',
projectName: opikProject ?? 'default',
workspace: opikWorkspace ?? 'default',
sdkIntegration: 'Flowise',
enableCallback: false
}
const opik: Tracer | undefined = getOpikTracer(opikOptions)
const rootSpan: Span | undefined = undefined
this.handlers['opik'] = { client: opik, opikProject, rootSpan }
}
}
async onChainStart(name: string, input: string, parentIds?: ICommonObject) {
const returnIds: ICommonObject = {
langSmith: {},
@@ -1077,6 +1124,11 @@ export class AnalyticHandler {
chainSpan.end()
}
}
if (shutdown) {
// Cleanup this instance when chain ends
AnalyticHandler.resetInstance(this.chatId)
}
}
async onChainError(returnIds: ICommonObject, error: string | object, shutdown = false) {
@@ -1155,9 +1207,14 @@ export class AnalyticHandler {
chainSpan.end()
}
}
if (shutdown) {
// Cleanup this instance when chain ends
AnalyticHandler.resetInstance(this.chatId)
}
}
async onLLMStart(name: string, input: string, parentIds: ICommonObject) {
async onLLMStart(name: string, input: string | BaseMessageLike[], parentIds: ICommonObject) {
const returnIds: ICommonObject = {
langSmith: {},
langFuse: {},
@@ -1169,13 +1226,18 @@ export class AnalyticHandler {
if (Object.prototype.hasOwnProperty.call(this.handlers, 'langSmith')) {
const parentRun: RunTree | undefined = this.handlers['langSmith'].chainRun[parentIds['langSmith'].chainRun]
if (parentRun) {
const inputs: any = {}
if (Array.isArray(input)) {
inputs.messages = input
} else {
inputs.prompts = [input]
}
const childLLMRun = await parentRun.createChild({
name,
run_type: 'llm',
inputs: {
prompts: [input]
}
inputs
})
await childLLMRun.postRun()
this.handlers['langSmith'].llmRun = { [childLLMRun.id]: childLLMRun }
+1
View File
@@ -11,3 +11,4 @@ export * from './storageUtils'
export * from './handler'
export * from './followUpPrompts'
export * from './validator'
export * from './agentflowv2Generator'
+13 -7
View File
@@ -712,7 +712,7 @@ export const mapChatMessageToBaseMessage = async (chatmessages: any[] = []): Pro
for (const message of chatmessages) {
if (message.role === 'apiMessage' || message.type === 'apiMessage') {
chatHistory.push(new AIMessage(message.content || ''))
} else if (message.role === 'userMessage' || message.role === 'userMessage') {
} else if (message.role === 'userMessage' || message.type === 'userMessage') {
// check for image/files uploads
if (message.fileUploads) {
// example: [{"type":"stored-file","name":"0_DiXc4ZklSTo3M8J4.jpg","mime":"image/jpeg"}]
@@ -788,17 +788,23 @@ export const mapChatMessageToBaseMessage = async (chatmessages: any[] = []): Pro
* @param {IMessage[]} chatHistory
* @returns {string}
*/
export const convertChatHistoryToText = (chatHistory: IMessage[] = []): string => {
export const convertChatHistoryToText = (chatHistory: IMessage[] | { content: string; role: string }[] = []): string => {
return chatHistory
.map((chatMessage) => {
if (chatMessage.type === 'apiMessage') {
return `Assistant: ${chatMessage.message}`
} else if (chatMessage.type === 'userMessage') {
return `Human: ${chatMessage.message}`
if (!chatMessage) return ''
const messageContent = 'message' in chatMessage ? chatMessage.message : chatMessage.content
if (!messageContent || messageContent.trim() === '') return ''
const messageType = 'type' in chatMessage ? chatMessage.type : chatMessage.role
if (messageType === 'apiMessage' || messageType === 'assistant') {
return `Assistant: ${messageContent}`
} else if (messageType === 'userMessage' || messageType === 'user') {
return `Human: ${messageContent}`
} else {
return `${chatMessage.message}`
return `${messageContent}`
}
})
.filter((message) => message !== '') // Remove empty messages
.join('\n')
}