Skip to content

Commit d9b683d

Browse files
authored
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
1 parent aff0647 commit d9b683d

15 files changed

Lines changed: 940 additions & 14 deletions

packages/agentflow/src/atoms/ArrayInput.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { IconPlus, IconTrash } from '@tabler/icons-react'
66

77
import type { InputParam, NodeData } from '@/core/types'
88

9-
import { type AsyncInputProps, NodeInputHandler } from './NodeInputHandler'
9+
import { type AsyncInputProps, type ConfigInputComponentProps, NodeInputHandler } from './NodeInputHandler'
1010

1111
export interface ArrayInputProps {
1212
inputParam: InputParam
@@ -15,6 +15,12 @@ export interface ArrayInputProps {
1515
onDataChange?: (params: { inputParam: InputParam; newValue: unknown }) => void
1616
itemParameters?: InputParam[][]
1717
AsyncInputComponent?: ComponentType<AsyncInputProps>
18+
ConfigInputComponent?: ComponentType<ConfigInputComponentProps>
19+
onConfigChange?: (
20+
configKey: string,
21+
configValues: Record<string, unknown>,
22+
arrayContext?: { parentParamName: string; arrayIndex: number }
23+
) => void
1824
}
1925

2026
export function ArrayInput({
@@ -23,7 +29,9 @@ export function ArrayInput({
2329
disabled = false,
2430
onDataChange,
2531
itemParameters: itemParametersProp,
26-
AsyncInputComponent
32+
AsyncInputComponent,
33+
ConfigInputComponent,
34+
onConfigChange
2735
}: ArrayInputProps) {
2836
const theme = useTheme()
2937

@@ -185,6 +193,10 @@ export function ArrayInput({
185193
disablePadding={false}
186194
onDataChange={itemHandlers[index]}
187195
AsyncInputComponent={AsyncInputComponent}
196+
ConfigInputComponent={ConfigInputComponent}
197+
onConfigChange={onConfigChange}
198+
arrayIndex={index}
199+
parentArrayParam={inputParam}
188200
/>
189201
))}
190202
</Box>

packages/agentflow/src/atoms/NodeInputHandler.test.tsx

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
44

55
import type { InputParam, NodeData } from '@/core/types'
66

7-
import { type AsyncInputProps, NodeInputHandler } from './NodeInputHandler'
7+
import { type AsyncInputProps, type ConfigInputComponentProps, NodeInputHandler } from './NodeInputHandler'
88

99
// ─── Mocks ────────────────────────────────────────────────────────────────────
1010

@@ -177,3 +177,146 @@ describe('NodeInputHandler – async onChange wiring', () => {
177177
})
178178
})
179179
})
180+
181+
describe('NodeInputHandler – loadConfig rendering', () => {
182+
const StubAsyncInput: ComponentType<AsyncInputProps> = ({ onChange }) => (
183+
<button data-testid='async-select' onClick={() => onChange('selected-value')}>
184+
Select
185+
</button>
186+
)
187+
188+
const StubConfigInput: ComponentType<ConfigInputComponentProps> = ({ inputParam }) => (
189+
<div data-testid={`config-input-${inputParam.name}`}>Config Accordion</div>
190+
)
191+
192+
it('renders ConfigInputComponent when loadConfig is true and value exists', () => {
193+
render(
194+
<NodeInputHandler
195+
inputParam={makeParam({ type: 'asyncOptions', loadConfig: true })}
196+
data={{ ...baseNodeData, inputValues: { myField: 'chatOpenAI' } }}
197+
isAdditionalParams
198+
onDataChange={mockOnDataChange}
199+
AsyncInputComponent={StubAsyncInput}
200+
ConfigInputComponent={StubConfigInput}
201+
onConfigChange={jest.fn()}
202+
/>
203+
)
204+
205+
expect(screen.getByTestId('config-input-myField')).toBeTruthy()
206+
})
207+
208+
it('does not render ConfigInputComponent when loadConfig is false', () => {
209+
render(
210+
<NodeInputHandler
211+
inputParam={makeParam({ type: 'asyncOptions', loadConfig: false })}
212+
data={{ ...baseNodeData, inputValues: { myField: 'chatOpenAI' } }}
213+
isAdditionalParams
214+
onDataChange={mockOnDataChange}
215+
AsyncInputComponent={StubAsyncInput}
216+
ConfigInputComponent={StubConfigInput}
217+
onConfigChange={jest.fn()}
218+
/>
219+
)
220+
221+
expect(screen.queryByTestId('config-input-myField')).toBeNull()
222+
})
223+
224+
it('does not render ConfigInputComponent when value is empty', () => {
225+
render(
226+
<NodeInputHandler
227+
inputParam={makeParam({ type: 'asyncOptions', loadConfig: true })}
228+
data={{ ...baseNodeData, inputValues: { myField: '' } }}
229+
isAdditionalParams
230+
onDataChange={mockOnDataChange}
231+
AsyncInputComponent={StubAsyncInput}
232+
ConfigInputComponent={StubConfigInput}
233+
onConfigChange={jest.fn()}
234+
/>
235+
)
236+
237+
expect(screen.queryByTestId('config-input-myField')).toBeNull()
238+
})
239+
240+
it('does not render ConfigInputComponent when component is not injected', () => {
241+
render(
242+
<NodeInputHandler
243+
inputParam={makeParam({ type: 'asyncOptions', loadConfig: true })}
244+
data={{ ...baseNodeData, inputValues: { myField: 'chatOpenAI' } }}
245+
isAdditionalParams
246+
onDataChange={mockOnDataChange}
247+
AsyncInputComponent={StubAsyncInput}
248+
/>
249+
)
250+
251+
expect(screen.queryByTestId('config-input-myField')).toBeNull()
252+
})
253+
254+
it('does not render ConfigInputComponent when onConfigChange is not provided', () => {
255+
render(
256+
<NodeInputHandler
257+
inputParam={makeParam({ type: 'asyncOptions', loadConfig: true })}
258+
data={{ ...baseNodeData, inputValues: { myField: 'chatOpenAI' } }}
259+
isAdditionalParams
260+
onDataChange={mockOnDataChange}
261+
AsyncInputComponent={StubAsyncInput}
262+
ConfigInputComponent={StubConfigInput}
263+
/>
264+
)
265+
266+
expect(screen.queryByTestId('config-input-myField')).toBeNull()
267+
})
268+
})
269+
270+
describe('NodeInputHandler – credential type rendering', () => {
271+
const StubAsyncInput: ComponentType<AsyncInputProps> = ({ onChange }) => (
272+
<button data-testid='credential-select' onClick={() => onChange('cred-id-123')}>
273+
Select Credential
274+
</button>
275+
)
276+
277+
it('renders AsyncInputComponent for credential type', () => {
278+
render(
279+
<NodeInputHandler
280+
inputParam={makeParam({ type: 'credential', name: 'FLOWISE_CREDENTIAL_ID', credentialNames: ['awsApi'] })}
281+
data={baseNodeData}
282+
isAdditionalParams
283+
onDataChange={mockOnDataChange}
284+
AsyncInputComponent={StubAsyncInput}
285+
/>
286+
)
287+
288+
expect(screen.getByTestId('credential-select')).toBeTruthy()
289+
})
290+
291+
it('renders nothing for credential type when no AsyncInputComponent', () => {
292+
const { container } = render(
293+
<NodeInputHandler
294+
inputParam={makeParam({ type: 'credential', name: 'FLOWISE_CREDENTIAL_ID', credentialNames: ['awsApi'] })}
295+
data={baseNodeData}
296+
isAdditionalParams
297+
onDataChange={mockOnDataChange}
298+
/>
299+
)
300+
301+
expect(container.querySelector('button')).toBeNull()
302+
})
303+
304+
it('calls onDataChange when credential onChange fires', () => {
305+
render(
306+
<NodeInputHandler
307+
inputParam={makeParam({ type: 'credential', name: 'FLOWISE_CREDENTIAL_ID', credentialNames: ['awsApi'] })}
308+
data={baseNodeData}
309+
isAdditionalParams
310+
onDataChange={mockOnDataChange}
311+
AsyncInputComponent={StubAsyncInput}
312+
/>
313+
)
314+
315+
fireEvent.click(screen.getByTestId('credential-select'))
316+
317+
expect(mockOnDataChange).toHaveBeenCalledWith({
318+
inputParam: expect.objectContaining({ name: 'FLOWISE_CREDENTIAL_ID', type: 'credential' }),
319+
newValue: 'cred-id-123'
320+
})
321+
})
322+
})

