From d9b683d42fbfa7bb776ea35f1a39c4227a911e86 Mon Sep 17 00:00:00 2001 From: j-sanaa Date: Mon, 16 Mar 2026 14:27:55 -0700 Subject: [PATCH] Fix/agentflow model parameters (#5967) * Initial flow * Add support for listRegions api endpoint * Added support for loading credentials in model parameters * Call the right end point for model under model parameters * Fix model parameters data re rendering on each key stroke * Fix gemini comments * Fixed review comments --- packages/agentflow/src/atoms/ArrayInput.tsx | 16 +- .../src/atoms/NodeInputHandler.test.tsx | 145 ++++++++- .../agentflow/src/atoms/NodeInputHandler.tsx | 61 +++- packages/agentflow/src/atoms/index.ts | 2 +- packages/agentflow/src/core/types/node.ts | 1 + .../src/core/utils/nodeFactory.test.ts | 54 ++++ .../agentflow/src/core/utils/nodeFactory.ts | 15 + .../src/features/node-editor/AsyncInput.tsx | 22 +- .../features/node-editor/ConfigInput.test.tsx | 303 ++++++++++++++++++ .../src/features/node-editor/ConfigInput.tsx | 228 +++++++++++++ .../features/node-editor/EditNodeDialog.tsx | 33 +- .../api/hooks/useAsyncOptions.ts | 13 +- .../api/loadMethodRegistry.test.ts | 35 +- .../infrastructure/api/loadMethodRegistry.ts | 17 +- .../agentflow/src/infrastructure/api/nodes.ts | 9 + 15 files changed, 940 insertions(+), 14 deletions(-) create mode 100644 packages/agentflow/src/features/node-editor/ConfigInput.test.tsx create mode 100644 packages/agentflow/src/features/node-editor/ConfigInput.tsx diff --git a/packages/agentflow/src/atoms/ArrayInput.tsx b/packages/agentflow/src/atoms/ArrayInput.tsx index b2a885447..b1e468582 100644 --- a/packages/agentflow/src/atoms/ArrayInput.tsx +++ b/packages/agentflow/src/atoms/ArrayInput.tsx @@ -6,7 +6,7 @@ import { IconPlus, IconTrash } from '@tabler/icons-react' import type { InputParam, NodeData } from '@/core/types' -import { type AsyncInputProps, NodeInputHandler } from './NodeInputHandler' +import { type AsyncInputProps, type ConfigInputComponentProps, NodeInputHandler } from './NodeInputHandler' export interface ArrayInputProps { inputParam: InputParam @@ -15,6 +15,12 @@ export interface ArrayInputProps { onDataChange?: (params: { inputParam: InputParam; newValue: unknown }) => void itemParameters?: InputParam[][] AsyncInputComponent?: ComponentType + ConfigInputComponent?: ComponentType + onConfigChange?: ( + configKey: string, + configValues: Record, + arrayContext?: { parentParamName: string; arrayIndex: number } + ) => void } export function ArrayInput({ @@ -23,7 +29,9 @@ export function ArrayInput({ disabled = false, onDataChange, itemParameters: itemParametersProp, - AsyncInputComponent + AsyncInputComponent, + ConfigInputComponent, + onConfigChange }: ArrayInputProps) { const theme = useTheme() @@ -185,6 +193,10 @@ export function ArrayInput({ disablePadding={false} onDataChange={itemHandlers[index]} AsyncInputComponent={AsyncInputComponent} + ConfigInputComponent={ConfigInputComponent} + onConfigChange={onConfigChange} + arrayIndex={index} + parentArrayParam={inputParam} /> ))} diff --git a/packages/agentflow/src/atoms/NodeInputHandler.test.tsx b/packages/agentflow/src/atoms/NodeInputHandler.test.tsx index f0a3c23d6..2926d1a81 100644 --- a/packages/agentflow/src/atoms/NodeInputHandler.test.tsx +++ b/packages/agentflow/src/atoms/NodeInputHandler.test.tsx @@ -4,7 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import type { InputParam, NodeData } from '@/core/types' -import { type AsyncInputProps, NodeInputHandler } from './NodeInputHandler' +import { type AsyncInputProps, type ConfigInputComponentProps, NodeInputHandler } from './NodeInputHandler' // ─── Mocks ──────────────────────────────────────────────────────────────────── @@ -177,3 +177,146 @@ describe('NodeInputHandler – async onChange wiring', () => { }) }) }) + +describe('NodeInputHandler – loadConfig rendering', () => { + const StubAsyncInput: ComponentType = ({ onChange }) => ( + + ) + + const StubConfigInput: ComponentType = ({ inputParam }) => ( +
Config Accordion
+ ) + + it('renders ConfigInputComponent when loadConfig is true and value exists', () => { + render( + + ) + + expect(screen.getByTestId('config-input-myField')).toBeTruthy() + }) + + it('does not render ConfigInputComponent when loadConfig is false', () => { + render( + + ) + + expect(screen.queryByTestId('config-input-myField')).toBeNull() + }) + + it('does not render ConfigInputComponent when value is empty', () => { + render( + + ) + + expect(screen.queryByTestId('config-input-myField')).toBeNull() + }) + + it('does not render ConfigInputComponent when component is not injected', () => { + render( + + ) + + expect(screen.queryByTestId('config-input-myField')).toBeNull() + }) + + it('does not render ConfigInputComponent when onConfigChange is not provided', () => { + render( + + ) + + expect(screen.queryByTestId('config-input-myField')).toBeNull() + }) +}) + +describe('NodeInputHandler – credential type rendering', () => { + const StubAsyncInput: ComponentType = ({ onChange }) => ( + + ) + + it('renders AsyncInputComponent for credential type', () => { + render( + + ) + + expect(screen.getByTestId('credential-select')).toBeTruthy() + }) + + it('renders nothing for credential type when no AsyncInputComponent', () => { + const { container } = render( + + ) + + expect(container.querySelector('button')).toBeNull() + }) + + it('calls onDataChange when credential onChange fires', () => { + render( + + ) + + fireEvent.click(screen.getByTestId('credential-select')) + + expect(mockOnDataChange).toHaveBeenCalledWith({ + inputParam: expect.objectContaining({ name: 'FLOWISE_CREDENTIAL_ID', type: 'credential' }), + newValue: 'cred-id-123' + }) + }) +}) diff --git a/packages/agentflow/src/atoms/NodeInputHandler.tsx b/packages/agentflow/src/atoms/NodeInputHandler.tsx index 9b852c28a..876bda924 100644 --- a/packages/agentflow/src/atoms/NodeInputHandler.tsx +++ b/packages/agentflow/src/atoms/NodeInputHandler.tsx @@ -28,6 +28,21 @@ export interface AsyncInputProps { inputValues?: Record } +/** Props passed to a config input component (loadConfig accordion). */ +export interface ConfigInputComponentProps { + data: NodeData + inputParam: InputParam + disabled?: boolean + arrayIndex?: number | null + parentArrayParam?: InputParam | null + onConfigChange: ( + configKey: string, + configValues: Record, + arrayContext?: { parentParamName: string; arrayIndex: number } + ) => void + AsyncInputComponent?: ComponentType +} + export interface NodeInputHandlerProps { inputAnchor?: InputAnchor inputParam?: InputParam @@ -39,6 +54,18 @@ export interface NodeInputHandlerProps { itemParameters?: InputParam[][] /** Renders asyncOptions / asyncMultiOptions fields. Lives in features/ to keep atoms free of infrastructure. */ AsyncInputComponent?: ComponentType + /** Renders loadConfig accordion beneath async dropdowns. Injected from features/ to keep atoms infrastructure-free. */ + ConfigInputComponent?: ComponentType + /** Callback for config value changes (from ConfigInputComponent). */ + onConfigChange?: ( + configKey: string, + configValues: Record, + arrayContext?: { parentParamName: string; arrayIndex: number } + ) => void + /** For array-based configs: index of current array item. */ + arrayIndex?: number | null + /** For array-based configs: the parent array InputParam definition. */ + parentArrayParam?: InputParam | null } // ─── Main component ─────────────────────────────────────────────────────────── @@ -56,7 +83,11 @@ export function NodeInputHandler({ disablePadding = false, onDataChange, itemParameters, - AsyncInputComponent + AsyncInputComponent, + ConfigInputComponent, + onConfigChange, + arrayIndex = null, + parentArrayParam = null }: NodeInputHandlerProps) { const theme = useTheme() const ref = useRef(null) @@ -208,11 +239,39 @@ export function NodeInputHandler({ onDataChange={onDataChange} itemParameters={itemParameters} AsyncInputComponent={AsyncInputComponent} + ConfigInputComponent={ConfigInputComponent} + onConfigChange={onConfigChange} /> ) case 'asyncOptions': case 'asyncMultiOptions': + if (!AsyncInputComponent) return null + return ( + <> + handleDataChange(v)} + nodeName={data.name} + inputValues={data.inputValues as Record | undefined} + /> + {inputParam.loadConfig && ConfigInputComponent && value && onConfigChange && ( + + )} + + ) + + case 'credential': if (!AsyncInputComponent) return null return ( { expect(result.outputAnchors).toHaveLength(0) }) + it('should prepend credential param when node has credential property', () => { + const nodeData = makeNodeData({ + inputs: [{ id: '', name: 'temperature', label: 'Temperature', type: 'number', default: 0.9 }] as NodeData['inputs'], + credential: { + label: 'AWS Credential', + name: 'credential', + type: 'credential', + credentialNames: ['awsApi'], + optional: true + } + } as Partial) + + const result = initNode(nodeData, 'n1', false) + + // Credential should be first, followed by regular params + expect(result.inputs).toHaveLength(2) + expect(result.inputs![0]).toEqual( + expect.objectContaining({ + name: 'FLOWISE_CREDENTIAL_ID', + label: 'AWS Credential', + type: 'credential', + credentialNames: ['awsApi'] + }) + ) + expect(result.inputs![1].name).toBe('temperature') + // Default value for credential should be empty string + expect(result.inputValues!['FLOWISE_CREDENTIAL_ID']).toBe('') + }) + + it('should not add credential param when node has no credential property', () => { + const nodeData = makeNodeData({ + inputs: [{ id: '', name: 'temperature', label: 'Temperature', type: 'number' }] as NodeData['inputs'] + }) + const result = initNode(nodeData, 'n1', false) + expect(result.inputs).toHaveLength(1) + expect(result.inputs![0].name).toBe('temperature') + }) + + it('should not add credential param when credentialNames is empty', () => { + const nodeData = makeNodeData({ + inputs: [{ id: '', name: 'temperature', label: 'Temperature', type: 'number' }] as NodeData['inputs'], + credential: { + label: 'Credential', + name: 'credential', + type: 'credential', + credentialNames: [] + } + } as Partial) + + const result = initNode(nodeData, 'n1', false) + expect(result.inputs).toHaveLength(1) + expect(result.inputs![0].name).toBe('temperature') + }) + it('should strip server-only metadata like filePath from node data', () => { const nodeData = makeNodeData({ filePath: '/some/server/path/Agent.js', diff --git a/packages/agentflow/src/core/utils/nodeFactory.ts b/packages/agentflow/src/core/utils/nodeFactory.ts index 9e855aa19..388c116a9 100644 --- a/packages/agentflow/src/core/utils/nodeFactory.ts +++ b/packages/agentflow/src/core/utils/nodeFactory.ts @@ -162,6 +162,21 @@ export function initNode(nodeData: NodeData, newNodeId: string, isAgentflow = tr } } + // Credential — extract top-level credential property and prepend to input definitions + const rawCredential = (nodeData as Record).credential as + | { name?: string; label?: string; type?: string; credentialNames?: string[]; optional?: boolean } + | undefined + + if (rawCredential?.credentialNames?.length) { + inputDefinitions.unshift({ + ...rawCredential, + id: `${newNodeId}-input-FLOWISE_CREDENTIAL_ID-credential`, + name: 'FLOWISE_CREDENTIAL_ID', + label: rawCredential.label ?? 'Credential', + type: 'credential' + }) + } + // Initialize default input values from definitions using initializeDefaultNodeData const initialInputValues = initializeDefaultNodeData(inputDefinitions) diff --git a/packages/agentflow/src/features/node-editor/AsyncInput.tsx b/packages/agentflow/src/features/node-editor/AsyncInput.tsx index 0e60081a1..ab984f61a 100644 --- a/packages/agentflow/src/features/node-editor/AsyncInput.tsx +++ b/packages/agentflow/src/features/node-editor/AsyncInput.tsx @@ -8,9 +8,24 @@ import type { AsyncInputProps } from '@/atoms' import type { NodeOption } from '@/core/types' import { useAsyncOptions } from '@/infrastructure/api/hooks' +/** + * Build the params object for useAsyncOptions. + * Only includes inputValues when the loadMethod actually needs them (e.g. listToolInputArgs). + * Including them unconditionally causes every keystroke in sibling text fields to change the + * serialised paramsKey, triggering unnecessary refetches for all async dropdowns. + */ +function buildAsyncParams( + loadMethod: string | undefined, + nodeName: string | undefined, + inputValues: Record | undefined +): Record | undefined { + const needsInputs = loadMethod === 'listToolInputArgs' + if (!nodeName && !(needsInputs && inputValues)) return undefined + return { ...(nodeName ? { nodeName } : {}), ...(needsInputs && inputValues ? { inputs: inputValues } : {}) } +} + function AsyncOptionsInput({ inputParam, value, disabled, onChange, nodeName, inputValues }: AsyncInputProps) { - const params = - nodeName || inputValues ? { ...(nodeName ? { nodeName } : {}), ...(inputValues ? { inputs: inputValues } : {}) } : undefined + const params = buildAsyncParams(inputParam.loadMethod, nodeName, inputValues) const { options, loading, error, refetch } = useAsyncOptions({ loadMethod: inputParam.loadMethod, credentialNames: inputParam.credentialNames, @@ -92,8 +107,7 @@ function AsyncOptionsInput({ inputParam, value, disabled, onChange, nodeName, in } function AsyncMultiOptionsInput({ inputParam, value, disabled, onChange, nodeName, inputValues }: AsyncInputProps) { - const params = - nodeName || inputValues ? { ...(nodeName ? { nodeName } : {}), ...(inputValues ? { inputs: inputValues } : {}) } : undefined + const params = buildAsyncParams(inputParam.loadMethod, nodeName, inputValues) const { options, loading, error, refetch } = useAsyncOptions({ loadMethod: inputParam.loadMethod, credentialNames: inputParam.credentialNames, diff --git a/packages/agentflow/src/features/node-editor/ConfigInput.test.tsx b/packages/agentflow/src/features/node-editor/ConfigInput.test.tsx new file mode 100644 index 000000000..8829591e7 --- /dev/null +++ b/packages/agentflow/src/features/node-editor/ConfigInput.test.tsx @@ -0,0 +1,303 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' + +import type { InputParam, NodeData } from '@/core/types' + +import { ConfigInput } from './ConfigInput' + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +jest.mock('@tabler/icons-react', () => ({ + IconArrowsMaximize: () => , + IconVariable: () => , + IconRefresh: () => , + IconSettings: () => +})) + +jest.mock('@mui/icons-material/ExpandMore', () => ({ + __esModule: true, + default: () => +})) + +const mockGetNodeByName = jest.fn() + +jest.mock('@/infrastructure/store', () => ({ + useApiContext: () => ({ + nodesApi: { getNodeByName: mockGetNodeByName } + }), + useAgentflowContext: () => ({ + updateNodeData: jest.fn(), + state: { nodes: [], edges: [] } + }), + useConfigContext: () => ({ isDarkMode: false }) +})) + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const mockOnConfigChange = jest.fn() + +const makeParentData = (overrides?: Partial): NodeData => ({ + id: 'agent-node-1', + name: 'agentAgentflow', + label: 'Agent 0', + inputValues: { agentModel: 'chatAlibabaTongyi' }, + ...overrides +}) + +const makeInputParam = (overrides?: Partial): InputParam => ({ + id: 'p1', + name: 'agentModel', + label: 'Model', + type: 'asyncOptions', + loadConfig: true, + ...overrides +}) + +/** Fake node definition returned by getNodeByName */ +const fakeNodeDefinition: NodeData = { + id: '', + name: 'chatAlibabaTongyi', + label: 'ChatAlibabaTongyi', + inputs: [ + { id: 'i1', name: 'chatAlibabaTongyi', label: 'Model Name', type: 'asyncOptions' } as InputParam, + { id: 'i2', name: 'temperature', label: 'Temperature', type: 'number', default: 0.9 } as InputParam, + { id: 'i3', name: 'maxTokens', label: 'Max Tokens', type: 'number', default: 1024 } as InputParam + ] +} + +beforeEach(() => { + jest.clearAllMocks() + mockGetNodeByName.mockResolvedValue(fakeNodeDefinition) +}) + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('ConfigInput', () => { + describe('rendering', () => { + it('renders accordion with model label after loading', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('ChatAlibabaTongyi Parameters')).toBeTruthy() + }) + }) + + it('renders nothing when no model is selected', () => { + const { container } = render( + + ) + + // No accordion should render + expect(container.querySelector('.MuiAccordion-root')).toBeNull() + }) + + it('calls getNodeByName with the selected model name', async () => { + render() + + await waitFor(() => { + expect(mockGetNodeByName).toHaveBeenCalledWith('chatAlibabaTongyi') + }) + }) + + it('renders parameter fields inside accordion details', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Temperature')).toBeTruthy() + expect(screen.getByText('Max Tokens')).toBeTruthy() + }) + }) + }) + + describe('config persistence', () => { + it('calls onConfigChange with config values after loading', async () => { + render() + + await waitFor(() => { + expect(mockOnConfigChange).toHaveBeenCalledWith( + 'agentModelConfig', + expect.objectContaining({ + agentModel: 'chatAlibabaTongyi', + temperature: 0.9, + maxTokens: 1024 + }), + undefined + ) + }) + }) + + it('calls onConfigChange with array context when arrayIndex is provided', async () => { + const parentArray: InputParam = { + id: 'arr', + name: 'agentTools', + label: 'Tools', + type: 'array' + } + + const parentData = makeParentData({ + inputValues: { + agentTools: [{ agentSelectedTool: 'requestsGet' }] + } + }) + + const param = makeInputParam({ + name: 'agentSelectedTool', + label: 'Tool' + }) + + render( + + ) + + await waitFor(() => { + expect(mockGetNodeByName).toHaveBeenCalledWith('requestsGet') + expect(mockOnConfigChange).toHaveBeenCalledWith('agentSelectedToolConfig', expect.any(Object), { + parentParamName: 'agentTools', + arrayIndex: 0 + }) + }) + }) + }) + + describe('existing config merge', () => { + it('reuses existing config when model matches', async () => { + const parentData = makeParentData({ + inputValues: { + agentModel: 'chatAlibabaTongyi', + agentModelConfig: { + agentModel: 'chatAlibabaTongyi', + temperature: 0.5, + maxTokens: 2048 + } + } + }) + + render() + + await waitFor(() => { + // Should call with the EXISTING config values (0.5, 2048), not defaults (0.9, 1024) + expect(mockOnConfigChange).toHaveBeenCalledWith( + 'agentModelConfig', + expect.objectContaining({ + temperature: 0.5, + maxTokens: 2048 + }), + undefined + ) + }) + }) + + it('resets to defaults when stored config model does not match current selection', async () => { + const parentData = makeParentData({ + inputValues: { + agentModel: 'chatAlibabaTongyi', + agentModelConfig: { + agentModel: 'chatOpenAI', // Different model — stale config + temperature: 0.3, + maxTokens: 4096 + } + } + }) + + render() + + await waitFor(() => { + // Should reset to defaults from initNode, NOT use the stale config + expect(mockOnConfigChange).toHaveBeenCalledWith( + 'agentModelConfig', + expect.objectContaining({ + agentModel: 'chatAlibabaTongyi', + temperature: 0.9, + maxTokens: 1024 + }), + undefined + ) + }) + }) + }) + + describe('internal changes', () => { + it('updates config when a parameter value changes', async () => { + render() + + // Wait for accordion to load + await waitFor(() => { + expect(screen.getByText('Temperature')).toBeTruthy() + }) + + // Expand the accordion + fireEvent.click(screen.getByText('ChatAlibabaTongyi Parameters')) + + // Find and change the temperature field + const temperatureInputs = screen.getAllByRole('spinbutton') + const tempInput = temperatureInputs[0] + fireEvent.change(tempInput, { target: { value: '0.7' } }) + + await waitFor(() => { + expect(mockOnConfigChange).toHaveBeenCalledWith( + 'agentModelConfig', + expect.objectContaining({ temperature: '0.7' }), + undefined + ) + }) + }) + }) + + describe('API error handling', () => { + it('renders nothing when API call fails', async () => { + mockGetNodeByName.mockRejectedValue(new Error('Not found')) + + const { container } = render( + + ) + + // Wait a tick for the async effect to settle + await waitFor(() => { + expect(mockGetNodeByName).toHaveBeenCalled() + }) + + expect(container.querySelector('.MuiAccordion-root')).toBeNull() + }) + }) + + describe('field visibility', () => { + it('hides params based on show/hide conditions', async () => { + const nodeDefnWithVisibility: NodeData = { + id: '', + name: 'chatAlibabaTongyi', + label: 'ChatAlibabaTongyi', + inputs: [ + { id: 'i1', name: 'chatAlibabaTongyi', label: 'Model Name', type: 'asyncOptions' } as InputParam, + { id: 'i2', name: 'temperature', label: 'Temperature', type: 'number', default: 0.9 } as InputParam, + { + id: 'i3', + name: 'advancedParam', + label: 'Advanced Param', + type: 'string', + show: { temperature: 999 } // Only show when temperature is 999 — effectively hidden + } as InputParam + ] + } + + mockGetNodeByName.mockResolvedValue(nodeDefnWithVisibility) + + render() + + await waitFor(() => { + expect(screen.getByText('Temperature')).toBeTruthy() + }) + + // advancedParam should NOT be visible (its show condition is not met) + expect(screen.queryByText('Advanced Param')).toBeNull() + }) + }) +}) diff --git a/packages/agentflow/src/features/node-editor/ConfigInput.tsx b/packages/agentflow/src/features/node-editor/ConfigInput.tsx new file mode 100644 index 000000000..3b67680b4 --- /dev/null +++ b/packages/agentflow/src/features/node-editor/ConfigInput.tsx @@ -0,0 +1,228 @@ +import { type ComponentType, useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from '@mui/material' +import { useTheme } from '@mui/material/styles' +import { IconSettings } from '@tabler/icons-react' + +import { type AsyncInputProps, NodeInputHandler } from '@/atoms' +import type { InputParam, NodeData } from '@/core/types' +import { evaluateFieldVisibility, initNode } from '@/core/utils' +import { useApiContext } from '@/infrastructure/store' + +// ─── Props ──────────────────────────────────────────────────────────────────── + +export interface ConfigInputProps { + data: NodeData // The parent node's data + inputParam: InputParam // The inputParam with loadConfig: true (e.g., the "agentModel" param). + disabled?: boolean + arrayIndex?: number | null // For array-based configs: the index of the array item. + parentArrayParam?: InputParam | null // For array-based configs: the parent array param definition. + onConfigChange: ( + // Callback to persist config values to the parent node's inputValues. + configKey: string, + configValues: Record, + arrayContext?: { parentParamName: string; arrayIndex: number } + ) => void + AsyncInputComponent?: ComponentType // Injected async input component +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Initialize default values for a set of input params. */ +function initializeDefaults(params: InputParam[]): Record { + const defaults: Record = {} + for (const p of params) { + defaults[p.name] = p.default ?? '' + } + return defaults +} + +/** Read the current selection value from parent data, handling array context. */ +function readCurrentValue( + data: NodeData, + paramName: string, + arrayIndex?: number | null, + parentArrayParam?: InputParam | null +): string | undefined { + if (arrayIndex != null && parentArrayParam) { + const arr = data.inputValues?.[parentArrayParam.name] + if (Array.isArray(arr) && arr[arrayIndex]) { + return arr[arrayIndex][paramName] as string | undefined + } + return undefined + } + return data.inputValues?.[paramName] as string | undefined +} + +/** Read existing config from parent data, handling array context. */ +function readExistingConfig( + data: NodeData, + paramName: string, + arrayIndex?: number | null, + parentArrayParam?: InputParam | null +): Record | undefined { + const configKey = `${paramName}Config` + if (arrayIndex != null && parentArrayParam) { + const arr = data.inputValues?.[parentArrayParam.name] + if (Array.isArray(arr) && arr[arrayIndex]) { + return arr[arrayIndex][configKey] as Record | undefined + } + return undefined + } + return data.inputValues?.[configKey] as Record | undefined +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function ConfigInput({ + data, + inputParam, + disabled = false, + arrayIndex = null, + parentArrayParam = null, + onConfigChange, + AsyncInputComponent +}: ConfigInputProps) { + const theme = useTheme() + const { nodesApi } = useApiContext() + + const [expanded, setExpanded] = useState(false) + /** The fetched & initialized sub-node data (input definitions + label). */ + const [configNodeData, setConfigNodeData] = useState(null) + + // Refs to avoid stale closures and prevent reactive loops + const onConfigChangeRef = useRef(onConfigChange) + onConfigChangeRef.current = onConfigChange + const dataRef = useRef(data) + dataRef.current = data + + // ── Derive current selection from parent ────────────────────────────────── + + const currentSelection = readCurrentValue(data, inputParam.name, arrayIndex, parentArrayParam) || '' + + // ── Persist helper (called imperatively, not via useEffect) ─────────────── + + const persistConfig = useCallback( + (values: Record) => { + const configKey = `${inputParam.name}Config` + const arrayCtx = parentArrayParam && arrayIndex != null ? { parentParamName: parentArrayParam.name, arrayIndex } : undefined + onConfigChangeRef.current(configKey, values, arrayCtx) + }, + [inputParam.name, parentArrayParam, arrayIndex] + ) + + // ── Derive config values from parent (single source of truth) ─────────── + + const configInputValues = useMemo((): Record => { + if (!configNodeData) return {} + const existing = readExistingConfig(data, inputParam.name, arrayIndex, parentArrayParam) + if (existing && existing[inputParam.name] === currentSelection) { + return existing + } + // No saved config or selection mismatch — fall back to defaults + const paramDefs = (configNodeData.inputs ?? []) as InputParam[] + return { ...initializeDefaults(paramDefs), [inputParam.name]: currentSelection } + }, [configNodeData, data, inputParam.name, arrayIndex, parentArrayParam, currentSelection]) + + // ── Derive visible params from definitions + current values ────────────── + + const configInputParams = useMemo((): InputParam[] => { + if (!configNodeData) return [] + const paramDefs = (configNodeData.inputs ?? []) as InputParam[] + return evaluateFieldVisibility(paramDefs, configInputValues) + }, [configNodeData, configInputValues]) + + // ── Fetch node definition when selection changes ───────────────────────── + + useEffect(() => { + if (!currentSelection) { + setConfigNodeData(null) + return + } + + let cancelled = false + + const load = async () => { + try { + const nodeDefn = await nodesApi.getNodeByName(currentSelection) + if (cancelled) return + + // initNode with isAgentflow=false so it doesn't create agentflow output anchors + const initialized = initNode(nodeDefn, `${currentSelection}_0`, false) + setConfigNodeData(initialized) + + // Persist initial config to parent + const paramDefs = (initialized.inputs ?? []) as InputParam[] + const existing = readExistingConfig(dataRef.current, inputParam.name, arrayIndex, parentArrayParam) + const values = + existing && existing[inputParam.name] === currentSelection + ? existing + : { ...initializeDefaults(paramDefs), [inputParam.name]: currentSelection } + persistConfig(values) + } catch { + setConfigNodeData(null) + } + } + + load() + return () => { + cancelled = true + } + }, [currentSelection, nodesApi, inputParam.name, arrayIndex, parentArrayParam, persistConfig]) + + // ── Internal change handler ─────────────────────────────────────────────── + + const handleInternalChange = useCallback( + ({ inputParam: changedParam, newValue }: { inputParam: InputParam; newValue: unknown }) => { + persistConfig({ ...configInputValues, [changedParam.name]: newValue }) + }, + [configInputValues, persistConfig] + ) + + // ── Render ──────────────────────────────────────────────────────────────── + + if (!configNodeData || configInputParams.length === 0) return null + + const configData: NodeData = { + ...configNodeData, + inputValues: configInputValues + } + + return ( + + setExpanded(isExpanded)}> + } sx={{ background: 'transparent' }}> + + + {configNodeData.label} Parameters + + + + {configInputParams + .filter((p) => p.display !== false) + .map((p, index) => ( + + ))} + + + + ) +} diff --git a/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx b/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx index 99e362832..7d8220e6d 100644 --- a/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx +++ b/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' import { useUpdateNodeInternals } from 'reactflow' import { Avatar, Box, ButtonBase, Dialog, DialogContent, Stack, TextField, Typography } from '@mui/material' @@ -11,6 +11,7 @@ import { buildDynamicOutputAnchors, evaluateFieldVisibility } from '@/core/utils import { useAgentflowContext, useConfigContext } from '@/infrastructure/store' import { AsyncInput } from './AsyncInput' +import { ConfigInput } from './ConfigInput' import { useDynamicOutputPorts } from './useDynamicOutputPorts' /** Array param names that should render as MessagesInput instead of generic ArrayInput. */ @@ -55,6 +56,10 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog const isConditionNode = data?.name === 'conditionAgentflow' const { cleanupOrphanedEdges } = useDynamicOutputPorts(data?.id ?? '', isConditionNode) + // Ref to read current data + const dataRef = useRef(data) + dataRef.current = data + const onNodeLabelChange = () => { if (!data || !nodeNameRef.current) return @@ -64,6 +69,30 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog updateNodeInternals(data.id) } + const onConfigChange = useCallback( + (configKey: string, configValues: Record, arrayContext?: { parentParamName: string; arrayIndex: number }) => { + const current = dataRef.current + if (!current) return + + let updatedInputValues: Record + + if (arrayContext) { + // Array-based config: write into the nested array item + const currentArray = [...((current.inputValues?.[arrayContext.parentParamName] as Record[]) ?? [])] + const updatedItem = { ...(currentArray[arrayContext.arrayIndex] ?? {}), [configKey]: configValues } + currentArray[arrayContext.arrayIndex] = updatedItem + updatedInputValues = { ...current.inputValues, [arrayContext.parentParamName]: currentArray } + } else { + // Top-level config + updatedInputValues = { ...current.inputValues, [configKey]: configValues } + } + + updateNodeData(current.id, { inputValues: updatedInputValues }) + setData({ ...current, inputValues: updatedInputValues }) + }, + [updateNodeData] + ) + const onCustomDataChange = ({ inputParam, newValue }: { inputParam: InputParam; newValue: unknown }) => { if (!data) return @@ -309,6 +338,8 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog onDataChange={onCustomDataChange} itemParameters={inputParam.type === 'array' ? arrayItemParameters[inputParam.name] : undefined} AsyncInputComponent={AsyncInput} + ConfigInputComponent={ConfigInput} + onConfigChange={onConfigChange} /> ) })} diff --git a/packages/agentflow/src/infrastructure/api/hooks/useAsyncOptions.ts b/packages/agentflow/src/infrastructure/api/hooks/useAsyncOptions.ts index fd82f86ad..7ba6884be 100644 --- a/packages/agentflow/src/infrastructure/api/hooks/useAsyncOptions.ts +++ b/packages/agentflow/src/infrastructure/api/hooks/useAsyncOptions.ts @@ -25,7 +25,7 @@ interface UseAsyncOptionsResult { * Fetches async option lists from the API using the loadMethodRegistry. */ export function useAsyncOptions({ loadMethod, credentialNames, params }: UseAsyncOptionsParams): UseAsyncOptionsResult { - const { chatModelsApi, toolsApi, credentialsApi, storesApi, embeddingsApi, runtimeStateApi, apiBaseUrl } = useApiContext() + const { chatModelsApi, toolsApi, credentialsApi, storesApi, embeddingsApi, runtimeStateApi, nodesApi, apiBaseUrl } = useApiContext() const [options, setOptions] = useState([]) const [loading, setLoading] = useState(true) @@ -67,7 +67,15 @@ export function useAsyncOptions({ loadMethod, credentialNames, params }: UseAsyn if (!fn) { throw new Error(`Unknown loadMethod: "${loadMethod}"`) } - const apis: ApiServices = { chatModelsApi, toolsApi, credentialsApi, storesApi, embeddingsApi, runtimeStateApi } + const apis: ApiServices = { + chatModelsApi, + toolsApi, + credentialsApi, + storesApi, + embeddingsApi, + runtimeStateApi, + nodesApi + } const stableParams = paramsKey ? (JSON.parse(paramsKey) as Record) : undefined const raw = await fn(apis, stableParams) result = normalizeOptions(raw, apiBaseUrl) @@ -103,6 +111,7 @@ export function useAsyncOptions({ loadMethod, credentialNames, params }: UseAsyn storesApi, embeddingsApi, runtimeStateApi, + nodesApi, apiBaseUrl ]) diff --git a/packages/agentflow/src/infrastructure/api/loadMethodRegistry.test.ts b/packages/agentflow/src/infrastructure/api/loadMethodRegistry.test.ts index 79945476a..6536b3e38 100644 --- a/packages/agentflow/src/infrastructure/api/loadMethodRegistry.test.ts +++ b/packages/agentflow/src/infrastructure/api/loadMethodRegistry.test.ts @@ -22,6 +22,12 @@ const mockApis: ApiServices = { }, runtimeStateApi: { getRuntimeStateKeys: jest.fn() + }, + nodesApi: { + getAllNodes: jest.fn(), + getNodeByName: jest.fn(), + getNodeIconUrl: jest.fn(), + loadNodeMethod: jest.fn() } } @@ -31,7 +37,7 @@ beforeEach(() => { describe('loadMethodRegistry', () => { describe('listModels', () => { - it('should call chatModelsApi.getChatModels()', async () => { + it('should call chatModelsApi.getChatModels() when no nodeName provided', async () => { const mockModels = [{ name: 'gpt-4', label: 'GPT-4' }] ;(mockApis.chatModelsApi.getChatModels as jest.Mock).mockResolvedValue(mockModels) @@ -39,6 +45,16 @@ describe('loadMethodRegistry', () => { expect(mockApis.chatModelsApi.getChatModels).toHaveBeenCalled() expect(result).toEqual(mockModels) }) + + it('should call nodesApi.loadNodeMethod() when nodeName is provided', async () => { + const mockBedrockModels = [{ name: 'anthropic.claude-3-haiku', label: 'Claude 3 Haiku' }] + ;(mockApis.nodesApi.loadNodeMethod as jest.Mock).mockResolvedValue(mockBedrockModels) + + const result = await loadMethodRegistry['listModels'](mockApis, { nodeName: 'awsChatBedrock' }) + expect(mockApis.nodesApi.loadNodeMethod).toHaveBeenCalledWith('awsChatBedrock', 'listModels') + expect(mockApis.chatModelsApi.getChatModels).not.toHaveBeenCalled() + expect(result).toEqual(mockBedrockModels) + }) }) describe('listTools', () => { @@ -66,6 +82,23 @@ describe('loadMethodRegistry', () => { }) }) + describe('listRegions', () => { + it('should call nodesApi.loadNodeMethod() with nodeName and listRegions', async () => { + const mockRegions = [{ name: 'us-east-1', label: 'US East (N. Virginia)' }] + ;(mockApis.nodesApi.loadNodeMethod as jest.Mock).mockResolvedValue(mockRegions) + + const result = await loadMethodRegistry['listRegions'](mockApis, { nodeName: 'awsChatBedrock' }) + expect(mockApis.nodesApi.loadNodeMethod).toHaveBeenCalledWith('awsChatBedrock', 'listRegions') + expect(result).toEqual(mockRegions) + }) + + it('should reject when nodeName param is missing', async () => { + await expect(loadMethodRegistry['listRegions'](mockApis)).rejects.toThrow( + '`listRegions` requires a string `nodeName` parameter.' + ) + }) + }) + describe('listCredentials', () => { it('should call credentialsApi.getCredentialsByName() with params.name', async () => { const mockCredentials = [{ id: '1', name: 'My Key', credentialName: 'openAIApi' }] diff --git a/packages/agentflow/src/infrastructure/api/loadMethodRegistry.ts b/packages/agentflow/src/infrastructure/api/loadMethodRegistry.ts index a62256fe9..e1d059f22 100644 --- a/packages/agentflow/src/infrastructure/api/loadMethodRegistry.ts +++ b/packages/agentflow/src/infrastructure/api/loadMethodRegistry.ts @@ -1,6 +1,7 @@ import type { CredentialsApi } from './credentials' import type { EmbeddingsApi } from './embeddings' import type { ChatModelsApi } from './models' +import type { NodesApi } from './nodes' import type { RuntimeStateApi } from './runtimeState' import type { StoresApi } from './stores' import type { ToolsApi } from './tools' @@ -12,6 +13,7 @@ export interface ApiServices { storesApi: StoresApi embeddingsApi: EmbeddingsApi runtimeStateApi: RuntimeStateApi + nodesApi: NodesApi } /** @@ -33,7 +35,13 @@ export interface ApiServices { * */ export const loadMethodRegistry: Record) => Promise> = { - listModels: (apis) => apis.chatModelsApi.getChatModels(), + listModels: (apis, params) => { + const nodeName = params?.nodeName as string | undefined + if (nodeName) { + return apis.nodesApi.loadNodeMethod(nodeName, 'listModels') + } + return apis.chatModelsApi.getChatModels() + }, listTools: (apis, params) => apis.toolsApi.getAllTools(params?.nodeName as string | undefined), listToolInputArgs: (apis, params) => apis.toolsApi.getToolInputArgs((params?.inputs as Record) ?? {}, params?.nodeName as string | undefined), @@ -41,6 +49,13 @@ export const loadMethodRegistry: Record apis.storesApi.getVectorStores(), listEmbeddings: (apis) => apis.embeddingsApi.getEmbeddings(), listRuntimeStateKeys: (apis) => apis.runtimeStateApi.getRuntimeStateKeys(), + listRegions: (apis, params) => { + const nodeName = params?.nodeName + if (typeof nodeName !== 'string') { + return Promise.reject(new Error('`listRegions` requires a string `nodeName` parameter.')) + } + return apis.nodesApi.loadNodeMethod(nodeName, 'listRegions') + }, listCredentials: (apis, params) => { const name = params?.name if (typeof name !== 'string') { diff --git a/packages/agentflow/src/infrastructure/api/nodes.ts b/packages/agentflow/src/infrastructure/api/nodes.ts index bcb7a2e3d..8ea60cdee 100644 --- a/packages/agentflow/src/infrastructure/api/nodes.ts +++ b/packages/agentflow/src/infrastructure/api/nodes.ts @@ -23,6 +23,15 @@ export function bindNodesApi(client: AxiosInstance) { return response.data }, + /** + * Call a loadMethod on a specific node (e.g. listRegions on awsChatBedrock). + * Maps to POST /node-load-method/{nodeName} with { loadMethod, ...body }. + */ + loadNodeMethod: async (nodeName: string, loadMethod: string, body?: Record): Promise => { + const response = await client.post(`/node-load-method/${nodeName}`, { loadMethod, ...body }) + return response.data + }, + /** * Get node icon URL */