From 44be94d5b54ce70037b09c26e902d2454f3b946e Mon Sep 17 00:00:00 2001 From: twwu Date: Tue, 18 Mar 2025 22:34:40 +0800 Subject: [PATCH] feat: add AJV for JSON schema validation and improve error handling --- .../json-schema-config-modal/code-editor.tsx | 133 ++++++++++++++++++ .../error-message.tsx | 27 ++++ .../json-importer.tsx | 104 ++------------ .../json-schema-config.tsx | 74 +++++++++- .../generated-result.tsx | 127 +++++------------ .../json-schema-generator/index.tsx | 1 + .../json-schema-generator/prompt-editor.tsx | 3 +- .../schema-editor.tsx | 95 ++----------- .../components/workflow/nodes/llm/utils.ts | 27 ++++ web/package.json | 1 + web/pnpm-lock.yaml | 3 + 11 files changed, 316 insertions(+), 279 deletions(-) create mode 100644 web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx create mode 100644 web/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message.tsx diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx new file mode 100644 index 0000000000..871cb6a305 --- /dev/null +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx @@ -0,0 +1,133 @@ +import React, { type FC, useCallback, useEffect, useRef } from 'react' +import useTheme from '@/hooks/use-theme' +import { Theme } from '@/types/app' +import classNames from '@/utils/classnames' +import { Editor } from '@monaco-editor/react' +import { RiClipboardLine, RiIndentIncrease } from '@remixicon/react' +import copy from 'copy-to-clipboard' + +type CodeEditorProps = { + value: string + onUpdate?: (value: string) => void + showFormatButton?: boolean + editorWrapperClassName?: string + readOnly?: boolean +} & React.HTMLAttributes + +const CodeEditor: FC = ({ + value, + onUpdate, + showFormatButton = true, + editorWrapperClassName, + readOnly = false, + className, +}) => { + const { theme } = useTheme() + const monacoRef = useRef(null) + const editorRef = useRef(null) + + useEffect(() => { + if (monacoRef.current) { + if (theme === Theme.light) + monacoRef.current.editor.setTheme('light-theme') + else + monacoRef.current.editor.setTheme('dark-theme') + } + }, [theme]) + + const handleEditorDidMount = useCallback((editor: any, monaco: any) => { + editorRef.current = editor + monacoRef.current = monaco + monaco.editor.defineTheme('light-theme', { + base: 'vs', + inherit: true, + rules: [], + colors: { + 'editor.background': '#00000000', + 'editor.lineHighlightBackground': '#00000000', + 'focusBorder': '#00000000', + }, + }) + monaco.editor.defineTheme('dark-theme', { + base: 'vs-dark', + inherit: true, + rules: [], + colors: { + 'editor.background': '#00000000', + 'editor.lineHighlightBackground': '#00000000', + 'focusBorder': '#00000000', + }, + }) + monaco.editor.setTheme('light-theme') + }, []) + + const formatJsonContent = useCallback(() => { + if (editorRef.current) + editorRef.current.getAction('editor.action.formatDocument')?.run() + }, []) + + const handleEditorChange = useCallback((value: string | undefined) => { + if (value) + onUpdate?.(value) + }, [onUpdate]) + + return ( +
+
+
+ JSON +
+
+ {showFormatButton && ( + + )} + +
+
+
+ +
+
+ ) +} + +export default React.memo(CodeEditor) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message.tsx new file mode 100644 index 0000000000..a51980e20f --- /dev/null +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import type { FC } from 'react' +import { RiErrorWarningFill } from '@remixicon/react' +import classNames from '@/utils/classnames' + +type ErrorMessageProps = { + message: string +} & React.HTMLAttributes + +const ErrorMessage: FC = ({ + message, + className, +}) => { + return ( +
+ +
+ {message} +
+
+ ) +} + +export default React.memo(ErrorMessage) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx index 6ea446d3ae..c49ea05cd6 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx @@ -2,12 +2,12 @@ import React, { type FC, useCallback, useEffect, useRef, useState } from 'react' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' import cn from '@/utils/classnames' import { useTranslation } from 'react-i18next' -import { RiClipboardLine, RiCloseLine, RiErrorWarningFill, RiIndentIncrease } from '@remixicon/react' -import copy from 'copy-to-clipboard' -import { Editor } from '@monaco-editor/react' +import { RiCloseLine } from '@remixicon/react' import Button from '@/app/components/base/button' import { checkDepth } from '../../utils' import { JSON_SCHEMA_MAX_DEPTH } from '@/config' +import CodeEditor from './code-editor' +import ErrorMessage from './error-message' type JsonImporterProps = { onSubmit: (schema: string) => void @@ -23,8 +23,6 @@ const JsonImporter: FC = ({ const [json, setJson] = useState('') const [parseError, setParseError] = useState(null) const importBtnRef = useRef(null) - const monacoRef = useRef(null) - const editorRef = useRef(null) useEffect(() => { if (importBtnRef.current) { @@ -34,28 +32,6 @@ const JsonImporter: FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - const handleEditorDidMount = useCallback((editor: any, monaco: any) => { - editorRef.current = editor - monacoRef.current = monaco - monaco.editor.defineTheme('light-theme', { - base: 'vs', - inherit: true, - rules: [], - colors: { - 'editor.background': '#00000000', - 'editor.lineHighlightBackground': '#00000000', - 'focusBorder': '#00000000', - }, - }) - monaco.editor.setTheme('light-theme') - }, []) - - const handleEditorChange = useCallback((value: string | undefined) => { - if (!value) - return - setJson(value) - }, []) - const handleTrigger = useCallback((e: React.MouseEvent) => { e.stopPropagation() setOpen(!open) @@ -65,11 +41,6 @@ const JsonImporter: FC = ({ setOpen(false) }, []) - const formatJsonContent = useCallback(() => { - if (editorRef.current) - editorRef.current.getAction('editor.action.formatDocument')?.run() - }, []) - const handleSubmit = useCallback(() => { try { const parsedJSON = JSON.parse(json) @@ -127,67 +98,14 @@ const JsonImporter: FC = ({ {/* Content */}
-
-
-
- JSON -
-
- - -
-
-
- -
-
- {parseError && ( -
- -
- {parseError.message} -
-
- )} + + {parseError && }
{/* Footer */}
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx index 1a67aae14f..e1ae28e4aa 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx @@ -9,8 +9,9 @@ import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import VisualEditor from './visual-editor' import SchemaEditor from './schema-editor' -import { jsonToSchema } from '../../utils' +import { getValidationErrorMessage, jsonToSchema, validateSchemaAgainstDraft7 } from '../../utils' import { MittProvider, VisualEditorContextProvider } from './visual-editor/context' +import ErrorMessage from './error-message' type JsonSchemaConfigProps = { defaultSchema?: SchemaRoot @@ -45,11 +46,45 @@ const JsonSchemaConfig: FC = ({ const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA) const [json, setJson] = useState(JSON.stringify(jsonSchema, null, 2)) const [btnWidth, setBtnWidth] = useState(0) + const [parseError, setParseError] = useState(null) + const [validationError, setValidationError] = useState('') const updateBtnWidth = useCallback((width: number) => { setBtnWidth(width + 32) }, []) + const handleTabChange = useCallback((value: SchemaView) => { + if (currentTab === value) return + if (currentTab === SchemaView.JsonSchema) { + try { + const schema = JSON.parse(json) + setParseError(null) + const ajvError = validateSchemaAgainstDraft7(schema) + if (ajvError.length > 0) { + setValidationError(getValidationErrorMessage(ajvError)) + return + } + else { + setJsonSchema(schema) + setValidationError('') + } + } + catch (error) { + setValidationError('') + if (error instanceof Error) + setParseError(error) + else + setParseError(new Error('Invalid JSON')) + return + } + } + else if (currentTab === SchemaView.VisualEditor) { + setJson(JSON.stringify(jsonSchema, null, 2)) + } + + setCurrentTab(value) + }, [currentTab, jsonSchema, json]) + const handleApplySchema = useCallback((schema: SchemaRoot) => { setJsonSchema(schema) }, []) @@ -69,6 +104,7 @@ const JsonSchemaConfig: FC = ({ const handleResetDefaults = useCallback(() => { setJsonSchema(defaultSchema || DEFAULT_SCHEMA) + setJson(JSON.stringify(defaultSchema || DEFAULT_SCHEMA, null, 2)) }, [defaultSchema]) const handleCancel = useCallback(() => { @@ -76,9 +112,33 @@ const JsonSchemaConfig: FC = ({ }, [onClose]) const handleSave = useCallback(() => { - onSave(jsonSchema) + let schema = jsonSchema + if (currentTab === SchemaView.JsonSchema) { + try { + schema = JSON.parse(json) + setParseError(null) + const ajvError = validateSchemaAgainstDraft7(schema) + if (ajvError.length > 0) { + setValidationError(getValidationErrorMessage(ajvError)) + return + } + else { + setJsonSchema(schema) + setValidationError('') + } + } + catch (error) { + setValidationError('') + if (error instanceof Error) + setParseError(error) + else + setParseError(new Error('Invalid JSON')) + return + } + } + onSave(schema) onClose() - }, [jsonSchema, onSave, onClose]) + }, [currentTab, jsonSchema, json, onSave, onClose]) return (
@@ -97,9 +157,7 @@ const JsonSchemaConfig: FC = ({ options={VIEW_TABS} value={currentTab} - onChange={(value: SchemaView) => { - setCurrentTab(value) - }} + onChange={handleTabChange} />
{/* JSON Schema Generator */} @@ -115,7 +173,7 @@ const JsonSchemaConfig: FC = ({ />
-
+
{currentTab === SchemaView.VisualEditor && ( @@ -132,6 +190,8 @@ const JsonSchemaConfig: FC = ({ onUpdate={handleSchemaEditorUpdate} /> )} + {parseError && } + {validationError && }
{/* Footer */}
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/generated-result.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/generated-result.tsx index 72915c7ec2..c7c84533fc 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/generated-result.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/generated-result.tsx @@ -1,17 +1,18 @@ -import React, { type FC, useCallback, useRef, useState } from 'react' +import React, { type FC, useCallback, useMemo, useState } from 'react' import type { SchemaRoot } from '../../../types' -import { RiArrowLeftLine, RiClipboardLine, RiCloseLine, RiSparklingLine } from '@remixicon/react' +import { RiArrowLeftLine, RiCloseLine, RiSparklingLine } from '@remixicon/react' import { useTranslation } from 'react-i18next' -import Editor from '@monaco-editor/react' -import copy from 'copy-to-clipboard' import Button from '@/app/components/base/button' +import CodeEditor from '../code-editor' +import ErrorMessage from '../error-message' +import { getValidationErrorMessage, validateSchemaAgainstDraft7 } from '../../../utils' type GeneratedResultProps = { schema: SchemaRoot onBack: () => void onRegenerate: () => void onClose: () => void - onApply: (schema: any) => void + onApply: () => void } const GeneratedResult: FC = ({ @@ -22,57 +23,36 @@ const GeneratedResult: FC = ({ onApply, }) => { const { t } = useTranslation() - const monacoRef = useRef(null) - const editorRef = useRef(null) + const [parseError, setParseError] = useState(null) + const [validationError, setValidationError] = useState('') - const formatJSON = (json: any): string => { + const formatJSON = (json: SchemaRoot) => { try { - if (typeof json === 'string') { - const parsed = JSON.parse(json) - return JSON.stringify(parsed, null, 2) - } - return JSON.stringify(json, null, 2) + const schema = JSON.stringify(json, null, 2) + setParseError(null) + return schema } catch (e) { - console.error('Failed to format JSON:', e) - return typeof json === 'string' ? json : JSON.stringify(json) + if (e instanceof Error) + setParseError(e) + else + setParseError(new Error('Invalid JSON')) + return '' } } - const [jsonSchema, setJsonSchema] = useState(formatJSON(schema)) - - const handleEditorDidMount = useCallback((editor: any, monaco: any) => { - editorRef.current = editor - monacoRef.current = monaco - monaco.editor.defineTheme('light-theme', { - base: 'vs', - inherit: true, - rules: [], - colors: { - 'editor.background': '#00000000', - 'editor.lineHighlightBackground': '#00000000', - 'focusBorder': '#00000000', - }, - }) - monaco.editor.setTheme('light-theme') - }, []) - - const handleEditorChange = useCallback((value: string | undefined) => { - if (!value) - return - setJsonSchema(value) - }, []) + const jsonSchema = useMemo(() => formatJSON(schema), [schema]) const handleApply = useCallback(() => { - try { - // Parse the JSON to ensure it's valid before applying - const parsedJSON = JSON.parse(jsonSchema) - onApply(parsedJSON) + const ajvError = validateSchemaAgainstDraft7(schema) + if (ajvError.length > 0) { + setValidationError(getValidationErrorMessage(ajvError)) } - catch { - // TODO: Handle invalid JSON error + else { + onApply() + setValidationError('') } - }, [jsonSchema, onApply]) + }, [schema, onApply]) return (
@@ -89,51 +69,16 @@ const GeneratedResult: FC = ({
{/* Content */} -
-
-
-
- JSON -
- -
-
- -
-
+
+ + {parseError && } + {validationError && }
{/* Footer */}
@@ -155,4 +100,4 @@ const GeneratedResult: FC = ({ ) } -export default GeneratedResult +export default React.memo(GeneratedResult) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx index 1ce1b79f3f..51a09f525f 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx @@ -83,6 +83,7 @@ export const JsonSchemaGenerator: FC = ({ const handleApply = () => { onApply(schema!) + setOpen(false) } return ( diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx index 345bb99755..3cb7a611b1 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx @@ -1,3 +1,4 @@ +import React from 'react' import type { FC } from 'react' import { RiCloseLine, RiSparklingFill } from '@remixicon/react' import { useTranslation } from 'react-i18next' @@ -85,4 +86,4 @@ const PromptEditor: FC = ({ ) } -export default PromptEditor +export default React.memo(PromptEditor) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor.tsx index c3ab2b121a..e78b9224b2 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor.tsx @@ -1,7 +1,5 @@ -import { Editor } from '@monaco-editor/react' -import { RiClipboardLine, RiIndentIncrease } from '@remixicon/react' -import copy from 'copy-to-clipboard' -import React, { type FC, useCallback, useRef } from 'react' +import React, { type FC } from 'react' +import CodeEditor from './code-editor' type SchemaEditorProps = { schema: string @@ -12,90 +10,13 @@ const SchemaEditor: FC = ({ schema, onUpdate, }) => { - const monacoRef = useRef(null) - const editorRef = useRef(null) - - const handleEditorDidMount = useCallback((editor: any, monaco: any) => { - editorRef.current = editor - monacoRef.current = monaco - monaco.editor.defineTheme('light-theme', { - base: 'vs', - inherit: true, - rules: [], - colors: { - 'editor.background': '#00000000', - 'editor.lineHighlightBackground': '#00000000', - 'focusBorder': '#00000000', - }, - }) - monaco.editor.setTheme('light-theme') - }, []) - - const formatJsonContent = useCallback(() => { - if (editorRef.current) - editorRef.current.getAction('editor.action.formatDocument')?.run() - }, []) - - const handleEditorChange = useCallback((value: string | undefined) => { - if (!value) - return - onUpdate(value) - }, [onUpdate]) - return ( -
-
-
- JSON -
-
- - -
-
-
- -
-
+ ) } diff --git a/web/app/components/workflow/nodes/llm/utils.ts b/web/app/components/workflow/nodes/llm/utils.ts index 3fa7243865..851ae823fb 100644 --- a/web/app/components/workflow/nodes/llm/utils.ts +++ b/web/app/components/workflow/nodes/llm/utils.ts @@ -1,5 +1,7 @@ import { ArrayType, Type } from './types' import type { ArrayItems, Field, LLMNodeType } from './types' +import Ajv, { type ErrorObject } from 'ajv' +import draft7MetaSchema from 'ajv/dist/refs/json-schema-draft-07.json' export const checkNodeValid = (payload: LLMNodeType) => { return true @@ -79,3 +81,28 @@ export const findPropertyWithPath = (target: any, path: string[]) => { current = current[key] return current } + +const ajv = new Ajv({ + allErrors: true, + verbose: true, + validateSchema: true, + meta: false, +}) +ajv.addMetaSchema(draft7MetaSchema) + +export const validateSchemaAgainstDraft7 = (schemaToValidate: any) => { + // Make sure the schema has the $schema property for draft-07 + if (!schemaToValidate.$schema) + schemaToValidate.$schema = 'http://json-schema.org/draft-07/schema#' + + const valid = ajv.validateSchema(schemaToValidate) + + return valid ? [] : ajv.errors || [] +} + +export const getValidationErrorMessage = (errors: ErrorObject[]) => { + const message = errors.map((error) => { + return `Error: ${error.instancePath} ${error.message} Details: ${JSON.stringify(error.params)}` + }).join('; ') + return message +} diff --git a/web/package.json b/web/package.json index 09f04a05a3..612138b98d 100644 --- a/web/package.json +++ b/web/package.json @@ -54,6 +54,7 @@ "@tanstack/react-query": "^5.60.5", "@tanstack/react-query-devtools": "^5.60.5", "ahooks": "^3.8.1", + "ajv": "^8.17.1", "class-variance-authority": "^0.7.0", "classnames": "^2.5.1", "copy-to-clipboard": "^3.3.3", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index b356504f4e..45838bcd72 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -103,6 +103,9 @@ importers: ahooks: specifier: ^3.8.1 version: 3.8.1(react@18.2.0) + ajv: + specifier: ^8.17.1 + version: 8.17.1 class-variance-authority: specifier: ^0.7.0 version: 0.7.0