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
This commit is contained in:
j-sanaa
2026-03-16 14:27:55 -07:00
committed by GitHub
parent aff06479aa
commit d9b683d42f
15 changed files with 940 additions and 14 deletions
+14 -2
View File
@@ -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<AsyncInputProps>
ConfigInputComponent?: ComponentType<ConfigInputComponentProps>
onConfigChange?: (
configKey: string,
configValues: Record<string, unknown>,
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}
/>
))}
</Box>
@@ -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<AsyncInputProps> = ({ onChange }) => (
<button data-testid='async-select' onClick={() => onChange('selected-value')}>
Select
</button>
)
const StubConfigInput: ComponentType<ConfigInputComponentProps> = ({ inputParam }) => (
<div data-testid={`config-input-${inputParam.name}`}>Config Accordion</div>
)
it('renders ConfigInputComponent when loadConfig is true and value exists', () => {
render(
<NodeInputHandler
inputParam={makeParam({ type: 'asyncOptions', loadConfig: true })}
data={{ ...baseNodeData, inputValues: { myField: 'chatOpenAI' } }}
isAdditionalParams
onDataChange={mockOnDataChange}
AsyncInputComponent={StubAsyncInput}
ConfigInputComponent={StubConfigInput}
onConfigChange={jest.fn()}
/>
)
expect(screen.getByTestId('config-input-myField')).toBeTruthy()
})
it('does not render ConfigInputComponent when loadConfig is false', () => {
render(
<NodeInputHandler
inputParam={makeParam({ type: 'asyncOptions', loadConfig: false })}
data={{ ...baseNodeData, inputValues: { myField: 'chatOpenAI' } }}
isAdditionalParams
onDataChange={mockOnDataChange}
AsyncInputComponent={StubAsyncInput}
ConfigInputComponent={StubConfigInput}
onConfigChange={jest.fn()}
/>
)
expect(screen.queryByTestId('config-input-myField')).toBeNull()
})
it('does not render ConfigInputComponent when value is empty', () => {
render(
<NodeInputHandler
inputParam={makeParam({ type: 'asyncOptions', loadConfig: true })}
data={{ ...baseNodeData, inputValues: { myField: '' } }}
isAdditionalParams
onDataChange={mockOnDataChange}
AsyncInputComponent={StubAsyncInput}
ConfigInputComponent={StubConfigInput}
onConfigChange={jest.fn()}
/>
)
expect(screen.queryByTestId('config-input-myField')).toBeNull()
})
it('does not render ConfigInputComponent when component is not injected', () => {
render(
<NodeInputHandler
inputParam={makeParam({ type: 'asyncOptions', loadConfig: true })}
data={{ ...baseNodeData, inputValues: { myField: 'chatOpenAI' } }}
isAdditionalParams
onDataChange={mockOnDataChange}
AsyncInputComponent={StubAsyncInput}
/>
)
expect(screen.queryByTestId('config-input-myField')).toBeNull()
})
it('does not render ConfigInputComponent when onConfigChange is not provided', () => {
render(
<NodeInputHandler
inputParam={makeParam({ type: 'asyncOptions', loadConfig: true })}
data={{ ...baseNodeData, inputValues: { myField: 'chatOpenAI' } }}
isAdditionalParams
onDataChange={mockOnDataChange}
AsyncInputComponent={StubAsyncInput}
ConfigInputComponent={StubConfigInput}
/>
)
expect(screen.queryByTestId('config-input-myField')).toBeNull()
})
})
describe('NodeInputHandler – credential type rendering', () => {
const StubAsyncInput: ComponentType<AsyncInputProps> = ({ onChange }) => (
<button data-testid='credential-select' onClick={() => onChange('cred-id-123')}>
Select Credential
</button>
)
it('renders AsyncInputComponent for credential type', () => {
render(
<NodeInputHandler
inputParam={makeParam({ type: 'credential', name: 'FLOWISE_CREDENTIAL_ID', credentialNames: ['awsApi'] })}
data={baseNodeData}
isAdditionalParams
onDataChange={mockOnDataChange}
AsyncInputComponent={StubAsyncInput}
/>
)
expect(screen.getByTestId('credential-select')).toBeTruthy()
})
it('renders nothing for credential type when no AsyncInputComponent', () => {
const { container } = render(
<NodeInputHandler
inputParam={makeParam({ type: 'credential', name: 'FLOWISE_CREDENTIAL_ID', credentialNames: ['awsApi'] })}
data={baseNodeData}
isAdditionalParams
onDataChange={mockOnDataChange}
/>
)
expect(container.querySelector('button')).toBeNull()
})
it('calls onDataChange when credential onChange fires', () => {
render(
<NodeInputHandler
inputParam={makeParam({ type: 'credential', name: 'FLOWISE_CREDENTIAL_ID', credentialNames: ['awsApi'] })}
data={baseNodeData}
isAdditionalParams
onDataChange={mockOnDataChange}
AsyncInputComponent={StubAsyncInput}
/>
)
fireEvent.click(screen.getByTestId('credential-select'))
expect(mockOnDataChange).toHaveBeenCalledWith({
inputParam: expect.objectContaining({ name: 'FLOWISE_CREDENTIAL_ID', type: 'credential' }),
newValue: 'cred-id-123'
})
})
})
@@ -28,6 +28,21 @@ export interface AsyncInputProps {
inputValues?: Record<string, unknown>
}
/** 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<string, unknown>,
arrayContext?: { parentParamName: string; arrayIndex: number }
) => void
AsyncInputComponent?: ComponentType<AsyncInputProps>
}
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<AsyncInputProps>
/** Renders loadConfig accordion beneath async dropdowns. Injected from features/ to keep atoms infrastructure-free. */
ConfigInputComponent?: ComponentType<ConfigInputComponentProps>
/** Callback for config value changes (from ConfigInputComponent). */
onConfigChange?: (
configKey: string,
configValues: Record<string, unknown>,
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<HTMLDivElement>(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 (
<>
<AsyncInputComponent
inputParam={inputParam}
value={value}
disabled={disabled}
onChange={(v) => handleDataChange(v)}
nodeName={data.name}
inputValues={data.inputValues as Record<string, unknown> | undefined}
/>
{inputParam.loadConfig && ConfigInputComponent && value && onConfigChange && (
<ConfigInputComponent
data={data}
inputParam={inputParam}
disabled={disabled}
arrayIndex={arrayIndex}
parentArrayParam={parentArrayParam}
onConfigChange={onConfigChange}
AsyncInputComponent={AsyncInputComponent}
/>
)}
</>
)
case 'credential':
if (!AsyncInputComponent) return null
return (
<AsyncInputComponent
+1 -1
View File
@@ -4,5 +4,5 @@ export { ConditionBuilder, type ConditionBuilderProps } from './ConditionBuilder
export { ExpandTextDialog, type ExpandTextDialogProps } from './ExpandTextDialog'
export { MainCard, type MainCardProps } from './MainCard'
export { type MessageEntry, MessagesInput, type MessagesInputProps } from './MessagesInput'
export { type AsyncInputProps, NodeInputHandler } from './NodeInputHandler'
export { type AsyncInputProps, type ConfigInputComponentProps, NodeInputHandler } from './NodeInputHandler'
export { StructuredOutputBuilder, type StructuredOutputBuilderProps, type StructuredOutputEntry } from './StructuredOutputBuilder'
@@ -80,6 +80,7 @@ export interface InputParam {
maxItems?: number // No agentflow nodes set this today — supported for forward-compat
array?: InputParam[] // Sub-field definitions for array-type params
loadMethod?: string // Registry key for async option loading (asyncOptions / asyncMultiOptions)
loadConfig?: boolean // When true, renders a config accordion beneath the async dropdown for the selected component
credentialNames?: string[] // If set, bypasses loadMethod and fetches matching credentials
}
@@ -163,6 +163,60 @@ describe('initNode', () => {
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<NodeData>)
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<NodeData>)
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',
@@ -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<string, unknown>).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)
@@ -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<string, unknown> | undefined
): Record<string, unknown> | 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,
@@ -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: () => <span data-testid='icon-expand' />,
IconVariable: () => <span data-testid='icon-variable' />,
IconRefresh: () => <span data-testid='icon-refresh' />,
IconSettings: () => <span data-testid='icon-settings' />
}))
jest.mock('@mui/icons-material/ExpandMore', () => ({
__esModule: true,
default: () => <span data-testid='expand-more-icon' />
}))
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>): NodeData => ({
id: 'agent-node-1',
name: 'agentAgentflow',
label: 'Agent 0',
inputValues: { agentModel: 'chatAlibabaTongyi' },
...overrides
})
const makeInputParam = (overrides?: Partial<InputParam>): 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(<ConfigInput data={makeParentData()} inputParam={makeInputParam()} onConfigChange={mockOnConfigChange} />)
await waitFor(() => {
expect(screen.getByText('ChatAlibabaTongyi Parameters')).toBeTruthy()
})
})
it('renders nothing when no model is selected', () => {
const { container } = render(
<ConfigInput
data={makeParentData({ inputValues: { agentModel: '' } })}
inputParam={makeInputParam()}
onConfigChange={mockOnConfigChange}
/>
)
// No accordion should render
expect(container.querySelector('.MuiAccordion-root')).toBeNull()
})
it('calls getNodeByName with the selected model name', async () => {
render(<ConfigInput data={makeParentData()} inputParam={makeInputParam()} onConfigChange={mockOnConfigChange} />)
await waitFor(() => {
expect(mockGetNodeByName).toHaveBeenCalledWith('chatAlibabaTongyi')
})
})
it('renders parameter fields inside accordion details', async () => {
render(<ConfigInput data={makeParentData()} inputParam={makeInputParam()} onConfigChange={mockOnConfigChange} />)
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(<ConfigInput data={makeParentData()} inputParam={makeInputParam()} onConfigChange={mockOnConfigChange} />)
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(
<ConfigInput
data={parentData}
inputParam={param}
arrayIndex={0}
parentArrayParam={parentArray}
onConfigChange={mockOnConfigChange}
/>
)
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(<ConfigInput data={parentData} inputParam={makeInputParam()} onConfigChange={mockOnConfigChange} />)
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(<ConfigInput data={parentData} inputParam={makeInputParam()} onConfigChange={mockOnConfigChange} />)
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(<ConfigInput data={makeParentData()} inputParam={makeInputParam()} onConfigChange={mockOnConfigChange} />)
// 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(
<ConfigInput data={makeParentData()} inputParam={makeInputParam()} onConfigChange={mockOnConfigChange} />
)
// 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(<ConfigInput data={makeParentData()} inputParam={makeInputParam()} onConfigChange={mockOnConfigChange} />)
await waitFor(() => {
expect(screen.getByText('Temperature')).toBeTruthy()
})
// advancedParam should NOT be visible (its show condition is not met)
expect(screen.queryByText('Advanced Param')).toBeNull()
})
})
})
@@ -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<string, unknown>,
arrayContext?: { parentParamName: string; arrayIndex: number }
) => void
AsyncInputComponent?: ComponentType<AsyncInputProps> // Injected async input component
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
/** Initialize default values for a set of input params. */
function initializeDefaults(params: InputParam[]): Record<string, unknown> {
const defaults: Record<string, unknown> = {}
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<string, unknown> | 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<string, unknown> | undefined
}
return undefined
}
return data.inputValues?.[configKey] as Record<string, unknown> | 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<NodeData | null>(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<string, unknown>) => {
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<string, unknown> => {
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 (
<Box
sx={{
p: 0,
mt: 1,
mb: 1,
border: 1,
borderColor: theme.palette.grey[900] + 25,
borderRadius: 2
}}
>
<Accordion sx={{ background: 'transparent' }} expanded={expanded} onChange={(_, isExpanded) => setExpanded(isExpanded)}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} sx={{ background: 'transparent' }}>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<IconSettings stroke={1.5} size='1.3rem' />
<Typography sx={{ ml: 1 }}>{configNodeData.label} Parameters</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
{configInputParams
.filter((p) => p.display !== false)
.map((p, index) => (
<NodeInputHandler
key={index}
inputParam={p}
data={configData}
disabled={disabled}
isAdditionalParams={true}
onDataChange={handleInternalChange}
AsyncInputComponent={AsyncInputComponent}
/>
))}
</AccordionDetails>
</Accordion>
</Box>
)
}
@@ -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<string, unknown>, arrayContext?: { parentParamName: string; arrayIndex: number }) => {
const current = dataRef.current
if (!current) return
let updatedInputValues: Record<string, unknown>
if (arrayContext) {
// Array-based config: write into the nested array item
const currentArray = [...((current.inputValues?.[arrayContext.parentParamName] as Record<string, unknown>[]) ?? [])]
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}
/>
)
})}
@@ -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<OptionItem[]>([])
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<string, unknown>) : 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
])
@@ -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' }]
@@ -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<string, (_apis: ApiServices, _params?: Record<string, unknown>) => Promise<unknown>> = {
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<string, unknown>) ?? {}, params?.nodeName as string | undefined),
@@ -41,6 +49,13 @@ export const loadMethodRegistry: Record<string, (_apis: ApiServices, _params?: R
listVectorStores: (apis) => 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') {
@@ -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<string, unknown>): Promise<unknown> => {
const response = await client.post(`/node-load-method/${nodeName}`, { loadMethod, ...body })
return response.data
},
/**
* Get node icon URL
*/