packages/agentflow/src/atoms/NodeInputHandler.tsx

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ export interface AsyncInputProps {
2828
inputValues?: Record<string, unknown>
2929
}
3030

31+
/** Props passed to a config input component (loadConfig accordion). */
32+
export interface ConfigInputComponentProps {
33+
data: NodeData
34+
inputParam: InputParam
35+
disabled?: boolean
36+
arrayIndex?: number | null
37+
parentArrayParam?: InputParam | null
38+
onConfigChange: (
39+
configKey: string,
40+
configValues: Record<string, unknown>,
41+
arrayContext?: { parentParamName: string; arrayIndex: number }
42+
) => void
43+
AsyncInputComponent?: ComponentType<AsyncInputProps>
44+
}
45+
3146
export interface NodeInputHandlerProps {
3247
inputAnchor?: InputAnchor
3348
inputParam?: InputParam
@@ -39,6 +54,18 @@ export interface NodeInputHandlerProps {
3954
itemParameters?: InputParam[][]
4055
/** Renders asyncOptions / asyncMultiOptions fields. Lives in features/ to keep atoms free of infrastructure. */
4156
AsyncInputComponent?: ComponentType<AsyncInputProps>
57+
/** Renders loadConfig accordion beneath async dropdowns. Injected from features/ to keep atoms infrastructure-free. */
58+
ConfigInputComponent?: ComponentType<ConfigInputComponentProps>
59+
/** Callback for config value changes (from ConfigInputComponent). */
60+
onConfigChange?: (
61+
configKey: string,
62+
configValues: Record<string, unknown>,
63+
arrayContext?: { parentParamName: string; arrayIndex: number }
64+
) => void
65+
/** For array-based configs: index of current array item. */
66+
arrayIndex?: number | null
67+
/** For array-based configs: the parent array InputParam definition. */
68+
parentArrayParam?: InputParam | null
4269
}
4370

4471
// ─── Main component ───────────────────────────────────────────────────────────
@@ -56,7 +83,11 @@ export function NodeInputHandler({
5683
disablePadding = false,
5784
onDataChange,
5885
itemParameters,
59-
AsyncInputComponent
86+
AsyncInputComponent,
87+
ConfigInputComponent,
88+
onConfigChange,
89+
arrayIndex = null,
90+
parentArrayParam = null
6091
}: NodeInputHandlerProps) {
6192
const theme = useTheme()
6293
const ref = useRef<HTMLDivElement>(null)
@@ -208,11 +239,39 @@ export function NodeInputHandler({
208239
onDataChange={onDataChange}
209240
itemParameters={itemParameters}
210241
AsyncInputComponent={AsyncInputComponent}
242+
ConfigInputComponent={ConfigInputComponent}
243+
onConfigChange={onConfigChange}
211244
/>
212245
)
213246

214247
case 'asyncOptions':
215248
case 'asyncMultiOptions':
249+
if (!AsyncInputComponent) return null
250+
return (
251+
<>
252+
<AsyncInputComponent
253+
inputParam={inputParam}
254+
value={value}
255+
disabled={disabled}
256+
onChange={(v) => handleDataChange(v)}
257+
nodeName={data.name}
258+
inputValues={data.inputValues as Record<string, unknown> | undefined}
259+
/>
260+
{inputParam.loadConfig && ConfigInputComponent && value && onConfigChange && (
261+
<ConfigInputComponent
262+
data={data}
263+
inputParam={inputParam}
264+
disabled={disabled}
265+
arrayIndex={arrayIndex}
266+
parentArrayParam={parentArrayParam}
267+
onConfigChange={onConfigChange}
268+
AsyncInputComponent={AsyncInputComponent}
269+
/>
270+
)}
271+
</>
272+
)
273+
274+
case 'credential':
216275
if (!AsyncInputComponent) return null
217276
return (
218277
<AsyncInputComponent

packages/agentflow/src/atoms/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@ export { ConditionBuilder, type ConditionBuilderProps } from './ConditionBuilder
44
export { ExpandTextDialog, type ExpandTextDialogProps } from './ExpandTextDialog'
55
export { MainCard, type MainCardProps } from './MainCard'
66
export { type MessageEntry, MessagesInput, type MessagesInputProps } from './MessagesInput'
7-
export { type AsyncInputProps, NodeInputHandler } from './NodeInputHandler'
7+
export { type AsyncInputProps, type ConfigInputComponentProps, NodeInputHandler } from './NodeInputHandler'
88
export { StructuredOutputBuilder, type StructuredOutputBuilderProps, type StructuredOutputEntry } from './StructuredOutputBuilder'

packages/agentflow/src/core/types/node.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export interface InputParam {
8080
maxItems?: number // No agentflow nodes set this today — supported for forward-compat
8181
array?: InputParam[] // Sub-field definitions for array-type params
8282
loadMethod?: string // Registry key for async option loading (asyncOptions / asyncMultiOptions)
83+
loadConfig?: boolean // When true, renders a config accordion beneath the async dropdown for the selected component
8384
credentialNames?: string[] // If set, bypasses loadMethod and fetches matching credentials
8485
}
8586

packages/agentflow/src/core/utils/nodeFactory.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,60 @@ describe('initNode', () => {
163163
expect(result.outputAnchors).toHaveLength(0)
164164
})
165165

166+
it('should prepend credential param when node has credential property', () => {
167+
const nodeData = makeNodeData({
168+
inputs: [{ id: '', name: 'temperature', label: 'Temperature', type: 'number', default: 0.9 }] as NodeData['inputs'],
169+
credential: {
170+
label: 'AWS Credential',
171+
name: 'credential',
172+
type: 'credential',
173+
credentialNames: ['awsApi'],
174+
optional: true
175+
}
176+
} as Partial<NodeData>)
177+
178+
const result = initNode(nodeData, 'n1', false)
179+
180+
// Credential should be first, followed by regular params
181+
expect(result.inputs).toHaveLength(2)
182+
expect(result.inputs![0]).toEqual(
183+
expect.objectContaining({
184+
name: 'FLOWISE_CREDENTIAL_ID',
185+
label: 'AWS Credential',
186+
type: 'credential',
187+
credentialNames: ['awsApi']
188+
})
189+
)
190+
expect(result.inputs![1].name).toBe('temperature')
191+
// Default value for credential should be empty string
192+
expect(result.inputValues!['FLOWISE_CREDENTIAL_ID']).toBe('')
193+
})
194+
195+
it('should not add credential param when node has no credential property', () => {
196+
const nodeData = makeNodeData({
197+
inputs: [{ id: '', name: 'temperature', label: 'Temperature', type: 'number' }] as NodeData['inputs']
198+
})
199+
const result = initNode(nodeData, 'n1', false)
200+
expect(result.inputs).toHaveLength(1)
201+
expect(result.inputs![0].name).toBe('temperature')
202+
})
203+
204+
it('should not add credential param when credentialNames is empty', () => {
205+
const nodeData = makeNodeData({
206+
inputs: [{ id: '', name: 'temperature', label: 'Temperature', type: 'number' }] as NodeData['inputs'],
207+
credential: {
208+
label: 'Credential',
209+
name: 'credential',
210+
type: 'credential',
211+
credentialNames: []
212+
}
213+
} as Partial<NodeData>)
214+
215+
const result = initNode(nodeData, 'n1', false)
216+
expect(result.inputs).toHaveLength(1)
217+
expect(result.inputs![0].name).toBe('temperature')
218+
})
219+
166220
it('should strip server-only metadata like filePath from node data', () => {
167221
const nodeData = makeNodeData({
168222
filePath: '/some/server/path/Agent.js',

packages/agentflow/src/core/utils/nodeFactory.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,21 @@ export function initNode(nodeData: NodeData, newNodeId: string, isAgentflow = tr
162162
}
163163
}
164164

165+
// Credential — extract top-level credential property and prepend to input definitions
166+
const rawCredential = (nodeData as Record<string, unknown>).credential as
167+
| { name?: string; label?: string; type?: string; credentialNames?: string[]; optional?: boolean }
168+
| undefined
169+
170+
if (rawCredential?.credentialNames?.length) {
171+
inputDefinitions.unshift({
172+
...rawCredential,
173+
id: `${newNodeId}-input-FLOWISE_CREDENTIAL_ID-credential`,
174+
name: 'FLOWISE_CREDENTIAL_ID',
175+
label: rawCredential.label ?? 'Credential',
176+
type: 'credential'
177+
})
178+
}
179+
165180
// Initialize default input values from definitions using initializeDefaultNodeData
166181
const initialInputValues = initializeDefaultNodeData(inputDefinitions)
167182

0 commit comments

Comments
 (0)