feat: enhance JSON Schema visual editor with new components and translations

This commit is contained in:
twwu 2025-03-14 16:43:43 +08:00
parent 7a2c831ef3
commit 4333820aa6
21 changed files with 1047 additions and 62 deletions

View File

@ -8,8 +8,9 @@ const textareaVariants = cva(
{
variants: {
size: {
regular: 'px-3 radius-md system-sm-regular',
large: 'px-4 radius-lg system-md-regular',
small: 'py-1 rounded-md system-xs-regular',
regular: 'px-3 rounded-md system-sm-regular',
large: 'px-4 rounded-lg system-md-regular',
},
},
defaultVariants: {

View File

@ -1,6 +1,6 @@
import React, { type FC, useCallback, useState } from 'react'
import Modal from '../../../../../base/modal'
import { type StructuredOutput, Type } from '../../types'
import { type Field, Type } from '../../types'
import { RiBracesLine, RiCloseLine, RiExternalLinkLine, RiTimelineView } from '@remixicon/react'
import { SegmentedControl } from '../../../../../base/segmented-control'
import JsonSchemaGenerator from './json-schema-generator'
@ -8,11 +8,12 @@ import Divider from '@/app/components/base/divider'
import JsonImporter from './json-importer'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import VisualEditor from './visual-editor'
type JsonSchemaConfigModalProps = {
isShow: boolean
defaultSchema: StructuredOutput
onSave: (schema: StructuredOutput) => void
defaultSchema?: Field
onSave: (schema: Field) => void
onClose: () => void
}
@ -21,18 +22,16 @@ enum SchemaView {
JsonSchema = 'jsonSchema',
}
const options = [
const VIEW_TABS = [
{ Icon: RiTimelineView, text: 'Visual Editor', value: SchemaView.VisualEditor },
{ Icon: RiBracesLine, text: 'JSON Schema', value: SchemaView.JsonSchema },
]
const DEFAULT_SCHEMA = {
schema: {
type: Type.object,
properties: {},
required: [],
additionalProperties: false,
},
const DEFAULT_SCHEMA: Field = {
type: Type.object,
properties: {},
required: [],
additionalProperties: false,
}
const JsonSchemaConfigModal: FC<JsonSchemaConfigModalProps> = ({
@ -54,6 +53,10 @@ const JsonSchemaConfigModal: FC<JsonSchemaConfigModalProps> = ({
const handleSubmit = useCallback(() => {}, [])
const handleUpdateSchema = useCallback((schema: Field) => {
setJsonSchema(schema)
}, [])
const handleResetDefaults = useCallback(() => {
setJsonSchema(defaultSchema || DEFAULT_SCHEMA)
}, [defaultSchema])
@ -73,7 +76,7 @@ const JsonSchemaConfigModal: FC<JsonSchemaConfigModalProps> = ({
onClose={onClose}
className='max-w-[960px] h-[800px] p-0'
>
<div className='flex flex-col'>
<div className='flex flex-col h-full'>
{/* Header */}
<div className='relative flex p-6 pr-14 pb-3'>
<div className='text-text-primary title-2xl-semi-bold grow truncate'>
@ -87,7 +90,7 @@ const JsonSchemaConfigModal: FC<JsonSchemaConfigModalProps> = ({
<div className='flex items-center justify-between px-6 py-2'>
{/* Tab */}
<SegmentedControl<SchemaView>
options={options}
options={VIEW_TABS}
value={currentTab}
onChange={(value: SchemaView) => {
setCurrentTab(value)
@ -108,8 +111,15 @@ const JsonSchemaConfigModal: FC<JsonSchemaConfigModalProps> = ({
</div>
</div>
<div className='px-6 grow'>
{currentTab === SchemaView.VisualEditor && <div className='h-full bg-components-input-bg-normal'>Visual Editor</div>}
{currentTab === SchemaView.JsonSchema && <div className='h-full bg-components-input-bg-normal'>JSON Schema</div>}
{currentTab === SchemaView.VisualEditor && (
<VisualEditor
schema={jsonSchema}
onChange={handleUpdateSchema}
/>
)}
{currentTab === SchemaView.JsonSchema && (
<div className='h-full rounded-xl bg-components-input-bg-normal'>JSON Schema</div>
)}
</div>
{/* Footer */}
<div className='flex items-center p-6 pt-5 gap-x-2'>

View File

@ -1,5 +1,5 @@
import React, { type FC, useCallback, useRef, useState } from 'react'
import type { StructuredOutput } from '../../../types'
import type { SchemaRoot } from '../../../types'
import { RiArrowLeftLine, RiClipboardLine, RiCloseLine, RiSparklingLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Editor from '@monaco-editor/react'
@ -7,7 +7,7 @@ import copy from 'copy-to-clipboard'
import Button from '@/app/components/base/button'
type GeneratedResultProps = {
schema: StructuredOutput
schema: SchemaRoot
onBack: () => void
onRegenerate: () => void
onClose: () => void

View File

@ -1,5 +1,5 @@
import React, { type FC, useCallback, useState } from 'react'
import { type StructuredOutput, Type } from '../../../types'
import { type SchemaRoot, Type } from '../../../types'
import {
PortalToFollowElem,
PortalToFollowElemContent,
@ -13,7 +13,7 @@ import PromptEditor from './prompt-editor'
import GeneratedResult from './generated-result'
type JsonSchemaGeneratorProps = {
onApply: (schema: StructuredOutput) => void
onApply: (schema: SchemaRoot) => void
crossAxisOffset?: number
}
@ -30,7 +30,7 @@ export const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
const { theme } = useTheme()
const [view, setView] = useState(GeneratorView.promptEditor)
const [instruction, setInstruction] = useState('')
const [schema, setSchema] = useState<StructuredOutput | null>(null)
const [schema, setSchema] = useState<SchemaRoot | null>(null)
const SchemaGenerator = theme === Theme.light ? SchemaGeneratorLight : SchemaGeneratorDark
const handleTrigger = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
@ -47,23 +47,21 @@ export const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
await new Promise<void>((resolve) => {
setTimeout(() => {
setSchema({
schema: {
type: Type.object,
properties: {
string_field_1: {
type: Type.string,
description: '可为空可为空可为空可为空可为空可为空可为空可为空可为空可为空',
},
string_field_2: {
type: Type.string,
description: '可为空可为空可为空可为空可为空可为空可为空可为空可为空可为空',
},
type: Type.object,
properties: {
string_field_1: {
type: Type.string,
description: '可为空可为空可为空可为空可为空可为空可为空可为空可为空可为空',
},
string_field_2: {
type: Type.string,
description: '可为空可为空可为空可为空可为空可为空可为空可为空可为空可为空',
},
required: [
'string_field_1',
],
additionalProperties: false,
},
required: [
'string_field_1',
],
additionalProperties: false,
})
resolve()
}, 1000)

View File

@ -0,0 +1,46 @@
import React, { type FC } from 'react'
import { useTranslation } from 'react-i18next'
type CardProps = {
name: string
type: string
required: boolean
description?: string
}
const Card: FC<CardProps> = ({
name,
type,
required,
description,
}) => {
const { t } = useTranslation()
return (
<div className='flex flex-col py-0.5'>
<div className='flex items-center gap-x-1 p-0.5 pl-1'>
<div className='px-1 py-0.5 text-text-primary system-sm-semibold'>
{name}
</div>
<div className='px-1 py-0.5 text-text-tertiary system-xs-medium'>
{type}
</div>
{
required && (
<div className='px-1 py-0.5 text-text-warning system-2xs-medium-uppercase'>
{t('workflow.nodes.llm.jsonSchema.required')}
</div>
)
}
</div>
{description && (
<div className='px-2 pb-1 text-text-tertiary system-xs-regular'>
{description}
</div>
)}
</div>
)
}
export default Card

View File

@ -0,0 +1,56 @@
import type { FC } from 'react'
import React from 'react'
import Tooltip from '@/app/components/base/tooltip'
import { RiAddCircleLine, RiDeleteBinLine, RiEditLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
type ActionsProps = {
disableAddBtn: boolean
onAddChildField: () => void
onEdit: () => void
onDelete: () => void
}
const Actions: FC<ActionsProps> = ({
disableAddBtn,
onAddChildField,
onEdit,
onDelete,
}) => {
const { t } = useTranslation()
return (
<div className='flex items-center gap-x-0.5'>
<Tooltip popupContent={t('workflow.nodes.llm.jsonSchema.addChildField')}>
<button
type='button'
className='flex items-center justify-center w-6 h-6 rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled'
onClick={onAddChildField}
disabled={disableAddBtn}
>
<RiAddCircleLine className='w-4 h-4'/>
</button>
</Tooltip>
<Tooltip popupContent={t('common.operation.edit')}>
<button
type='button'
className='flex items-center justify-center w-6 h-6 rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary'
onClick={onEdit}
>
<RiEditLine className='w-4 h-4' />
</button>
</Tooltip>
<Tooltip popupContent={t('common.operation.remove')}>
<button
type='button'
className='flex items-center justify-center w-6 h-6 rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive'
onClick={onDelete}
>
<RiDeleteBinLine className='w-4 h-4' />
</button>
</Tooltip>
</div>
)
}
export default React.memo(Actions)

View File

@ -0,0 +1,28 @@
import React, { type FC } from 'react'
import Button from '@/app/components/base/button'
import { useTranslation } from 'react-i18next'
type AdvancedActionsProps = {
onCancel: () => void
onConfirm: () => void
}
const AdvancedActions: FC<AdvancedActionsProps> = ({
onCancel,
onConfirm,
}) => {
const { t } = useTranslation()
return (
<div className='flex items-center gap-x-1'>
<Button size='small' variant='secondary' onClick={onCancel}>
{t('common.operation.cancel')}
</Button>
<Button size='small' variant='primary' onClick={onConfirm}>
{t('common.operation.confirm')}
</Button>
</div>
)
}
export default React.memo(AdvancedActions)

View File

@ -0,0 +1,83 @@
import React, { type FC, useCallback, useState } from 'react'
import { RiArrowDownDoubleLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Textarea from '@/app/components/base/textarea'
export type AdvancedOptionsType = {
enum: string
}
type AdvancedOptionsProps = {
options: AdvancedOptionsType
onChange: (options: AdvancedOptionsType) => void
}
const AdvancedOptions: FC<AdvancedOptionsProps> = ({
onChange,
options,
}) => {
const { t } = useTranslation()
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false)
const [enumValue, setEnumValue] = useState(options.enum)
const handleEnumChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setEnumValue(e.target.value)
}, [])
const handleEnumBlur = useCallback((e: React.FocusEvent<HTMLTextAreaElement>) => {
onChange({ enum: e.target.value })
}, [onChange])
// const handleEnumChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
// const value = e.target.value
// onChange({ enum: value })
// }, [onChange])
const handleToggleAdvancedOptions = useCallback(() => {
setShowAdvancedOptions(prev => !prev)
}, [])
return (
<div className='border-t border-divider-subtle'>
{showAdvancedOptions ? (
<div className='flex flex-col px-2 py-1.5 gap-y-1'>
<div className='flex items-center gap-x-2 w-full'>
<span className='text-text-tertiary system-2xs-medium-uppercase'>
{t('workflow.nodes.llm.jsonSchema.stringValidations')}
</span>
<div className='grow'>
<Divider type='horizontal' className='h-px my-0 bg-line-divider-bg' />
</div>
</div>
<div className='flex flex-col'>
<div className='flex items-center h-6 text-text-secondary system-xs-medium'>
Enum
</div>
<Textarea
size='small'
className='min-h-6'
value={enumValue}
onChange={handleEnumChange}
onBlur={handleEnumBlur}
placeholder={'\'abcd\', 1, 1.5, \'etc\''}
/>
</div>
</div>
) : (
<button
type='button'
className='flex items-center pl-1.5 pt-2 pr-2 pb-1 gap-x-0.5'
onClick={handleToggleAdvancedOptions}
>
<RiArrowDownDoubleLine className='w-3 h-3 text-text-tertiary' />
<span className='text-text-tertiary system-xs-regular'>
{t('workflow.nodes.llm.jsonSchema.showAdvancedOptions')}
</span>
</button>
)}
</div>
)
}
export default AdvancedOptions

View File

@ -0,0 +1,179 @@
import React, { type FC, useCallback, useMemo, useState } from 'react'
import type { SchemaEnumType } from '../../../../types'
import { ArrayType, Type } from '../../../../types'
import type { TypeItem } from './type-selector'
import TypeSelector from './type-selector'
import RequiredSwitch from './required-switch'
import Divider from '@/app/components/base/divider'
import Actions from './actions'
import AdvancedActions from './advanced-actions'
import AdvancedOptions, { type AdvancedOptionsType } from './advanced-options'
import { useTranslation } from 'react-i18next'
import { useUnmount } from 'ahooks'
import classNames from '@/utils/classnames'
export type EditData = {
name: string
type: Type | ArrayType
required: boolean
description: string
enum?: SchemaEnumType
}
type EditCardProps = {
fields: EditData
onPropertyNameChange: (name: string) => void
onTypeChange: (type: Type | ArrayType) => void
onRequiredChange: (name: string) => void
onDescriptionChange: (description: string) => void
onAdvancedOptionsChange: (options: AdvancedOptionsType) => void
onDelete: (name: string) => void
onCancel: () => void
}
const TYPE_OPTIONS = [
{ value: Type.string, text: 'string' },
{ value: Type.number, text: 'number' },
{ value: Type.boolean, text: 'boolean' },
{ value: Type.object, text: 'object' },
{ value: ArrayType.string, text: 'array[string]' },
{ value: ArrayType.number, text: 'array[number]' },
{ value: ArrayType.boolean, text: 'array[boolean]' },
{ value: ArrayType.object, text: 'array[object]' },
]
const EditCard: FC<EditCardProps> = ({
fields,
onPropertyNameChange,
onTypeChange,
onRequiredChange,
onDescriptionChange,
onAdvancedOptionsChange,
onDelete,
onCancel,
}) => {
const { t } = useTranslation()
const [propertyName, setPropertyName] = useState(fields.name)
const [description, setDescription] = useState(fields.description)
const [AdvancedEditing, setAdvancedEditing] = useState(!fields)
const handlePropertyNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setPropertyName(e.target.value)
}, [])
const handlePropertyNameBlur = useCallback((e: React.FocusEvent<HTMLInputElement>) => {
onPropertyNameChange(e.target.value)
}, [onPropertyNameChange])
const handleTypeChange = useCallback((item: TypeItem) => {
onTypeChange(item.value)
}, [onTypeChange])
const toggleRequired = useCallback(() => {
onRequiredChange(propertyName)
}, [onRequiredChange, propertyName])
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setDescription(e.target.value)
}, [])
const handleDescriptionBlur = useCallback((e: React.FocusEvent<HTMLInputElement>) => {
onDescriptionChange(e.target.value)
}, [onDescriptionChange])
const advancedOptions = useMemo(() => {
return { enum: (fields.enum || []).join(', ') }
}, [fields.enum])
const handleAdvancedOptionsChange = useCallback((options: AdvancedOptionsType) => {
onAdvancedOptionsChange(options)
}, [onAdvancedOptionsChange])
const handleConfirm = useCallback(() => {
setAdvancedEditing(false)
}, [])
const handleDelete = useCallback(() => {
onDelete(propertyName)
}, [onDelete, propertyName])
const handleEdit = useCallback(() => {
setAdvancedEditing(true)
}, [])
useUnmount(() => {
onPropertyNameChange(propertyName)
})
const disableAddBtn = fields.type !== Type.object && fields.type !== ArrayType.object
const hasAdvancedOptions = fields.type === Type.string || fields.type === Type.number
return (
<div className='flex flex-col py-0.5 rounded-lg bg-components-panel-bg shadow-sm shadow-shadow-shadow-4'>
<div className='flex items-center pl-1 pr-0.5'>
<div className='flex items-center gap-x-1 grow'>
<input
value={propertyName}
className='max-w-20 h-5 rounded-[5px] px-1 py-0.5 text-text-primary system-sm-semibold placeholder:text-text-placeholder
placeholder:system-sm-semibold hover:bg-state-base-hover border border-transparent focus:border-components-input-border-active
focus:bg-components-input-bg-active focus:shadow-xs shadow-shadow-shadow-3 caret-[#295EFF] outline-none'
placeholder={t('workflow.nodes.llm.jsonSchema.fieldNamePlaceholder')}
onChange={handlePropertyNameChange}
onBlur={handlePropertyNameBlur}
/>
<TypeSelector
currentValue={fields.type}
items={TYPE_OPTIONS}
onSelect={handleTypeChange}
popupClassName={'z-[1000]'}
/>
{
fields.required && (
<div className='px-1 py-0.5 text-text-warning system-2xs-medium-uppercase'>
{t('workflow.nodes.llm.jsonSchema.required')}
</div>
)
}
</div>
<RequiredSwitch
defaultValue={fields.required}
toggleRequired={toggleRequired}
/>
<Divider type='vertical' className='h-3' />
{AdvancedEditing ? (
<AdvancedActions
onCancel={() => { }}
onConfirm={handleConfirm}
/>
) : (
<Actions
disableAddBtn={disableAddBtn}
onAddChildField={() => { }}
onDelete={handleDelete}
onEdit={handleEdit}/>
)}
</div>
{(description || AdvancedEditing) && (
<div className={classNames(AdvancedEditing ? 'p-2 pt-1' : 'px-2 pb-1')}>
<input
value={description}
className='w-full h-4 p-0 text-text-tertiary system-xs-regular placeholder:text-text-placeholder placeholder:system-xs-regular caret-[#295EFF] outline-none'
placeholder={t('workflow.nodes.llm.jsonSchema.descriptionPlaceholder')}
onChange={handleDescriptionChange}
onBlur={handleDescriptionBlur}
/>
</div>
)}
{AdvancedEditing && hasAdvancedOptions && (
<AdvancedOptions
options={advancedOptions}
onChange={handleAdvancedOptionsChange}
/>
)}
</div>
)
}
export default EditCard

View File

@ -0,0 +1,25 @@
import React from 'react'
import type { FC } from 'react'
import Switch from '@/app/components/base/switch'
import { useTranslation } from 'react-i18next'
type RequiredSwitchProps = {
defaultValue: boolean
toggleRequired: () => void
}
const RequiredSwitch: FC<RequiredSwitchProps> = ({
defaultValue,
toggleRequired,
}) => {
const { t } = useTranslation()
return (
<div className='flex items-center gap-x-1 px-1.5 py-1 rounded-[5px] border border-divider-subtle bg-background-default-lighter'>
<span className='text-text-secondary system-2xs-medium-uppercase'>{t('workflow.nodes.llm.jsonSchema.required')}</span>
<Switch size='xs' defaultValue={defaultValue} onChange={toggleRequired} />
</div>
)
}
export default React.memo(RequiredSwitch)

View File

@ -0,0 +1,69 @@
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import type { ArrayType, Type } from '../../../../types'
import type { FC } from 'react'
import { useState } from 'react'
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
import cn from '@/utils/classnames'
export type TypeItem = {
value: Type | ArrayType
text: string
}
type TypeSelectorProps = {
items: TypeItem[]
currentValue: Type | ArrayType
onSelect: (item: TypeItem) => void
popupClassName?: string
}
const TypeSelector: FC<TypeSelectorProps> = ({
items,
currentValue,
onSelect,
popupClassName,
}) => {
const [open, setOpen] = useState(false)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={{
mainAxis: 4,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div className={cn(
'flex items-center p-0.5 pl-1 rounded-[5px] hover:bg-state-base-hover',
open && 'bg-state-base-hover',
)}>
<span className='text-text-tertiary system-xs-medium'>{currentValue}</span>
<RiArrowDownSLine className='w-4 h-4 text-text-tertiary' />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={popupClassName}>
<div className='w-40 p-1 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5'>
{items.map((item) => {
const isSelected = item.value === currentValue
return (<div
key={item.value}
className={'flex items-center gap-x-1 px-2 py-1 rounded-lg hover:bg-state-base-hover'}
onClick={() => {
onSelect(item)
setOpen(false)
}}
>
<span className='px-1 text-text-secondary system-sm-medium'>{item.text}</span>
{isSelected && <RiCheckLine className='w-4 h-4 text-text-accent' />}
</div>
)
})}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default TypeSelector

View File

@ -0,0 +1,27 @@
import type { FC } from 'react'
import type { Field } from '../../../types'
import SchemaNode from './schema-node'
type VisualEditorProps = {
schema: Field
onChange: (schema: Field) => void
}
const VisualEditor: FC<VisualEditorProps> = ({
schema,
onChange,
}) => {
return (
<div className='h-full rounded-xl p-1 pl-2 bg-background-section-burn'>
<SchemaNode
name='structured_output'
schema={schema}
required={false}
onChange={onChange}
depth={0}
/>
</div>
)
}
export default VisualEditor

View File

@ -0,0 +1,375 @@
import type { FC } from 'react'
import React, { useCallback, useRef, useState } from 'react'
import { ArrayType } from '../../../types'
import { type ArrayItems, type Field, Type } from '../../../types'
import type { AdvancedOptionsType } from './edit-card/advanced-options'
import classNames from '@/utils/classnames'
import { RiArrowDropDownLine, RiArrowDropRightLine } from '@remixicon/react'
import { getFieldType, getHasChildren } from '../../../utils'
import Divider from '@/app/components/base/divider'
import EditCard from './edit-card'
import Card from './card'
import produce from 'immer'
type SchemaNodeProps = {
name: string
required: boolean
schema: Field
depth: number
onChange: (schema: Field) => void
onPropertyNameChange?: (name: string) => void
onRequiredChange?: (name: string) => void
onNodeDelete?: (name: string) => void
}
// Support 10 levels of indentation
const indentPadding: Record<number, string> = {
0: 'pl-0',
1: 'pl-[20px]',
2: 'pl-[40px]',
3: 'pl-[60px]',
4: 'pl-[80px]',
5: 'pl-[100px]',
6: 'pl-[120px]',
7: 'pl-[140px]',
8: 'pl-[160px]',
9: 'pl-[180px]',
}
const indentLeft: Record<number, string> = {
1: 'left-0',
2: 'left-[20px]',
3: 'left-[40px]',
4: 'left-[60px]',
5: 'left-[80px]',
6: 'left-[100px]',
7: 'left-[120px]',
8: 'left-[140px]',
9: 'left-[160px]',
}
const SchemaNode: FC<SchemaNodeProps> = ({
name,
required,
schema,
onChange,
onPropertyNameChange,
onRequiredChange,
onNodeDelete,
depth,
}) => {
const [isExpanded, setIsExpanded] = useState(true)
const [isHovering, setIsHovering] = useState(false)
const hoverTimer = useRef<any>(null)
const hasChildren = getHasChildren(schema)
const isEditing = isHovering && depth > 0
const type = getFieldType(schema)
const handleExpand = () => {
setIsExpanded(!isExpanded)
}
const handleMouseEnter = () => {
hoverTimer.current = setTimeout(() => {
setIsHovering(true)
}, 100)
}
const handleMouseLeave = () => {
clearTimeout(hoverTimer.current)
setIsHovering(false)
}
const handlePropertyNameChange = useCallback((oldName: string, newName: string) => {
if (oldName === newName) return
if (schema.type === Type.object) {
const properties = schema.properties || {}
if (properties[newName]) {
// TODO: Show error message
return
}
const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
acc[key === oldName ? newName : key] = value
return acc
}, {} as Record<string, Field>)
const required = schema.required || []
const newRequired = produce(required, (draft) => {
const index = draft.indexOf(oldName)
if (index !== -1)
draft.splice(index, 1, newName)
})
onChange({
...schema,
properties: newProperties,
required: newRequired,
})
return
}
if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
const properties = schema.items.properties || {}
if (properties[newName]) {
// TODO: Show error message
return
}
const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
acc[key === oldName ? newName : key] = value
return acc
}, {} as Record<string, Field>)
const required = schema.items.required || []
const newRequired = produce(required, (draft) => {
const index = draft.indexOf(oldName)
if (index !== -1)
draft.splice(index, 1, newName)
})
onChange({
...schema,
items: {
...schema.items,
properties: newProperties,
required: newRequired,
},
})
}
}, [onChange, schema])
const handleTypeChange = useCallback((newType: Type | ArrayType) => {
if (schema.type === newType) return
const newSchema = produce(schema, (draft) => {
if (draft.type === Type.object) {
delete draft.properties
delete draft.required
}
if (draft.type === Type.array)
delete draft.items
switch (newType) {
case Type.object:
draft.type = Type.object
draft.properties = {}
draft.required = []
break
case ArrayType.string:
draft.type = Type.array
draft.items = {
type: Type.string,
}
break
case ArrayType.number:
draft.type = Type.array
draft.items = {
type: Type.number,
}
break
case ArrayType.boolean:
draft.type = Type.array
draft.items = {
type: Type.boolean,
}
break
case ArrayType.object:
draft.type = Type.array
draft.items = {
type: Type.object,
properties: {},
required: [],
}
break
default:
draft.type = newType as Type
}
})
onChange(newSchema)
}, [onChange, schema])
const toggleRequired = useCallback((name: string) => {
if (schema.type === Type.object) {
const required = schema.required || []
const newRequired = required.includes(name)
? required.filter(item => item !== name)
: [...required, name]
onChange({
...schema,
required: newRequired,
})
return
}
if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
const required = schema.items.required || []
const newRequired = required.includes(name)
? required.filter(item => item !== name)
: [...required, name]
onChange({
...schema,
items: {
...schema.items,
required: newRequired,
},
})
}
}, [onChange, schema])
const handleDescriptionChange = useCallback((description: string) => {
onChange({
...schema,
description,
})
}, [onChange, schema])
const handleAdvancedOptionsChange = useCallback((advancedOptions: AdvancedOptionsType) => {
const newAdvancedOptions = {
enum: advancedOptions.enum.replace(' ', '').split(','),
}
onChange({
...schema,
...newAdvancedOptions,
})
}, [onChange, schema])
const handleNodeDelete = useCallback((name: string) => {
const newSchema = produce(schema, (draft) => {
if (draft.type === Type.object && draft.properties) {
delete draft.properties[name]
draft.required = draft.required?.filter(item => item !== name)
}
if (draft.type === Type.array && draft.items?.properties && draft.items?.type === Type.object) {
delete draft.items.properties[name]
draft.items.required = draft.items.required?.filter(item => item !== name)
}
})
onChange(newSchema)
}, [onChange, schema])
const handlePropertyChange = useCallback((name: string, propertySchema: Field) => {
onChange({
...schema,
properties: {
...(schema.properties || {}),
[name]: propertySchema,
},
})
}, [onChange, schema])
const handleItemsPropertyChange = useCallback((name: string, itemsSchema: Field) => {
onChange({
...schema,
items: {
...schema.items,
properties: {
...(schema.items?.properties || {}),
[name]: itemsSchema as ArrayItems,
},
} as ArrayItems,
})
}, [onChange, schema])
return (
<div className='relative'>
<div className={classNames('relative z-10', indentPadding[depth])}>
{depth > 0 && hasChildren && (
<div className={classNames(
'flex items-center absolute top-0 w-5 h-7 px-0.5 z-10 bg-background-section-burn',
indentLeft[depth],
)}>
<button
onClick={handleExpand}
className='py-0.5 text-text-tertiary hover:text-text-accent'
>
{
isExpanded
? <RiArrowDropDownLine className='w-4 h-4' />
: <RiArrowDropRightLine className='w-4 h-4' />
}
</button>
</div>
)}
<div
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{isEditing ? (
<EditCard
fields={{
name,
type,
required,
description: schema.description || '',
enum: schema.enum || [],
}}
onPropertyNameChange={onPropertyNameChange!}
onTypeChange={handleTypeChange}
onRequiredChange={onRequiredChange!}
onDescriptionChange={handleDescriptionChange}
onAdvancedOptionsChange={handleAdvancedOptionsChange}
onDelete={onNodeDelete!}
onCancel={() => {}}
/>
) : (
<Card
name={name}
type={type}
required={required}
description={schema.description}
/>
)}
</div>
</div>
<div className={classNames(
'flex justify-center w-5 h-[calc(100%-1.75rem)] absolute top-7 z-0',
indentLeft[depth + 1],
)}>
<Divider type='vertical' className='bg-divider-subtle mx-0' />
</div>
{isExpanded && hasChildren && (
<>
{schema.type === Type.object && schema.properties && (
Object.entries(schema.properties).map(([key, childSchema]) => (
<SchemaNode
key={key}
name={key}
required={!!schema.required?.includes(key)}
schema={childSchema}
onChange={handlePropertyChange.bind(null, key)}
onPropertyNameChange={handlePropertyNameChange.bind(null, key)}
onRequiredChange={toggleRequired}
onNodeDelete={handleNodeDelete}
depth={depth + 1}
/>
))
)}
{schema.type === Type.array
&& schema.items
&& schema.items.type === Type.object
&& schema.items.properties
&& (
Object.entries(schema.items.properties).map(([key, childSchema]) => (
<SchemaNode
key={key}
name={key}
required={!!schema.items?.required?.includes(key)}
schema={childSchema}
onChange={handleItemsPropertyChange.bind(null, key)}
onPropertyNameChange={handlePropertyNameChange.bind(null, key)}
onRequiredChange={toggleRequired}
onNodeDelete={handleNodeDelete}
depth={depth + 1}
/>
))
)}
</>
)}
</div>
)
}
export default React.memo(SchemaNode)

View File

@ -35,6 +35,9 @@ export enum ArrayType {
export type TypeWithArray = Type | ArrayType
type ArrayItemType = Exclude<Type, Type.array>
export type ArrayItems = Omit<Field, 'type'> & { type: ArrayItemType }
export type SchemaEnumType = string[] | number[]
export type Field = {
type: Type
@ -43,18 +46,18 @@ export type Field = {
}
required?: string[] // Key of required properties in object
description?: string
items?: { // Array has items. Define the item type
type: ArrayItemType
}
enum?: string[] // Enum values
items?: ArrayItems // Array has items. Define the item type
enum?: SchemaEnumType // Enum values
additionalProperties?: false // Required in object by api. Just set false
}
export type StructuredOutput = {
schema: {
type: Type.object,
properties: Record<string, Field>
required: string[]
additionalProperties: false
}
schema: SchemaRoot
}
export type SchemaRoot = {
type: Type.object
properties: Record<string, Field>
required?: string[]
additionalProperties: false
}

View File

@ -12,3 +12,13 @@ export const getFieldType = (field: Field) => {
return ArrayType[items.type]
}
export const getHasChildren = (schema: Field) => {
const complexTypes = [Type.object, Type.array]
if (!complexTypes.includes(schema.type))
return false
if (schema.type === Type.object)
return schema.properties && Object.keys(schema.properties).length > 0
if (schema.type === Type.array)
return schema.items && schema.items.type === Type.object && schema.items.properties && Object.keys(schema.items.properties).length > 0
}

View File

@ -1,19 +1,77 @@
'use client'
import { ToolTipContent } from '../components/base/tooltip/content'
import { SwitchPluginVersion } from '../components/workflow/nodes/_base/components/switch-plugin-version'
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import { type Field, Type } from '../components/workflow/nodes/llm/types'
import JsonSchemaConfigModal from '../components/workflow/nodes/llm/components/json-schema-config-modal'
export default function Page() {
const { t } = useTranslation()
return <div className="p-20">
<SwitchPluginVersion
uniqueIdentifier={'langgenius/openai:12'}
tooltip={<ToolTipContent
title={t('workflow.nodes.agent.unsupportedStrategy')}
>
{t('workflow.nodes.agent.strategyNotFoundDescAndSwitchVersion')}
</ToolTipContent>}
/>
const [show, setShow] = useState(false)
const [schema, setSchema] = useState<Field>({
type: Type.object,
properties: {
userId: {
type: Type.number,
description: 'The user ID',
},
id: {
type: Type.number,
},
title: {
type: Type.string,
},
completed: {
type: Type.boolean,
},
locations: {
type: Type.array,
items: {
type: Type.object,
properties: {
x: {
type: Type.object,
properties: {
x1: {
type: Type.array,
items: {
type: Type.number,
},
},
},
required: [
'x1',
],
},
y: {
type: Type.number,
},
},
required: [
'x',
'y',
],
},
},
},
required: [
'userId',
'id',
'title',
],
additionalProperties: false,
})
return <div className='flex flex-col p-20 h-full w-full overflow-hidden'>
<button onClick={() => setShow(true)} className='shrink-0'>Open Json Schema Config</button>
{show && <JsonSchemaConfigModal
isShow={show}
defaultSchema={schema}
onSave={(schema) => {
setSchema(schema)
}}
onClose={() => setShow(false)}
/>}
<pre className='bg-gray-50 p-4 rounded-lg overflow-auto grow'>
{JSON.stringify(schema, null, 2)}
</pre>
</div>
}

View File

@ -426,6 +426,13 @@ const translation = {
apply: 'Apply',
doc: 'Learn more about structured output',
resetDefaults: 'Reset Defaults',
required: 'required',
addField: 'Add Field',
addChildField: 'Add Child Field',
showAdvancedOptions: 'Show advanced options',
stringValidations: 'String Validations',
fieldNamePlaceholder: 'Field Name',
descriptionPlaceholder: 'Add description',
},
},
knowledgeRetrieval: {

View File

@ -426,6 +426,13 @@ const translation = {
apply: '应用',
doc: '了解有关结构化输出的更多信息',
resetDefaults: '恢复默认值',
required: '必填',
addField: '添加字段',
addChildField: '添加子字段',
showAdvancedOptions: '显示高级选项',
stringValidations: '字符串验证',
fieldNamePlaceholder: '字段名',
descriptionPlaceholder: '添加描述',
},
},
knowledgeRetrieval: {

View File

@ -113,6 +113,7 @@ const config = {
'dataset-option-card-purple-gradient': 'var(--color-dataset-option-card-purple-gradient)',
'dataset-option-card-orange-gradient': 'var(--color-dataset-option-card-orange-gradient)',
'dataset-chunk-list-mask-bg': 'var(--color-dataset-chunk-list-mask-bg)',
'line-divider-bg': 'var(--color-line-divider-bg)',
},
animation: {
'spin-slow': 'spin 2s linear infinite',

View File

@ -33,7 +33,7 @@ html[data-theme="dark"] {
rgba(240, 68, 56, 0.3) 0%,
rgba(0, 0, 0, 0) 100%);
--color-toast-info-bg: linear-gradient(92deg,
rgba(11, 165, 236, 0.3) 0%),
rgba(11, 165, 236, 0.3) 0%);
--color-account-teams-bg: linear-gradient(271deg,
rgba(34, 34, 37, 0.9) -0.1%,
rgba(29, 29, 32, 0.9) 98.26%
@ -61,4 +61,5 @@ html[data-theme="dark"] {
180deg,
rgba(24, 24, 27, 0.08) 0%,
rgba(0, 0, 0, 0) 100%);
--color-line-divider-bg: linear-gradient(90deg, rgba(200, 206, 218, 0.14) 0%, rgba(0, 0, 0, 0) 100%,);
}

View File

@ -33,7 +33,7 @@ html[data-theme="light"] {
rgba(240, 68, 56, 0.25) 0%,
rgba(255, 255, 255, 0) 100%);
--color-toast-info-bg: linear-gradient(92deg,
rgba(11, 165, 236, 0.25) 0%),
rgba(11, 165, 236, 0.25) 0%);
--color-account-teams-bg: linear-gradient(271deg,
rgba(249, 250, 251, 0.9) -0.1%,
rgba(242, 244, 247, 0.9) 98.26%
@ -61,4 +61,5 @@ html[data-theme="light"] {
180deg,
rgba(200, 206, 218, 0.2) 0%,
rgba(255, 255, 255, 0) 100%);
--color-line-divider-bg: linear-gradient(90deg, rgba(16, 24, 40, 0.08) 0%, rgba(255, 255, 255, 0) 100%);
}