diff --git a/web/__tests__/workflow-parallel-limit.test.tsx b/web/__tests__/workflow-parallel-limit.test.tsx
new file mode 100644
index 000000000..0843122ab
--- /dev/null
+++ b/web/__tests__/workflow-parallel-limit.test.tsx
@@ -0,0 +1,301 @@
+/**
+ * MAX_PARALLEL_LIMIT Configuration Bug Test
+ *
+ * This test reproduces and verifies the fix for issue #23083:
+ * MAX_PARALLEL_LIMIT environment variable does not take effect in iteration panel
+ */
+
+import { render, screen } from '@testing-library/react'
+import React from 'react'
+
+// Mock environment variables before importing constants
+const originalEnv = process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT
+
+// Test with different environment values
+function setupEnvironment(value?: string) {
+ if (value)
+ process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT = value
+ else
+ delete process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT
+
+ // Clear module cache to force re-evaluation
+ jest.resetModules()
+}
+
+function restoreEnvironment() {
+ if (originalEnv)
+ process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT = originalEnv
+ else
+ delete process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT
+
+ jest.resetModules()
+}
+
+// Mock i18next with proper implementation
+jest.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => {
+ if (key.includes('MaxParallelismTitle')) return 'Max Parallelism'
+ if (key.includes('MaxParallelismDesc')) return 'Maximum number of parallel executions'
+ if (key.includes('parallelMode')) return 'Parallel Mode'
+ if (key.includes('parallelPanelDesc')) return 'Enable parallel execution'
+ if (key.includes('errorResponseMethod')) return 'Error Response Method'
+ return key
+ },
+ }),
+ initReactI18next: {
+ type: '3rdParty',
+ init: jest.fn(),
+ },
+}))
+
+// Mock i18next module completely to prevent initialization issues
+jest.mock('i18next', () => ({
+ use: jest.fn().mockReturnThis(),
+ init: jest.fn().mockReturnThis(),
+ t: jest.fn(key => key),
+ isInitialized: true,
+}))
+
+// Mock the useConfig hook
+jest.mock('@/app/components/workflow/nodes/iteration/use-config', () => ({
+ __esModule: true,
+ default: () => ({
+ inputs: {
+ is_parallel: true,
+ parallel_nums: 5,
+ error_handle_mode: 'terminated',
+ },
+ changeParallel: jest.fn(),
+ changeParallelNums: jest.fn(),
+ changeErrorHandleMode: jest.fn(),
+ }),
+}))
+
+// Mock other components
+jest.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => {
+ return function MockVarReferencePicker() {
+ return
VarReferencePicker
+ }
+})
+
+jest.mock('@/app/components/workflow/nodes/_base/components/split', () => {
+ return function MockSplit() {
+ return Split
+ }
+})
+
+jest.mock('@/app/components/workflow/nodes/_base/components/field', () => {
+ return function MockField({ title, children }: { title: string, children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+ )
+ }
+})
+
+jest.mock('@/app/components/base/switch', () => {
+ return function MockSwitch({ defaultValue }: { defaultValue: boolean }) {
+ return
+ }
+})
+
+jest.mock('@/app/components/base/select', () => {
+ return function MockSelect() {
+ return
+ }
+})
+
+// Use defaultValue to avoid controlled input warnings
+jest.mock('@/app/components/base/slider', () => {
+ return function MockSlider({ value, max, min }: { value: number, max: number, min: number }) {
+ return (
+
+ )
+ }
+})
+
+// Use defaultValue to avoid controlled input warnings
+jest.mock('@/app/components/base/input', () => {
+ return function MockInput({ type, max, min, value }: { type: string, max: number, min: number, value: number }) {
+ return (
+
+ )
+ }
+})
+
+describe('MAX_PARALLEL_LIMIT Configuration Bug', () => {
+ const mockNodeData = {
+ id: 'test-iteration-node',
+ type: 'iteration' as const,
+ data: {
+ title: 'Test Iteration',
+ desc: 'Test iteration node',
+ iterator_selector: ['test'],
+ output_selector: ['output'],
+ is_parallel: true,
+ parallel_nums: 5,
+ error_handle_mode: 'terminated' as const,
+ },
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ afterEach(() => {
+ restoreEnvironment()
+ })
+
+ afterAll(() => {
+ restoreEnvironment()
+ })
+
+ describe('Environment Variable Parsing', () => {
+ it('should parse MAX_PARALLEL_LIMIT from NEXT_PUBLIC_MAX_PARALLEL_LIMIT environment variable', () => {
+ setupEnvironment('25')
+ const { MAX_PARALLEL_LIMIT } = require('@/config')
+ expect(MAX_PARALLEL_LIMIT).toBe(25)
+ })
+
+ it('should fallback to default when environment variable is not set', () => {
+ setupEnvironment() // No environment variable
+ const { MAX_PARALLEL_LIMIT } = require('@/config')
+ expect(MAX_PARALLEL_LIMIT).toBe(10)
+ })
+
+ it('should handle invalid environment variable values', () => {
+ setupEnvironment('invalid')
+ const { MAX_PARALLEL_LIMIT } = require('@/config')
+
+ // Should fall back to default when parsing fails
+ expect(MAX_PARALLEL_LIMIT).toBe(10)
+ })
+
+ it('should handle empty environment variable', () => {
+ setupEnvironment('')
+ const { MAX_PARALLEL_LIMIT } = require('@/config')
+
+ // Should fall back to default when empty
+ expect(MAX_PARALLEL_LIMIT).toBe(10)
+ })
+
+ // Edge cases for boundary values
+ it('should clamp MAX_PARALLEL_LIMIT to MIN when env is 0 or negative', () => {
+ setupEnvironment('0')
+ let { MAX_PARALLEL_LIMIT } = require('@/config')
+ expect(MAX_PARALLEL_LIMIT).toBe(10) // Falls back to default
+
+ setupEnvironment('-5')
+ ;({ MAX_PARALLEL_LIMIT } = require('@/config'))
+ expect(MAX_PARALLEL_LIMIT).toBe(10) // Falls back to default
+ })
+
+ it('should handle float numbers by parseInt behavior', () => {
+ setupEnvironment('12.7')
+ const { MAX_PARALLEL_LIMIT } = require('@/config')
+ // parseInt truncates to integer
+ expect(MAX_PARALLEL_LIMIT).toBe(12)
+ })
+ })
+
+ describe('UI Component Integration (Main Fix Verification)', () => {
+ it('should render iteration panel with environment-configured max value', () => {
+ // Set environment variable to a different value
+ setupEnvironment('30')
+
+ // Import Panel after setting environment
+ const Panel = require('@/app/components/workflow/nodes/iteration/panel').default
+ const { MAX_PARALLEL_LIMIT } = require('@/config')
+
+ render(
+ ,
+ )
+
+ // Behavior-focused assertion: UI max should equal MAX_PARALLEL_LIMIT
+ const numberInput = screen.getByTestId('number-input')
+ expect(numberInput).toHaveAttribute('data-max', String(MAX_PARALLEL_LIMIT))
+
+ const slider = screen.getByTestId('slider')
+ expect(slider).toHaveAttribute('data-max', String(MAX_PARALLEL_LIMIT))
+
+ // Verify the actual values
+ expect(MAX_PARALLEL_LIMIT).toBe(30)
+ expect(numberInput.getAttribute('data-max')).toBe('30')
+ expect(slider.getAttribute('data-max')).toBe('30')
+ })
+
+ it('should maintain UI consistency with different environment values', () => {
+ setupEnvironment('15')
+ const Panel = require('@/app/components/workflow/nodes/iteration/panel').default
+ const { MAX_PARALLEL_LIMIT } = require('@/config')
+
+ render(
+ ,
+ )
+
+ // Both input and slider should use the same max value from MAX_PARALLEL_LIMIT
+ const numberInput = screen.getByTestId('number-input')
+ const slider = screen.getByTestId('slider')
+
+ expect(numberInput.getAttribute('data-max')).toBe(slider.getAttribute('data-max'))
+ expect(numberInput.getAttribute('data-max')).toBe(String(MAX_PARALLEL_LIMIT))
+ })
+ })
+
+ describe('Legacy Constant Verification (For Transition Period)', () => {
+ // Marked as transition/deprecation tests
+ it('should maintain MAX_ITERATION_PARALLEL_NUM for backward compatibility', () => {
+ const { MAX_ITERATION_PARALLEL_NUM } = require('@/app/components/workflow/constants')
+ expect(typeof MAX_ITERATION_PARALLEL_NUM).toBe('number')
+ expect(MAX_ITERATION_PARALLEL_NUM).toBe(10) // Hardcoded legacy value
+ })
+
+ it('should demonstrate MAX_PARALLEL_LIMIT vs legacy constant difference', () => {
+ setupEnvironment('50')
+ const { MAX_PARALLEL_LIMIT } = require('@/config')
+ const { MAX_ITERATION_PARALLEL_NUM } = require('@/app/components/workflow/constants')
+
+ // MAX_PARALLEL_LIMIT is configurable, MAX_ITERATION_PARALLEL_NUM is not
+ expect(MAX_PARALLEL_LIMIT).toBe(50)
+ expect(MAX_ITERATION_PARALLEL_NUM).toBe(10)
+ expect(MAX_PARALLEL_LIMIT).not.toBe(MAX_ITERATION_PARALLEL_NUM)
+ })
+ })
+
+ describe('Constants Validation', () => {
+ it('should validate that required constants exist and have correct types', () => {
+ const { MAX_PARALLEL_LIMIT } = require('@/config')
+ const { MIN_ITERATION_PARALLEL_NUM } = require('@/app/components/workflow/constants')
+ expect(typeof MAX_PARALLEL_LIMIT).toBe('number')
+ expect(typeof MIN_ITERATION_PARALLEL_NUM).toBe('number')
+ expect(MAX_PARALLEL_LIMIT).toBeGreaterThanOrEqual(MIN_ITERATION_PARALLEL_NUM)
+ })
+ })
+})
diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts
index 0ef4dc9de..5bf053e2c 100644
--- a/web/app/components/workflow/constants.ts
+++ b/web/app/components/workflow/constants.ts
@@ -437,14 +437,6 @@ export const NODE_LAYOUT_HORIZONTAL_PADDING = 60
export const NODE_LAYOUT_VERTICAL_PADDING = 60
export const NODE_LAYOUT_MIN_DISTANCE = 100
-let maxParallelLimit = 10
-
-if (process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT && process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT !== '')
- maxParallelLimit = Number.parseInt(process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT)
-else if (globalThis.document?.body?.getAttribute('data-public-max-parallel-limit') && globalThis.document.body.getAttribute('data-public-max-parallel-limit') !== '')
- maxParallelLimit = Number.parseInt(globalThis.document.body.getAttribute('data-public-max-parallel-limit') as string)
-
-export const PARALLEL_LIMIT = maxParallelLimit
export const PARALLEL_DEPTH_LIMIT = 3
export const RETRIEVAL_OUTPUT_STRUCT = `{
diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts
index 8bc9d3436..f9120f45b 100644
--- a/web/app/components/workflow/hooks/use-workflow.ts
+++ b/web/app/components/workflow/hooks/use-workflow.ts
@@ -30,7 +30,6 @@ import {
} from '../utils'
import {
PARALLEL_DEPTH_LIMIT,
- PARALLEL_LIMIT,
SUPPORT_OUTPUT_VARS_NODE,
} from '../constants'
import { CUSTOM_NOTE_NODE } from '../note-node/constants'
@@ -48,6 +47,7 @@ import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/ite
import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants'
import { basePath } from '@/utils/var'
import { canFindTool } from '@/utils'
+import { MAX_PARALLEL_LIMIT } from '@/config'
export const useIsChatMode = () => {
const appDetail = useAppStore(s => s.appDetail)
@@ -270,8 +270,6 @@ export const useWorkflow = () => {
})
setNodes(newNodes)
}
-
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, [store])
const isVarUsedInNodes = useCallback((varSelector: ValueSelector) => {
@@ -310,9 +308,9 @@ export const useWorkflow = () => {
edges,
} = store.getState()
const connectedEdges = edges.filter(edge => edge.source === nodeId && edge.sourceHandle === nodeHandle)
- if (connectedEdges.length > PARALLEL_LIMIT - 1) {
+ if (connectedEdges.length > MAX_PARALLEL_LIMIT - 1) {
const { setShowTips } = workflowStore.getState()
- setShowTips(t('workflow.common.parallelTip.limit', { num: PARALLEL_LIMIT }))
+ setShowTips(t('workflow.common.parallelTip.limit', { num: MAX_PARALLEL_LIMIT }))
return false
}
diff --git a/web/app/components/workflow/nodes/iteration/panel.tsx b/web/app/components/workflow/nodes/iteration/panel.tsx
index 4b529f078..23e93b0dd 100644
--- a/web/app/components/workflow/nodes/iteration/panel.tsx
+++ b/web/app/components/workflow/nodes/iteration/panel.tsx
@@ -3,7 +3,7 @@ import React from 'react'
import { useTranslation } from 'react-i18next'
import VarReferencePicker from '../_base/components/variable/var-reference-picker'
import Split from '../_base/components/split'
-import { MAX_ITERATION_PARALLEL_NUM, MIN_ITERATION_PARALLEL_NUM } from '../../constants'
+import { MIN_ITERATION_PARALLEL_NUM } from '../../constants'
import type { IterationNodeType } from './types'
import useConfig from './use-config'
import { ErrorHandleMode, type NodePanelProps } from '@/app/components/workflow/types'
@@ -12,6 +12,7 @@ import Switch from '@/app/components/base/switch'
import Select from '@/app/components/base/select'
import Slider from '@/app/components/base/slider'
import Input from '@/app/components/base/input'
+import { MAX_PARALLEL_LIMIT } from '@/config'
const i18nPrefix = 'workflow.nodes.iteration'
@@ -96,11 +97,11 @@ const Panel: FC> = ({
inputs.is_parallel && (
{t(`${i18nPrefix}.MaxParallelismDesc`)}
}>
- { changeParallelNums(Number(e.target.value)) }} />
+ { changeParallelNums(Number(e.target.value)) }} />
diff --git a/web/config/index.ts b/web/config/index.ts
index 667723aaa..4a8b07d6e 100644
--- a/web/config/index.ts
+++ b/web/config/index.ts
@@ -13,12 +13,18 @@ const getBooleanConfig = (envVar: string | undefined, dataAttrKey: DatasetAttr,
}
const getNumberConfig = (envVar: string | undefined, dataAttrKey: DatasetAttr, defaultValue: number) => {
- if (envVar)
- return Number.parseInt(envVar)
+ if (envVar) {
+ const parsed = Number.parseInt(envVar)
+ if (!Number.isNaN(parsed) && parsed > 0)
+ return parsed
+ }
const attrValue = globalThis.document?.body?.getAttribute(dataAttrKey)
- if (attrValue)
- return Number.parseInt(attrValue)
+ if (attrValue) {
+ const parsed = Number.parseInt(attrValue)
+ if (!Number.isNaN(parsed) && parsed > 0)
+ return parsed
+ }
return defaultValue
}
@@ -265,6 +271,7 @@ export const FULL_DOC_PREVIEW_LENGTH = 50
export const JSON_SCHEMA_MAX_DEPTH = 10
export const MAX_TOOLS_NUM = getNumberConfig(process.env.NEXT_PUBLIC_MAX_TOOLS_NUM, DatasetAttr.DATA_PUBLIC_MAX_TOOLS_NUM, 10)
+export const MAX_PARALLEL_LIMIT = getNumberConfig(process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT, DatasetAttr.DATA_PUBLIC_MAX_PARALLEL_LIMIT, 10)
export const TEXT_GENERATION_TIMEOUT_MS = getNumberConfig(process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS, DatasetAttr.DATA_PUBLIC_TEXT_GENERATION_TIMEOUT_MS, 60000)
export const LOOP_NODE_MAX_COUNT = getNumberConfig(process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT, DatasetAttr.DATA_PUBLIC_LOOP_NODE_MAX_COUNT, 100)
export const MAX_ITERATIONS_NUM = getNumberConfig(process.env.NEXT_PUBLIC_MAX_ITERATIONS_NUM, DatasetAttr.DATA_PUBLIC_MAX_ITERATIONS_NUM, 99)