import React, { type FC, useCallback, useMemo, useRef, useState } from 'react'
|
import type { SchemaEnumType } from '../../../../types'
|
import { ArrayType, Type } from '../../../../types'
|
import type { TypeItem } from './type-selector'
|
import TypeSelector from './type-selector'
|
import RequiredSwitch from './required-switch'
|
import Divider from '@/app/components/base/divider'
|
import Actions from './actions'
|
import AdvancedActions from './advanced-actions'
|
import AdvancedOptions, { type AdvancedOptionsType } from './advanced-options'
|
import { useTranslation } from 'react-i18next'
|
import classNames from '@/utils/classnames'
|
import { useVisualEditorStore } from '../store'
|
import { useMittContext } from '../context'
|
import { useUnmount } from 'ahooks'
|
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
|
import AutoWidthInput from './auto-width-input'
|
|
export type EditData = {
|
name: string
|
type: Type | ArrayType
|
required: boolean
|
description?: string
|
enum?: SchemaEnumType
|
}
|
|
type Options = {
|
description?: string
|
enum?: SchemaEnumType
|
}
|
|
type EditCardProps = {
|
fields: EditData
|
depth: number
|
path: string[]
|
parentPath: string[]
|
}
|
|
const TYPE_OPTIONS = [
|
{ value: Type.string, text: 'string' },
|
{ value: Type.number, text: 'number' },
|
// { value: Type.boolean, text: 'boolean' },
|
{ value: Type.object, text: 'object' },
|
{ value: ArrayType.string, text: 'array[string]' },
|
{ value: ArrayType.number, text: 'array[number]' },
|
// { value: ArrayType.boolean, text: 'array[boolean]' },
|
{ value: ArrayType.object, text: 'array[object]' },
|
]
|
|
const MAXIMUM_DEPTH_TYPE_OPTIONS = [
|
{ value: Type.string, text: 'string' },
|
{ value: Type.number, text: 'number' },
|
// { value: Type.boolean, text: 'boolean' },
|
{ value: ArrayType.string, text: 'array[string]' },
|
{ value: ArrayType.number, text: 'array[number]' },
|
// { value: ArrayType.boolean, text: 'array[boolean]' },
|
]
|
|
const EditCard: FC<EditCardProps> = ({
|
fields,
|
depth,
|
path,
|
parentPath,
|
}) => {
|
const { t } = useTranslation()
|
const [currentFields, setCurrentFields] = useState(fields)
|
const [backupFields, setBackupFields] = useState<EditData | null>(null)
|
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
|
const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
|
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
|
const setAdvancedEditing = useVisualEditorStore(state => state.setAdvancedEditing)
|
const { emit, useSubscribe } = useMittContext()
|
const blurWithActions = useRef(false)
|
|
const maximumDepthReached = depth === JSON_SCHEMA_MAX_DEPTH
|
const disableAddBtn = maximumDepthReached || (currentFields.type !== Type.object && currentFields.type !== ArrayType.object)
|
const hasAdvancedOptions = currentFields.type === Type.string || currentFields.type === Type.number
|
const isAdvancedEditing = advancedEditing || isAddingNewField
|
|
const advancedOptions = useMemo(() => {
|
let enumValue = ''
|
if (currentFields.type === Type.string || currentFields.type === Type.number)
|
enumValue = (currentFields.enum || []).join(', ')
|
return { enum: enumValue }
|
}, [currentFields.type, currentFields.enum])
|
|
useSubscribe('restorePropertyName', () => {
|
setCurrentFields(prev => ({ ...prev, name: fields.name }))
|
})
|
|
useSubscribe('fieldChangeSuccess', () => {
|
isAddingNewField && setIsAddingNewField(false)
|
advancedEditing && setAdvancedEditing(false)
|
})
|
|
const emitPropertyNameChange = useCallback(() => {
|
emit('propertyNameChange', { path, parentPath, oldFields: fields, fields: currentFields })
|
}, [fields, currentFields, path, parentPath, emit])
|
|
const emitPropertyTypeChange = useCallback((type: Type | ArrayType) => {
|
emit('propertyTypeChange', { path, parentPath, oldFields: fields, fields: { ...currentFields, type } })
|
}, [fields, currentFields, path, parentPath, emit])
|
|
const emitPropertyRequiredToggle = useCallback(() => {
|
emit('propertyRequiredToggle', { path, parentPath, oldFields: fields, fields: currentFields })
|
}, [emit, path, parentPath, fields, currentFields])
|
|
const emitPropertyOptionsChange = useCallback((options: Options) => {
|
emit('propertyOptionsChange', { path, parentPath, oldFields: fields, fields: { ...currentFields, ...options } })
|
}, [emit, path, parentPath, fields, currentFields])
|
|
const emitPropertyDelete = useCallback(() => {
|
emit('propertyDelete', { path, parentPath, oldFields: fields, fields: currentFields })
|
}, [emit, path, parentPath, fields, currentFields])
|
|
const emitPropertyAdd = useCallback(() => {
|
emit('addField', { path })
|
}, [emit, path])
|
|
const emitFieldChange = useCallback(() => {
|
emit('fieldChange', { path, parentPath, oldFields: fields, fields: currentFields })
|
}, [emit, path, parentPath, fields, currentFields])
|
|
const handlePropertyNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
setCurrentFields(prev => ({ ...prev, name: e.target.value }))
|
}, [])
|
|
const handlePropertyNameBlur = useCallback(() => {
|
if (isAdvancedEditing) return
|
emitPropertyNameChange()
|
}, [isAdvancedEditing, emitPropertyNameChange])
|
|
const handleTypeChange = useCallback((item: TypeItem) => {
|
setCurrentFields(prev => ({ ...prev, type: item.value }))
|
if (isAdvancedEditing) return
|
emitPropertyTypeChange(item.value)
|
}, [isAdvancedEditing, emitPropertyTypeChange])
|
|
const toggleRequired = useCallback(() => {
|
setCurrentFields(prev => ({ ...prev, required: !prev.required }))
|
if (isAdvancedEditing) return
|
emitPropertyRequiredToggle()
|
}, [isAdvancedEditing, emitPropertyRequiredToggle])
|
|
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
setCurrentFields(prev => ({ ...prev, description: e.target.value }))
|
}, [])
|
|
const handleDescriptionBlur = useCallback(() => {
|
if (isAdvancedEditing) return
|
emitPropertyOptionsChange({ description: currentFields.description, enum: currentFields.enum })
|
}, [isAdvancedEditing, emitPropertyOptionsChange, currentFields])
|
|
const handleAdvancedOptionsChange = useCallback((options: AdvancedOptionsType) => {
|
let enumValue: any = options.enum
|
if (enumValue === '') {
|
enumValue = undefined
|
}
|
else {
|
enumValue = options.enum.replace(/\s/g, '').split(',')
|
if (currentFields.type === Type.number)
|
enumValue = (enumValue as SchemaEnumType).map(value => Number(value)).filter(num => !Number.isNaN(num))
|
}
|
setCurrentFields(prev => ({ ...prev, enum: enumValue }))
|
if (isAdvancedEditing) return
|
emitPropertyOptionsChange({ description: currentFields.description, enum: enumValue })
|
}, [isAdvancedEditing, emitPropertyOptionsChange, currentFields])
|
|
const handleDelete = useCallback(() => {
|
blurWithActions.current = true
|
emitPropertyDelete()
|
}, [emitPropertyDelete])
|
|
const handleAdvancedEdit = useCallback(() => {
|
setBackupFields({ ...currentFields })
|
setAdvancedEditing(true)
|
}, [currentFields, setAdvancedEditing])
|
|
const handleAddChildField = useCallback(() => {
|
blurWithActions.current = true
|
emitPropertyAdd()
|
}, [emitPropertyAdd])
|
|
const handleConfirm = useCallback(() => {
|
emitFieldChange()
|
}, [emitFieldChange])
|
|
const handleCancel = useCallback(() => {
|
if (isAddingNewField) {
|
blurWithActions.current = true
|
emit('restoreSchema')
|
setIsAddingNewField(false)
|
return
|
}
|
if (backupFields) {
|
setCurrentFields(backupFields)
|
setBackupFields(null)
|
}
|
setAdvancedEditing(false)
|
}, [isAddingNewField, emit, setIsAddingNewField, setAdvancedEditing, backupFields])
|
|
useUnmount(() => {
|
if (isAdvancedEditing || blurWithActions.current) return
|
emitFieldChange()
|
})
|
|
return (
|
<div className='flex flex-col rounded-lg bg-components-panel-bg py-0.5 shadow-sm shadow-shadow-shadow-4'>
|
<div className='flex h-6 items-center pl-1 pr-0.5'>
|
<div className='flex grow items-center gap-x-1'>
|
<AutoWidthInput
|
value={currentFields.name}
|
placeholder={t('workflow.nodes.llm.jsonSchema.fieldNamePlaceholder')}
|
minWidth={80}
|
maxWidth={300}
|
onChange={handlePropertyNameChange}
|
onBlur={handlePropertyNameBlur}
|
/>
|
<TypeSelector
|
currentValue={currentFields.type}
|
items={maximumDepthReached ? MAXIMUM_DEPTH_TYPE_OPTIONS : TYPE_OPTIONS}
|
onSelect={handleTypeChange}
|
popupClassName={'z-[1000]'}
|
/>
|
{
|
currentFields.required && (
|
<div className='system-2xs-medium-uppercase px-1 py-0.5 text-text-warning'>
|
{t('workflow.nodes.llm.jsonSchema.required')}
|
</div>
|
)
|
}
|
</div>
|
<RequiredSwitch
|
defaultValue={currentFields.required}
|
toggleRequired={toggleRequired}
|
/>
|
<Divider type='vertical' className='h-3' />
|
{isAdvancedEditing ? (
|
<AdvancedActions
|
isConfirmDisabled={currentFields.name === ''}
|
onCancel={handleCancel}
|
onConfirm={handleConfirm}
|
/>
|
) : (
|
<Actions
|
disableAddBtn={disableAddBtn}
|
onAddChildField={handleAddChildField}
|
onDelete={handleDelete}
|
onEdit={handleAdvancedEdit}
|
/>
|
)}
|
</div>
|
|
{(fields.description || isAdvancedEditing) && (
|
<div className={classNames('flex', isAdvancedEditing ? 'p-2 pt-1' : 'px-2 pb-1')}>
|
<input
|
value={currentFields.description}
|
className='system-xs-regular placeholder:system-xs-regular h-4 w-full p-0 text-text-tertiary caret-[#295EFF] outline-none placeholder:text-text-placeholder'
|
placeholder={t('workflow.nodes.llm.jsonSchema.descriptionPlaceholder')}
|
onChange={handleDescriptionChange}
|
onBlur={handleDescriptionBlur}
|
onKeyUp={e => e.key === 'Enter' && e.currentTarget.blur()}
|
/>
|
</div>
|
)}
|
|
{isAdvancedEditing && hasAdvancedOptions && (
|
<AdvancedOptions
|
options={advancedOptions}
|
onChange={handleAdvancedOptionsChange}
|
/>
|
)}
|
</div>
|
)
|
}
|
|
export default EditCard
|