feat(workflow): add selection context menu helpers and integrate with context menu component (#34013)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: lif <1835304752@qq.com>
Co-authored-by: hjlarry <hjlarry@163.com>
Co-authored-by: Stephen Zhou <hi@hyoban.cc>
Co-authored-by: tmimmanuel <14046872+tmimmanuel@users.noreply.github.com>
Co-authored-by: Desel72 <pedroluiscolmenares722@gmail.com>
Co-authored-by: Renzo <170978465+RenzoMXD@users.noreply.github.com>
Co-authored-by: Krishna Chaitanya <krishnabkc15@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Coding On Star
2026-03-25 17:21:48 +08:00
committed by GitHub
parent f87dafa229
commit 7fbb1c96db
87 changed files with 13256 additions and 2105 deletions

View File

@ -0,0 +1,143 @@
import type { FileUploadConfigResponse } from '@/models/common'
import type { VarInInspect } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import { ToastContext } from '@/app/components/base/toast/context'
import { VarType } from '@/app/components/workflow/types'
import { VarInInspectType } from '@/types/workflow'
import {
BoolArraySection,
ErrorMessages,
FileEditorSection,
JsonEditorSection,
TextEditorSection,
} from '../value-content-sections'
vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor', () => ({
default: ({ schema, onUpdate }: { schema: string, onUpdate: (value: string) => void }) => (
<textarea data-testid="schema-editor" value={schema} onChange={event => onUpdate(event.target.value)} />
),
}))
vi.mock('@/next/navigation', () => ({
useParams: () => ({ token: '' }),
}))
describe('value-content sections', () => {
const createFileUploadConfig = (): FileUploadConfigResponse => ({
batch_count_limit: 10,
image_file_batch_limit: 10,
single_chunk_attachment_limit: 10,
attachment_image_file_size_limit: 2,
file_size_limit: 15,
file_upload_limit: 5,
workflow_file_upload_limit: 5,
})
const createVar = (overrides: Partial<VarInInspect>): VarInInspect => ({
id: 'var-1',
name: 'query',
type: VarInInspectType.node,
value_type: VarType.string,
value: '',
...overrides,
} as VarInInspect)
it('should render the text editor section and forward text changes', () => {
const handleTextChange = vi.fn()
render(
<TextEditorSection
currentVar={createVar({ value_type: VarType.string })}
value="hello"
textEditorDisabled={false}
isTruncated={false}
onTextChange={handleTextChange}
/>,
)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'updated' } })
expect(handleTextChange).toHaveBeenCalledWith('updated')
})
it('should render the textarea editor for non-string values', () => {
const handleTextChange = vi.fn()
render(
<TextEditorSection
currentVar={createVar({ name: 'count', value_type: VarType.number })}
value="12"
textEditorDisabled={false}
isTruncated={false}
onTextChange={handleTextChange}
/>,
)
fireEvent.change(screen.getByRole('textbox'), { target: { value: '24' } })
expect(handleTextChange).toHaveBeenCalledWith('24')
})
it('should update a boolean array item by index', () => {
const onChange = vi.fn()
render(<BoolArraySection values={[true, false]} onChange={onChange} />)
fireEvent.click(screen.getAllByText('True')[1])
expect(onChange).toHaveBeenCalledWith([true, true])
})
it('should render schema editor and error messages', () => {
const onChange = vi.fn()
render(
<>
<JsonEditorSection
hasChunks={false}
valueType={VarType.object}
json="{}"
readonly={false}
isTruncated={false}
onChange={onChange}
/>
<ErrorMessages
parseError={new Error('Broken JSON')}
validationError="Too deep"
/>
</>,
)
fireEvent.change(screen.getByTestId('schema-editor'), { target: { value: '{"foo":1}' } })
expect(onChange).toHaveBeenCalledWith('{"foo":1}')
expect(screen.getByText('Broken JSON')).toBeInTheDocument()
expect(screen.getByText('Too deep')).toBeInTheDocument()
})
it('should render chunk preview when the json editor has chunks', () => {
render(
<JsonEditorSection
hasChunks
schemaType="general_structure"
valueType={VarType.object}
json="{}"
readonly={false}
isTruncated={false}
onChange={vi.fn()}
/>,
)
expect(screen.getByTestId('schema-editor')).toBeInTheDocument()
})
it('should render the file editor section', () => {
render(
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() }}>
<FileEditorSection
currentVar={createVar({ name: 'files', value_type: VarType.file })}
fileValue={[]}
fileUploadConfig={createFileUploadConfig()}
textEditorDisabled={false}
onChange={vi.fn()}
/>
</ToastContext.Provider>,
)
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
})
})

