Skip to content

Commit 9d07534

Browse files
authored
Fix/agentflow multiOptions rendering (#5964)
* added support fo multi options * add support for store amad embeddings api * Changed naming of all apiClients to be consistent * Add correct index to ArrayInput * Log error when parsing multiOptions value fails Add error logging for failed parsing of multiOptions value. * Fix error handling
1 parent 0973446 commit 9d07534

17 files changed

Lines changed: 254 additions & 61 deletions

packages/agentflow/src/atoms/ArrayInput.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type ComponentType, useCallback, useMemo } from 'react'
1+
import { type ComponentType, useCallback, useEffect, useMemo, useRef } from 'react'
22

33
import { Box, Button, Chip, IconButton } from '@mui/material'
44
import { useTheme } from '@mui/material/styles'
@@ -34,6 +34,17 @@ export function ArrayInput({
3434
[data.inputValues, inputParam.name]
3535
)
3636

37+
// Stable keys for array items — avoids using index as React key
38+
const idCounterRef = useRef(0)
39+
const itemKeysRef = useRef<string[]>([])
40+
41+
// Grow keys array when new items appear (e.g. on mount or external data changes)
42+
useEffect(() => {
43+
while (itemKeysRef.current.length < arrayItems.length) {
44+
itemKeysRef.current.push(`item-${idCounterRef.current++}`)
45+
}
46+
}, [arrayItems.length])
47+
3748
// Use pre-computed itemParameters
3849
// Falls back to raw field definitions for nested arrays without show/hide conditions.
3950
const itemParameters = useMemo<InputParam[][]>(
@@ -94,6 +105,7 @@ export function ArrayInput({
94105
const handleDeleteItem = useCallback(
95106
(indexToDelete: number) => {
96107
const updatedArrayItems = arrayItems.filter((_, i) => i !== indexToDelete)
108+
itemKeysRef.current.splice(indexToDelete, 1)
97109

98110
// Notify parent of change (parent will update props, causing re-render)
99111
onDataChange?.({ inputParam, newValue: updatedArrayItems })
@@ -125,7 +137,7 @@ export function ArrayInput({
125137

126138
return (
127139
<Box
128-
key={index}
140+
key={itemKeysRef.current[index]}
129141
sx={{
130142
p: 2,
131143
mt: 2,

packages/agentflow/src/atoms/NodeInputHandler.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ComponentType, useCallback, useEffect, useRef, useState } from 'react'
22
import { Handle, Position, useUpdateNodeInternals } from 'reactflow'
33

44
import { Box, FormControlLabel, IconButton, MenuItem, Select, Switch, TextField, Tooltip, TooltipProps, Typography } from '@mui/material'
5+
import Autocomplete from '@mui/material/Autocomplete'
56
import { styled, useTheme } from '@mui/material/styles'
67
import { tooltipClasses } from '@mui/material/Tooltip'
78
import { IconArrowsMaximize, IconVariable } from '@tabler/icons-react'
@@ -136,6 +137,47 @@ export function NodeInputHandler({
136137
</Select>
137138
)
138139

140+
case 'multiOptions': {
141+
// Stored as JSON-serialized array of names, e.g. '["option1","option2"]'
142+
const staticOptions = (inputParam.options ?? []).map((opt) => (typeof opt === 'string' ? { label: opt, name: opt } : opt))
143+
144+
let selectedNames: string[] = []
145+
if (typeof value === 'string' && value.startsWith('[')) {
146+
try {
147+
const parsed = JSON.parse(value)
148+
if (Array.isArray(parsed) && parsed.every((item) => typeof item === 'string')) {
149+
selectedNames = parsed
150+
}
151+
} catch (e) {
152+
console.error('Failed to parse multiOptions value:', value, e)
153+
selectedNames = []
154+
}
155+
} else if (Array.isArray(value)) {
156+
selectedNames = value.filter((item): item is string => typeof item === 'string')
157+
}
158+
159+
const selectedOptions = staticOptions.filter((o) => selectedNames.includes(o.name))
160+
161+
return (
162+
<Autocomplete<{ label: string; name: string }, true>
163+
multiple
164+
filterSelectedOptions
165+
size='small'
166+
disabled={disabled}
167+
options={staticOptions}
168+
value={selectedOptions}
169+
getOptionLabel={(o) => o.label}
170+
isOptionEqualToValue={(o, v) => o.name === v.name}
171+
onChange={(_e, selection) => {
172+
const names = selection.map((s) => s.name)
173+
handleDataChange(names.length > 0 ? JSON.stringify(names) : '')
174+
}}
175+
sx={{ mt: 1 }}
176+
renderInput={(params) => <TextField {...params} />}
177+
/>
178+
)
179+
}
180+
139181
case 'array':
140182
return (
141183
<ArrayInput

packages/agentflow/src/infrastructure/api/chatflows.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { AxiosInstance } from 'axios'
22

3-
import { createChatflowsApi } from './chatflows'
3+
import { bindChatflowsApi } from './chatflows'
44

55
const mockClient = {
66
get: jest.fn(),
@@ -13,8 +13,8 @@ beforeEach(() => {
1313
jest.clearAllMocks()
1414
})
1515

16-
describe('createChatflowsApi', () => {
17-
const api = createChatflowsApi(mockClient)
16+
describe('bindChatflowsApi', () => {
17+
const api = bindChatflowsApi(mockClient)
1818

1919
describe('getAllChatflows', () => {
2020
it('should call GET /chatflows', async () => {

packages/agentflow/src/infrastructure/api/chatflows.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { Chatflow, FlowData } from '@/core/types'
55
/**
66
* Create chatflows API functions bound to a client instance
77
*/
8-
export function createChatflowsApi(client: AxiosInstance) {
8+
export function bindChatflowsApi(client: AxiosInstance) {
99
return {
1010
/**
1111
* Get all chatflows
@@ -104,4 +104,4 @@ export function createChatflowsApi(client: AxiosInstance) {
104104
}
105105
}
106106

107-
export type ChatflowsApi = ReturnType<typeof createChatflowsApi>
107+
export type ChatflowsApi = ReturnType<typeof bindChatflowsApi>

packages/agentflow/src/infrastructure/api/client.test.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import axios from 'axios'
22

3-
import { createApiClient } from './client'
3+
import { bindApiClient } from './client'
44

55
jest.mock('axios', () => {
66
const mockResponseInterceptors = { use: jest.fn() }
@@ -21,9 +21,9 @@ beforeEach(() => {
2121
jest.clearAllMocks()
2222
})
2323

24-
describe('createApiClient', () => {
24+
describe('bindApiClient', () => {
2525
it('should create client with correct baseURL', () => {
26-
createApiClient('https://flowise.example.com')
26+
bindApiClient('https://flowise.example.com')
2727
expect(mockedAxios.create).toHaveBeenCalledWith(
2828
expect.objectContaining({
2929
baseURL: 'https://flowise.example.com/api/v1'
@@ -32,7 +32,7 @@ describe('createApiClient', () => {
3232
})
3333

3434
it('should set Content-Type header', () => {
35-
createApiClient('https://flowise.example.com')
35+
bindApiClient('https://flowise.example.com')
3636
expect(mockedAxios.create).toHaveBeenCalledWith(
3737
expect.objectContaining({
3838
headers: expect.objectContaining({
@@ -43,7 +43,7 @@ describe('createApiClient', () => {
4343
})
4444

4545
it('should set Authorization header when token is provided', () => {
46-
createApiClient('https://flowise.example.com', 'my-token')
46+
bindApiClient('https://flowise.example.com', 'my-token')
4747
expect(mockedAxios.create).toHaveBeenCalledWith(
4848
expect.objectContaining({
4949
headers: expect.objectContaining({
@@ -54,40 +54,40 @@ describe('createApiClient', () => {
5454
})
5555

5656
it('should not set Authorization header when no token', () => {
57-
createApiClient('https://flowise.example.com')
57+
bindApiClient('https://flowise.example.com')
5858
const headers = mockedAxios.create.mock.calls[0][0]?.headers as Record<string, string>
5959
expect(headers['Authorization']).toBeUndefined()
6060
})
6161

6262
it('should register request and response interceptors', () => {
63-
const client = createApiClient('https://flowise.example.com')
63+
const client = bindApiClient('https://flowise.example.com')
6464
expect(client.interceptors.request.use).toHaveBeenCalledTimes(1)
6565
expect(client.interceptors.response.use).toHaveBeenCalledTimes(1)
6666
})
6767

6868
it('should pass config through request interceptor', () => {
69-
const client = createApiClient('https://flowise.example.com')
69+
const client = bindApiClient('https://flowise.example.com')
7070
const successHandler = (client.interceptors.request.use as jest.Mock).mock.calls[0][0]
7171
const config = { url: '/chatflows', headers: {} }
7272
expect(successHandler(config)).toBe(config)
7373
})
7474

7575
it('should pass response through response interceptor', () => {
76-
const client = createApiClient('https://flowise.example.com')
76+
const client = bindApiClient('https://flowise.example.com')
7777
const successHandler = (client.interceptors.response.use as jest.Mock).mock.calls[0][0]
7878
const response = { data: {}, status: 200 }
7979
expect(successHandler(response)).toBe(response)
8080
})
8181

8282
it('should reject request interceptor errors', async () => {
83-
const client = createApiClient('https://flowise.example.com')
83+
const client = bindApiClient('https://flowise.example.com')
8484
const errorHandler = (client.interceptors.request.use as jest.Mock).mock.calls[0][1]
8585
const error = new Error('Network error')
8686
await expect(errorHandler(error)).rejects.toBe(error)
8787
})
8888

8989
it('should reject 401 errors through response interceptor', async () => {
90-
const client = createApiClient('https://flowise.example.com', 'tok')
90+
const client = bindApiClient('https://flowise.example.com', 'tok')
9191
const errorHandler = (client.interceptors.response.use as jest.Mock).mock.calls[0][1]
9292

9393
const error = {
@@ -106,7 +106,7 @@ describe('createApiClient', () => {
106106
})
107107

108108
it('should pass through non-401 errors without logging', async () => {
109-
const client = createApiClient('https://flowise.example.com')
109+
const client = bindApiClient('https://flowise.example.com')
110110
const errorHandler = (client.interceptors.response.use as jest.Mock).mock.calls[0][1]
111111

112112
const error = { response: { status: 500 }, message: 'Server error' }
@@ -117,7 +117,7 @@ describe('createApiClient', () => {
117117
})
118118

119119
it('should not set withCredentials by default', () => {
120-
createApiClient('https://flowise.example.com')
120+
bindApiClient('https://flowise.example.com')
121121
expect(mockedAxios.create).toHaveBeenCalledWith(
122122
expect.not.objectContaining({
123123
withCredentials: true
@@ -130,7 +130,7 @@ describe('createApiClient', () => {
130130
config.withCredentials = true
131131
return config
132132
})
133-
const client = createApiClient('https://flowise.example.com', undefined, interceptor)
133+
const client = bindApiClient('https://flowise.example.com', undefined, interceptor)
134134
const successHandler = (client.interceptors.request.use as jest.Mock).mock.calls[0][0]
135135
const config = { url: '/chatflows', headers: {} }
136136
const result = successHandler(config)
@@ -139,7 +139,7 @@ describe('createApiClient', () => {
139139
})
140140

141141
it('should pass config through when no requestInterceptor is provided', () => {
142-
const client = createApiClient('https://flowise.example.com')
142+
const client = bindApiClient('https://flowise.example.com')
143143
const successHandler = (client.interceptors.request.use as jest.Mock).mock.calls[0][0]
144144
const config = { url: '/chatflows', headers: {} }
145145
expect(successHandler(config)).toBe(config)
@@ -149,7 +149,7 @@ describe('createApiClient', () => {
149149
const interceptor = jest.fn(() => {
150150
throw new Error('interceptor broke')
151151
})
152-
const client = createApiClient('https://flowise.example.com', undefined, interceptor)
152+
const client = bindApiClient('https://flowise.example.com', undefined, interceptor)
153153
const successHandler = (client.interceptors.request.use as jest.Mock).mock.calls[0][0]
154154
const config = { url: '/chatflows', headers: {} }
155155
const consoleSpy = jest.spyOn(console, 'error').mockImplementation()

packages/agentflow/src/infrastructure/api/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { RequestInterceptor } from '@/core/types'
88
* @param token - Authentication token (optional)
99
* @param requestInterceptor - Optional callback to customize outgoing requests
1010
*/
11-
export function createApiClient(apiBaseUrl: string, token?: string, requestInterceptor?: RequestInterceptor): AxiosInstance {
11+
export function bindApiClient(apiBaseUrl: string, token?: string, requestInterceptor?: RequestInterceptor): AxiosInstance {
1212
const headers: Record<string, string> = {
1313
'Content-Type': 'application/json'
1414
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { AxiosInstance } from 'axios'
2+
3+
import type { NodeOption } from '@/core/types'
4+
5+
/**
6+
* Create embeddings API functions bound to a client instance
7+
*/
8+
export function bindEmbeddingsApi(client: AxiosInstance) {
9+
return {
10+
/**
11+
* Get all available embedding models
12+
*/
13+
getEmbeddings: async (): Promise<NodeOption[]> => {
14+
const response = await client.post('/node-load-method/agentAgentflow', { loadMethod: 'listEmbeddings' })
15+
return response.data
16+
}
17+
}
18+
}
19+
20+
export type EmbeddingsApi = ReturnType<typeof bindEmbeddingsApi>

packages/agentflow/src/infrastructure/api/hooks/useAsyncOptions.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ interface UseAsyncOptionsResult {
2525
* Fetches async option lists from the API using the loadMethodRegistry.
2626
*/
2727
export function useAsyncOptions({ loadMethod, credentialNames, params }: UseAsyncOptionsParams): UseAsyncOptionsResult {
28-
const { chatModelsApi, toolsApi, credentialsApi, apiBaseUrl } = useApiContext()
28+
const { chatModelsApi, toolsApi, credentialsApi, storesApi, embeddingsApi, runtimeStateApi, apiBaseUrl } = useApiContext()
2929

3030
const [options, setOptions] = useState<OptionItem[]>([])
3131
const [loading, setLoading] = useState(true)
@@ -67,7 +67,7 @@ export function useAsyncOptions({ loadMethod, credentialNames, params }: UseAsyn
6767
if (!fn) {
6868
throw new Error(`Unknown loadMethod: "${loadMethod}"`)
6969
}
70-
const apis: ApiServices = { chatModelsApi, toolsApi, credentialsApi }
70+
const apis: ApiServices = { chatModelsApi, toolsApi, credentialsApi, storesApi, embeddingsApi, runtimeStateApi }
7171
const stableParams = paramsKey ? (JSON.parse(paramsKey) as Record<string, unknown>) : undefined
7272
const raw = await fn(apis, stableParams)
7373
result = normalizeOptions(raw, apiBaseUrl)
@@ -92,7 +92,19 @@ export function useAsyncOptions({ loadMethod, credentialNames, params }: UseAsyn
9292
return () => {
9393
cancelled = true
9494
}
95-
}, [loadMethod, credentialNamesKey, paramsKey, fetchCounter, chatModelsApi, toolsApi, credentialsApi, apiBaseUrl])
95+
}, [
96+
loadMethod,
97+
credentialNamesKey,
98+
paramsKey,
99+
fetchCounter,
100+
chatModelsApi,
101+
toolsApi,
102+
credentialsApi,
103+
storesApi,
104+
embeddingsApi,
105+
runtimeStateApi,
106+
apiBaseUrl
107+
])
96108

97109
return { options, loading, error, refetch }
98110
}
Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
// API infrastructure - External data layer
2-
export { type ChatflowsApi, createChatflowsApi } from './chatflows'
3-
export { createApiClient } from './client'
2+
export { bindChatflowsApi, type ChatflowsApi } from './chatflows'
3+
export { bindApiClient } from './client'
44
export { bindCredentialsApi, type CredentialsApi } from './credentials'
5+
export { bindEmbeddingsApi, type EmbeddingsApi } from './embeddings'
56
export { type ApiServices, getLoadMethod, loadMethodRegistry } from './loadMethodRegistry'
67
export { bindChatModelsApi, type ChatModelsApi } from './models'
7-
export { createNodesApi, type NodesApi } from './nodes'
8+
export { bindNodesApi, type NodesApi } from './nodes'
9+
export { bindRuntimeStateApi, type RuntimeStateApi } from './runtimeState'
10+
export { bindStoresApi, type StoresApi } from './stores'
811
export { bindToolsApi, type ToolsApi } from './tools'

packages/agentflow/src/infrastructure/api/loadMethodRegistry.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ const mockApis: ApiServices = {
1212
credentialsApi: {
1313
getAllCredentials: jest.fn(),
1414
getCredentialsByName: jest.fn()
15+
},
16+
storesApi: {
17+
getStores: jest.fn(),
18+
getVectorStores: jest.fn()
19+
},
20+
embeddingsApi: {
21+
getEmbeddings: jest.fn()
22+
},
23+
runtimeStateApi: {
24+
getRuntimeStateKeys: jest.fn()
1525
}
1626
}
1727

0 commit comments

Comments
 (0)