Merge remote-tracking branch 'origin/main' into feat/support-agent-sandbox

This commit is contained in:
yyh
2026-03-24 19:30:56 +08:00
95 changed files with 10275 additions and 2761 deletions

View File

@ -1,7 +1,6 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import ModelParameterModal from '../index'
let isAPIKeySet = true
let parameterRules: Array<Record<string, unknown>> | undefined = [
{
name: 'temperature',
@ -40,7 +39,7 @@ let activeTextGenerationModelList: Array<Record<string, unknown>> = [
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
isAPIKeySet,
isAPIKeySet: true,
}),
}))
@ -50,6 +49,7 @@ vi.mock('@/service/use-common', () => ({
data: parameterRules,
},
isLoading: isRulesLoading,
isPending: isRulesLoading,
}),
}))
@ -62,12 +62,18 @@ vi.mock('../../hooks', () => ({
}))
vi.mock('../parameter-item', () => ({
default: ({ parameterRule, onChange, onSwitch }: {
default: ({ parameterRule, onChange, onSwitch, nodesOutputVars, availableNodes }: {
parameterRule: { name: string, label: { en_US: string } }
onChange: (v: number) => void
onSwitch: (checked: boolean, val: unknown) => void
nodesOutputVars?: unknown[]
availableNodes?: unknown[]
}) => (
<div data-testid={`param-${parameterRule.name}`}>
<div
data-testid={`param-${parameterRule.name}`}
data-has-nodes-output-vars={!!nodesOutputVars}
data-has-available-nodes={!!availableNodes}
>
{parameterRule.label.en_US}
<button onClick={() => onChange(0.9)}>Change</button>
<button onClick={() => onSwitch(false, undefined)}>Remove</button>
@ -119,7 +125,6 @@ describe('ModelParameterModal', () => {
beforeEach(() => {
vi.clearAllMocks()
isAPIKeySet = true
isRulesLoading = false
parameterRules = [
{
@ -233,6 +238,26 @@ describe('ModelParameterModal', () => {
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
it('should pass nodesOutputVars and availableNodes to ParameterItem', () => {
const mockNodesOutputVars = [{ nodeId: 'n1', title: 'Node', vars: [] }]
const mockAvailableNodes = [{ id: 'n1', data: { title: 'Node', type: 'llm' } }]
render(
<ModelParameterModal
{...defaultProps}
isInWorkflow
nodesOutputVars={mockNodesOutputVars as never}
availableNodes={mockAvailableNodes as never}
/>,
)
fireEvent.click(screen.getByText('Open Settings'))
const paramEl = screen.getByTestId('param-temperature')
expect(paramEl).toHaveAttribute('data-has-nodes-output-vars', 'true')
expect(paramEl).toHaveAttribute('data-has-available-nodes', 'true')
})
it('should support custom triggers, workflow mode, and missing default model values', async () => {
render(
<ModelParameterModal

View File

@ -1,5 +1,10 @@
import type { ModelParameterRule } from '../../declarations'
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum } from '@/app/components/workflow/types'
import ParameterItem from '../parameter-item'
vi.mock('../../hooks', () => ({
@ -18,6 +23,29 @@ vi.mock('@/app/components/base/tag-input', () => ({
),
}))
let promptEditorOnChange: ((text: string) => void) | undefined
let capturedWorkflowNodesMap: Record<string, { title: string, type: string }> | undefined
vi.mock('@/app/components/base/prompt-editor', () => ({
default: ({ value, onChange, workflowVariableBlock }: {
value: string
onChange: (text: string) => void
workflowVariableBlock?: {
show: boolean
variables: NodeOutPutVar[]
workflowNodesMap?: Record<string, { title: string, type: string }>
}
}) => {
promptEditorOnChange = onChange
capturedWorkflowNodesMap = workflowVariableBlock?.workflowNodesMap
return (
<div data-testid="prompt-editor" data-value={value} data-has-workflow-vars={!!workflowVariableBlock?.variables}>
{value}
</div>
)
},
}))
describe('ParameterItem', () => {
const createRule = (overrides: Partial<ModelParameterRule> = {}): ModelParameterRule => ({
name: 'temp',
@ -30,9 +58,10 @@ describe('ParameterItem', () => {
beforeEach(() => {
vi.clearAllMocks()
promptEditorOnChange = undefined
capturedWorkflowNodesMap = undefined
})
// Float tests
it('should render float controls and clamp numeric input to max', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 1 })} value={0.7} onChange={onChange} />)
@ -50,7 +79,6 @@ describe('ParameterItem', () => {
expect(onChange).toHaveBeenCalledWith(0.1)
})
// Int tests
it('should render int controls and clamp numeric input', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'int', min: 0, max: 10 })} value={5} onChange={onChange} />)
@ -75,22 +103,17 @@ describe('ParameterItem', () => {
it('should render int input without slider if min or max is missing', () => {
render(<ParameterItem parameterRule={createRule({ type: 'int', min: 0 })} value={5} />)
expect(screen.queryByRole('slider')).not.toBeInTheDocument()
// No max -> precision step
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '0')
})
// Slider events (uses generic value mock for slider)
it('should handle slide change and clamp values', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 10 })} value={0.7} onChange={onChange} />)
// Test that the actual slider triggers the onChange logic correctly
// The implementation of Slider uses onChange(val) directly via the mock
fireEvent.click(screen.getByTestId('slider-btn'))
expect(onChange).toHaveBeenCalledWith(2)
})
// Text & String tests
it('should render exact string input and propagate text changes', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'string', name: 'prompt' })} value="initial" onChange={onChange} />)
@ -109,21 +132,17 @@ describe('ParameterItem', () => {
it('should render select for string with options', () => {
render(<ParameterItem parameterRule={createRule({ type: 'string', options: ['a', 'b'] })} value="a" />)
// Select renders the selected value in the trigger
expect(screen.getByText('a')).toBeInTheDocument()
})
// Tag Tests
it('should render tag input for tag type', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'tag', tagPlaceholder: { en_US: 'placeholder', zh_Hans: 'placeholder' } })} value={['a']} onChange={onChange} />)
expect(screen.getByText('placeholder')).toBeInTheDocument()
// Trigger mock tag input
fireEvent.click(screen.getByTestId('tag-input'))
expect(onChange).toHaveBeenCalledWith(['tag1', 'tag2'])
})
// Boolean tests
it('should render boolean radios and update value on click', () => {
const onChange = vi.fn()
render(<ParameterItem parameterRule={createRule({ type: 'boolean', default: false })} value={true} onChange={onChange} />)
@ -131,7 +150,6 @@ describe('ParameterItem', () => {
expect(onChange).toHaveBeenCalledWith(false)
})
// Switch tests
it('should call onSwitch with current value when optional switch is toggled off', () => {
const onSwitch = vi.fn()
render(<ParameterItem parameterRule={createRule()} value={0.7} onSwitch={onSwitch} />)
@ -146,7 +164,6 @@ describe('ParameterItem', () => {
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
// Default Value Fallbacks (rendering without value)
it('should use default values if value is undefined', () => {
const { rerender } = render(<ParameterItem parameterRule={createRule({ type: 'float', default: 0.5 })} />)
expect(screen.getByRole('spinbutton')).toHaveValue(0.5)
@ -158,26 +175,102 @@ describe('ParameterItem', () => {
expect(screen.getByText('True')).toBeInTheDocument()
expect(screen.getByText('False')).toBeInTheDocument()
// Without default
rerender(<ParameterItem parameterRule={createRule({ type: 'float' })} />) // min is 0 by default in createRule
rerender(<ParameterItem parameterRule={createRule({ type: 'float' })} />)
expect(screen.getByRole('spinbutton')).toHaveValue(0)
})
// Input Blur
it('should reset input to actual bound value on blur', () => {
render(<ParameterItem parameterRule={createRule({ type: 'float', min: 0, max: 1 })} />)
const input = screen.getByRole('spinbutton')
// change local state (which triggers clamp internally to let's say 1.4 -> 1 but leaves input text, though handleInputChange updates local state)
// Actually our test fires a change so localValue = 1, then blur sets it
fireEvent.change(input, { target: { value: '5' } })
fireEvent.blur(input)
expect(input).toHaveValue(1)
})
// Unsupported
it('should render no input for unsupported parameter type', () => {
render(<ParameterItem parameterRule={createRule({ type: 'unsupported' as unknown as string })} value={0.7} />)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
})
describe('workflow variable reference', () => {
const mockNodesOutputVars: NodeOutPutVar[] = [
{ nodeId: 'node1', title: 'LLM Node', vars: [] },
]
const mockAvailableNodes: Node[] = [
{ id: 'node1', type: 'custom', position: { x: 0, y: 0 }, data: { title: 'LLM Node', type: BlockEnum.LLM } } as Node,
{ id: 'start', type: 'custom', position: { x: 0, y: 0 }, data: { title: 'Start', type: BlockEnum.Start } } as Node,
]
it('should build workflowNodesMap and render PromptEditor for string type', () => {
const onChange = vi.fn()
render(
<ParameterItem
parameterRule={createRule({ type: 'string', name: 'system_prompt' })}
value="hello {{#node1.output#}}"
onChange={onChange}
isInWorkflow
nodesOutputVars={mockNodesOutputVars}
availableNodes={mockAvailableNodes}
/>,
)
const editor = screen.getByTestId('prompt-editor')
expect(editor).toBeInTheDocument()
expect(editor).toHaveAttribute('data-has-workflow-vars', 'true')
expect(capturedWorkflowNodesMap).toBeDefined()
expect(capturedWorkflowNodesMap!.node1.title).toBe('LLM Node')
expect(capturedWorkflowNodesMap!.sys.title).toBe('workflow.blocks.start')
expect(capturedWorkflowNodesMap!.sys.type).toBe(BlockEnum.Start)
promptEditorOnChange?.('updated text')
expect(onChange).toHaveBeenCalledWith('updated text')
})
it('should build workflowNodesMap and render PromptEditor for text type', () => {
const onChange = vi.fn()
render(
<ParameterItem
parameterRule={createRule({ type: 'text', name: 'user_prompt' })}
value="some long text"
onChange={onChange}
isInWorkflow
nodesOutputVars={mockNodesOutputVars}
availableNodes={mockAvailableNodes}
/>,
)
const editor = screen.getByTestId('prompt-editor')
expect(editor).toBeInTheDocument()
expect(editor).toHaveAttribute('data-has-workflow-vars', 'true')
expect(capturedWorkflowNodesMap).toBeDefined()
promptEditorOnChange?.('new long text')
expect(onChange).toHaveBeenCalledWith('new long text')
})
it('should fall back to plain input when not in workflow mode for string type', () => {
render(
<ParameterItem
parameterRule={createRule({ type: 'string', name: 'system_prompt' })}
value="plain"
/>,
)
expect(screen.queryByTestId('prompt-editor')).not.toBeInTheDocument()
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
it('should return undefined workflowNodesMap when not in workflow mode', () => {
render(
<ParameterItem
parameterRule={createRule({ type: 'string', name: 'system_prompt' })}
value="plain"
availableNodes={mockAvailableNodes}
/>,
)
expect(capturedWorkflowNodesMap).toBeUndefined()
})
})
})

View File

@ -9,6 +9,10 @@ import type {
} from '../declarations'
import type { ParameterValue } from './parameter-item'
import type { TriggerProps } from './trigger'
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
@ -46,6 +50,8 @@ export type ModelParameterModalProps = {
readonly?: boolean
isInWorkflow?: boolean
scope?: string
nodesOutputVars?: NodeOutPutVar[]
availableNodes?: Node[]
}
const ModelParameterModal: FC<ModelParameterModalProps> = ({
@ -62,11 +68,18 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
renderTrigger,
readonly,
isInWorkflow,
nodesOutputVars,
availableNodes,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const settingsIconRef = useRef<HTMLDivElement>(null)
const { data: parameterRulesData, isLoading } = useModelParameterRules(provider, modelId)
const {
data: parameterRulesData,
isPending,
isLoading,
} = useModelParameterRules(provider, modelId)
const isRulesLoading = isPending || isLoading
const {
currentProvider,
currentModel,
@ -192,7 +205,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
}
</div>
{
isLoading
isRulesLoading
? <div className="py-5"><Loading /></div>
: (
[
@ -206,6 +219,8 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
onChange={v => handleParamChange(parameter.name, v)}
onSwitch={(checked, assignValue) => handleSwitch(parameter.name, checked, assignValue)}
isInWorkflow={isInWorkflow}
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
/>
))
)
@ -214,7 +229,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
)
}
{
!parameterRules.length && isLoading && (
!parameterRules.length && isRulesLoading && (
<div className="px-4 py-5"><Loading /></div>
)
}

View File

@ -1,11 +1,18 @@
import type { ModelParameterRule } from '../declarations'
import { useEffect, useRef, useState } from 'react'
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PromptEditor from '@/app/components/base/prompt-editor'
import Radio from '@/app/components/base/radio'
import Slider from '@/app/components/base/slider'
import Switch from '@/app/components/base/switch'
import TagInput from '@/app/components/base/tag-input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { BlockEnum } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
import { useLanguage } from '../hooks'
import { isNullOrUndefined } from '../utils'
@ -18,18 +25,43 @@ type ParameterItemProps = {
onChange?: (value: ParameterValue) => void
onSwitch?: (checked: boolean, assignValue: ParameterValue) => void
isInWorkflow?: boolean
nodesOutputVars?: NodeOutPutVar[]
availableNodes?: Node[]
}
function ParameterItem({
parameterRule,
value,
onChange,
onSwitch,
isInWorkflow,
nodesOutputVars,
availableNodes = [],
}: ParameterItemProps) {
const { t } = useTranslation()
const language = useLanguage()
const [localValue, setLocalValue] = useState(value)
const numberInputRef = useRef<HTMLInputElement>(null)
const workflowNodesMap = useMemo(() => {
if (!isInWorkflow || !availableNodes.length)
return undefined
return availableNodes.reduce<Record<string, Pick<Node['data'], 'title' | 'type'>>>((acc, node) => {
acc[node.id] = {
title: node.data.title,
type: node.data.type,
}
if (node.data.type === BlockEnum.Start) {
acc.sys = {
title: t('blocks.start', { ns: 'workflow' }),
type: BlockEnum.Start,
}
}
return acc
}, {})
}, [availableNodes, isInWorkflow, t])
const getDefaultValue = () => {
let defaultValue: ParameterValue
@ -196,6 +228,25 @@ function ParameterItem({
}
if (parameterRule.type === 'string' && !parameterRule.options?.length) {
if (isInWorkflow && nodesOutputVars) {
return (
<div className="ml-4 w-[200px] rounded-lg bg-components-input-bg-normal px-2 py-1">
<PromptEditor
compact
className="min-h-[22px] text-[13px]"
value={renderValue as string}
onChange={(text) => { handleInputChange(text) }}
workflowVariableBlock={{
show: true,
variables: nodesOutputVars,
workflowNodesMap,
}}
editable
/>
</div>
)
}
return (
<input
className={cn(isInWorkflow ? 'w-[150px]' : 'w-full', 'ml-4 flex h-8 appearance-none items-center rounded-lg bg-components-input-bg-normal px-3 text-components-input-text-filled outline-none system-sm-regular')}
@ -206,6 +257,25 @@ function ParameterItem({
}
if (parameterRule.type === 'text') {
if (isInWorkflow && nodesOutputVars) {
return (
<div className="ml-4 w-full rounded-lg bg-components-input-bg-normal px-2 py-1">
<PromptEditor
compact
className="min-h-[56px] text-[13px]"
value={renderValue as string}
onChange={(text) => { handleInputChange(text) }}
workflowVariableBlock={{
show: true,
variables: nodesOutputVars,
workflowNodesMap,
}}
editable
/>
</div>
)
}
return (
<textarea
className="ml-4 h-20 w-full rounded-lg bg-components-input-bg-normal px-1 text-components-input-text-filled system-sm-regular"
@ -215,7 +285,7 @@ function ParameterItem({
)
}
if (parameterRule.type === 'string' && !!parameterRule?.options?.length) {
if (parameterRule.type === 'string' && !!parameterRule.options?.length) {
return (
<Select
value={renderValue as string}