mirror of
https://github.com/FlowiseAI/Flowise.git
synced 2026-10-12 02:02:17 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user