mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 01:18:05 +08:00
Merge remote-tracking branch 'origin/main' into feat/support-agent-sandbox
This commit is contained in:
@ -0,0 +1,195 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { ChatVarType } from '../../type'
|
||||
import { useVariableModalState } from '../use-variable-modal-state'
|
||||
|
||||
vi.mock('uuid', () => ({
|
||||
v4: () => 'generated-id',
|
||||
}))
|
||||
|
||||
const createOptions = (overrides: Partial<Parameters<typeof useVariableModalState>[0]> = {}) => ({
|
||||
chatVar: undefined,
|
||||
conversationVariables: [],
|
||||
notify: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
t: (key: string) => key,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useVariableModalState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should build initial state from an existing array object variable', () => {
|
||||
const { result } = renderHook(() => useVariableModalState(createOptions({
|
||||
chatVar: {
|
||||
id: 'var-1',
|
||||
name: 'payload',
|
||||
description: 'desc',
|
||||
value_type: ChatVarType.ArrayObject,
|
||||
value: [{ enabled: true }],
|
||||
},
|
||||
})))
|
||||
|
||||
expect(result.current.name).toBe('payload')
|
||||
expect(result.current.description).toBe('desc')
|
||||
expect(result.current.type).toBe(ChatVarType.ArrayObject)
|
||||
expect(result.current.editInJSON).toBe(true)
|
||||
expect(result.current.editorContent).toBe(JSON.stringify([{ enabled: true }]))
|
||||
})
|
||||
|
||||
it('should update state when changing types and editing scalar values', () => {
|
||||
const { result } = renderHook(() => useVariableModalState(createOptions()))
|
||||
|
||||
act(() => {
|
||||
result.current.handleTypeChange(ChatVarType.Object)
|
||||
})
|
||||
expect(result.current.type).toBe(ChatVarType.Object)
|
||||
expect(result.current.objectValue).toHaveLength(1)
|
||||
|
||||
act(() => {
|
||||
result.current.handleTypeChange(ChatVarType.Number)
|
||||
result.current.handleStringOrNumberChange([12])
|
||||
})
|
||||
expect(result.current.value).toBe(12)
|
||||
|
||||
act(() => {
|
||||
result.current.setDescription('note')
|
||||
result.current.setValue(true)
|
||||
})
|
||||
expect(result.current.description).toBe('note')
|
||||
expect(result.current.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should toggle object values between form and json modes', () => {
|
||||
const { result } = renderHook(() => useVariableModalState(createOptions({
|
||||
chatVar: {
|
||||
id: 'var-2',
|
||||
name: 'config',
|
||||
description: '',
|
||||
value_type: ChatVarType.Object,
|
||||
value: { timeout: 30 },
|
||||
},
|
||||
})))
|
||||
|
||||
act(() => {
|
||||
result.current.handleEditorChange(true)
|
||||
})
|
||||
expect(result.current.editInJSON).toBe(true)
|
||||
expect(result.current.editorContent).toBe(JSON.stringify({ timeout: 30 }))
|
||||
|
||||
act(() => {
|
||||
result.current.handleEditorValueChange('{"timeout":45}')
|
||||
result.current.handleEditorChange(false)
|
||||
})
|
||||
expect(result.current.editInJSON).toBe(false)
|
||||
expect(result.current.objectValue).toEqual([
|
||||
{ key: 'timeout', type: ChatVarType.Number, value: 45 },
|
||||
])
|
||||
})
|
||||
|
||||
it('should reset object form values when leaving empty json mode', () => {
|
||||
const { result } = renderHook(() => useVariableModalState(createOptions({
|
||||
chatVar: {
|
||||
id: 'var-3',
|
||||
name: 'config',
|
||||
description: '',
|
||||
value_type: ChatVarType.Object,
|
||||
value: {},
|
||||
},
|
||||
})))
|
||||
|
||||
act(() => {
|
||||
result.current.handleEditorChange(true)
|
||||
result.current.handleEditorValueChange('')
|
||||
result.current.handleEditorChange(false)
|
||||
})
|
||||
|
||||
expect(result.current.objectValue).toHaveLength(1)
|
||||
expect(result.current.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should handle array editor toggles and invalid json safely', () => {
|
||||
const { result } = renderHook(() => useVariableModalState(createOptions()))
|
||||
|
||||
act(() => {
|
||||
result.current.handleTypeChange(ChatVarType.ArrayString)
|
||||
result.current.setValue(['a', '', 'b'])
|
||||
result.current.handleEditorChange(true)
|
||||
})
|
||||
expect(result.current.editInJSON).toBe(true)
|
||||
expect(result.current.value).toEqual(['a', 'b'])
|
||||
|
||||
act(() => {
|
||||
result.current.handleEditorValueChange('[invalid')
|
||||
})
|
||||
expect(result.current.editorContent).toBe('[invalid')
|
||||
expect(result.current.value).toEqual(['a', 'b'])
|
||||
|
||||
act(() => {
|
||||
result.current.handleEditorChange(false)
|
||||
})
|
||||
expect(result.current.value).toEqual(['a', 'b'])
|
||||
|
||||
act(() => {
|
||||
result.current.handleTypeChange(ChatVarType.ArrayBoolean)
|
||||
result.current.setValue([true, false])
|
||||
result.current.handleEditorChange(true)
|
||||
})
|
||||
expect(result.current.editorContent).toBe(JSON.stringify(['True', 'False']))
|
||||
})
|
||||
|
||||
it('should notify and stop saving when object keys are invalid', () => {
|
||||
const notify = vi.fn()
|
||||
const onSave = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const { result } = renderHook(() => useVariableModalState(createOptions({
|
||||
notify,
|
||||
onClose,
|
||||
onSave,
|
||||
})))
|
||||
|
||||
act(() => {
|
||||
result.current.handleVarNameChange({ target: { value: 'config' } } as ChangeEvent<HTMLInputElement>)
|
||||
result.current.handleTypeChange(ChatVarType.Object)
|
||||
result.current.setObjectValue([{ key: '', type: ChatVarType.String, value: 'secret' }])
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'chatVariable.modal.objectKeyRequired' })
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should save a new variable and close when state is valid', () => {
|
||||
const onSave = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
const { result } = renderHook(() => useVariableModalState(createOptions({
|
||||
onClose,
|
||||
onSave,
|
||||
})))
|
||||
|
||||
act(() => {
|
||||
result.current.handleVarNameChange({ target: { value: 'greeting' } } as ChangeEvent<HTMLInputElement>)
|
||||
result.current.handleStringOrNumberChange(['hello'])
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
description: '',
|
||||
id: 'generated-id',
|
||||
name: 'greeting',
|
||||
value: 'hello',
|
||||
value_type: ChatVarType.String,
|
||||
})
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,123 @@
|
||||
import { ChatVarType } from '../../type'
|
||||
import {
|
||||
buildObjectValueItems,
|
||||
formatChatVariableValue,
|
||||
formatObjectValueFromList,
|
||||
getEditorMinHeight,
|
||||
getEditorToggleLabelKey,
|
||||
getPlaceholderByType,
|
||||
getTypeChangeState,
|
||||
parseEditorContent,
|
||||
validateVariableName,
|
||||
} from '../variable-modal.helpers'
|
||||
|
||||
describe('variable-modal helpers', () => {
|
||||
it('should build object items from a conversation variable value', () => {
|
||||
expect(buildObjectValueItems()).toHaveLength(1)
|
||||
|
||||
expect(buildObjectValueItems({
|
||||
id: 'var-1',
|
||||
name: 'config',
|
||||
description: '',
|
||||
value_type: ChatVarType.Object,
|
||||
value: { apiKey: 'secret', timeout: 30 },
|
||||
})).toEqual([
|
||||
{ key: 'apiKey', type: ChatVarType.String, value: 'secret' },
|
||||
{ key: 'timeout', type: ChatVarType.Number, value: 30 },
|
||||
])
|
||||
})
|
||||
|
||||
it('should format object and array values for saving', () => {
|
||||
expect(formatObjectValueFromList([
|
||||
{ key: 'apiKey', type: ChatVarType.String, value: 'secret' },
|
||||
{ key: '', type: ChatVarType.Number, value: 1 },
|
||||
])).toEqual({ apiKey: 'secret' })
|
||||
|
||||
expect(formatChatVariableValue({
|
||||
editInJSON: false,
|
||||
objectValue: [{ key: 'enabled', type: ChatVarType.String, value: 'true' }],
|
||||
type: ChatVarType.Object,
|
||||
value: undefined,
|
||||
})).toEqual({ enabled: 'true' })
|
||||
|
||||
expect(formatChatVariableValue({
|
||||
editInJSON: true,
|
||||
objectValue: [],
|
||||
type: ChatVarType.Object,
|
||||
value: { count: 1 },
|
||||
})).toEqual({ count: 1 })
|
||||
|
||||
expect(formatChatVariableValue({
|
||||
editInJSON: false,
|
||||
objectValue: [],
|
||||
type: ChatVarType.ArrayString,
|
||||
value: ['a', '', 'b'],
|
||||
})).toEqual(['a', 'b'])
|
||||
|
||||
expect(formatChatVariableValue({
|
||||
editInJSON: false,
|
||||
objectValue: [],
|
||||
type: ChatVarType.Number,
|
||||
value: undefined,
|
||||
})).toBe(0)
|
||||
|
||||
expect(formatChatVariableValue({
|
||||
editInJSON: false,
|
||||
objectValue: [],
|
||||
type: ChatVarType.Boolean,
|
||||
value: undefined,
|
||||
})).toBe(true)
|
||||
|
||||
expect(formatChatVariableValue({
|
||||
editInJSON: false,
|
||||
objectValue: [],
|
||||
type: ChatVarType.ArrayBoolean,
|
||||
value: undefined,
|
||||
})).toEqual([])
|
||||
})
|
||||
|
||||
it('should derive placeholders, editor defaults, and editor toggle labels', () => {
|
||||
expect(getEditorMinHeight(ChatVarType.ArrayObject)).toBe('240px')
|
||||
expect(getEditorMinHeight(ChatVarType.Object)).toBe('120px')
|
||||
expect(getPlaceholderByType(ChatVarType.ArrayBoolean)).toBeTruthy()
|
||||
expect(getTypeChangeState(ChatVarType.Boolean).value).toBe(false)
|
||||
expect(getTypeChangeState(ChatVarType.ArrayBoolean).value).toEqual([false])
|
||||
expect(getTypeChangeState(ChatVarType.Object).objectValue).toHaveLength(1)
|
||||
expect(getTypeChangeState(ChatVarType.ArrayObject).editInJSON).toBe(true)
|
||||
expect(getEditorToggleLabelKey(ChatVarType.Object, true)).toBe('chatVariable.modal.editInForm')
|
||||
expect(getEditorToggleLabelKey(ChatVarType.ArrayString, false)).toBe('chatVariable.modal.editInJSON')
|
||||
})
|
||||
|
||||
it('should parse boolean arrays from JSON editor content', () => {
|
||||
expect(parseEditorContent({
|
||||
content: '["True","false",true,false,"invalid"]',
|
||||
type: ChatVarType.ArrayBoolean,
|
||||
})).toEqual([true, false, true, false])
|
||||
|
||||
expect(parseEditorContent({
|
||||
content: '{"enabled":true}',
|
||||
type: ChatVarType.Object,
|
||||
})).toEqual({ enabled: true })
|
||||
})
|
||||
|
||||
it('should validate variable names and notify when invalid', () => {
|
||||
const notify = vi.fn()
|
||||
const t = (key: string) => key
|
||||
|
||||
expect(validateVariableName({
|
||||
name: 'valid_name',
|
||||
notify,
|
||||
t,
|
||||
})).toBe(true)
|
||||
|
||||
expect(validateVariableName({
|
||||
name: '1invalid',
|
||||
notify,
|
||||
t,
|
||||
})).toBe(false)
|
||||
|
||||
expect(notify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'error',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,198 @@
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
|
||||
import { ChatVarType } from '../../type'
|
||||
import VariableModal from '../variable-modal'
|
||||
|
||||
vi.mock('uuid', () => ({
|
||||
v4: () => 'generated-id',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const renderVariableModal = (props?: Partial<React.ComponentProps<typeof VariableModal>>) => {
|
||||
const onClose = vi.fn()
|
||||
const onSave = vi.fn()
|
||||
|
||||
const result = renderWorkflowComponent(
|
||||
React.createElement(
|
||||
VariableModal,
|
||||
{
|
||||
onClose,
|
||||
onSave,
|
||||
...props,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
return { ...result, onClose, onSave }
|
||||
}
|
||||
|
||||
describe('variable-modal', () => {
|
||||
const mockToastError = vi.mocked(toast.error)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should create a new string variable and close after saving', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onClose, onSave } = renderVariableModal()
|
||||
|
||||
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'greeting')
|
||||
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.valuePlaceholder'), 'hello')
|
||||
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.descriptionPlaceholder'), 'demo variable')
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
id: 'generated-id',
|
||||
name: 'greeting',
|
||||
value_type: ChatVarType.String,
|
||||
value: 'hello',
|
||||
description: 'demo variable',
|
||||
})
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject duplicate variable names from the workflow store', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSave, store } = renderVariableModal()
|
||||
|
||||
store.setState({
|
||||
conversationVariables: [{
|
||||
id: 'var-1',
|
||||
name: 'existing_name',
|
||||
description: '',
|
||||
value_type: ChatVarType.String,
|
||||
value: '',
|
||||
}],
|
||||
})
|
||||
|
||||
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'existing_name')
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
|
||||
expect(mockToastError.mock.calls.at(-1)?.[0]).toBe('name is existed')
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should load an existing object variable and save object values edited in form mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSave } = renderVariableModal({
|
||||
chatVar: {
|
||||
id: 'var-2',
|
||||
name: 'config',
|
||||
description: 'settings',
|
||||
value_type: ChatVarType.Object,
|
||||
value: { apiKey: 'secret', timeout: 30 },
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByDisplayValue('config')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('secret')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('30')).toBeInTheDocument()
|
||||
|
||||
await user.clear(screen.getByDisplayValue('secret'))
|
||||
await user.type(screen.getByDisplayValue('30'), '5')
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
id: 'var-2',
|
||||
name: 'config',
|
||||
value_type: ChatVarType.Object,
|
||||
value: {
|
||||
apiKey: null,
|
||||
timeout: 305,
|
||||
},
|
||||
description: 'settings',
|
||||
})
|
||||
})
|
||||
|
||||
it('should switch types and use default values for boolean arrays', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSave } = renderVariableModal()
|
||||
|
||||
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'flags')
|
||||
await user.click(screen.getByText('string'))
|
||||
await user.click(screen.getByText('array[boolean]'))
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
id: 'generated-id',
|
||||
name: 'flags',
|
||||
value_type: ChatVarType.ArrayBoolean,
|
||||
value: [false],
|
||||
description: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('should toggle object editing modes without changing behavior', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderVariableModal({
|
||||
chatVar: {
|
||||
id: 'var-3',
|
||||
name: 'payload',
|
||||
description: '',
|
||||
value_type: ChatVarType.Object,
|
||||
value: { enabled: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('workflow.chatVariable.modal.editInJSON'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByText('workflow.chatVariable.modal.editInForm'))
|
||||
expect(screen.getByDisplayValue('enabled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should validate variable names on blur and preserve underscore replacement', () => {
|
||||
renderVariableModal()
|
||||
const input = screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'bad name' } })
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect((input as HTMLInputElement).value).toBe('bad_name')
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop invalid variable names before they are stored in local state', async () => {
|
||||
const { onSave } = renderVariableModal()
|
||||
const input = screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder') as HTMLInputElement
|
||||
|
||||
fireEvent.change(input, { target: { value: '1bad' } })
|
||||
await userEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
expect(input.value).toBe('')
|
||||
expect(mockToastError).toHaveBeenCalled()
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should edit number variables through the value input', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSave } = renderVariableModal()
|
||||
|
||||
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'timeout')
|
||||
await user.click(screen.getByText('string'))
|
||||
await user.click(screen.getByText('number'))
|
||||
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.valuePlaceholder'), '3')
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
id: 'generated-id',
|
||||
name: 'timeout',
|
||||
value_type: ChatVarType.Number,
|
||||
value: 3,
|
||||
description: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,234 @@
|
||||
import type { ObjectValueItem, ToastPayload } from './variable-modal.helpers'
|
||||
import type { ConversationVariable } from '@/app/components/workflow/types'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { v4 as uuid4 } from 'uuid'
|
||||
import { DEFAULT_OBJECT_VALUE } from '@/app/components/workflow/panel/chat-variable-panel/components/object-value-item'
|
||||
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
|
||||
import {
|
||||
buildObjectValueItems,
|
||||
formatChatVariableValue,
|
||||
formatObjectValueFromList,
|
||||
getEditorMinHeight,
|
||||
getPlaceholderByType,
|
||||
getTypeChangeState,
|
||||
parseEditorContent,
|
||||
validateVariableName,
|
||||
} from './variable-modal.helpers'
|
||||
|
||||
type UseVariableModalStateOptions = {
|
||||
chatVar?: ConversationVariable
|
||||
conversationVariables: ConversationVariable[]
|
||||
notify: (props: ToastPayload) => void
|
||||
onClose: () => void
|
||||
onSave: (chatVar: ConversationVariable) => void
|
||||
t: (key: string, options?: Record<string, unknown>) => string
|
||||
}
|
||||
|
||||
type VariableModalState = {
|
||||
description: string
|
||||
editInJSON: boolean
|
||||
editorContent?: string
|
||||
name: string
|
||||
objectValue: ObjectValueItem[]
|
||||
type: ChatVarType
|
||||
value: unknown
|
||||
}
|
||||
|
||||
const buildObjectValueListFromRecord = (record: Record<string, string | number>) => {
|
||||
return Object.keys(record).map(key => ({
|
||||
key,
|
||||
type: typeof record[key] === 'string' ? ChatVarType.String : ChatVarType.Number,
|
||||
value: record[key],
|
||||
}))
|
||||
}
|
||||
|
||||
const buildInitialState = (chatVar?: ConversationVariable): VariableModalState => {
|
||||
if (!chatVar) {
|
||||
return {
|
||||
description: '',
|
||||
editInJSON: false,
|
||||
editorContent: undefined,
|
||||
name: '',
|
||||
objectValue: [DEFAULT_OBJECT_VALUE],
|
||||
type: ChatVarType.String,
|
||||
value: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
description: chatVar.description,
|
||||
editInJSON: chatVar.value_type === ChatVarType.ArrayObject,
|
||||
editorContent: chatVar.value_type === ChatVarType.ArrayObject ? JSON.stringify(chatVar.value) : undefined,
|
||||
name: chatVar.name,
|
||||
objectValue: buildObjectValueItems(chatVar),
|
||||
type: chatVar.value_type,
|
||||
value: chatVar.value,
|
||||
}
|
||||
}
|
||||
|
||||
export const useVariableModalState = ({
|
||||
chatVar,
|
||||
conversationVariables,
|
||||
notify,
|
||||
onClose,
|
||||
onSave,
|
||||
t,
|
||||
}: UseVariableModalStateOptions) => {
|
||||
const [state, setState] = useState<VariableModalState>(() => buildInitialState(chatVar))
|
||||
|
||||
const editorMinHeight = useMemo(() => getEditorMinHeight(state.type), [state.type])
|
||||
const placeholder = useMemo(() => getPlaceholderByType(state.type), [state.type])
|
||||
|
||||
const handleVarNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setState(prev => ({ ...prev, name: e.target.value || '' }))
|
||||
}
|
||||
|
||||
const handleTypeChange = (nextType: ChatVarType) => {
|
||||
const nextState = getTypeChangeState(nextType)
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
editInJSON: nextState.editInJSON,
|
||||
editorContent: nextState.editorContent,
|
||||
objectValue: nextState.objectValue ?? prev.objectValue,
|
||||
type: nextType,
|
||||
value: nextState.value,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleStringOrNumberChange = (nextValue: Array<string | number | undefined>) => {
|
||||
setState(prev => ({ ...prev, value: nextValue[0] }))
|
||||
}
|
||||
|
||||
const handleEditorChange = (nextEditInJSON: boolean) => {
|
||||
setState((prev) => {
|
||||
const nextState: VariableModalState = {
|
||||
...prev,
|
||||
editInJSON: nextEditInJSON,
|
||||
}
|
||||
|
||||
if (prev.type === ChatVarType.Object) {
|
||||
if (nextEditInJSON) {
|
||||
const nextValue = !prev.objectValue[0].key ? undefined : formatObjectValueFromList(prev.objectValue)
|
||||
nextState.value = nextValue
|
||||
nextState.editorContent = JSON.stringify(nextValue)
|
||||
return nextState
|
||||
}
|
||||
|
||||
if (!prev.editorContent) {
|
||||
nextState.value = undefined
|
||||
nextState.objectValue = [DEFAULT_OBJECT_VALUE]
|
||||
return nextState
|
||||
}
|
||||
|
||||
try {
|
||||
const nextValue = JSON.parse(prev.editorContent) as Record<string, string | number>
|
||||
nextState.value = nextValue
|
||||
nextState.objectValue = buildObjectValueListFromRecord(nextValue)
|
||||
}
|
||||
catch {
|
||||
// ignore JSON.parse errors
|
||||
}
|
||||
return nextState
|
||||
}
|
||||
|
||||
if (prev.type === ChatVarType.ArrayString || prev.type === ChatVarType.ArrayNumber) {
|
||||
if (nextEditInJSON) {
|
||||
const nextValue = (Array.isArray(prev.value) && prev.value.length && prev.value.filter(Boolean).length)
|
||||
? prev.value.filter(Boolean)
|
||||
: undefined
|
||||
nextState.value = nextValue
|
||||
if (!prev.editorContent)
|
||||
nextState.editorContent = JSON.stringify(nextValue)
|
||||
return nextState
|
||||
}
|
||||
|
||||
nextState.value = Array.isArray(prev.value) && prev.value.length ? prev.value : [undefined]
|
||||
return nextState
|
||||
}
|
||||
|
||||
if (prev.type === ChatVarType.ArrayBoolean && Array.isArray(prev.value) && nextEditInJSON)
|
||||
nextState.editorContent = JSON.stringify(prev.value.map(item => item ? 'True' : 'False'))
|
||||
|
||||
return nextState
|
||||
})
|
||||
}
|
||||
|
||||
const handleEditorValueChange = (content: string) => {
|
||||
setState((prev) => {
|
||||
const nextState: VariableModalState = {
|
||||
...prev,
|
||||
editorContent: content,
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
nextState.value = undefined
|
||||
return nextState
|
||||
}
|
||||
|
||||
try {
|
||||
nextState.value = parseEditorContent({ content, type: prev.type })
|
||||
}
|
||||
catch {
|
||||
// ignore JSON.parse errors
|
||||
}
|
||||
|
||||
return nextState
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!validateVariableName({ name: state.name, notify, t }))
|
||||
return
|
||||
|
||||
if (!chatVar && conversationVariables.some(item => item.name === state.name)) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: t('chatVariable.modal.name', { ns: 'workflow' }) }),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (state.type === ChatVarType.Object && state.objectValue.some(item => !item.key && !!item.value)) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t('chatVariable.modal.objectKeyRequired', { ns: 'workflow' }),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
onSave({
|
||||
description: state.description,
|
||||
id: chatVar ? chatVar.id : uuid4(),
|
||||
name: state.name,
|
||||
value: formatChatVariableValue({
|
||||
editInJSON: state.editInJSON,
|
||||
objectValue: state.objectValue,
|
||||
type: state.type,
|
||||
value: state.value,
|
||||
}),
|
||||
value_type: state.type,
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
return {
|
||||
description: state.description,
|
||||
editInJSON: state.editInJSON,
|
||||
editorContent: state.editorContent,
|
||||
editorMinHeight,
|
||||
handleEditorChange,
|
||||
handleEditorValueChange,
|
||||
handleSave,
|
||||
handleStringOrNumberChange,
|
||||
handleTypeChange,
|
||||
handleVarNameChange,
|
||||
name: state.name,
|
||||
objectValue: state.objectValue,
|
||||
placeholder,
|
||||
setDescription: (description: string) => setState(prev => ({ ...prev, description })),
|
||||
setObjectValue: (objectValue: ObjectValueItem[]) => setState(prev => ({ ...prev, objectValue })),
|
||||
setValue: (value: unknown) => setState(prev => ({ ...prev, value })),
|
||||
type: state.type,
|
||||
value: state.value,
|
||||
}
|
||||
}
|
||||
@ -57,6 +57,7 @@ const VariableModalTrigger = ({
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[11]">
|
||||
<VariableModal
|
||||
key={chatVar?.id ?? 'new'}
|
||||
chatVar={chatVar}
|
||||
onSave={onSave}
|
||||
onClose={() => {
|
||||
|
||||
@ -0,0 +1,170 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ChatVarType } from '../type'
|
||||
import type { ConversationVariable } from '@/app/components/workflow/types'
|
||||
import { checkKeys } from '@/utils/var'
|
||||
import { ChatVarType as ChatVarTypeEnum } from '../type'
|
||||
import {
|
||||
arrayBoolPlaceholder,
|
||||
arrayNumberPlaceholder,
|
||||
arrayObjectPlaceholder,
|
||||
arrayStringPlaceholder,
|
||||
objectPlaceholder,
|
||||
} from '../utils'
|
||||
import { DEFAULT_OBJECT_VALUE } from './object-value-item'
|
||||
|
||||
export type ObjectValueItem = {
|
||||
key: string
|
||||
type: ChatVarType
|
||||
value: string | number | undefined
|
||||
}
|
||||
|
||||
export type ToastPayload = {
|
||||
type?: 'success' | 'error' | 'warning' | 'info'
|
||||
size?: 'md' | 'sm'
|
||||
duration?: number
|
||||
message: string
|
||||
children?: ReactNode
|
||||
onClose?: () => void
|
||||
className?: string
|
||||
customComponent?: ReactNode
|
||||
}
|
||||
|
||||
export const typeList = [
|
||||
ChatVarTypeEnum.String,
|
||||
ChatVarTypeEnum.Number,
|
||||
ChatVarTypeEnum.Boolean,
|
||||
ChatVarTypeEnum.Object,
|
||||
ChatVarTypeEnum.ArrayString,
|
||||
ChatVarTypeEnum.ArrayNumber,
|
||||
ChatVarTypeEnum.ArrayBoolean,
|
||||
ChatVarTypeEnum.ArrayObject,
|
||||
]
|
||||
|
||||
export const getEditorMinHeight = (type: ChatVarType) =>
|
||||
type === ChatVarTypeEnum.ArrayObject ? '240px' : '120px'
|
||||
|
||||
export const getPlaceholderByType = (type: ChatVarType) => {
|
||||
if (type === ChatVarTypeEnum.ArrayString)
|
||||
return arrayStringPlaceholder
|
||||
if (type === ChatVarTypeEnum.ArrayNumber)
|
||||
return arrayNumberPlaceholder
|
||||
if (type === ChatVarTypeEnum.ArrayObject)
|
||||
return arrayObjectPlaceholder
|
||||
if (type === ChatVarTypeEnum.ArrayBoolean)
|
||||
return arrayBoolPlaceholder
|
||||
return objectPlaceholder
|
||||
}
|
||||
|
||||
export const buildObjectValueItems = (chatVar?: ConversationVariable): ObjectValueItem[] => {
|
||||
if (!chatVar || !chatVar.value || Object.keys(chatVar.value).length === 0)
|
||||
return [DEFAULT_OBJECT_VALUE]
|
||||
|
||||
return Object.keys(chatVar.value).map((key) => {
|
||||
const itemValue = chatVar.value[key]
|
||||
return {
|
||||
key,
|
||||
type: typeof itemValue === 'string' ? ChatVarTypeEnum.String : ChatVarTypeEnum.Number,
|
||||
value: itemValue,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const formatObjectValueFromList = (list: ObjectValueItem[]) => {
|
||||
return list.reduce<Record<string, string | number | null>>((acc, curr) => {
|
||||
if (curr.key)
|
||||
acc[curr.key] = curr.value || null
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
export const formatChatVariableValue = ({
|
||||
editInJSON,
|
||||
objectValue,
|
||||
type,
|
||||
value,
|
||||
}: {
|
||||
editInJSON: boolean
|
||||
objectValue: ObjectValueItem[]
|
||||
type: ChatVarType
|
||||
value: unknown
|
||||
}) => {
|
||||
switch (type) {
|
||||
case ChatVarTypeEnum.String:
|
||||
return value || ''
|
||||
case ChatVarTypeEnum.Number:
|
||||
return value || 0
|
||||
case ChatVarTypeEnum.Boolean:
|
||||
return value === undefined ? true : value
|
||||
case ChatVarTypeEnum.Object:
|
||||
return editInJSON ? value : formatObjectValueFromList(objectValue)
|
||||
case ChatVarTypeEnum.ArrayString:
|
||||
case ChatVarTypeEnum.ArrayNumber:
|
||||
case ChatVarTypeEnum.ArrayObject:
|
||||
return Array.isArray(value) ? value.filter(Boolean) : []
|
||||
case ChatVarTypeEnum.ArrayBoolean:
|
||||
return value || []
|
||||
}
|
||||
}
|
||||
|
||||
export const validateVariableName = ({
|
||||
name,
|
||||
notify,
|
||||
t,
|
||||
}: {
|
||||
name: string
|
||||
notify: (props: ToastPayload) => void
|
||||
t: (key: string, options?: Record<string, unknown>) => string
|
||||
}) => {
|
||||
const { isValid, errorMessageKey } = checkKeys([name], false)
|
||||
if (!isValid) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t(`varKeyError.${errorMessageKey}`, { ns: 'appDebug', key: t('env.modal.name', { ns: 'workflow' }) }),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const getTypeChangeState = (nextType: ChatVarType) => {
|
||||
return {
|
||||
editInJSON: nextType === ChatVarTypeEnum.ArrayObject,
|
||||
editorContent: undefined as string | undefined,
|
||||
objectValue: nextType === ChatVarTypeEnum.Object ? [DEFAULT_OBJECT_VALUE] : undefined,
|
||||
value:
|
||||
nextType === ChatVarTypeEnum.Boolean
|
||||
? false
|
||||
: nextType === ChatVarTypeEnum.ArrayBoolean
|
||||
? [false]
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const parseEditorContent = ({
|
||||
content,
|
||||
type,
|
||||
}: {
|
||||
content: string
|
||||
type: ChatVarType
|
||||
}) => {
|
||||
const parsed = JSON.parse(content)
|
||||
if (type !== ChatVarTypeEnum.ArrayBoolean)
|
||||
return parsed
|
||||
|
||||
return parsed
|
||||
.map((item: string | boolean) => {
|
||||
if (item === 'True' || item === 'true' || item === true)
|
||||
return true
|
||||
if (item === 'False' || item === 'false' || item === false)
|
||||
return false
|
||||
return undefined
|
||||
})
|
||||
.filter((item?: boolean) => item !== undefined)
|
||||
}
|
||||
|
||||
export const getEditorToggleLabelKey = (type: ChatVarType, editInJSON: boolean) => {
|
||||
if (type === ChatVarTypeEnum.Object)
|
||||
return editInJSON ? 'chatVariable.modal.editInForm' : 'chatVariable.modal.editInJSON'
|
||||
|
||||
return editInJSON ? 'chatVariable.modal.oneByOne' : 'chatVariable.modal.editInJSON'
|
||||
}
|
||||
@ -0,0 +1,217 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ObjectValueItem } from './variable-modal.helpers'
|
||||
import { RiDraftLine, RiInputField } from '@remixicon/react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import { ChatVarType } from '../type'
|
||||
import ArrayBoolList from './array-bool-list'
|
||||
import ArrayValueList from './array-value-list'
|
||||
import BoolValue from './bool-value'
|
||||
import ObjectValueList from './object-value-list'
|
||||
import VariableTypeSelector from './variable-type-select'
|
||||
|
||||
type SectionTitleProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const SectionTitle = ({ children }: SectionTitleProps) => (
|
||||
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{children}</div>
|
||||
)
|
||||
|
||||
type NameSectionProps = {
|
||||
name: string
|
||||
onBlur: (value: string) => void
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
placeholder: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export const NameSection = ({
|
||||
name,
|
||||
onBlur,
|
||||
onChange,
|
||||
placeholder,
|
||||
title,
|
||||
}: NameSectionProps) => (
|
||||
<div className="mb-4">
|
||||
<SectionTitle>{title}</SectionTitle>
|
||||
<div className="flex">
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={name}
|
||||
onChange={onChange}
|
||||
onBlur={e => onBlur(e.target.value)}
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
type TypeSectionProps = {
|
||||
list: ChatVarType[]
|
||||
onSelect: (value: ChatVarType) => void
|
||||
title: string
|
||||
type: ChatVarType
|
||||
}
|
||||
|
||||
export const TypeSection = ({
|
||||
list,
|
||||
onSelect,
|
||||
title,
|
||||
type,
|
||||
}: TypeSectionProps) => (
|
||||
<div className="mb-4">
|
||||
<SectionTitle>{title}</SectionTitle>
|
||||
<div className="flex">
|
||||
<VariableTypeSelector
|
||||
value={type}
|
||||
list={list}
|
||||
onSelect={onSelect}
|
||||
popupClassName="w-[327px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
type ValueSectionProps = {
|
||||
editorContent?: string
|
||||
editorMinHeight: string
|
||||
editInJSON: boolean
|
||||
objectValue: ObjectValueItem[]
|
||||
onArrayBoolChange: (value: boolean[]) => void
|
||||
onArrayChange: (value: Array<string | number | undefined>) => void
|
||||
onEditorChange: (nextEditInJson: boolean) => void
|
||||
onEditorValueChange: (content: string) => void
|
||||
onObjectChange: (value: ObjectValueItem[]) => void
|
||||
onValueChange: (value: boolean) => void
|
||||
placeholder: ReactNode
|
||||
t: (key: string, options?: Record<string, unknown>) => string
|
||||
toggleLabelKey?: string
|
||||
type: ChatVarType
|
||||
value: unknown
|
||||
}
|
||||
|
||||
export const ValueSection = ({
|
||||
editorContent,
|
||||
editorMinHeight,
|
||||
editInJSON,
|
||||
objectValue,
|
||||
onArrayBoolChange,
|
||||
onArrayChange,
|
||||
onEditorChange,
|
||||
onEditorValueChange,
|
||||
onObjectChange,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
t,
|
||||
toggleLabelKey,
|
||||
type,
|
||||
value,
|
||||
}: ValueSectionProps) => (
|
||||
<div className="mb-4">
|
||||
<div className="mb-1 flex h-6 items-center justify-between text-text-secondary system-sm-semibold">
|
||||
<div>{t('chatVariable.modal.value', { ns: 'workflow' })}</div>
|
||||
{toggleLabelKey && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
className="text-text-tertiary"
|
||||
onClick={() => onEditorChange(!editInJSON)}
|
||||
>
|
||||
{editInJSON ? <RiInputField className="mr-1 h-3.5 w-3.5" /> : <RiDraftLine className="mr-1 h-3.5 w-3.5" />}
|
||||
{t(toggleLabelKey, { ns: 'workflow' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex">
|
||||
{type === ChatVarType.String && (
|
||||
<textarea
|
||||
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
|
||||
value={(value as string) || ''}
|
||||
placeholder={t('chatVariable.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
|
||||
onChange={e => onArrayChange([e.target.value])}
|
||||
/>
|
||||
)}
|
||||
{type === ChatVarType.Number && (
|
||||
<Input
|
||||
placeholder={t('chatVariable.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
|
||||
value={value as number | undefined}
|
||||
onChange={e => onArrayChange([Number(e.target.value)])}
|
||||
type="number"
|
||||
/>
|
||||
)}
|
||||
{type === ChatVarType.Boolean && (
|
||||
<BoolValue
|
||||
value={value as boolean}
|
||||
onChange={onValueChange}
|
||||
/>
|
||||
)}
|
||||
{type === ChatVarType.Object && !editInJSON && (
|
||||
<ObjectValueList
|
||||
list={objectValue}
|
||||
onChange={onObjectChange}
|
||||
/>
|
||||
)}
|
||||
{type === ChatVarType.ArrayString && !editInJSON && (
|
||||
<ArrayValueList
|
||||
isString
|
||||
list={(value as Array<string | undefined>) || [undefined]}
|
||||
onChange={onArrayChange}
|
||||
/>
|
||||
)}
|
||||
{type === ChatVarType.ArrayNumber && !editInJSON && (
|
||||
<ArrayValueList
|
||||
isString={false}
|
||||
list={(value as Array<number | undefined>) || [undefined]}
|
||||
onChange={onArrayChange}
|
||||
/>
|
||||
)}
|
||||
{type === ChatVarType.ArrayBoolean && !editInJSON && (
|
||||
<ArrayBoolList
|
||||
list={(value as boolean[]) || [true]}
|
||||
onChange={onArrayBoolChange}
|
||||
/>
|
||||
)}
|
||||
{editInJSON && (
|
||||
<div className="w-full rounded-[10px] bg-components-input-bg-normal py-2 pl-3 pr-1" style={{ height: editorMinHeight }}>
|
||||
<CodeEditor
|
||||
isExpand
|
||||
noWrapper
|
||||
language={CodeLanguage.json}
|
||||
value={editorContent}
|
||||
placeholder={<div className="whitespace-pre">{placeholder}</div>}
|
||||
onChange={onEditorValueChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
type DescriptionSectionProps = {
|
||||
description: string
|
||||
onChange: (value: string) => void
|
||||
placeholder: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export const DescriptionSection = ({
|
||||
description,
|
||||
onChange,
|
||||
placeholder,
|
||||
title,
|
||||
}: DescriptionSectionProps) => (
|
||||
<div>
|
||||
<SectionTitle>{title}</SectionTitle>
|
||||
<div className="flex">
|
||||
<textarea
|
||||
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
|
||||
value={description}
|
||||
placeholder={placeholder}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -1,31 +1,26 @@
|
||||
import type { ToastPayload } from './variable-modal.helpers'
|
||||
import type { ConversationVariable } from '@/app/components/workflow/types'
|
||||
import { RiCloseLine, RiDraftLine, RiInputField } from '@remixicon/react'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { v4 as uuid4 } from 'uuid'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import ArrayValueList from '@/app/components/workflow/panel/chat-variable-panel/components/array-value-list'
|
||||
import { DEFAULT_OBJECT_VALUE } from '@/app/components/workflow/panel/chat-variable-panel/components/object-value-item'
|
||||
import ObjectValueList from '@/app/components/workflow/panel/chat-variable-panel/components/object-value-list'
|
||||
import VariableTypeSelector from '@/app/components/workflow/panel/chat-variable-panel/components/variable-type-select'
|
||||
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
|
||||
import {
|
||||
arrayBoolPlaceholder,
|
||||
arrayNumberPlaceholder,
|
||||
arrayObjectPlaceholder,
|
||||
arrayStringPlaceholder,
|
||||
objectPlaceholder,
|
||||
} from '@/app/components/workflow/panel/chat-variable-panel/utils'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { checkKeys, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
|
||||
import ArrayBoolList from './array-bool-list'
|
||||
import BoolValue from './bool-value'
|
||||
import { replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
|
||||
import { useVariableModalState } from './use-variable-modal-state'
|
||||
import {
|
||||
getEditorToggleLabelKey,
|
||||
typeList,
|
||||
validateVariableName,
|
||||
} from './variable-modal.helpers'
|
||||
import {
|
||||
DescriptionSection,
|
||||
NameSection,
|
||||
TypeSection,
|
||||
ValueSection,
|
||||
} from './variable-modal.sections'
|
||||
|
||||
export type ModalPropsType = {
|
||||
chatVar?: ConversationVariable
|
||||
@ -33,23 +28,6 @@ export type ModalPropsType = {
|
||||
onSave: (chatVar: ConversationVariable) => void
|
||||
}
|
||||
|
||||
type ObjectValueItem = {
|
||||
key: string
|
||||
type: ChatVarType
|
||||
value: string | number | undefined
|
||||
}
|
||||
|
||||
const typeList = [
|
||||
ChatVarType.String,
|
||||
ChatVarType.Number,
|
||||
ChatVarType.Boolean,
|
||||
ChatVarType.Object,
|
||||
ChatVarType.ArrayString,
|
||||
ChatVarType.ArrayNumber,
|
||||
ChatVarType.ArrayBoolean,
|
||||
ChatVarType.ArrayObject,
|
||||
]
|
||||
|
||||
const ChatVariableModal = ({
|
||||
chatVar,
|
||||
onClose,
|
||||
@ -57,215 +35,43 @@ const ChatVariableModal = ({
|
||||
}: ModalPropsType) => {
|
||||
const { t } = useTranslation()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const [name, setName] = React.useState('')
|
||||
const [type, setType] = React.useState<ChatVarType>(ChatVarType.String)
|
||||
const [value, setValue] = React.useState<any>()
|
||||
const [objectValue, setObjectValue] = React.useState<ObjectValueItem[]>([DEFAULT_OBJECT_VALUE])
|
||||
const [editorContent, setEditorContent] = React.useState<string>()
|
||||
const [editInJSON, setEditInJSON] = React.useState(false)
|
||||
const [description, setDescription] = React.useState<string>('')
|
||||
|
||||
const editorMinHeight = useMemo(() => {
|
||||
if (type === ChatVarType.ArrayObject)
|
||||
return '240px'
|
||||
return '120px'
|
||||
}, [type])
|
||||
const placeholder = useMemo(() => {
|
||||
if (type === ChatVarType.ArrayString)
|
||||
return arrayStringPlaceholder
|
||||
if (type === ChatVarType.ArrayNumber)
|
||||
return arrayNumberPlaceholder
|
||||
if (type === ChatVarType.ArrayObject)
|
||||
return arrayObjectPlaceholder
|
||||
if (type === ChatVarType.ArrayBoolean)
|
||||
return arrayBoolPlaceholder
|
||||
return objectPlaceholder
|
||||
}, [type])
|
||||
const getObjectValue = useCallback(() => {
|
||||
const raw = chatVar?.value
|
||||
if (!chatVar || raw === null || typeof raw !== 'object' || Array.isArray(raw) || Object.keys(raw).length === 0)
|
||||
return [DEFAULT_OBJECT_VALUE]
|
||||
|
||||
return Object.keys(raw).map((key) => {
|
||||
const v = raw[key]
|
||||
const isStr = typeof v === 'string'
|
||||
const isNum = typeof v === 'number'
|
||||
return {
|
||||
key,
|
||||
type: isStr ? ChatVarType.String : ChatVarType.Number,
|
||||
value: isStr || isNum ? v : undefined,
|
||||
}
|
||||
})
|
||||
}, [chatVar])
|
||||
const formatValueFromObject = useCallback((list: ObjectValueItem[]) => {
|
||||
return list.reduce((acc: any, curr) => {
|
||||
if (curr.key)
|
||||
acc[curr.key] = curr.value || null
|
||||
return acc
|
||||
}, {})
|
||||
const notify = React.useCallback(({ children, message, type = 'info' }: ToastPayload) => {
|
||||
toast[type](message, children ? { description: children } : undefined)
|
||||
}, [])
|
||||
const {
|
||||
description,
|
||||
editInJSON,
|
||||
editorContent,
|
||||
editorMinHeight,
|
||||
handleEditorChange,
|
||||
handleEditorValueChange,
|
||||
handleSave,
|
||||
handleStringOrNumberChange,
|
||||
handleTypeChange,
|
||||
handleVarNameChange,
|
||||
name,
|
||||
objectValue,
|
||||
placeholder,
|
||||
setDescription,
|
||||
setObjectValue,
|
||||
setValue,
|
||||
type,
|
||||
value,
|
||||
} = useVariableModalState({
|
||||
chatVar,
|
||||
conversationVariables: workflowStore.getState().conversationVariables,
|
||||
notify,
|
||||
onClose,
|
||||
onSave,
|
||||
t,
|
||||
})
|
||||
|
||||
const formatValue = (value: any) => {
|
||||
switch (type) {
|
||||
case ChatVarType.String:
|
||||
return value || ''
|
||||
case ChatVarType.Number:
|
||||
return value || 0
|
||||
case ChatVarType.Boolean:
|
||||
return value === undefined ? true : value
|
||||
case ChatVarType.Object:
|
||||
return editInJSON ? value : formatValueFromObject(objectValue)
|
||||
case ChatVarType.ArrayString:
|
||||
case ChatVarType.ArrayNumber:
|
||||
case ChatVarType.ArrayObject:
|
||||
return value?.filter(Boolean) || []
|
||||
case ChatVarType.ArrayBoolean:
|
||||
return value || []
|
||||
}
|
||||
}
|
||||
|
||||
const checkVariableName = (value: string) => {
|
||||
const { isValid, errorMessageKey } = checkKeys([value], false)
|
||||
if (!isValid) {
|
||||
toast.error(t(`varKeyError.${errorMessageKey}`, { ns: 'appDebug', key: t('env.modal.name', { ns: 'workflow' }) }))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handleVarNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
replaceSpaceWithUnderscoreInVarNameInput(e.target)
|
||||
if (!!e.target.value && !checkVariableName(e.target.value))
|
||||
if (e.target.value && !validateVariableName({ name: e.target.value, notify, t }))
|
||||
return
|
||||
setName(e.target.value || '')
|
||||
handleVarNameChange(e)
|
||||
}
|
||||
|
||||
const handleTypeChange = (v: ChatVarType) => {
|
||||
setValue(undefined)
|
||||
setEditorContent(undefined)
|
||||
if (v === ChatVarType.ArrayObject)
|
||||
setEditInJSON(true)
|
||||
if (v === ChatVarType.String || v === ChatVarType.Number || v === ChatVarType.Object)
|
||||
setEditInJSON(false)
|
||||
if (v === ChatVarType.Boolean)
|
||||
setValue(false)
|
||||
if (v === ChatVarType.ArrayBoolean)
|
||||
setValue([false])
|
||||
setType(v)
|
||||
}
|
||||
|
||||
const handleEditorChange = (editInJSON: boolean) => {
|
||||
if (type === ChatVarType.Object) {
|
||||
if (editInJSON) {
|
||||
const newValue = !objectValue[0].key ? undefined : formatValueFromObject(objectValue)
|
||||
setValue(newValue)
|
||||
setEditorContent(JSON.stringify(newValue))
|
||||
}
|
||||
else {
|
||||
if (!editorContent) {
|
||||
setValue(undefined)
|
||||
setObjectValue([DEFAULT_OBJECT_VALUE])
|
||||
}
|
||||
else {
|
||||
try {
|
||||
const newValue = JSON.parse(editorContent)
|
||||
setValue(newValue)
|
||||
const newObjectValue = Object.keys(newValue).map((key) => {
|
||||
return {
|
||||
key,
|
||||
type: typeof newValue[key] === 'string' ? ChatVarType.String : ChatVarType.Number,
|
||||
value: newValue[key],
|
||||
}
|
||||
})
|
||||
setObjectValue(newObjectValue)
|
||||
}
|
||||
catch {
|
||||
// ignore JSON.parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (type === ChatVarType.ArrayString || type === ChatVarType.ArrayNumber) {
|
||||
if (editInJSON) {
|
||||
const newValue = (value?.length && value.filter(Boolean).length) ? value.filter(Boolean) : undefined
|
||||
setValue(newValue)
|
||||
if (!editorContent)
|
||||
setEditorContent(JSON.stringify(newValue))
|
||||
}
|
||||
else {
|
||||
setValue(value?.length ? value : [undefined])
|
||||
}
|
||||
}
|
||||
|
||||
if (type === ChatVarType.ArrayBoolean) {
|
||||
if (editInJSON)
|
||||
setEditorContent(JSON.stringify(value.map((item: boolean) => item ? 'True' : 'False')))
|
||||
}
|
||||
setEditInJSON(editInJSON)
|
||||
}
|
||||
|
||||
const handleEditorValueChange = (content: string) => {
|
||||
if (!content) {
|
||||
setEditorContent(content)
|
||||
return setValue(undefined)
|
||||
}
|
||||
else {
|
||||
setEditorContent(content)
|
||||
try {
|
||||
let newValue = JSON.parse(content)
|
||||
if (type === ChatVarType.ArrayBoolean) {
|
||||
newValue = newValue.map((item: string | boolean) => {
|
||||
if (item === 'True' || item === 'true' || item === true)
|
||||
return true
|
||||
if (item === 'False' || item === 'false' || item === false)
|
||||
return false
|
||||
return undefined
|
||||
}).filter((item?: boolean) => item !== undefined)
|
||||
}
|
||||
setValue(newValue)
|
||||
}
|
||||
catch {
|
||||
// ignore JSON.parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!checkVariableName(name))
|
||||
return
|
||||
const varList = workflowStore.getState().conversationVariables
|
||||
if (!chatVar && varList.some(chatVar => chatVar.name === name))
|
||||
return toast.error(t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: t('chatVariable.modal.name', { ns: 'workflow' }) }))
|
||||
if (type === ChatVarType.Object && objectValue.some(item => !item.key && !!item.value))
|
||||
return toast.error(t('chatVariable.modal.objectKeyRequired', { ns: 'workflow' }))
|
||||
|
||||
onSave({
|
||||
id: chatVar ? chatVar.id : uuid4(),
|
||||
name,
|
||||
value_type: type,
|
||||
value: formatValue(value),
|
||||
description,
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (chatVar) {
|
||||
setName(chatVar.name)
|
||||
setType(chatVar.value_type)
|
||||
setValue(chatVar.value)
|
||||
setDescription(chatVar.description)
|
||||
setObjectValue(getObjectValue())
|
||||
if (chatVar.value_type === ChatVarType.ArrayObject) {
|
||||
setEditorContent(JSON.stringify(chatVar.value))
|
||||
setEditInJSON(true)
|
||||
}
|
||||
else {
|
||||
setEditInJSON(false)
|
||||
}
|
||||
}
|
||||
}, [chatVar, getObjectValue])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex h-full w-[360px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl', type === ChatVarType.Object && 'w-[480px]')}
|
||||
@ -282,135 +88,49 @@ const ChatVariableModal = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[480px] overflow-y-auto px-4 py-2">
|
||||
{/* name */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('chatVariable.modal.name', { ns: 'workflow' })}</div>
|
||||
<div className="flex">
|
||||
<Input
|
||||
placeholder={t('chatVariable.modal.namePlaceholder', { ns: 'workflow' }) || ''}
|
||||
value={name}
|
||||
onChange={handleVarNameChange}
|
||||
onBlur={e => checkVariableName(e.target.value)}
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* type */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('chatVariable.modal.type', { ns: 'workflow' })}</div>
|
||||
<div className="flex">
|
||||
<VariableTypeSelector
|
||||
value={type}
|
||||
list={typeList}
|
||||
onSelect={handleTypeChange}
|
||||
popupClassName="w-[327px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* default value */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-1 flex h-6 items-center justify-between text-text-secondary system-sm-semibold">
|
||||
<div>{t('chatVariable.modal.value', { ns: 'workflow' })}</div>
|
||||
{(type === ChatVarType.ArrayString || type === ChatVarType.ArrayNumber || type === ChatVarType.ArrayBoolean) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
className="text-text-tertiary"
|
||||
onClick={() => handleEditorChange(!editInJSON)}
|
||||
>
|
||||
{editInJSON ? <RiInputField className="mr-1 h-3.5 w-3.5" /> : <RiDraftLine className="mr-1 h-3.5 w-3.5" />}
|
||||
{editInJSON ? t('chatVariable.modal.oneByOne', { ns: 'workflow' }) : t('chatVariable.modal.editInJSON', { ns: 'workflow' })}
|
||||
</Button>
|
||||
)}
|
||||
{type === ChatVarType.Object && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
className="text-text-tertiary"
|
||||
onClick={() => handleEditorChange(!editInJSON)}
|
||||
>
|
||||
{editInJSON ? <RiInputField className="mr-1 h-3.5 w-3.5" /> : <RiDraftLine className="mr-1 h-3.5 w-3.5" />}
|
||||
{editInJSON ? t('chatVariable.modal.editInForm', { ns: 'workflow' }) : t('chatVariable.modal.editInJSON', { ns: 'workflow' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex">
|
||||
{type === ChatVarType.String && (
|
||||
// Input will remove \n\r, so use Textarea just like description area
|
||||
<textarea
|
||||
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
|
||||
value={value}
|
||||
placeholder={t('chatVariable.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{type === ChatVarType.Number && (
|
||||
<Input
|
||||
placeholder={t('chatVariable.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
|
||||
value={value}
|
||||
onChange={e => setValue(Number(e.target.value))}
|
||||
type="number"
|
||||
/>
|
||||
)}
|
||||
{type === ChatVarType.Boolean && (
|
||||
<BoolValue
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
)}
|
||||
{type === ChatVarType.Object && !editInJSON && (
|
||||
<ObjectValueList
|
||||
list={objectValue}
|
||||
onChange={setObjectValue}
|
||||
/>
|
||||
)}
|
||||
{type === ChatVarType.ArrayString && !editInJSON && (
|
||||
<ArrayValueList
|
||||
isString
|
||||
list={value || [undefined]}
|
||||
onChange={setValue}
|
||||
/>
|
||||
)}
|
||||
{type === ChatVarType.ArrayNumber && !editInJSON && (
|
||||
<ArrayValueList
|
||||
isString={false}
|
||||
list={value || [undefined]}
|
||||
onChange={setValue}
|
||||
/>
|
||||
)}
|
||||
{type === ChatVarType.ArrayBoolean && !editInJSON && (
|
||||
<ArrayBoolList
|
||||
list={value || [true]}
|
||||
onChange={setValue}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editInJSON && (
|
||||
<div className="w-full rounded-[10px] bg-components-input-bg-normal py-2 pl-3 pr-1" style={{ height: editorMinHeight }}>
|
||||
<CodeEditor
|
||||
isExpand
|
||||
noWrapper
|
||||
language={CodeLanguage.json}
|
||||
value={editorContent}
|
||||
placeholder={<div className="whitespace-pre">{placeholder}</div>}
|
||||
onChange={handleEditorValueChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* description */}
|
||||
<div className="">
|
||||
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('chatVariable.modal.description', { ns: 'workflow' })}</div>
|
||||
<div className="flex">
|
||||
<textarea
|
||||
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
|
||||
value={description}
|
||||
placeholder={t('chatVariable.modal.descriptionPlaceholder', { ns: 'workflow' }) || ''}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<NameSection
|
||||
name={name}
|
||||
onBlur={nextName => validateVariableName({ name: nextName, notify, t })}
|
||||
onChange={handleNameChange}
|
||||
placeholder={t('chatVariable.modal.namePlaceholder', { ns: 'workflow' }) || ''}
|
||||
title={t('chatVariable.modal.name', { ns: 'workflow' })}
|
||||
/>
|
||||
<TypeSection
|
||||
type={type}
|
||||
list={typeList}
|
||||
onSelect={handleTypeChange}
|
||||
title={t('chatVariable.modal.type', { ns: 'workflow' })}
|
||||
/>
|
||||
<ValueSection
|
||||
type={type}
|
||||
value={value}
|
||||
objectValue={objectValue}
|
||||
editInJSON={editInJSON}
|
||||
editorContent={editorContent}
|
||||
editorMinHeight={editorMinHeight}
|
||||
onArrayBoolChange={setValue}
|
||||
onArrayChange={type === ChatVarType.String || type === ChatVarType.Number ? handleStringOrNumberChange : setValue}
|
||||
onEditorChange={handleEditorChange}
|
||||
onEditorValueChange={handleEditorValueChange}
|
||||
onObjectChange={setObjectValue}
|
||||
onValueChange={setValue}
|
||||
placeholder={placeholder}
|
||||
t={t}
|
||||
toggleLabelKey={
|
||||
type === ChatVarType.Object
|
||||
|| type === ChatVarType.ArrayString
|
||||
|| type === ChatVarType.ArrayNumber
|
||||
|| type === ChatVarType.ArrayBoolean
|
||||
? getEditorToggleLabelKey(type, editInJSON)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<DescriptionSection
|
||||
description={description}
|
||||
onChange={setDescription}
|
||||
placeholder={t('chatVariable.modal.descriptionPlaceholder', { ns: 'workflow' }) || ''}
|
||||
title={t('chatVariable.modal.description', { ns: 'workflow' })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse rounded-b-2xl p-4 pt-2">
|
||||
<div className="flex gap-2">
|
||||
|
||||
Reference in New Issue
Block a user