dify/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx
Stephen Zhou a84c2d36a3
style: format with vp fmt (#38803)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-12 15:57:46 +00:00

368 lines
13 KiB
TypeScript

import type { Item as TypeSelectItem } from '@/app/components/app/configuration/config-var/config-modal/type-select'
import type {
FormInputItem,
FormInputItemDefault,
ParagraphFormInput,
} from '@/app/components/workflow/nodes/human-input/types'
import type { UploadFileSetting, ValueSelector } from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { Kbd, KbdGroup } from '@langgenius/dify-ui/kbd'
import { formatForDisplay } from '@tanstack/react-hotkeys'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import TypeSelector from '@/app/components/app/configuration/config-var/config-modal/type-select'
import ConfigSelect from '@/app/components/app/configuration/config-var/config-select'
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import {
createDefaultFormInputByType,
createDefaultParagraphFormInput,
isFileFormInput,
isFileListFormInput,
isParagraphFormInput,
isSelectFormInput,
} from '@/app/components/workflow/nodes/human-input/types'
import { InputVarType, VarType } from '@/app/components/workflow/types'
import PrePopulate from './pre-populate'
import TypeSwitch from './type-switch'
const i18nPrefix = 'nodes.humanInput.insertInputField'
type InputFieldProps = {
nodeId: string
isEdit: boolean
payload?: FormInputItem
unavailableVariableNames?: string[]
onChange: (newPayload: FormInputItem) => void
onCancel: () => void
}
const InputField: React.FC<InputFieldProps> = ({
nodeId,
isEdit,
payload,
unavailableVariableNames = [],
onChange,
onCancel,
}) => {
const { t } = useTranslation()
const [tempPayload, setTempPayload] = useState<FormInputItem>(
() => payload || createDefaultParagraphFormInput(),
)
const fieldTypeItems = useMemo<TypeSelectItem[]>(() => {
return [
{
name: t(($) => $['variableConfig.paragraph'], { ns: 'appDebug' }),
value: InputVarType.paragraph,
},
{
name: t(($) => $['variableConfig.select'], { ns: 'appDebug' }),
value: InputVarType.select,
},
{
name: t(($) => $['variableConfig.single-file'], { ns: 'appDebug' }),
value: InputVarType.singleFile,
},
{
name: t(($) => $['variableConfig.multi-files'], { ns: 'appDebug' }),
value: InputVarType.multiFiles,
},
]
}, [t])
const paragraphPayload = useMemo<ParagraphFormInput>(() => {
if (isParagraphFormInput(tempPayload)) {
return {
...tempPayload,
default: tempPayload.default || createDefaultParagraphFormInput().default,
}
}
return createDefaultParagraphFormInput(tempPayload.output_variable_name)
}, [tempPayload])
const unavailableVariableNameSet = useMemo(() => {
return new Set(unavailableVariableNames.map((name) => name.trim()).filter(Boolean))
}, [unavailableVariableNames])
const variableNameError = useMemo(() => {
const name = tempPayload.output_variable_name.trim()
if (!name) return null
if (name.includes(' ')) return 'variableNameInvalid'
if (!/^[a-z_]\w{0,29}$/.test(name)) return 'variableNameInvalid'
if (unavailableVariableNameSet.has(name)) return 'variableNameDuplicated'
return null
}, [tempPayload.output_variable_name, unavailableVariableNameSet])
const nameValid = useMemo(() => {
return !!tempPayload.output_variable_name.trim() && !variableNameError
}, [tempPayload.output_variable_name, variableNameError])
const handleSave = useCallback(() => {
if (!nameValid) return
if (isFileListFormInput(tempPayload)) {
const value = tempPayload.number_limits ?? 5
if (!Number.isFinite(value)) {
onChange({
...tempPayload,
number_limits: 1,
})
return
}
}
onChange(tempPayload)
}, [nameValid, onChange, tempPayload])
const handleTypeChange = useCallback((item: TypeSelectItem) => {
setTempPayload((prev) =>
createDefaultFormInputByType(item.value as FormInputItem['type'], prev.output_variable_name),
)
}, [])
const handleDefaultValueChange = useCallback(
(key: keyof FormInputItemDefault) => {
return (value: ValueSelector | string) => {
const nextValue = produce(paragraphPayload, (draft) => {
if (key === 'selector') {
draft.default.type = 'variable'
draft.default.selector = value as ValueSelector
} else if (key === 'value') {
draft.default.type = 'constant'
draft.default.value = value as string
} else if (key === 'type') {
draft.default.type = value as 'constant' | 'variable'
}
})
setTempPayload(nextValue)
}
},
[paragraphPayload],
)
const handleSelectOptionsChange = useCallback((options: string[]) => {
setTempPayload((prev) => {
if (!isSelectFormInput(prev)) return prev
return {
...prev,
option_source: {
...prev.option_source,
type: 'constant',
value: options,
},
}
})
}, [])
const handleSelectOptionSourceTypeChange = useCallback((isVariable: boolean) => {
setTempPayload((prev) => {
if (!isSelectFormInput(prev)) return prev
return {
...prev,
option_source: {
...prev.option_source,
type: isVariable ? 'variable' : 'constant',
},
}
})
}, [])
const handleSelectOptionSourceSelectorChange = useCallback((selector: ValueSelector | string) => {
setTempPayload((prev) => {
if (!isSelectFormInput(prev)) return prev
return {
...prev,
option_source: {
...prev.option_source,
type: 'variable',
selector: selector as ValueSelector,
},
}
})
}, [])
const handleFilePayloadChange = useCallback((payload: UploadFileSetting) => {
setTempPayload((prev) => {
if (!isFileFormInput(prev)) return prev
return {
...prev,
allowed_file_extensions: payload.allowed_file_extensions || [],
allowed_file_types: payload.allowed_file_types,
allowed_file_upload_methods: payload.allowed_file_upload_methods,
}
})
}, [])
const handleFileListPayloadChange = useCallback((payload: UploadFileSetting) => {
setTempPayload((prev) => {
if (!isFileListFormInput(prev)) return prev
return {
...prev,
allowed_file_extensions: payload.allowed_file_extensions || [],
allowed_file_types: payload.allowed_file_types,
allowed_file_upload_methods: payload.allowed_file_upload_methods,
number_limits: payload.max_length,
}
})
}, [])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
handleSave()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [handleSave])
return (
<div className="flex max-h-[var(--shortcut-popup-max-height,80dvh)] w-[372px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]">
<div className="shrink-0 p-3 pb-2">
<div className="system-md-semibold text-text-primary">
{t(($) => $[`${i18nPrefix}.title`], { ns: 'workflow' })}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3 pt-0 pb-0">
<div className="mt-3">
<div className="system-xs-medium text-text-secondary">
{t(($) => $[`${i18nPrefix}.fieldType`], { ns: 'workflow' })}
</div>
<div className="mt-1.5">
<TypeSelector
value={tempPayload.type}
items={fieldTypeItems}
onSelect={handleTypeChange}
/>
</div>
</div>
<div className="mt-3">
<div className="system-xs-medium text-text-secondary">
{t(($) => $[`${i18nPrefix}.saveResponseAs`], { ns: 'workflow' })}
<span className="relative system-xs-regular text-text-destructive-secondary">*</span>
</div>
<Input
className="mt-1.5"
placeholder={t(($) => $[`${i18nPrefix}.saveResponseAsPlaceholder`], { ns: 'workflow' })}
value={tempPayload.output_variable_name}
onChange={(e) => {
setTempPayload((prev) => ({ ...prev, output_variable_name: e.target.value }))
}}
autoFocus
/>
{tempPayload.output_variable_name && variableNameError && (
<div className="mt-1 px-1 system-xs-regular text-text-destructive-secondary">
{t(($) => $[`${i18nPrefix}.${variableNameError}`], { ns: 'workflow' })}
</div>
)}
</div>
{isParagraphFormInput(tempPayload) && (
<div className="mt-4">
<div className="mb-1.5 system-xs-medium text-text-secondary">
{t(($) => $[`${i18nPrefix}.prePopulateField`], { ns: 'workflow' })}
</div>
<PrePopulate
isVariable={paragraphPayload.default.type === 'variable'}
onIsVariableChange={(isVariable) => {
handleDefaultValueChange('type')(isVariable ? 'variable' : 'constant')
}}
nodeId={nodeId}
valueSelector={paragraphPayload.default.selector}
onValueSelectorChange={handleDefaultValueChange('selector')}
value={paragraphPayload.default.value}
onValueChange={handleDefaultValueChange('value')}
/>
</div>
)}
{isSelectFormInput(tempPayload) && (
<div className="mt-4">
<div className="mb-1.5 system-xs-medium text-text-secondary">
{t(($) => $[`${i18nPrefix}.options`], { ns: 'workflow' })}
</div>
{tempPayload.option_source.type === 'variable' ? (
<div className="relative min-h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal px-3 pt-2 pb-8">
<VarReferencePicker
nodeId={nodeId}
value={tempPayload.option_source.selector}
onChange={handleSelectOptionSourceSelectorChange}
readonly={false}
isJustShowValue
filterVar={(varPayload) => varPayload.type === VarType.arrayString}
/>
<TypeSwitch
className="absolute bottom-1 left-1.5"
isVariable
onIsVariableChange={handleSelectOptionSourceTypeChange}
/>
</div>
) : (
<div
className={cn(
'rounded-lg border border-transparent bg-components-input-bg-normal p-2',
)}
>
<ConfigSelect
options={tempPayload.option_source.value}
onChange={handleSelectOptionsChange}
/>
<TypeSwitch
className="mt-2"
isVariable={false}
onIsVariableChange={handleSelectOptionSourceTypeChange}
/>
</div>
)}
</div>
)}
{isFileFormInput(tempPayload) && (
<div className="mt-4">
<FileUploadSetting
payload={{
...tempPayload,
max_length: 1,
}}
isMultiple={false}
onChange={handleFilePayloadChange}
/>
</div>
)}
{isFileListFormInput(tempPayload) && (
<div className="mt-4">
<FileUploadSetting
payload={{
...tempPayload,
max_length: tempPayload.number_limits || 5,
}}
isMultiple
onChange={handleFileListPayloadChange}
/>
</div>
)}
</div>
<div className="shrink-0 bg-components-panel-bg p-3">
<div className="flex justify-end space-x-2">
<Button onClick={onCancel}>{t(($) => $['operation.cancel'], { ns: 'common' })}</Button>
{isEdit ? (
<Button variant="primary" onClick={handleSave} disabled={!nameValid}>
{t(($) => $['operation.save'], { ns: 'common' })}
</Button>
) : (
<Button className="flex" variant="primary" disabled={!nameValid} onClick={handleSave}>
<span className="mr-1">
{t(($) => $[`${i18nPrefix}.insert`], { ns: 'workflow' })}
</span>
<KbdGroup>
{['Mod', 'Enter'].map((key) => (
<Kbd key={key} color="white">
{formatForDisplay(key)}
</Kbd>
))}
</KbdGroup>
</Button>
)}
</div>
</div>
</div>
)
}
export default InputField