View File

@ -0,0 +1,48 @@
describe('value-content helpers branch coverage', () => {
afterEach(() => {
vi.resetModules()
vi.clearAllMocks()
})
it('should return validation errors for invalid schemas, over-deep schemas, and draft7 violations', async () => {
const validateSchemaAgainstDraft7 = vi.fn()
const getValidationErrorMessage = vi.fn(() => 'draft7 error')
vi.doMock('@/app/components/workflow/nodes/llm/utils', () => ({
checkJsonSchemaDepth: (schema: Record<string, unknown>) => schema.depth as number,
getValidationErrorMessage,
validateSchemaAgainstDraft7,
}))
vi.doMock('../utils', () => ({
validateJSONSchema: (schema: Record<string, unknown>) => {
if (schema.kind === 'invalid')
return { success: false, error: new Error('schema invalid') }
return { success: true }
},
}))
const { validateInspectJsonValue } = await import('../value-content.helpers')
expect(validateInspectJsonValue('{"kind":"invalid"}', 'object')).toMatchObject({
success: false,
validationError: 'schema invalid',
parseError: null,
})
expect(validateInspectJsonValue('{"depth":99}', 'object')).toMatchObject({
success: false,
validationError: expect.stringContaining('Schema exceeds maximum depth'),
parseError: null,
})
validateSchemaAgainstDraft7.mockReturnValueOnce([{ message: 'broken' }])
expect(validateInspectJsonValue('{"depth":1}', 'object')).toMatchObject({
success: false,
validationError: 'draft7 error',
parseError: null,
})
expect(getValidationErrorMessage).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,80 @@
import type { VarInInspect } from '@/types/workflow'
import { VarType } from '@/app/components/workflow/types'
import { VarInInspectType } from '@/types/workflow'
import {
formatInspectFileValue,
getValueEditorState,
isFileValueUploaded,
validateInspectJsonValue,
} from '../value-content.helpers'
describe('value-content helpers', () => {
const createVar = (overrides: Partial<VarInInspect>): VarInInspect => ({
id: 'var-1',
name: 'query',
type: VarInInspectType.node,
value_type: VarType.string,
value: '',
...overrides,
} as VarInInspect)
it('should derive editor modes from the variable shape', () => {
expect(getValueEditorState(createVar({
type: VarInInspectType.environment,
name: 'api_key',
value_type: VarType.string,
value: 'secret',
}))).toMatchObject({
showTextEditor: true,
textEditorDisabled: true,
showJSONEditor: false,
})
expect(getValueEditorState(createVar({
name: 'payload',
value_type: VarType.object,
value: { foo: 1 },
schemaType: 'general_structure',
}))).toMatchObject({
showJSONEditor: true,
hasChunks: true,
})
expect(getValueEditorState(createVar({
type: VarInInspectType.system,
name: 'files',
value_type: VarType.arrayFile,
value: [],
}))).toMatchObject({
isSysFiles: true,
showFileEditor: true,
showJSONEditor: false,
})
})
it('should format file values and detect upload completion', () => {
expect(formatInspectFileValue(createVar({
name: 'file',
value_type: VarType.file,
value: { id: 'file-1' },
}))).toHaveLength(1)
expect(isFileValueUploaded([{ upload_file_id: 'file-1' }])).toBe(true)
expect(isFileValueUploaded([{ upload_file_id: '' }])).toBe(false)
expect(formatInspectFileValue(createVar({
type: VarInInspectType.system,
name: 'files',
value_type: VarType.arrayFile,
value: [{ id: 'file-2' }],
}))).toHaveLength(1)
})
it('should validate json input and surface parse errors', () => {
expect(validateInspectJsonValue('{"foo":1}', 'object').success).toBe(true)
expect(validateInspectJsonValue('[]', 'array[any]')).toMatchObject({ success: true })
expect(validateInspectJsonValue('{', 'object')).toMatchObject({
success: false,
parseError: expect.any(Error),
})
})
})

View File

@ -0,0 +1,410 @@
import type { VarInInspect } from '@/types/workflow'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWorkflowComponent } from '@/app/components/workflow/__tests__/workflow-test-env'
import { VarType } from '@/app/components/workflow/types'
import { VarInInspectType } from '@/types/workflow'
import ValueContent from '../value-content'
vi.mock('@/app/components/base/file-uploader/utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/file-uploader/utils')>()
return {
...actual,
getProcessedFiles: (files: unknown[]) => files,
}
})
vi.mock('@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor', () => ({
default: ({ schema, onUpdate }: { schema: string, onUpdate: (value: string) => void }) => (
<textarea data-testid="json-editor" value={schema} onChange={event => onUpdate(event.target.value)} />
),
}))
vi.mock('../value-content-sections', () => ({
TextEditorSection: ({
value,
onTextChange,
}: {
value: string
onTextChange: (value: string) => void
}) => <textarea aria-label="value-text-editor" value={value ?? ''} onChange={event => onTextChange(event.target.value)} />,
BoolArraySection: ({
onChange,
}: {
onChange: (value: boolean[]) => void
}) => <button onClick={() => onChange([true, true])}>bool-array-editor</button>,
JsonEditorSection: ({
json,
onChange,
}: {
json: string
onChange: (value: string) => void
}) => <textarea data-testid="json-editor" value={json} onChange={event => onChange(event.target.value)} />,
FileEditorSection: ({
onChange,
}: {
onChange: (files: Array<Record<string, unknown>>) => void
}) => (
<div>
<button onClick={() => onChange([{ upload_file_id: '' }])}>file-pending</button>
<button onClick={() => onChange([{ upload_file_id: 'file-1', name: 'report.pdf' }])}>file-uploaded</button>
<button onClick={() => onChange([
{ upload_file_id: 'file-1', name: 'a.pdf' },
{ upload_file_id: 'file-2', name: 'b.pdf' },
])}
>
file-array-uploaded
</button>
</div>
),
ErrorMessages: ({
parseError,
validationError,
}: {
parseError: Error | null
validationError: string
}) => (
<div>
{parseError && <div>{parseError.message}</div>}
{validationError && <div>{validationError}</div>}
</div>
),
}))
vi.mock('@/next/navigation', () => ({
useParams: () => ({ token: '' }),
}))
describe('ValueContent', () => {
const createVar = (overrides: Partial<VarInInspect>): VarInInspect => ({
id: 'var-default',
name: 'query',
type: VarInInspectType.node,
value_type: VarType.string,
value: '',
...overrides,
} as VarInInspect)
beforeEach(() => {
vi.clearAllMocks()
})
it('should debounce text changes for string variables', async () => {
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-1',
value_type: VarType.string,
value: 'hello',
})}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.change(screen.getByLabelText('value-text-editor'), { target: { value: 'updated' } })
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('var-1', 'updated')
})
})
it('should surface parse errors from invalid json input', async () => {
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-2',
name: 'payload',
value_type: VarType.object,
value: { foo: 1 },
})}
handleValueChange={vi.fn()}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.change(screen.getByTestId('json-editor'), { target: { value: '{' } })
await waitFor(() => {
expect(screen.getByText(/json/i)).toBeInTheDocument()
})
})
it('should debounce numeric changes', async () => {
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-3',
name: 'count',
value_type: VarType.number,
value: 1,
})}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.change(screen.getByLabelText('value-text-editor'), { target: { value: '24.5' } })
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('var-3', 24.5)
})
expect(handleValueChange).toHaveBeenCalledTimes(1)
})
it('should update boolean values', async () => {
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-4',
name: 'enabled',
value_type: VarType.boolean,
value: false,
})}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.click(screen.getByText('True'))
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('var-4', true)
})
})
it('should not emit changes when the content is truncated', async () => {
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-5',
value_type: VarType.string,
value: 'hello',
})}
handleValueChange={handleValueChange}
isTruncated
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.change(screen.getByLabelText('value-text-editor'), { target: { value: 'updated' } })
await waitFor(() => {
expect(handleValueChange).not.toHaveBeenCalled()
})
})
it('should update boolean array values', async () => {
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-6',
name: 'flags',
value_type: VarType.arrayBoolean,
value: [true, false],
})}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.click(screen.getByText('bool-array-editor'))
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('var-6', [true, true])
})
})
it('should parse valid json values', async () => {
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-7',
name: 'payload',
value_type: VarType.object,
value: { foo: 1 },
})}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.change(screen.getByTestId('json-editor'), { target: { value: '{"foo":2}' } })
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('var-7', { foo: 2 })
})
})
it('should update uploaded single file values and ignore pending uploads', async () => {
const handleValueChange = vi.fn()
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-8',
name: 'files',
value_type: VarType.file,
value: null,
})}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.click(screen.getByText('file-pending'))
await waitFor(() => {
expect(handleValueChange).not.toHaveBeenCalled()
})
fireEvent.click(screen.getByText('file-uploaded'))
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('var-8', expect.objectContaining({ upload_file_id: 'file-1' }))
})
})
it('should update uploaded file arrays and react to resize observer changes', async () => {
const handleValueChange = vi.fn()
const observe = vi.fn()
const disconnect = vi.fn()
const originalResizeObserver = globalThis.ResizeObserver
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, 'clientHeight')
Object.defineProperty(HTMLDivElement.prototype, 'clientHeight', {
configurable: true,
get: () => 120,
})
class MockResizeObserver {
callback: ResizeObserverCallback
constructor(callback: ResizeObserverCallback) {
this.callback = callback
}
observe = (target: Element) => {
observe(target)
this.callback([{
borderBoxSize: [{ inlineSize: 20 }],
} as unknown as ResizeObserverEntry], this as unknown as ResizeObserver)
}
disconnect = disconnect
}
vi.stubGlobal('ResizeObserver', MockResizeObserver as unknown as typeof ResizeObserver)
renderWorkflowComponent(
<ValueContent
currentVar={createVar({
id: 'var-9',
name: 'files',
type: VarInInspectType.system,
value_type: VarType.arrayFile,
value: [],
})}
handleValueChange={handleValueChange}
isTruncated={false}
/>,
{
initialStoreState: {
fileUploadConfig: {
workflow_file_upload_limit: 5,
} as never,
},
},
)
fireEvent.click(screen.getByText('file-array-uploaded'))
await waitFor(() => {
expect(handleValueChange).toHaveBeenCalledWith('var-9', expect.arrayContaining([
expect.objectContaining({ upload_file_id: 'file-1' }),
expect.objectContaining({ upload_file_id: 'file-2' }),
]))
})
expect(observe).toHaveBeenCalled()
expect(document.querySelector('[style="height: 100px;"]')).toBeInTheDocument()
if (originalClientHeight)
Object.defineProperty(HTMLDivElement.prototype, 'clientHeight', originalClientHeight)
else
delete (HTMLDivElement.prototype as { clientHeight?: number }).clientHeight
if (originalResizeObserver)
vi.stubGlobal('ResizeObserver', originalResizeObserver)
else
vi.unstubAllGlobals()
expect(disconnect).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,190 @@
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { FileUploadConfigResponse } from '@/models/common'
import type { VarInInspect } from '@/types/workflow'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import Textarea from '@/app/components/base/textarea'
import ErrorMessage from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message'
import SchemaEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import { cn } from '@/utils/classnames'
import { PreviewMode } from '../../base/features/types'
import BoolValue from '../panel/chat-variable-panel/components/bool-value'
import DisplayContent from './display-content'
import LargeDataAlert from './large-data-alert'
import { PreviewType } from './types'
type TextEditorSectionProps = {
currentVar: VarInInspect
value: unknown
textEditorDisabled: boolean
isTruncated: boolean
onTextChange: (value: string) => void
}
export const TextEditorSection = ({
currentVar,
value,
textEditorDisabled,
isTruncated,
onTextChange,
}: TextEditorSectionProps) => {
return (
<>
{isTruncated && <LargeDataAlert className="absolute left-3 right-3 top-1" />}
{currentVar.value_type === 'string'
? (
<DisplayContent
previewType={PreviewType.Markdown}
varType={currentVar.value_type}
mdString={typeof value === 'string' ? value : String(value ?? '')}
readonly={textEditorDisabled}
handleTextChange={onTextChange}
className={cn(isTruncated && 'pt-[36px]')}
/>
)
: (
<Textarea
readOnly={textEditorDisabled}
disabled={textEditorDisabled || isTruncated}
className={cn('h-full', isTruncated && 'pt-[48px]')}
value={typeof value === 'number' ? value : String(value ?? '')}
onChange={e => onTextChange(e.target.value)}
/>
)}
</>
)
}
type BoolArraySectionProps = {
values: boolean[]
onChange: (nextValue: boolean[]) => void
}
export const BoolArraySection = ({
values,
onChange,
}: BoolArraySectionProps) => {
return (
<div className="w-[295px] space-y-1">
{values.map((value, index) => (
<BoolValue
key={`${index}-${String(value)}`}
value={value}
onChange={(newValue) => {
const nextValue = [...values]
nextValue[index] = newValue
onChange(nextValue)
}}
/>
))}
</div>
)
}
type JsonEditorSectionProps = {
hasChunks: boolean
schemaType?: string
valueType: VarInInspect['value_type']
json: string
readonly: boolean
isTruncated: boolean
onChange: (value: string) => void
}
export const JsonEditorSection = ({
hasChunks,
schemaType,
valueType,
json,
readonly,
isTruncated,
onChange,
}: JsonEditorSectionProps) => {
if (hasChunks) {
return (
<DisplayContent
previewType={PreviewType.Chunks}
varType={valueType}
schemaType={schemaType ?? ''}
jsonString={json ?? '{}'}
readonly={readonly}
handleEditorChange={onChange}
/>
)
}
return (
<SchemaEditor
readonly={readonly || isTruncated}
className="overflow-y-auto"
hideTopMenu
schema={json}
onUpdate={onChange}
isTruncated={isTruncated}
/>
)
}
type FileEditorSectionProps = {
currentVar: VarInInspect
fileValue: FileEntity[]
fileUploadConfig?: FileUploadConfigResponse
textEditorDisabled: boolean
onChange: (files: FileEntity[]) => void
}
export const FileEditorSection = ({
currentVar,
fileValue,
fileUploadConfig,
textEditorDisabled,
onChange,
}: FileEditorSectionProps) => {
return (
<div className="max-w-[460px]">
<FileUploaderInAttachmentWrapper
value={fileValue}
onChange={onChange}
fileConfig={{
allowed_file_types: [
SupportUploadFileTypes.image,
SupportUploadFileTypes.document,
SupportUploadFileTypes.audio,
SupportUploadFileTypes.video,
],
allowed_file_extensions: [
...FILE_EXTS[SupportUploadFileTypes.image],
...FILE_EXTS[SupportUploadFileTypes.document],
...FILE_EXTS[SupportUploadFileTypes.audio],
...FILE_EXTS[SupportUploadFileTypes.video],
],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: currentVar.value_type === 'file' ? 1 : fileUploadConfig?.workflow_file_upload_limit || 5,
fileUploadConfig,
preview_config: {
mode: PreviewMode.NewPage,
file_type_list: ['application/pdf'],
},
}}
isDisabled={textEditorDisabled}
/>
</div>
)
}
export const ErrorMessages = ({
parseError,
validationError,
}: {
parseError: Error | null
validationError: string
}) => {
return (
<>
{parseError && <ErrorMessage className="mt-1" message={parseError.message} />}
{validationError && <ErrorMessage className="mt-1" message={validationError} />}
</>
)
}

View File

@ -0,0 +1,77 @@
import type { VarInInspect } from '@/types/workflow'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import {
checkJsonSchemaDepth,
getValidationErrorMessage,
validateSchemaAgainstDraft7,
} from '@/app/components/workflow/nodes/llm/utils'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
import { VarInInspectType } from '@/types/workflow'
import { CHUNK_SCHEMA_TYPES } from './types'
import { validateJSONSchema } from './utils'
type UploadedFileLike = {
upload_file_id?: string
}
export const getValueEditorState = (currentVar: VarInInspect) => {
const showTextEditor = currentVar.value_type === 'secret' || currentVar.value_type === 'string' || currentVar.value_type === 'number'
const showBoolEditor = typeof currentVar.value === 'boolean'
const showBoolArrayEditor = Array.isArray(currentVar.value) && currentVar.value.every(v => typeof v === 'boolean')
const isSysFiles = currentVar.type === VarInInspectType.system && currentVar.name === 'files'
const showJSONEditor = !isSysFiles && ['object', 'array[string]', 'array[number]', 'array[object]', 'array[any]'].includes(currentVar.value_type)
const showFileEditor = isSysFiles || currentVar.value_type === 'file' || currentVar.value_type === 'array[file]'
const textEditorDisabled = currentVar.type === VarInInspectType.environment || (currentVar.type === VarInInspectType.system && currentVar.name !== 'query' && currentVar.name !== 'files')
const JSONEditorDisabled = currentVar.value_type === 'array[any]'
const hasChunks = !!currentVar.schemaType && CHUNK_SCHEMA_TYPES.includes(currentVar.schemaType)
return {
showTextEditor,
showBoolEditor,
showBoolArrayEditor,
isSysFiles,
showJSONEditor,
showFileEditor,
textEditorDisabled,
JSONEditorDisabled,
hasChunks,
}
}
export const formatInspectFileValue = (currentVar: VarInInspect) => {
if (currentVar.value_type === 'file')
return currentVar.value ? getProcessedFilesFromResponse([currentVar.value]) : []
if (currentVar.value_type === 'array[file]' || (currentVar.type === VarInInspectType.system && currentVar.name === 'files'))
return currentVar.value && currentVar.value.length > 0 ? getProcessedFilesFromResponse(currentVar.value) : []
return []
}
export const validateInspectJsonValue = (value: string, type: string) => {
try {
const newJSONSchema = JSON.parse(value)
const result = validateJSONSchema(newJSONSchema, type)
if (!result.success)
return { success: false, validationError: result.error.message, parseError: null }
if (type === 'object' || type === 'array[object]') {
const schemaDepth = checkJsonSchemaDepth(newJSONSchema)
if (schemaDepth > JSON_SCHEMA_MAX_DEPTH)
return { success: false, validationError: `Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`, parseError: null }
const validationErrors = validateSchemaAgainstDraft7(newJSONSchema)
if (validationErrors.length > 0)
return { success: false, validationError: getValidationErrorMessage(validationErrors), parseError: null }
}
return { success: true, validationError: '', parseError: null }
}
catch (error) {
return {
success: false,
validationError: '',
parseError: error instanceof Error ? error : new Error('Invalid JSON'),
}
}
}
export const isFileValueUploaded = (fileList: UploadedFileLike[]) => fileList.every(file => file.upload_file_id)

View File

@ -2,31 +2,23 @@ import type { VarInInspect } from '@/types/workflow'
import { useDebounceFn } from 'ahooks'
import * as React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import { getProcessedFiles, getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import Textarea from '@/app/components/base/textarea'
import ErrorMessage from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message'
import SchemaEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor'
import {
checkJsonSchemaDepth,
getValidationErrorMessage,
validateSchemaAgainstDraft7,
} from '@/app/components/workflow/nodes/llm/utils'
import { getProcessedFiles } from '@/app/components/base/file-uploader/utils'
import { useStore } from '@/app/components/workflow/store'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import {
validateJSONSchema,
} from '@/app/components/workflow/variable-inspect/utils'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
import { TransferMethod } from '@/types/app'
import { VarInInspectType } from '@/types/workflow'
import { cn } from '@/utils/classnames'
import { PreviewMode } from '../../base/features/types'
import BoolValue from '../panel/chat-variable-panel/components/bool-value'
import DisplayContent from './display-content'
import LargeDataAlert from './large-data-alert'
import { CHUNK_SCHEMA_TYPES, PreviewType } from './types'
import {
BoolArraySection,
ErrorMessages,
FileEditorSection,
JsonEditorSection,
TextEditorSection,
} from './value-content-sections'
import {
formatInspectFileValue,
getValueEditorState,
isFileValueUploaded,
validateInspectJsonValue,
} from './value-content.helpers'
type Props = {
currentVar: VarInInspect
@ -42,35 +34,24 @@ const ValueContent = ({
const contentContainerRef = useRef<HTMLDivElement>(null)
const errorMessageRef = useRef<HTMLDivElement>(null)
const [editorHeight, setEditorHeight] = useState(0)
const showTextEditor = currentVar.value_type === 'secret' || currentVar.value_type === 'string' || currentVar.value_type === 'number'
const showBoolEditor = typeof currentVar.value === 'boolean'
const showBoolArrayEditor = Array.isArray(currentVar.value) && currentVar.value.every(v => typeof v === 'boolean')
const isSysFiles = currentVar.type === VarInInspectType.system && currentVar.name === 'files'
const showJSONEditor = !isSysFiles && (currentVar.value_type === 'object' || currentVar.value_type === 'array[string]' || currentVar.value_type === 'array[number]' || currentVar.value_type === 'array[object]' || currentVar.value_type === 'array[any]')
const showFileEditor = isSysFiles || currentVar.value_type === 'file' || currentVar.value_type === 'array[file]'
const textEditorDisabled = currentVar.type === VarInInspectType.environment || (currentVar.type === VarInInspectType.system && currentVar.name !== 'query' && currentVar.name !== 'files')
const JSONEditorDisabled = currentVar.value_type === 'array[any]'
const {
showTextEditor,
showBoolEditor,
showBoolArrayEditor,
isSysFiles,
showJSONEditor,
showFileEditor,
textEditorDisabled,
JSONEditorDisabled,
hasChunks,
} = useMemo(() => getValueEditorState(currentVar), [currentVar])
const fileUploadConfig = useStore(s => s.fileUploadConfig)
const hasChunks = useMemo(() => {
if (!currentVar.schemaType)
return false
return CHUNK_SCHEMA_TYPES.includes(currentVar.schemaType)
}, [currentVar.schemaType])
const formatFileValue = (value: VarInInspect) => {
if (value.value_type === 'file')
return value.value ? getProcessedFilesFromResponse([value.value]) : []
if (value.value_type === 'array[file]' || (value.type === VarInInspectType.system && currentVar.name === 'files'))
return value.value && value.value.length > 0 ? getProcessedFilesFromResponse(value.value) : []
return []
}
const [value, setValue] = useState<any>()
const [json, setJson] = useState('')
const [parseError, setParseError] = useState<Error | null>(null)
const [validationError, setValidationError] = useState<string>('')
const [fileValue, setFileValue] = useState<any>(() => formatFileValue(currentVar))
const [fileValue, setFileValue] = useState<any>(() => formatInspectFileValue(currentVar))
const { run: debounceValueChange } = useDebounceFn(handleValueChange, { wait: 500 })
@ -87,7 +68,7 @@ const ValueContent = ({
setJson(currentVar.value != null ? JSON.stringify(currentVar.value, null, 2) : '')
if (showFileEditor)
setFileValue(formatFileValue(currentVar))
setFileValue(formatInspectFileValue(currentVar))
}, [currentVar.id, currentVar.value])
const handleTextChange = (value: string) => {
@ -105,40 +86,10 @@ const ValueContent = ({
}
const jsonValueValidate = (value: string, type: string) => {
try {
const newJSONSchema = JSON.parse(value)
setParseError(null)
const result = validateJSONSchema(newJSONSchema, type)
if (!result.success) {
setValidationError(result.error.message)
return false
}
if (type === 'object' || type === 'array[object]') {
const schemaDepth = checkJsonSchemaDepth(newJSONSchema)
if (schemaDepth > JSON_SCHEMA_MAX_DEPTH) {
setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`)
return false
}
const validationErrors = validateSchemaAgainstDraft7(newJSONSchema)
if (validationErrors.length > 0) {
setValidationError(getValidationErrorMessage(validationErrors))
return false
}
}
setValidationError('')
return true
}
catch (error) {
setValidationError('')
if (error instanceof Error) {
setParseError(error)
return false
}
else {
setParseError(new Error('Invalid JSON'))
return false
}
}
const result = validateInspectJsonValue(value, type)
setParseError(result.parseError)
setValidationError(result.validationError)
return result.success
}
const handleEditorChange = (value: string) => {
@ -151,13 +102,11 @@ const ValueContent = ({
}
}
const fileValueValidate = (fileList: any[]) => fileList.every(file => file.upload_file_id)
const handleFileChange = (value: any[]) => {
setFileValue(value)
// check every file upload progress
// invoke update api after every file uploaded
if (!fileValueValidate(value))
if (!isFileValueUploaded(value))
return
if (currentVar.value_type === 'file')
debounceValueChange(currentVar.id, value[0])
@ -189,31 +138,13 @@ const ValueContent = ({
>
<div className={cn('relative grow')} style={{ height: `${editorHeight}px` }}>
{showTextEditor && (
<>
{isTruncated && <LargeDataAlert className="absolute left-3 right-3 top-1" />}
{
currentVar.value_type === 'string'
? (
<DisplayContent
previewType={PreviewType.Markdown}
varType={currentVar.value_type}
mdString={value as any}
readonly={textEditorDisabled}
handleTextChange={handleTextChange}
className={cn(isTruncated && 'pt-[36px]')}
/>
)
: (
<Textarea
readOnly={textEditorDisabled}
disabled={textEditorDisabled || isTruncated}
className={cn('h-full', isTruncated && 'pt-[48px]')}
value={value as any}
onChange={e => handleTextChange(e.target.value)}
/>
)
}
</>
<TextEditorSection
currentVar={currentVar}
value={value}
textEditorDisabled={textEditorDisabled}
isTruncated={isTruncated}
onTextChange={handleTextChange}
/>
)}
{showBoolEditor && (
<div className="w-[295px]">
@ -228,79 +159,41 @@ const ValueContent = ({
)}
{
showBoolArrayEditor && (
<div className="w-[295px] space-y-1">
{currentVar.value.map((v: boolean, i: number) => (
<BoolValue
key={i}
value={v}
onChange={(newValue) => {
const newArray = [...(currentVar.value as boolean[])]
newArray[i] = newValue
setValue(newArray)
debounceValueChange(currentVar.id, newArray)
}}
/>
))}
</div>
<BoolArraySection
values={currentVar.value as boolean[]}
onChange={(newArray) => {
setValue(newArray)
debounceValueChange(currentVar.id, newArray)
}}
/>
)
}
{showJSONEditor && (
hasChunks
? (
<DisplayContent
previewType={PreviewType.Chunks}
varType={currentVar.value_type}
schemaType={currentVar.schemaType ?? ''}
jsonString={json ?? '{}'}
readonly={JSONEditorDisabled}
handleEditorChange={handleEditorChange}
/>
)
: (
<SchemaEditor
readonly={JSONEditorDisabled || isTruncated}
className="overflow-y-auto"
hideTopMenu
schema={json}
onUpdate={handleEditorChange}
isTruncated={isTruncated}
/>
)
<JsonEditorSection
hasChunks={hasChunks}
schemaType={currentVar.schemaType}
valueType={currentVar.value_type}
json={json}
readonly={JSONEditorDisabled}
isTruncated={isTruncated}
onChange={handleEditorChange}
/>
)}
{showFileEditor && (
<div className="max-w-[460px]">
<FileUploaderInAttachmentWrapper
value={fileValue}
onChange={files => handleFileChange(getProcessedFiles(files))}
fileConfig={{
allowed_file_types: [
SupportUploadFileTypes.image,
SupportUploadFileTypes.document,
SupportUploadFileTypes.audio,
SupportUploadFileTypes.video,
],
allowed_file_extensions: [
...FILE_EXTS[SupportUploadFileTypes.image],
...FILE_EXTS[SupportUploadFileTypes.document],
...FILE_EXTS[SupportUploadFileTypes.audio],
...FILE_EXTS[SupportUploadFileTypes.video],
],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: currentVar.value_type === 'file' ? 1 : fileUploadConfig?.workflow_file_upload_limit || 5,
fileUploadConfig,
preview_config: {
mode: PreviewMode.NewPage,
file_type_list: ['application/pdf'],
},
}}
isDisabled={textEditorDisabled}
/>
</div>
<FileEditorSection
currentVar={currentVar}
fileValue={fileValue}
fileUploadConfig={fileUploadConfig}
textEditorDisabled={textEditorDisabled}
onChange={files => handleFileChange(getProcessedFiles(files))}
/>
)}
</div>
<div ref={errorMessageRef} className="shrink-0">
{parseError && <ErrorMessage className="mt-1" message={parseError.message} />}
{validationError && <ErrorMessage className="mt-1" message={validationError} />}
<ErrorMessages
parseError={parseError}
validationError={validationError}
/>
</div>
</div>
)