feat: replace ajv with jsonschema for JSON validation and update related components

This commit is contained in:
twwu 2025-03-28 15:06:39 +08:00
parent 0319e35b4d
commit 9ee8fa644e
12 changed files with 320 additions and 120 deletions

View File

@ -4,7 +4,7 @@ import cn from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
import Button from '@/app/components/base/button'
import { checkDepth } from '../../utils'
import { checkJsonDepth } from '../../utils'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
import CodeEditor from './code-editor'
import ErrorMessage from './error-message'
@ -59,7 +59,7 @@ const JsonImporter: FC<JsonImporterProps> = ({
setParseError(new Error('Root must be an object, not an array or primitive value.'))
return
}
const maxDepth = checkDepth(parsedJSON)
const maxDepth = checkJsonDepth(parsedJSON)
if (maxDepth > JSON_SCHEMA_MAX_DEPTH) {
setParseError({
type: 'error',
@ -72,10 +72,10 @@ const JsonImporter: FC<JsonImporterProps> = ({
setOpen(false)
}
catch (e: any) {
if (e instanceof SyntaxError)
if (e instanceof Error)
setParseError(e)
else
setParseError(new Error('Unknown error'))
setParseError(new Error('Invalid JSON'))
}
}, [onSubmit, json])

View File

@ -9,12 +9,20 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import VisualEditor from './visual-editor'
import SchemaEditor from './schema-editor'
import { convertBooleanToString, getValidationErrorMessage, jsonToSchema, validateSchemaAgainstDraft7 } from '../../utils'
import {
checkJsonSchemaDepth,
convertBooleanToString,
getValidationErrorMessage,
jsonToSchema,
preValidateSchema,
validateSchemaAgainstDraft7,
} from '../../utils'
import { MittProvider, VisualEditorContextProvider } from './visual-editor/context'
import ErrorMessage from './error-message'
import { useVisualEditorStore } from './visual-editor/store'
import Toast from '@/app/components/base/toast'
import { useGetLanguage } from '@/context/i18n'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
type JsonSchemaConfigProps = {
defaultSchema?: SchemaRoot
@ -74,18 +82,26 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
if (currentTab === value) return
if (currentTab === SchemaView.JsonSchema) {
try {
const parsedJson = JSON.parse(json)
const schema = convertBooleanToString(parsedJson)
const schema = JSON.parse(json)
setParseError(null)
const ajvError = validateSchemaAgainstDraft7(schema)
if (ajvError.length > 0) {
setValidationError(getValidationErrorMessage(ajvError))
const result = preValidateSchema(schema)
if (!result.success) {
setValidationError(result.error.message)
return
}
else {
setJsonSchema(schema)
setValidationError('')
const schemaDepth = checkJsonSchemaDepth(schema)
if (schemaDepth > JSON_SCHEMA_MAX_DEPTH) {
setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`)
return
}
convertBooleanToString(schema)
const validationErrors = validateSchemaAgainstDraft7(schema)
if (validationErrors.length > 0) {
setValidationError(getValidationErrorMessage(validationErrors))
return
}
setJsonSchema(schema)
setValidationError('')
}
catch (error) {
setValidationError('')
@ -135,7 +151,7 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
const handleResetDefaults = useCallback(() => {
if (currentTab === SchemaView.VisualEditor) {
setHoveringProperty('')
setHoveringProperty(null)
advancedEditing && setAdvancedEditing(false)
isAddingNewField && setIsAddingNewField(false)
}
@ -153,15 +169,24 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
try {
schema = JSON.parse(json)
setParseError(null)
const ajvError = validateSchemaAgainstDraft7(schema)
if (ajvError.length > 0) {
setValidationError(getValidationErrorMessage(ajvError))
const result = preValidateSchema(schema)
if (!result.success) {
setValidationError(result.error.message)
return
}
else {
setJsonSchema(schema)
setValidationError('')
const schemaDepth = checkJsonSchemaDepth(schema)
if (schemaDepth > JSON_SCHEMA_MAX_DEPTH) {
setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`)
return
}
convertBooleanToString(schema)
const validationErrors = validateSchemaAgainstDraft7(schema)
if (validationErrors.length > 0) {
setValidationError(getValidationErrorMessage(validationErrors))
return
}
setJsonSchema(schema)
setValidationError('')
}
catch (error) {
setValidationError('')

View File

@ -47,14 +47,13 @@ const GeneratedResult: FC<GeneratedResultProps> = ({
const jsonSchema = useMemo(() => formatJSON(schema), [schema])
const handleApply = useCallback(() => {
const ajvError = validateSchemaAgainstDraft7(schema)
if (ajvError.length > 0) {
setValidationError(getValidationErrorMessage(ajvError))
}
else {
onApply()
setValidationError('')
const validationErrors = validateSchemaAgainstDraft7(schema)
if (validationErrors.length > 0) {
setValidationError(getValidationErrorMessage(validationErrors))
return
}
onApply()
setValidationError('')
}, [schema, onApply])
return (

View File

@ -47,6 +47,15 @@ const TYPE_OPTIONS = [
{ value: ArrayType.object, text: 'array[object]' },
]
const MAXIMUM_DEPTH_TYPE_OPTIONS = [
{ value: Type.string, text: 'string' },
{ value: Type.number, text: 'number' },
// { value: Type.boolean, text: 'boolean' },
{ value: ArrayType.string, text: 'array[string]' },
{ value: ArrayType.number, text: 'array[number]' },
// { value: ArrayType.boolean, text: 'array[boolean]' },
]
const EditCard: FC<EditCardProps> = ({
fields,
depth,
@ -63,7 +72,8 @@ const EditCard: FC<EditCardProps> = ({
const { emit, useSubscribe } = useMittContext()
const blurWithActions = useRef(false)
const disableAddBtn = depth >= JSON_SCHEMA_MAX_DEPTH || (currentFields.type !== Type.object && currentFields.type !== ArrayType.object)
const maximumDepthReached = depth === JSON_SCHEMA_MAX_DEPTH
const disableAddBtn = maximumDepthReached || (currentFields.type !== Type.object && currentFields.type !== ArrayType.object)
const hasAdvancedOptions = currentFields.type === Type.string || currentFields.type === Type.number
const isAdvancedEditing = advancedEditing || isAddingNewField
@ -205,7 +215,7 @@ const EditCard: FC<EditCardProps> = ({
/>
<TypeSelector
currentValue={currentFields.type}
items={TYPE_OPTIONS}
items={maximumDepthReached ? MAXIMUM_DEPTH_TYPE_OPTIONS : TYPE_OPTIONS}
onSelect={handleTypeChange}
popupClassName={'z-[1000]'}
/>

View File

@ -19,7 +19,7 @@ const VisualEditor: FC<VisualEditorProps> = (props) => {
schema={schema}
required={false}
path={[]}
depth={1}
depth={0}
/>
</div>
)

View File

@ -23,28 +23,31 @@ type SchemaNodeProps = {
// Support 10 levels of indentation
const indentPadding: Record<number, string> = {
1: 'pl-0',
2: 'pl-[20px]',
3: 'pl-[40px]',
4: 'pl-[60px]',
5: 'pl-[80px]',
6: 'pl-[100px]',
7: 'pl-[120px]',
8: 'pl-[140px]',
9: 'pl-[160px]',
10: 'pl-[180px]',
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]',
10: 'pl-[200px]',
}
const indentLeft: Record<number, string> = {
2: 'left-0',
3: 'left-[20px]',
4: 'left-[40px]',
5: 'left-[60px]',
6: 'left-[80px]',
7: 'left-[100px]',
8: 'left-[120px]',
9: 'left-[140px]',
10: 'left-[160px]',
0: 'left-0',
1: 'left-[20px]',
2: 'left-[40px]',
3: 'left-[60px]',
4: 'left-[80px]',
5: 'left-[100px]',
6: 'left-[120px]',
7: 'left-[140px]',
8: 'left-[160px]',
9: 'left-[180px]',
10: 'left-[200px]',
}
const SchemaNode: FC<SchemaNodeProps> = ({
@ -61,13 +64,13 @@ const SchemaNode: FC<SchemaNodeProps> = ({
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
const { run: setHoveringPropertyDebounced } = useDebounceFn((path: string) => {
const { run: setHoveringPropertyDebounced } = useDebounceFn((path: string | null) => {
setHoveringProperty(path)
}, { wait: 50 })
const hasChildren = useMemo(() => getHasChildren(schema), [schema])
const type = useMemo(() => getFieldType(schema), [schema])
const isHovering = hoveringProperty === path.join('.') && depth > 1
const isHovering = hoveringProperty === path.join('.')
const handleExpand = () => {
setIsExpanded(!isExpanded)
@ -80,16 +83,16 @@ const SchemaNode: FC<SchemaNodeProps> = ({
const handleMouseLeave = () => {
if (advancedEditing || isAddingNewField) return
setHoveringPropertyDebounced('')
setHoveringPropertyDebounced(null)
}
return (
<div className='relative'>
<div className={classNames('relative z-10', indentPadding[depth])}>
{depth > 1 && hasChildren && (
{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],
indentLeft[depth - 1],
)}>
<button
onClick={handleExpand}
@ -108,7 +111,7 @@ const SchemaNode: FC<SchemaNodeProps> = ({
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{isHovering ? (
{(isHovering && depth > 0) ? (
<EditCard
fields={{
name,
@ -135,7 +138,7 @@ const SchemaNode: FC<SchemaNodeProps> = ({
<div className={classNames(
'flex justify-center w-5 absolute top-7 z-0',
schema.description ? 'h-[calc(100%-3rem)]' : 'h-[calc(100%-1.75rem)]',
indentLeft[depth + 1],
indentLeft[depth],
)}>
<Divider
type='vertical'
@ -180,7 +183,7 @@ const SchemaNode: FC<SchemaNodeProps> = ({
)}
{
depth === 1 && !isAddingNewField && (
depth === 0 && !isAddingNewField && (
<AddField />
)
}

View File

@ -4,8 +4,8 @@ import type { SchemaRoot } from '../../../types'
import { VisualEditorContext } from './context'
type VisualEditorStore = {
hoveringProperty: string | ''
setHoveringProperty: (propertyPath: string) => void
hoveringProperty: string | null
setHoveringProperty: (propertyPath: string | null) => void
isAddingNewField: boolean
setIsAddingNewField: (isAdding: boolean) => void
advancedEditing: boolean
@ -15,8 +15,8 @@ type VisualEditorStore = {
}
export const createVisualEditorStore = () => createStore<VisualEditorStore>(set => ({
hoveringProperty: '',
setHoveringProperty: (propertyPath: string) => set({ hoveringProperty: propertyPath }),
hoveringProperty: null,
setHoveringProperty: (propertyPath: string | null) => set({ hoveringProperty: propertyPath }),
isAddingNewField: false,
setIsAddingNewField: (isAdding: boolean) => set({ isAddingNewField: isAdding }),
advancedEditing: false,

View File

@ -1,8 +1,9 @@
import { ArrayType, Type } from './types'
import type { ArrayItems, Field, LLMNodeType } from './types'
import type { ErrorObject } from 'ajv'
import { validateDraft07 } from '@/public/validate-esm.mjs'
import type { Schema, ValidationError } from 'jsonschema'
import { Validator } from 'jsonschema'
import produce from 'immer'
import { z } from 'zod'
export const checkNodeValid = (payload: LLMNodeType) => {
return true
@ -58,22 +59,37 @@ export const jsonToSchema = (json: any): Field => {
return schema
}
export const checkDepth = (json: any, currentDepth = 1) => {
const type = inferType(json)
if (type !== Type.object && type !== Type.array)
return currentDepth
export const checkJsonDepth = (json: any) => {
if (!json || typeof json !== 'object')
return 0
let maxDepth = currentDepth
if (type === Type.object) {
Object.keys(json).forEach((key) => {
const depth = checkDepth(json[key], currentDepth + 1)
maxDepth = Math.max(maxDepth, depth)
})
let maxDepth = 0
if (Array.isArray(json) && json[0] && typeof json[0] === 'object') {
maxDepth = checkJsonDepth(json[0]) + 1
}
else if (type === Type.array && json.length > 0) {
const depth = checkDepth(json[0], currentDepth + 1)
maxDepth = Math.max(maxDepth, depth)
else if (typeof json === 'object') {
const propertyDepths = Object.values(json).map(value => checkJsonDepth(value))
maxDepth = propertyDepths.length ? Math.max(...propertyDepths) + 1 : 1
}
return maxDepth
}
export const checkJsonSchemaDepth = (schema: Field) => {
if (!schema || typeof schema !== 'object')
return 0
let maxDepth = 0
if (schema.type === Type.object && schema.properties) {
const propertyDepths = Object.values(schema.properties).map(value => checkJsonSchemaDepth(value))
maxDepth = propertyDepths.length ? Math.max(...propertyDepths) + 1 : 1
}
else if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
maxDepth = checkJsonSchemaDepth(schema.items) + 1
}
return maxDepth
}
@ -84,6 +100,169 @@ export const findPropertyWithPath = (target: any, path: string[]) => {
return current
}
const draft07MetaSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
$id: 'http://json-schema.org/draft-07/schema#',
title: 'Core schema meta-schema',
definitions: {
schemaArray: {
type: 'array',
minItems: 1,
items: { $ref: '#' },
},
nonNegativeInteger: {
type: 'integer',
minimum: 0,
},
nonNegativeIntegerDefault0: {
allOf: [
{ $ref: '#/definitions/nonNegativeInteger' },
{ default: 0 },
],
},
simpleTypes: {
enum: [
'array',
'boolean',
'integer',
'null',
'number',
'object',
'string',
],
},
stringArray: {
type: 'array',
items: { type: 'string' },
uniqueItems: true,
default: [],
},
},
type: ['object', 'boolean'],
properties: {
$id: {
type: 'string',
format: 'uri-reference',
},
$schema: {
type: 'string',
format: 'uri',
},
$ref: {
type: 'string',
format: 'uri-reference',
},
title: {
type: 'string',
},
description: {
type: 'string',
},
default: true,
readOnly: {
type: 'boolean',
default: false,
},
examples: {
type: 'array',
items: true,
},
multipleOf: {
type: 'number',
exclusiveMinimum: 0,
},
maximum: {
type: 'number',
},
exclusiveMaximum: {
type: 'number',
},
minimum: {
type: 'number',
},
exclusiveMinimum: {
type: 'number',
},
maxLength: { $ref: '#/definitions/nonNegativeInteger' },
minLength: { $ref: '#/definitions/nonNegativeIntegerDefault0' },
pattern: {
type: 'string',
format: 'regex',
},
additionalItems: { $ref: '#' },
items: {
anyOf: [
{ $ref: '#' },
{ $ref: '#/definitions/schemaArray' },
],
default: true,
},
maxItems: { $ref: '#/definitions/nonNegativeInteger' },
minItems: { $ref: '#/definitions/nonNegativeIntegerDefault0' },
uniqueItems: {
type: 'boolean',
default: false,
},
contains: { $ref: '#' },
maxProperties: { $ref: '#/definitions/nonNegativeInteger' },
minProperties: { $ref: '#/definitions/nonNegativeIntegerDefault0' },
required: { $ref: '#/definitions/stringArray' },
additionalProperties: { $ref: '#' },
definitions: {
type: 'object',
additionalProperties: { $ref: '#' },
default: {},
},
properties: {
type: 'object',
additionalProperties: { $ref: '#' },
default: {},
},
patternProperties: {
type: 'object',
additionalProperties: { $ref: '#' },
propertyNames: { format: 'regex' },
default: {},
},
dependencies: {
type: 'object',
additionalProperties: {
anyOf: [
{ $ref: '#' },
{ $ref: '#/definitions/stringArray' },
],
},
},
propertyNames: { $ref: '#' },
const: true,
enum: {
type: 'array',
items: true,
minItems: 1,
uniqueItems: true,
},
type: {
anyOf: [
{ $ref: '#/definitions/simpleTypes' },
{
type: 'array',
items: { $ref: '#/definitions/simpleTypes' },
minItems: 1,
uniqueItems: true,
},
],
},
format: { type: 'string' },
allOf: { $ref: '#/definitions/schemaArray' },
anyOf: { $ref: '#/definitions/schemaArray' },
oneOf: { $ref: '#/definitions/schemaArray' },
not: { $ref: '#' },
},
default: true,
} as unknown as Schema
const validator = new Validator()
export const validateSchemaAgainstDraft7 = (schemaToValidate: any) => {
const schema = produce(schemaToValidate, (draft: any) => {
// Make sure the schema has the $schema property for draft-07
@ -91,17 +270,20 @@ export const validateSchemaAgainstDraft7 = (schemaToValidate: any) => {
draft.$schema = 'http://json-schema.org/draft-07/schema#'
})
const valid = validateDraft07(schema)
const result = validator.validate(schema, draft07MetaSchema, {
nestedErrors: true,
throwError: false,
})
// Access errors from the validation result
const errors = valid ? [] : (validateDraft07 as any).errors || []
const errors = result.valid ? [] : result.errors || []
return errors
}
export const getValidationErrorMessage = (errors: ErrorObject[]) => {
export const getValidationErrorMessage = (errors: ValidationError[]) => {
const message = errors.map((error) => {
return `Error: ${error.instancePath} ${error.message} Details: ${JSON.stringify(error.params)}`
return `Error: ${error.path.join('.')} ${error.message} Details: ${JSON.stringify(error.stack)}`
}).join('; ')
return message
}
@ -125,3 +307,15 @@ export const convertBooleanToString = (schema: any) => {
}
return schema
}
const schemaRootObject = z.object({
type: z.literal('object'),
properties: z.record(z.string(), z.any()),
required: z.array(z.string()),
additionalProperties: z.boolean().optional(),
})
export const preValidateSchema = (schema: any) => {
const result = schemaRootObject.safeParse(schema)
return result
}

View File

@ -1,35 +0,0 @@
const fs = require('node:fs')
const path = require('node:path')
const Ajv = require('ajv')
const standaloneCode = require('ajv/dist/standalone').default
const ajv = new Ajv({
allErrors: true,
verbose: true,
code: { source: true, esm: true },
})
const moduleCode = standaloneCode(ajv, {
validateDraft07: 'http://json-schema.org/draft-07/schema#',
})
/**
* @see {@link https://github.com/ajv-validator/ajv/issues/2209}
*/
const preamble = [
'"use strict";',
].join('')
const imports = new Set()
const requireRegex = /const (\S+)\s*=\s*require\((.+)\)\.(\S+);/g
const replaced = moduleCode
.replace(requireRegex, (_match, p1, p2, p3) => {
imports.add(`import { ${p3} as ${p1} } from ${p2};`)
return ''
})
.replace('"use strict";', '')
const uglyOutput = [preamble, Array.from(imports).join(''), replaced].join(
'',
)
fs.writeFileSync(path.join(__dirname, '../public/validate-esm.mjs'), uglyOutput)

View File

@ -56,7 +56,6 @@
"@tanstack/react-query": "^5.60.5",
"@tanstack/react-query-devtools": "^5.60.5",
"ahooks": "^3.8.4",
"ajv": "^8.17.1",
"class-variance-authority": "^0.7.0",
"classnames": "^2.5.1",
"copy-to-clipboard": "^3.3.3",
@ -74,6 +73,7 @@
"immer": "^9.0.19",
"js-audio-recorder": "^1.0.7",
"js-cookie": "^3.0.5",
"jsonschema": "^1.5.0",
"jwt-decode": "^4.0.0",
"katex": "^0.16.21",
"ky": "^1.7.2",

11
web/pnpm-lock.yaml generated
View File

@ -103,9 +103,6 @@ importers:
ahooks:
specifier: ^3.8.4
version: 3.8.4(react@19.0.0)
ajv:
specifier: ^8.17.1
version: 8.17.1
class-variance-authority:
specifier: ^0.7.0
version: 0.7.0
@ -157,6 +154,9 @@ importers:
js-cookie:
specifier: ^3.0.5
version: 3.0.5
jsonschema:
specifier: ^1.5.0
version: 1.5.0
jwt-decode:
specifier: ^4.0.0
version: 4.0.0
@ -5854,6 +5854,9 @@ packages:
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
jsonschema@1.5.0:
resolution: {integrity: sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw==}
jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
@ -15253,6 +15256,8 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
jsonschema@1.5.0: {}
jsx-ast-utils@3.3.5:
dependencies:
array-includes: 3.1.8

File diff suppressed because one or more lines are too long