diff --git a/packages/agentflow/src/atoms/ConditionBuilder.test.tsx b/packages/agentflow/src/atoms/ConditionBuilder.test.tsx index f0c7acddb..90f2f2bda 100644 --- a/packages/agentflow/src/atoms/ConditionBuilder.test.tsx +++ b/packages/agentflow/src/atoms/ConditionBuilder.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import type { InputParam, NodeData } from '@/core/types' import { ConditionBuilder } from './ConditionBuilder' +import type { VariableItem } from './VariablePicker' // --- Mocks --- const mockOnDataChange = jest.fn() @@ -10,13 +11,15 @@ const mockOnDataChange = jest.fn() jest.mock('./NodeInputHandler', () => ({ NodeInputHandler: ({ inputParam, - onDataChange + onDataChange, + variableItems }: { inputParam: InputParam data: NodeData onDataChange: (args: { inputParam: InputParam; newValue: unknown }) => void + variableItems?: unknown[] }) => ( -
+
onDataChange({ inputParam, newValue: e.target.value })} />
@@ -146,6 +149,25 @@ describe('ConditionBuilder', () => { }) }) + it('should clear value1 and value2 when operation changes', () => { + const data: NodeData = { + ...mockNodeData, + inputs: { + conditions: [{ type: 'string', value1: 'hello', operation: 'equal', value2: 'world' }] + } + } as NodeData + + render() + + const operationInput = screen.getByTestId('input-operation') + fireEvent.change(operationInput, { target: { value: 'contains' } }) + + expect(mockOnDataChange).toHaveBeenCalledWith({ + inputParam: conditionInputParam, + newValue: [{ type: 'string', value1: '', operation: 'contains', value2: '' }] + }) + }) + it('should disable buttons when disabled prop is true', () => { const data: NodeData = { ...mockNodeData, @@ -207,6 +229,35 @@ describe('ConditionBuilder', () => { expect(screen.queryByTestId('input-handler-value2')).not.toBeInTheDocument() }) + it('should forward variableItems to NodeInputHandler only for params with acceptVariable', () => { + const inputParamWithAcceptVariable: InputParam = { + ...conditionInputParam, + array: [ + { id: 'type', name: 'type', label: 'Type', type: 'options', default: 'string' } as InputParam, + { id: 'value1', name: 'value1', label: 'Value 1', type: 'string', default: '', acceptVariable: true } as InputParam, + { id: 'value2', name: 'value2', label: 'Value 2', type: 'string', default: '', acceptVariable: true } as InputParam + ] + } + const data: NodeData = { + ...mockNodeData, + inputs: { conditions: [{ type: 'string', value1: '', value2: '' }] } + } as NodeData + const variableItems: VariableItem[] = [{ label: 'question', value: '{{question}}', category: 'Chat Context' }] + + render( + + ) + + expect(screen.getByTestId('input-handler-type')).toHaveAttribute('data-has-variables', 'false') + expect(screen.getByTestId('input-handler-value1')).toHaveAttribute('data-has-variables', 'true') + expect(screen.getByTestId('input-handler-value2')).toHaveAttribute('data-has-variables', 'true') + }) + it('should render fields for each condition item', () => { const data: NodeData = { ...mockNodeData, diff --git a/packages/agentflow/src/atoms/ConditionBuilder.tsx b/packages/agentflow/src/atoms/ConditionBuilder.tsx index 633a7e1f0..c63472c5c 100644 --- a/packages/agentflow/src/atoms/ConditionBuilder.tsx +++ b/packages/agentflow/src/atoms/ConditionBuilder.tsx @@ -9,6 +9,7 @@ import type { InputParam, NodeData } from '@/core/types' import { NodeInputHandler } from './NodeInputHandler' import { useStableKeys } from './useStableKeys' +import type { VariableItem } from './VariablePicker' export interface ConditionBuilderProps { inputParam: InputParam @@ -16,6 +17,7 @@ export interface ConditionBuilderProps { disabled?: boolean onDataChange?: (params: { inputParam: InputParam; newValue: unknown }) => void itemParameters?: InputParam[][] + variableItems?: VariableItem[] } /** @@ -28,7 +30,8 @@ export function ConditionBuilder({ data, disabled = false, onDataChange, - itemParameters: itemParametersProp + itemParameters: itemParametersProp, + variableItems }: ConditionBuilderProps) { const theme = useTheme() @@ -49,6 +52,10 @@ export function ConditionBuilder({ const updatedArrayItems = [...arrayItems] const updatedItem = { ...updatedArrayItems[itemIndex] } updatedItem[changedParam.name] = newValue + if (changedParam.name === 'operation') { + updatedItem.value1 = '' + updatedItem.value2 = '' + } updatedArrayItems[itemIndex] = updatedItem onDataChange?.({ inputParam, newValue: updatedArrayItems }) }, @@ -137,6 +144,7 @@ export function ConditionBuilder({ isAdditionalParams={true} disablePadding={false} onDataChange={itemHandlers[index]} + variableItems={param.acceptVariable ? variableItems : undefined} /> ))} diff --git a/packages/agentflow/src/atoms/NodeInputHandler.test.tsx b/packages/agentflow/src/atoms/NodeInputHandler.test.tsx index 8e41ddbf5..82f55b877 100644 --- a/packages/agentflow/src/atoms/NodeInputHandler.test.tsx +++ b/packages/agentflow/src/atoms/NodeInputHandler.test.tsx @@ -379,17 +379,18 @@ describe('NodeInputHandler – json type', () => { expect(screen.queryByTestId('json-input')).toBeNull() }) - it('renders inline JsonInput for json with acceptVariable but no variableItems', () => { + it('renders a button for json with acceptVariable even when no variableItems are provided', () => { render( ) - expect(screen.getByTestId('json-input')).toBeTruthy() + expect(screen.getByRole('button', { name: 'My Field' })).toBeTruthy() + expect(screen.queryByTestId('json-input')).toBeNull() }) }) diff --git a/packages/agentflow/src/atoms/NodeInputHandler.tsx b/packages/agentflow/src/atoms/NodeInputHandler.tsx index eec1a57ca..ee0f06ff6 100644 --- a/packages/agentflow/src/atoms/NodeInputHandler.tsx +++ b/packages/agentflow/src/atoms/NodeInputHandler.tsx @@ -48,6 +48,7 @@ export interface AsyncInputProps { disabled: boolean onChange: (newValue: string) => void nodeName?: string + nodeId?: string inputValues?: Record } @@ -303,7 +304,7 @@ export function NodeInputHandler({ case 'json': { const jsonStr = typeof value === 'string' ? value : JSON.stringify(value || {}) - if (inputParam.acceptVariable && variableItems && variableItems.length > 0) { + if (inputParam.acceptVariable) { // acceptVariable: show a button that opens a dialog with JsonInput + variable support return ( + )} handleDataChange(code)} language={inputParam.codeLanguage} disabled={disabled} /> - {inputParam.codeExample && !disabled && ( - - )} ) @@ -378,6 +379,7 @@ export function NodeInputHandler({ disabled={disabled} onChange={(v) => handleDataChange(v)} nodeName={data.name} + nodeId={data.id} inputValues={data.inputs as Record | undefined} /> {inputParam.loadConfig && ConfigInputComponent && value && onConfigChange && ( @@ -403,6 +405,7 @@ export function NodeInputHandler({ disabled={disabled} onChange={(v) => handleDataChange(v)} nodeName={data.name} + nodeId={data.id} inputValues={data.inputs as Record | undefined} /> ) @@ -541,7 +544,7 @@ export function NodeInputHandler({ )} - {inputParam?.type === 'json' && inputParam.acceptVariable && variableItems && variableItems.length > 0 && ( + {inputParam?.type === 'json' && inputParam.acceptVariable && ( setJsonDialogOpen(false)} fullWidth maxWidth='sm'> {inputParam.label} diff --git a/packages/agentflow/src/features/canvas/components/NodeToolIcons.test.tsx b/packages/agentflow/src/features/canvas/components/NodeToolIcons.test.tsx new file mode 100644 index 000000000..d4b6b7d86 --- /dev/null +++ b/packages/agentflow/src/features/canvas/components/NodeToolIcons.test.tsx @@ -0,0 +1,298 @@ +import { darken } from '@mui/material/styles' +import { render, screen } from '@testing-library/react' +import { Mock } from 'jest-mock' + +import { useConfigContext } from '@/infrastructure/store' + +import { NodeToolIcons } from './NodeToolIcons' + +// --- Mocks --- +jest.mock('@/infrastructure/store', () => ({ + useApiContext: () => ({ apiBaseUrl: 'http://localhost:3000' }), + useConfigContext: jest.fn(() => ({ isDarkMode: false })) +})) + +jest.mock('@mui/material/styles', () => ({ + ...jest.requireActual('@mui/material/styles'), + darken: jest.fn((color: string, coefficient: number) => `darken(${color},${coefficient})`) +})) + +jest.mock('../nodeIcons', () => ({ + getBuiltInOpenAIToolIcon: (toolName: string) => { + const icons: Record = { + web_search_preview: , + code_interpreter: , + image_generation: + } + return icons[toolName] ?? null + }, + getBuiltInGeminiToolIcon: (toolName: string) => { + const icons: Record = { + urlContext: , + googleSearch: , + codeExecution: + } + return icons[toolName] ?? null + }, + getBuiltInAnthropicToolIcon: (toolName: string) => { + const icons: Record = { + web_search_20250305: , + web_fetch_20250910: + } + return icons[toolName] ?? null + } +})) + +// --- Helpers --- +function renderToolIcons(inputs: Record, nodeColor = '#4A90D9') { + return render() +} + +// --- Tests --- + +describe('NodeToolIcons', () => { + describe('renders nothing', () => { + it('returns null when inputs is undefined', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('returns null when inputs has no tool fields', () => { + const { container } = renderToolIcons({ llmModel: 'gpt-4', someOtherField: 'value' }) + expect(container.firstChild).toBeNull() + }) + + it('returns null when tool arrays are empty', () => { + const { container } = renderToolIcons({ llmTools: [], agentTools: [] }) + expect(container.firstChild).toBeNull() + }) + + it('returns null when llmTools is a non-array (JSON string guard)', () => { + // Non-array values should be ignored, not crash + const { container } = renderToolIcons({ llmTools: '[{"llmSelectedTool":"customTool"}]' }) + expect(container.firstChild).toBeNull() + }) + + it('returns null when agentKnowledgeVSEmbeddings is a non-array', () => { + const { container } = renderToolIcons({ agentKnowledgeVSEmbeddings: 'not-an-array' }) + expect(container.firstChild).toBeNull() + }) + }) + + describe('llmTools', () => { + it('renders icon for each tool in llmTools', () => { + renderToolIcons({ + llmTools: [{ llmSelectedTool: 'customTool1' }, { llmSelectedTool: 'customTool2' }] + }) + const imgs = screen.getAllByRole('img') + expect(imgs).toHaveLength(2) + expect(imgs[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/customTool1') + expect(imgs[1]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/customTool2') + }) + + it('skips entries without llmSelectedTool property', () => { + renderToolIcons({ + llmTools: [{ llmSelectedTool: 'toolA' }, { someOtherProp: 'value' }] + }) + const imgs = screen.getAllByRole('img') + expect(imgs).toHaveLength(1) + expect(imgs[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/toolA') + }) + }) + + describe('agentTools', () => { + it('renders icon for each tool in agentTools', () => { + renderToolIcons({ + agentTools: [{ agentSelectedTool: 'agentToolX' }] + }) + const imgs = screen.getAllByRole('img') + expect(imgs).toHaveLength(1) + expect(imgs[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/agentToolX') + }) + }) + + describe('selectedTool / toolAgentflowSelectedTool', () => { + it('renders icon for selectedTool', () => { + renderToolIcons({ selectedTool: 'myTool' }) + const img = screen.getByRole('img') + expect(img).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/myTool') + }) + + it('falls back to toolAgentflowSelectedTool when selectedTool is absent', () => { + renderToolIcons({ toolAgentflowSelectedTool: 'fallbackTool' }) + const img = screen.getByRole('img') + expect(img).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/fallbackTool') + }) + + it('prefers selectedTool over toolAgentflowSelectedTool', () => { + renderToolIcons({ selectedTool: 'primary', toolAgentflowSelectedTool: 'secondary' }) + const imgs = screen.getAllByRole('img') + // Only one icon should render (the resolved single tool) + expect(imgs).toHaveLength(1) + expect(imgs[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/primary') + }) + + it('renders nothing when both selectedTool and toolAgentflowSelectedTool are absent', () => { + const { container } = renderToolIcons({ llmModel: 'gpt-4' }) + expect(container.firstChild).toBeNull() + }) + }) + + describe('agentKnowledgeVSEmbeddings', () => { + it('renders vectorStore and embeddingModel icons per entry', () => { + renderToolIcons({ + agentKnowledgeVSEmbeddings: [{ vectorStore: 'pinecone', embeddingModel: 'openaiEmbeddings' }] + }) + const imgs = screen.getAllByRole('img') + expect(imgs).toHaveLength(2) + expect(imgs[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/pinecone') + expect(imgs[1]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/openaiEmbeddings') + }) + + it('renders only vectorStore icon when embeddingModel is absent', () => { + renderToolIcons({ + agentKnowledgeVSEmbeddings: [{ vectorStore: 'pinecone' }] + }) + const imgs = screen.getAllByRole('img') + expect(imgs).toHaveLength(1) + expect(imgs[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/pinecone') + }) + + it('renders icons for multiple knowledge entries', () => { + renderToolIcons({ + agentKnowledgeVSEmbeddings: [ + { vectorStore: 'pinecone', embeddingModel: 'openaiEmbeddings' }, + { vectorStore: 'weaviate', embeddingModel: 'cohereEmbeddings' } + ] + }) + const imgs = screen.getAllByRole('img') + expect(imgs).toHaveLength(4) + }) + }) + + describe('built-in OpenAI tools', () => { + it('renders icon for web_search_preview as array', () => { + renderToolIcons({ agentToolsBuiltInOpenAI: ['web_search_preview'] }) + expect(screen.getByTestId('openai-web-search')).toBeInTheDocument() + }) + + it('renders icon for code_interpreter', () => { + renderToolIcons({ agentToolsBuiltInOpenAI: ['code_interpreter'] }) + expect(screen.getByTestId('openai-code-interpreter')).toBeInTheDocument() + }) + + it('renders icon for image_generation', () => { + renderToolIcons({ agentToolsBuiltInOpenAI: ['image_generation'] }) + expect(screen.getByTestId('openai-image-generation')).toBeInTheDocument() + }) + + it('accepts JSON string input', () => { + renderToolIcons({ agentToolsBuiltInOpenAI: '["web_search_preview","code_interpreter"]' }) + expect(screen.getByTestId('openai-web-search')).toBeInTheDocument() + expect(screen.getByTestId('openai-code-interpreter')).toBeInTheDocument() + }) + + it('renders no icons for unknown built-in tool names', () => { + renderToolIcons({ agentToolsBuiltInOpenAI: ['unknown_tool'] }) + expect(screen.queryByRole('img')).not.toBeInTheDocument() + expect(screen.queryByTestId(/^openai-/)).not.toBeInTheDocument() + }) + }) + + describe('built-in Gemini tools', () => { + it('renders icon for urlContext', () => { + renderToolIcons({ agentToolsBuiltInGemini: ['urlContext'] }) + expect(screen.getByTestId('gemini-url-context')).toBeInTheDocument() + }) + + it('renders icon for googleSearch', () => { + renderToolIcons({ agentToolsBuiltInGemini: ['googleSearch'] }) + expect(screen.getByTestId('gemini-google-search')).toBeInTheDocument() + }) + + it('renders icon for codeExecution', () => { + renderToolIcons({ agentToolsBuiltInGemini: ['codeExecution'] }) + expect(screen.getByTestId('gemini-code-execution')).toBeInTheDocument() + }) + + it('accepts JSON string input', () => { + renderToolIcons({ agentToolsBuiltInGemini: '["googleSearch"]' }) + expect(screen.getByTestId('gemini-google-search')).toBeInTheDocument() + }) + }) + + describe('built-in Anthropic tools', () => { + it('renders icon for web_search_20250305', () => { + renderToolIcons({ agentToolsBuiltInAnthropic: ['web_search_20250305'] }) + expect(screen.getByTestId('anthropic-web-search')).toBeInTheDocument() + }) + + it('renders icon for web_fetch_20250910', () => { + renderToolIcons({ agentToolsBuiltInAnthropic: ['web_fetch_20250910'] }) + expect(screen.getByTestId('anthropic-web-fetch')).toBeInTheDocument() + }) + + it('accepts JSON string input', () => { + renderToolIcons({ agentToolsBuiltInAnthropic: '["web_search_20250305","web_fetch_20250910"]' }) + expect(screen.getByTestId('anthropic-web-search')).toBeInTheDocument() + expect(screen.getByTestId('anthropic-web-fetch')).toBeInTheDocument() + }) + + it('renders no icons for unknown built-in tool names', () => { + renderToolIcons({ agentToolsBuiltInAnthropic: ['unknown_anthropic_tool'] }) + expect(screen.queryByRole('img')).not.toBeInTheDocument() + expect(screen.queryByTestId(/^anthropic-/)).not.toBeInTheDocument() + }) + }) + + describe('multiple tool groups', () => { + it('renders icons from multiple tool groups simultaneously', () => { + renderToolIcons({ + llmTools: [{ llmSelectedTool: 'customTool' }], + agentToolsBuiltInOpenAI: ['web_search_preview'], + agentToolsBuiltInGemini: ['googleSearch'] + }) + expect(screen.getByRole('img')).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/customTool') + expect(screen.getByTestId('openai-web-search')).toBeInTheDocument() + expect(screen.getByTestId('gemini-google-search')).toBeInTheDocument() + }) + }) + + describe('dark mode', () => { + afterEach(() => { + ;(useConfigContext as Mock).mockReturnValue({ isDarkMode: false }) + }) + + it('renders built-in tool icons in dark mode without crashing', () => { + ;(useConfigContext as Mock).mockReturnValue({ isDarkMode: true }) + expect(() => renderToolIcons({ agentToolsBuiltInOpenAI: ['web_search_preview'] })).not.toThrow() + }) + + it('uses a higher darken coefficient in dark mode than in light mode', () => { + const nodeColor = '#4A90D9' + + ;(useConfigContext as Mock).mockReturnValue({ isDarkMode: true }) + renderToolIcons({ agentToolsBuiltInOpenAI: ['web_search_preview'] }, nodeColor) + expect(darken).toHaveBeenCalledWith(nodeColor, 0.5) + ;(useConfigContext as Mock).mockReturnValue({ isDarkMode: false }) + renderToolIcons({ agentToolsBuiltInOpenAI: ['web_search_preview'] }, nodeColor) + expect(darken).toHaveBeenCalledWith(nodeColor, 0.2) + }) + }) + + describe('edge cases', () => { + it('handles invalid JSON string for built-in tools gracefully', () => { + const { container } = renderToolIcons({ agentToolsBuiltInOpenAI: 'not-valid-json' }) + expect(container.firstChild).toBeNull() + }) + + it('handles empty JSON array string', () => { + const { container } = renderToolIcons({ agentToolsBuiltInOpenAI: '[]' }) + expect(container.firstChild).toBeNull() + }) + + it('does not crash when nodeColor is undefined', () => { + expect(() => render()).not.toThrow() + }) + }) +}) diff --git a/packages/agentflow/src/features/canvas/components/NodeToolIcons.tsx b/packages/agentflow/src/features/canvas/components/NodeToolIcons.tsx new file mode 100644 index 000000000..6f5f791be --- /dev/null +++ b/packages/agentflow/src/features/canvas/components/NodeToolIcons.tsx @@ -0,0 +1,165 @@ +import { memo } from 'react' + +import { Box } from '@mui/material' +import { darken } from '@mui/material/styles' + +import { useApiContext, useConfigContext } from '@/infrastructure/store' + +import { getBuiltInAnthropicToolIcon, getBuiltInGeminiToolIcon, getBuiltInOpenAIToolIcon } from '../nodeIcons' + +interface ToolEntry { + [key: string]: unknown +} + +type BuiltInProvider = 'openai' | 'gemini' | 'anthropic' + +interface ToolConfig { + tools: ToolEntry[] + toolProperty: string | string[] + builtInProvider?: BuiltInProvider +} + +const builtInIconGetters: Record React.ReactElement | null> = { + openai: getBuiltInOpenAIToolIcon, + gemini: getBuiltInGeminiToolIcon, + anthropic: getBuiltInAnthropicToolIcon +} + +export interface NodeToolIconsProps { + inputs?: Record + nodeColor?: string +} + +function parseToolArray(value: unknown): ToolEntry[] { + if (!value) return [] + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed.map((tool: unknown) => ({ builtInTool: tool })) : [] + } catch { + return [] + } + } + if (Array.isArray(value)) return value.map((tool) => ({ builtInTool: tool })) + return [] +} + +function NodeToolIconsComponent({ inputs, nodeColor = '#4A90D9' }: NodeToolIconsProps) { + const { apiBaseUrl } = useApiContext() + const { isDarkMode } = useConfigContext() + + if (!inputs) return null + + const singleTool = inputs.selectedTool ?? inputs.toolAgentflowSelectedTool + + const toolConfigs: ToolConfig[] = [ + { tools: Array.isArray(inputs.llmTools) ? (inputs.llmTools as ToolEntry[]) : [], toolProperty: 'llmSelectedTool' }, + { tools: Array.isArray(inputs.agentTools) ? (inputs.agentTools as ToolEntry[]) : [], toolProperty: 'agentSelectedTool' }, + { + tools: singleTool ? [{ selectedTool: singleTool }] : [], + toolProperty: ['selectedTool', 'toolAgentflowSelectedTool'] + }, + { + tools: Array.isArray(inputs.agentKnowledgeVSEmbeddings) ? (inputs.agentKnowledgeVSEmbeddings as ToolEntry[]) : [], + toolProperty: ['vectorStore', 'embeddingModel'] + }, + { + tools: parseToolArray(inputs.agentToolsBuiltInOpenAI), + toolProperty: 'builtInTool', + builtInProvider: 'openai' as const + }, + { + tools: parseToolArray(inputs.agentToolsBuiltInGemini), + toolProperty: 'builtInTool', + builtInProvider: 'gemini' as const + }, + { + tools: parseToolArray(inputs.agentToolsBuiltInAnthropic), + toolProperty: 'builtInTool', + builtInProvider: 'anthropic' as const + } + ] + + const validConfigs = toolConfigs.filter((config) => config.tools.length > 0) + if (validConfigs.length === 0) return null + + const builtInBg = isDarkMode ? darken(nodeColor, 0.5) : darken(nodeColor, 0.2) + + return ( + <> + {validConfigs.map((config, configIndex) => ( + + {config.tools.flatMap((tool, toolIndex) => { + if (Array.isArray(config.toolProperty)) { + return config.toolProperty + .filter((prop) => tool[prop]) + .map((prop, propIndex) => { + const toolName = tool[prop] as string + return ( + + ) + }) + } + + const toolName = tool[config.toolProperty as string] as string + if (!toolName) return [] + + if (config.builtInProvider) { + const icon = builtInIconGetters[config.builtInProvider](toolName) + if (!icon) return [] + return [ + + {icon} + + ] + } + + return [ + + ] + })} + + ))} + + ) +} + +export const NodeToolIcons = memo(NodeToolIconsComponent) diff --git a/packages/agentflow/src/features/canvas/containers/AgentFlowNode.tsx b/packages/agentflow/src/features/canvas/containers/AgentFlowNode.tsx index 4bc74ba43..d6b208387 100644 --- a/packages/agentflow/src/features/canvas/containers/AgentFlowNode.tsx +++ b/packages/agentflow/src/features/canvas/containers/AgentFlowNode.tsx @@ -12,6 +12,7 @@ import { NodeModelConfigs } from '../components/NodeModelConfigs' import { getMinimumNodeHeight, NodeOutputHandles } from '../components/NodeOutputHandles' import { NodeStatusIndicator, NodeWarningIndicator } from '../components/NodeStatusIndicator' import { NodeToolbarActions } from '../components/NodeToolbarActions' +import { NodeToolIcons } from '../components/NodeToolIcons' import { useOpenNodeEditor } from '../hooks' import { useNodeColors } from '../hooks/useNodeColors' import { CardWrapper } from '../styled' @@ -106,6 +107,7 @@ function AgentFlowNodeComponent({ data }: AgentFlowNodeProps) { {data.label} + diff --git a/packages/agentflow/src/features/node-editor/AsyncInput.test.tsx b/packages/agentflow/src/features/node-editor/AsyncInput.test.tsx index c43118057..7dd5e9889 100644 --- a/packages/agentflow/src/features/node-editor/AsyncInput.test.tsx +++ b/packages/agentflow/src/features/node-editor/AsyncInput.test.tsx @@ -63,10 +63,11 @@ jest.mock('@/infrastructure/api/hooks', () => ({ })) let mockNodes: Array<{ id: string; type: string; position: { x: number; y: number }; data: Record }> = [] +let mockEdges: Array<{ id: string; source: string; target: string; targetHandle?: string; type: string }> = [] jest.mock('@/infrastructure/store', () => ({ useAgentflowContext: () => ({ - state: { nodes: mockNodes, edges: [] } + state: { nodes: mockNodes, edges: mockEdges } }) })) @@ -96,6 +97,7 @@ const idleResult = (): MockAsyncResult => ({ options: [], loading: false, error: beforeEach(() => { jest.clearAllMocks() mockNodes = [] + mockEdges = [] mockUseAsyncOptions.mockReturnValue({ options: [], loading: true, error: null, refetch: mockRefetch }) }) @@ -731,6 +733,101 @@ describe('AsyncInput', () => { }) }) + describe('AsyncInput – listPreviousNodes', () => { + const makeFlowNode = (id: string, name: string, label: string) => ({ + id, + type: 'agentflowNode', + position: { x: 0, y: 0 }, + data: { name, label, inputs: {} } + }) + + const makeEdge = (source: string, target: string) => ({ + id: `${source}-${target}`, + source, + target, + targetHandle: target, + type: 'agentflowEdge' + }) + + it('does not call useAsyncOptions — options come from flow state only', () => { + mockNodes = [makeFlowNode('loop_0', 'loopAgentflow', 'Loop 0')] + + render( + + ) + + expect(mockUseAsyncOptions).not.toHaveBeenCalled() + }) + + it('renders ancestor nodes from flow state as dropdown options', async () => { + mockNodes = [ + makeFlowNode('start_0', 'startAgentflow', 'Start'), + makeFlowNode('agent_0', 'agentAgentflow', 'Agent 0'), + makeFlowNode('loop_0', 'loopAgentflow', 'Loop 0') + ] + mockEdges = [makeEdge('start_0', 'agent_0'), makeEdge('agent_0', 'loop_0')] + + render( + + ) + + fireEvent.mouseDown(screen.getByRole('combobox')) + await waitFor(() => { + expect(screen.getByText('Start')).toBeTruthy() + expect(screen.getByText('Agent 0')).toBeTruthy() + }) + }) + + it('shows no options when the loop node has no ancestors', async () => { + mockNodes = [makeFlowNode('loop_0', 'loopAgentflow', 'Loop 0')] + mockEdges = [] + + render( + + ) + + fireEvent.mouseDown(screen.getByRole('combobox')) + await waitFor(() => expect(screen.getByText('No options')).toBeTruthy()) + }) + + it('clears stored value when the selected ancestor node is removed from the flow', async () => { + const mockChange = jest.fn() + mockNodes = [makeFlowNode('loop_0', 'loopAgentflow', 'Loop 0')] + mockEdges = [] + + // Value points to a node that is no longer in the flow / no longer an ancestor + render( + + ) + + await waitFor(() => expect(mockChange).toHaveBeenCalledWith('')) + }) + }) + describe('AsyncInput – Refresh button', () => { it('does NOT show refresh button when inputParam.refresh is falsy', () => { mockUseAsyncOptions.mockReturnValue({ diff --git a/packages/agentflow/src/features/node-editor/AsyncInput.tsx b/packages/agentflow/src/features/node-editor/AsyncInput.tsx index 990b04447..678ffb4f3 100644 --- a/packages/agentflow/src/features/node-editor/AsyncInput.tsx +++ b/packages/agentflow/src/features/node-editor/AsyncInput.tsx @@ -1,12 +1,13 @@ -import { Fragment, useCallback, useEffect, useState } from 'react' +import { Fragment, useCallback, useEffect, useRef, useState } from 'react' import { Box, CircularProgress, IconButton, TextField, Typography } from '@mui/material' import Autocomplete from '@mui/material/Autocomplete' import { IconEdit, IconRefresh } from '@tabler/icons-react' import type { AsyncInputProps } from '@/atoms' +import { Dropdown } from '@/atoms' import type { FlowNode, NodeOption } from '@/core/types' -import { getDefinedStateKeys } from '@/core/utils' +import { getDefinedStateKeys, getUpstreamNodes } from '@/core/utils' import { useAsyncOptions } from '@/infrastructure/api/hooks' import { useAgentflowContext } from '@/infrastructure/store' @@ -36,6 +37,21 @@ function buildAsyncParams( } } +/** + * Returns all ancestor nodes for the given node ID as dropdown options. + * Used directly by AsyncOptionsDropdown when loadMethod === 'listPreviousNodes', + * bypassing useAsyncOptions entirely — no server call needed. + */ +function useFlowAncestorNodeOptions(nodeId?: string): NodeOption[] { + const { state } = useAgentflowContext() + if (!nodeId) return [] + return getUpstreamNodes(nodeId, state.nodes as FlowNode[], state.edges, /* includeStart */ true).map((n) => ({ + label: n.data.label, + name: `${n.id}-${n.data.label}`, + description: n.id + })) +} + /** * Extract state keys from all nodes except the given node. * Excludes the current node to avoid a circular reference where a node's own @@ -47,7 +63,87 @@ function useFlowStateKeys(excludeNodeName?: string): string[] { return getDefinedStateKeys(nodes as FlowNode[]) } -function AsyncOptionsInput({ inputParam, value, disabled, onChange, nodeName, inputValues }: AsyncInputProps) { +/** Wraps the three-step async option fetching setup into a single hook. */ +function useAsyncOptionData( + inputParam: AsyncInputProps['inputParam'], + nodeName: string | undefined, + inputValues: Record | undefined +) { + const stateKeys = useFlowStateKeys(nodeName) + const params = buildAsyncParams(inputParam.loadMethod, nodeName, inputValues, stateKeys) + return useAsyncOptions({ + loadMethod: inputParam.loadMethod, + credentialNames: inputParam.credentialNames, + params + }) +} + +/** Error state with a retry button. Shared between single- and multi-select dropdowns. */ +function AsyncFetchError({ error, onRetry }: { error: string; onRetry: () => void }) { + return ( + + + {error} + + + + + + ) +} + +/** Renders the image + label + description block for a single option. */ +function OptionContent({ option }: { option: NodeOption }) { + return ( + <> + {option.imageSrc && ( + + )} + + {option.label} + {option.description && {option.description}} + + + ) +} + +/** Loading spinner end adornment. Shared between single- and multi-select inputs. */ +function LoadingEndAdornment({ loading, original }: { loading: boolean; original: React.ReactNode }) { + return ( + + {loading ? : null} + {original} + + ) +} + +/** Dropdown for listPreviousNodes — reads ancestor nodes from flow state, no server call. */ +function PreviousNodesDropdown({ value, disabled, onChange, nodeId }: Pick) { + const options = useFlowAncestorNodeOptions(nodeId) + const stringValue = typeof value === 'string' ? value : '' + + const onChangeRef = useRef(onChange) + useEffect(() => { + onChangeRef.current = onChange + }) + + // Clear stored value if the selected node no longer exists in the flow. + // onChange is accessed via ref so an unstable parent callback can't retrigger this effect. + useEffect(() => { + if (stringValue && !options.some((o) => o.name === stringValue)) { + onChangeRef.current('') + } + }, [stringValue, options]) + + return +} + +function AsyncOptionsInput({ inputParam, value, disabled, onChange, nodeName, nodeId, inputValues }: AsyncInputProps) { const isCredential = !!inputParam.credentialNames?.length const [createDialogOpen, setCreateDialogOpen] = useState(false) const [editDialogOpen, setEditDialogOpen] = useState(false) @@ -83,20 +179,32 @@ function AsyncOptionsInput({ inputParam, value, disabled, onChange, nodeName, in return ( <> - { - setPendingValue(null) - onChange(v) - }} - nodeName={nodeName} - inputValues={inputValues} - isCredential={isCredential} - onCreateNew={() => setCreateDialogOpen(true)} - /> + {inputParam.loadMethod === 'listPreviousNodes' ? ( + { + setPendingValue(null) + onChange(v) + }} + nodeId={nodeId} + /> + ) : ( + { + setPendingValue(null) + onChange(v) + }} + nodeName={nodeName} + inputValues={inputValues} + isCredential={isCredential} + onCreateNew={() => setCreateDialogOpen(true)} + /> + )} {selectedCredentialId && ( setEditDialogOpen(true)}> @@ -146,13 +254,7 @@ function AsyncOptionsDropdown({ isCredential, onCreateNew }: AsyncInputProps & { isCredential: boolean; onCreateNew: () => void }) { - const stateKeys = useFlowStateKeys(nodeName) - const params = buildAsyncParams(inputParam.loadMethod, nodeName, inputValues, stateKeys) - const { options, loading, error, refetch } = useAsyncOptions({ - loadMethod: inputParam.loadMethod, - credentialNames: inputParam.credentialNames, - params - }) + const { options, loading, error, refetch } = useAsyncOptionData(inputParam, nodeName, inputValues) // Append "- Create New -" sentinel for credential dropdowns const displayOptions = isCredential ? [...options, { label: '- Create New -', name: CREATE_NEW_SENTINEL }] : options @@ -165,24 +267,21 @@ function AsyncOptionsDropdown({ setInputText(matchedValue?.label ?? '') }, [matchedValue?.label]) - // Clear the stored value if it no longer matches any available option + const onChangeRef = useRef(onChange) + useEffect(() => { + onChangeRef.current = onChange + }) + + // Clear the stored value if it no longer matches any available option. + // onChange is accessed via ref so an unstable parent callback can't retrigger this effect. useEffect(() => { if (!loading && value && typeof value === 'string' && !options.some((o) => o.name === value)) { - onChange('') + onChangeRef.current('') } - }, [loading, value, options, onChange]) + }, [loading, value, options]) if (error) { - return ( - - - {error} - - - - - - ) + return } return ( @@ -217,20 +316,7 @@ function AsyncOptionsDropdown({ {option.label} ) : ( - <> - {option.imageSrc && ( - - )} - - {option.label} - {option.description && {option.description}} - - + )} )} @@ -252,12 +338,7 @@ function AsyncOptionsDropdown({ {params.InputProps.startAdornment} ), - endAdornment: ( - - {loading ? : null} - {params.InputProps.endAdornment} - - ) + endAdornment: }} /> )} @@ -290,25 +371,10 @@ function AsyncMultiOptionsInput({ inputParam, value, disabled, onChange, nodeNam /** Inner multi-select component. Remounted via key to force a fresh fetch. */ function AsyncMultiOptionsDropdown({ inputParam, value, disabled, onChange, nodeName, inputValues }: AsyncInputProps) { - const stateKeys = useFlowStateKeys(nodeName) - const params = buildAsyncParams(inputParam.loadMethod, nodeName, inputValues, stateKeys) - const { options, loading, error, refetch } = useAsyncOptions({ - loadMethod: inputParam.loadMethod, - credentialNames: inputParam.credentialNames, - params - }) + const { options, loading, error, refetch } = useAsyncOptionData(inputParam, nodeName, inputValues) if (error) { - return ( - - - {error} - - - - - - ) + return } // Stored as JSON-serialized array of names, e.g. '["option1","option2"]' @@ -347,18 +413,7 @@ function AsyncMultiOptionsDropdown({ inputParam, value, disabled, onChange, node sx={{ flexGrow: 1 }} renderOption={(props, option) => ( - {option.imageSrc && ( - - )} - - {option.label} - {option.description && {option.description}} - + )} renderInput={(params) => ( @@ -366,12 +421,7 @@ function AsyncMultiOptionsDropdown({ inputParam, value, disabled, onChange, node {...params} InputProps={{ ...params.InputProps, - endAdornment: ( - - {loading ? : null} - {params.InputProps.endAdornment} - - ) + endAdornment: }} /> )} diff --git a/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx b/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx index 4e4037c3c..8825d82e5 100644 --- a/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx +++ b/packages/agentflow/src/features/node-editor/EditNodeDialog.tsx @@ -342,6 +342,7 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog disabled={dialogProps.disabled} onDataChange={onCustomDataChange} itemParameters={arrayItemParameters[inputParam.name]} + variableItems={variableItems} /> ) }