mirror of
https://git.mirrors.martin98.com/https://github.com/langgenius/dify.git
synced 2025-08-16 06:35:56 +08:00
feat: add AJV for JSON schema validation and improve error handling
This commit is contained in:
parent
80a928a7b1
commit
44be94d5b5
@ -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<HTMLDivElement>
|
||||||
|
|
||||||
|
const CodeEditor: FC<CodeEditorProps> = ({
|
||||||
|
value,
|
||||||
|
onUpdate,
|
||||||
|
showFormatButton = true,
|
||||||
|
editorWrapperClassName,
|
||||||
|
readOnly = false,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const monacoRef = useRef<any>(null)
|
||||||
|
const editorRef = useRef<any>(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 (
|
||||||
|
<div className={classNames('flex flex-col h-full bg-components-input-bg-normal overflow-hidden', className)}>
|
||||||
|
<div className='flex items-center justify-between pl-2 pt-1 pr-1'>
|
||||||
|
<div className='py-0.5 text-text-secondary system-xs-semibold-uppercase'>
|
||||||
|
<span className='px-1 py-0.5'>JSON</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-x-0.5'>
|
||||||
|
{showFormatButton && (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='flex items-center justify-center h-6 w-6'
|
||||||
|
onClick={formatJsonContent}
|
||||||
|
>
|
||||||
|
<RiIndentIncrease className='w-4 h-4 text-text-tertiary' />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='flex items-center justify-center h-6 w-6'
|
||||||
|
onClick={() => copy(value)}>
|
||||||
|
<RiClipboardLine className='w-4 h-4 text-text-tertiary' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={classNames('relative', editorWrapperClassName)}>
|
||||||
|
<Editor
|
||||||
|
height='100%'
|
||||||
|
defaultLanguage='json'
|
||||||
|
value={value}
|
||||||
|
onChange={handleEditorChange}
|
||||||
|
onMount={handleEditorDidMount}
|
||||||
|
options={{
|
||||||
|
readOnly,
|
||||||
|
domReadOnly: true,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
tabSize: 2,
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
wordWrap: 'on',
|
||||||
|
wrappingIndent: 'same',
|
||||||
|
// Add these options
|
||||||
|
overviewRulerBorder: false,
|
||||||
|
hideCursorInOverviewRuler: true,
|
||||||
|
renderLineHighlightOnlyWhenFocus: false,
|
||||||
|
renderLineHighlight: 'none',
|
||||||
|
// Hide scrollbar borders
|
||||||
|
scrollbar: {
|
||||||
|
vertical: 'hidden',
|
||||||
|
horizontal: 'hidden',
|
||||||
|
verticalScrollbarSize: 0,
|
||||||
|
horizontalScrollbarSize: 0,
|
||||||
|
alwaysConsumeMouseWheel: false,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(CodeEditor)
|
@ -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<HTMLDivElement>
|
||||||
|
|
||||||
|
const ErrorMessage: FC<ErrorMessageProps> = ({
|
||||||
|
message,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={classNames(
|
||||||
|
'flex gap-x-1 mt-1 p-2 rounded-lg border-[0.5px] border-components-panel-border bg-toast-error-bg',
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
|
<RiErrorWarningFill className='shrink-0 w-4 h-4 text-text-destructive' />
|
||||||
|
<div className='grow text-text-primary system-xs-medium max-h-12 overflow-y-auto break-words'>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(ErrorMessage)
|
@ -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 { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||||
import cn from '@/utils/classnames'
|
import cn from '@/utils/classnames'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { RiClipboardLine, RiCloseLine, RiErrorWarningFill, RiIndentIncrease } from '@remixicon/react'
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
import copy from 'copy-to-clipboard'
|
|
||||||
import { Editor } from '@monaco-editor/react'
|
|
||||||
import Button from '@/app/components/base/button'
|
import Button from '@/app/components/base/button'
|
||||||
import { checkDepth } from '../../utils'
|
import { checkDepth } from '../../utils'
|
||||||
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
|
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
|
||||||
|
import CodeEditor from './code-editor'
|
||||||
|
import ErrorMessage from './error-message'
|
||||||
|
|
||||||
type JsonImporterProps = {
|
type JsonImporterProps = {
|
||||||
onSubmit: (schema: string) => void
|
onSubmit: (schema: string) => void
|
||||||
@ -23,8 +23,6 @@ const JsonImporter: FC<JsonImporterProps> = ({
|
|||||||
const [json, setJson] = useState('')
|
const [json, setJson] = useState('')
|
||||||
const [parseError, setParseError] = useState<any>(null)
|
const [parseError, setParseError] = useState<any>(null)
|
||||||
const importBtnRef = useRef<HTMLButtonElement>(null)
|
const importBtnRef = useRef<HTMLButtonElement>(null)
|
||||||
const monacoRef = useRef<any>(null)
|
|
||||||
const editorRef = useRef<any>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (importBtnRef.current) {
|
if (importBtnRef.current) {
|
||||||
@ -34,28 +32,6 @@ const JsonImporter: FC<JsonImporterProps> = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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<HTMLElement, MouseEvent>) => {
|
const handleTrigger = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setOpen(!open)
|
setOpen(!open)
|
||||||
@ -65,11 +41,6 @@ const JsonImporter: FC<JsonImporterProps> = ({
|
|||||||
setOpen(false)
|
setOpen(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const formatJsonContent = useCallback(() => {
|
|
||||||
if (editorRef.current)
|
|
||||||
editorRef.current.getAction('editor.action.formatDocument')?.run()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
try {
|
try {
|
||||||
const parsedJSON = JSON.parse(json)
|
const parsedJSON = JSON.parse(json)
|
||||||
@ -127,67 +98,14 @@ const JsonImporter: FC<JsonImporterProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className='px-4 py-2'>
|
<div className='px-4 py-2'>
|
||||||
<div className='flex flex-col h-full rounded-lg bg-components-input-bg-normal overflow-hidden'>
|
<CodeEditor
|
||||||
<div className='flex items-center justify-between pl-2 pt-1 pr-1'>
|
className='rounded-lg'
|
||||||
<div className='py-0.5 text-text-secondary system-xs-semibold-uppercase'>
|
editorWrapperClassName='h-[340px]'
|
||||||
<span className='px-1 py-0.5'>JSON</span>
|
value={json}
|
||||||
</div>
|
onUpdate={setJson}
|
||||||
<div className='flex items-center gap-x-0.5'>
|
showFormatButton={false}
|
||||||
<button
|
/>
|
||||||
type='button'
|
{parseError && <ErrorMessage message={parseError.message} />}
|
||||||
className='flex items-center justify-center h-6 w-6'
|
|
||||||
onClick={formatJsonContent}
|
|
||||||
>
|
|
||||||
<RiIndentIncrease className='w-4 h-4 text-text-tertiary' />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
className='flex items-center justify-center h-6 w-6'
|
|
||||||
onClick={() => copy(json)}>
|
|
||||||
<RiClipboardLine className='w-4 h-4 text-text-tertiary' />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='relative h-[340px]'>
|
|
||||||
<Editor
|
|
||||||
height='100%'
|
|
||||||
defaultLanguage='json'
|
|
||||||
value={json}
|
|
||||||
onChange={handleEditorChange}
|
|
||||||
onMount={handleEditorDidMount}
|
|
||||||
options={{
|
|
||||||
readOnly: false,
|
|
||||||
domReadOnly: true,
|
|
||||||
minimap: { enabled: false },
|
|
||||||
tabSize: 2,
|
|
||||||
scrollBeyondLastLine: false,
|
|
||||||
wordWrap: 'on',
|
|
||||||
wrappingIndent: 'same',
|
|
||||||
// Add these options
|
|
||||||
overviewRulerBorder: false,
|
|
||||||
hideCursorInOverviewRuler: true,
|
|
||||||
renderLineHighlightOnlyWhenFocus: false,
|
|
||||||
renderLineHighlight: 'none',
|
|
||||||
// Hide scrollbar borders
|
|
||||||
scrollbar: {
|
|
||||||
vertical: 'hidden',
|
|
||||||
horizontal: 'hidden',
|
|
||||||
verticalScrollbarSize: 0,
|
|
||||||
horizontalScrollbarSize: 0,
|
|
||||||
alwaysConsumeMouseWheel: false,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{parseError && (
|
|
||||||
<div className='flex gap-x-1 mt-1 p-2 rounded-lg border-[0.5px] border-components-panel-border bg-toast-error-bg'>
|
|
||||||
<RiErrorWarningFill className='shrink-0 w-4 h-4 text-text-destructive' />
|
|
||||||
<div className='grow text-text-primary system-xs-medium'>
|
|
||||||
{parseError.message}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className='flex items-center justify-end gap-x-2 p-4 pt-2'>
|
<div className='flex items-center justify-end gap-x-2 p-4 pt-2'>
|
||||||
|
@ -9,8 +9,9 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import Button from '@/app/components/base/button'
|
import Button from '@/app/components/base/button'
|
||||||
import VisualEditor from './visual-editor'
|
import VisualEditor from './visual-editor'
|
||||||
import SchemaEditor from './schema-editor'
|
import SchemaEditor from './schema-editor'
|
||||||
import { jsonToSchema } from '../../utils'
|
import { getValidationErrorMessage, jsonToSchema, validateSchemaAgainstDraft7 } from '../../utils'
|
||||||
import { MittProvider, VisualEditorContextProvider } from './visual-editor/context'
|
import { MittProvider, VisualEditorContextProvider } from './visual-editor/context'
|
||||||
|
import ErrorMessage from './error-message'
|
||||||
|
|
||||||
type JsonSchemaConfigProps = {
|
type JsonSchemaConfigProps = {
|
||||||
defaultSchema?: SchemaRoot
|
defaultSchema?: SchemaRoot
|
||||||
@ -45,11 +46,45 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
|
|||||||
const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA)
|
const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA)
|
||||||
const [json, setJson] = useState(JSON.stringify(jsonSchema, null, 2))
|
const [json, setJson] = useState(JSON.stringify(jsonSchema, null, 2))
|
||||||
const [btnWidth, setBtnWidth] = useState(0)
|
const [btnWidth, setBtnWidth] = useState(0)
|
||||||
|
const [parseError, setParseError] = useState<Error | null>(null)
|
||||||
|
const [validationError, setValidationError] = useState<string>('')
|
||||||
|
|
||||||
const updateBtnWidth = useCallback((width: number) => {
|
const updateBtnWidth = useCallback((width: number) => {
|
||||||
setBtnWidth(width + 32)
|
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) => {
|
const handleApplySchema = useCallback((schema: SchemaRoot) => {
|
||||||
setJsonSchema(schema)
|
setJsonSchema(schema)
|
||||||
}, [])
|
}, [])
|
||||||
@ -69,6 +104,7 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
|
|||||||
|
|
||||||
const handleResetDefaults = useCallback(() => {
|
const handleResetDefaults = useCallback(() => {
|
||||||
setJsonSchema(defaultSchema || DEFAULT_SCHEMA)
|
setJsonSchema(defaultSchema || DEFAULT_SCHEMA)
|
||||||
|
setJson(JSON.stringify(defaultSchema || DEFAULT_SCHEMA, null, 2))
|
||||||
}, [defaultSchema])
|
}, [defaultSchema])
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
@ -76,9 +112,33 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
|
|||||||
}, [onClose])
|
}, [onClose])
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
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()
|
onClose()
|
||||||
}, [jsonSchema, onSave, onClose])
|
}, [currentTab, jsonSchema, json, onSave, onClose])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col h-full'>
|
<div className='flex flex-col h-full'>
|
||||||
@ -97,9 +157,7 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
|
|||||||
<SegmentedControl<SchemaView>
|
<SegmentedControl<SchemaView>
|
||||||
options={VIEW_TABS}
|
options={VIEW_TABS}
|
||||||
value={currentTab}
|
value={currentTab}
|
||||||
onChange={(value: SchemaView) => {
|
onChange={handleTabChange}
|
||||||
setCurrentTab(value)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div className='flex items-center gap-x-0.5'>
|
<div className='flex items-center gap-x-0.5'>
|
||||||
{/* JSON Schema Generator */}
|
{/* JSON Schema Generator */}
|
||||||
@ -115,7 +173,7 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='px-6 grow overflow-hidden'>
|
<div className='flex flex-col gap-y-1 px-6 grow overflow-hidden'>
|
||||||
{currentTab === SchemaView.VisualEditor && (
|
{currentTab === SchemaView.VisualEditor && (
|
||||||
<MittProvider>
|
<MittProvider>
|
||||||
<VisualEditorContextProvider>
|
<VisualEditorContextProvider>
|
||||||
@ -132,6 +190,8 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
|
|||||||
onUpdate={handleSchemaEditorUpdate}
|
onUpdate={handleSchemaEditorUpdate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{parseError && <ErrorMessage message={parseError.message} />}
|
||||||
|
{validationError && <ErrorMessage message={validationError} />}
|
||||||
</div>
|
</div>
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className='flex items-center p-6 pt-5 gap-x-2'>
|
<div className='flex items-center p-6 pt-5 gap-x-2'>
|
||||||
|
@ -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 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 { useTranslation } from 'react-i18next'
|
||||||
import Editor from '@monaco-editor/react'
|
|
||||||
import copy from 'copy-to-clipboard'
|
|
||||||
import Button from '@/app/components/base/button'
|
import Button from '@/app/components/base/button'
|
||||||
|
import CodeEditor from '../code-editor'
|
||||||
|
import ErrorMessage from '../error-message'
|
||||||
|
import { getValidationErrorMessage, validateSchemaAgainstDraft7 } from '../../../utils'
|
||||||
|
|
||||||
type GeneratedResultProps = {
|
type GeneratedResultProps = {
|
||||||
schema: SchemaRoot
|
schema: SchemaRoot
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
onRegenerate: () => void
|
onRegenerate: () => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onApply: (schema: any) => void
|
onApply: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const GeneratedResult: FC<GeneratedResultProps> = ({
|
const GeneratedResult: FC<GeneratedResultProps> = ({
|
||||||
@ -22,57 +23,36 @@ const GeneratedResult: FC<GeneratedResultProps> = ({
|
|||||||
onApply,
|
onApply,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const monacoRef = useRef<any>(null)
|
const [parseError, setParseError] = useState<Error | null>(null)
|
||||||
const editorRef = useRef<any>(null)
|
const [validationError, setValidationError] = useState<string>('')
|
||||||
|
|
||||||
const formatJSON = (json: any): string => {
|
const formatJSON = (json: SchemaRoot) => {
|
||||||
try {
|
try {
|
||||||
if (typeof json === 'string') {
|
const schema = JSON.stringify(json, null, 2)
|
||||||
const parsed = JSON.parse(json)
|
setParseError(null)
|
||||||
return JSON.stringify(parsed, null, 2)
|
return schema
|
||||||
}
|
|
||||||
return JSON.stringify(json, null, 2)
|
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
console.error('Failed to format JSON:', e)
|
if (e instanceof Error)
|
||||||
return typeof json === 'string' ? json : JSON.stringify(json)
|
setParseError(e)
|
||||||
|
else
|
||||||
|
setParseError(new Error('Invalid JSON'))
|
||||||
|
return ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [jsonSchema, setJsonSchema] = useState(formatJSON(schema))
|
const jsonSchema = useMemo(() => formatJSON(schema), [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 handleApply = useCallback(() => {
|
const handleApply = useCallback(() => {
|
||||||
try {
|
const ajvError = validateSchemaAgainstDraft7(schema)
|
||||||
// Parse the JSON to ensure it's valid before applying
|
if (ajvError.length > 0) {
|
||||||
const parsedJSON = JSON.parse(jsonSchema)
|
setValidationError(getValidationErrorMessage(ajvError))
|
||||||
onApply(parsedJSON)
|
|
||||||
}
|
}
|
||||||
catch {
|
else {
|
||||||
// TODO: Handle invalid JSON error
|
onApply()
|
||||||
|
setValidationError('')
|
||||||
}
|
}
|
||||||
}, [jsonSchema, onApply])
|
}, [schema, onApply])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col w-[480px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9'>
|
<div className='flex flex-col w-[480px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9'>
|
||||||
@ -89,51 +69,16 @@ const GeneratedResult: FC<GeneratedResultProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className='w-full h-[468px] px-4 py-2'>
|
<div className='px-4 py-2'>
|
||||||
<div className='flex flex-col h-full rounded-lg bg-components-input-bg-normal overflow-hidden'>
|
<CodeEditor
|
||||||
<div className='flex items-center justify-between pl-2 pt-1 pr-1'>
|
className='rounded-lg'
|
||||||
<div className='py-0.5 text-text-secondary system-xs-semibold-uppercase'>
|
editorWrapperClassName='h-[424px]'
|
||||||
<span className='px-1 py-0.5'>JSON</span>
|
value={jsonSchema}
|
||||||
</div>
|
readOnly
|
||||||
<button
|
showFormatButton={false}
|
||||||
type='button'
|
/>
|
||||||
className='flex items-center justify-center h-6 w-6'
|
{parseError && <ErrorMessage message={parseError.message} />}
|
||||||
onClick={() => copy(jsonSchema)}>
|
{validationError && <ErrorMessage message={validationError} />}
|
||||||
<RiClipboardLine className='w-4 h-4 text-text-tertiary' />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className='relative grow'>
|
|
||||||
<Editor
|
|
||||||
height='100%'
|
|
||||||
defaultLanguage='json'
|
|
||||||
value={jsonSchema}
|
|
||||||
onChange={handleEditorChange}
|
|
||||||
onMount={handleEditorDidMount}
|
|
||||||
options={{
|
|
||||||
readOnly: true,
|
|
||||||
domReadOnly: true,
|
|
||||||
minimap: { enabled: false },
|
|
||||||
tabSize: 2,
|
|
||||||
scrollBeyondLastLine: false,
|
|
||||||
wordWrap: 'on',
|
|
||||||
wrappingIndent: 'same',
|
|
||||||
// Add these options
|
|
||||||
overviewRulerBorder: false,
|
|
||||||
hideCursorInOverviewRuler: true,
|
|
||||||
renderLineHighlightOnlyWhenFocus: false,
|
|
||||||
renderLineHighlight: 'none',
|
|
||||||
// Hide scrollbar borders
|
|
||||||
scrollbar: {
|
|
||||||
vertical: 'hidden',
|
|
||||||
horizontal: 'hidden',
|
|
||||||
verticalScrollbarSize: 0,
|
|
||||||
horizontalScrollbarSize: 0,
|
|
||||||
alwaysConsumeMouseWheel: false,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className='flex items-center justify-between p-4 pt-2'>
|
<div className='flex items-center justify-between p-4 pt-2'>
|
||||||
@ -155,4 +100,4 @@ const GeneratedResult: FC<GeneratedResultProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default GeneratedResult
|
export default React.memo(GeneratedResult)
|
||||||
|
@ -83,6 +83,7 @@ export const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
|
|||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
onApply(schema!)
|
onApply(schema!)
|
||||||
|
setOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import React from 'react'
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import { RiCloseLine, RiSparklingFill } from '@remixicon/react'
|
import { RiCloseLine, RiSparklingFill } from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@ -85,4 +86,4 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PromptEditor
|
export default React.memo(PromptEditor)
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import { Editor } from '@monaco-editor/react'
|
import React, { type FC } from 'react'
|
||||||
import { RiClipboardLine, RiIndentIncrease } from '@remixicon/react'
|
import CodeEditor from './code-editor'
|
||||||
import copy from 'copy-to-clipboard'
|
|
||||||
import React, { type FC, useCallback, useRef } from 'react'
|
|
||||||
|
|
||||||
type SchemaEditorProps = {
|
type SchemaEditorProps = {
|
||||||
schema: string
|
schema: string
|
||||||
@ -12,90 +10,13 @@ const SchemaEditor: FC<SchemaEditorProps> = ({
|
|||||||
schema,
|
schema,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}) => {
|
}) => {
|
||||||
const monacoRef = useRef<any>(null)
|
|
||||||
const editorRef = useRef<any>(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 (
|
return (
|
||||||
<div className='flex flex-col h-full rounded-xl bg-components-input-bg-normal overflow-hidden'>
|
<CodeEditor
|
||||||
<div className='flex items-center justify-between pl-2 pt-1 pr-1'>
|
className='rounded-xl'
|
||||||
<div className='py-0.5 text-text-secondary system-xs-semibold-uppercase'>
|
editorWrapperClassName='grow'
|
||||||
<span className='px-1 py-0.5'>JSON</span>
|
value={schema}
|
||||||
</div>
|
onUpdate={onUpdate}
|
||||||
<div className='flex items-center gap-x-0.5'>
|
/>
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
className='flex items-center justify-center h-6 w-6'
|
|
||||||
onClick={formatJsonContent}
|
|
||||||
>
|
|
||||||
<RiIndentIncrease className='w-4 h-4 text-text-tertiary' />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type='button'
|
|
||||||
className='flex items-center justify-center h-6 w-6'
|
|
||||||
onClick={() => copy(schema)}>
|
|
||||||
<RiClipboardLine className='w-4 h-4 text-text-tertiary' />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='relative grow'>
|
|
||||||
<Editor
|
|
||||||
height='100%'
|
|
||||||
defaultLanguage='json'
|
|
||||||
value={schema}
|
|
||||||
onChange={handleEditorChange}
|
|
||||||
onMount={handleEditorDidMount}
|
|
||||||
options={{
|
|
||||||
readOnly: false,
|
|
||||||
domReadOnly: true,
|
|
||||||
minimap: { enabled: false },
|
|
||||||
tabSize: 2,
|
|
||||||
scrollBeyondLastLine: false,
|
|
||||||
wordWrap: 'on',
|
|
||||||
wrappingIndent: 'same',
|
|
||||||
// Add these options
|
|
||||||
overviewRulerBorder: false,
|
|
||||||
hideCursorInOverviewRuler: true,
|
|
||||||
renderLineHighlightOnlyWhenFocus: false,
|
|
||||||
renderLineHighlight: 'none',
|
|
||||||
// Hide scrollbar borders
|
|
||||||
scrollbar: {
|
|
||||||
vertical: 'hidden',
|
|
||||||
horizontal: 'hidden',
|
|
||||||
verticalScrollbarSize: 0,
|
|
||||||
horizontalScrollbarSize: 0,
|
|
||||||
alwaysConsumeMouseWheel: false,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { ArrayType, Type } from './types'
|
import { ArrayType, Type } from './types'
|
||||||
import type { ArrayItems, Field, LLMNodeType } 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) => {
|
export const checkNodeValid = (payload: LLMNodeType) => {
|
||||||
return true
|
return true
|
||||||
@ -79,3 +81,28 @@ export const findPropertyWithPath = (target: any, path: string[]) => {
|
|||||||
current = current[key]
|
current = current[key]
|
||||||
return current
|
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
|
||||||
|
}
|
||||||
|
@ -54,6 +54,7 @@
|
|||||||
"@tanstack/react-query": "^5.60.5",
|
"@tanstack/react-query": "^5.60.5",
|
||||||
"@tanstack/react-query-devtools": "^5.60.5",
|
"@tanstack/react-query-devtools": "^5.60.5",
|
||||||
"ahooks": "^3.8.1",
|
"ahooks": "^3.8.1",
|
||||||
|
"ajv": "^8.17.1",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"copy-to-clipboard": "^3.3.3",
|
"copy-to-clipboard": "^3.3.3",
|
||||||
|
3
web/pnpm-lock.yaml
generated
3
web/pnpm-lock.yaml
generated
@ -103,6 +103,9 @@ importers:
|
|||||||
ahooks:
|
ahooks:
|
||||||
specifier: ^3.8.1
|
specifier: ^3.8.1
|
||||||
version: 3.8.1(react@18.2.0)
|
version: 3.8.1(react@18.2.0)
|
||||||
|
ajv:
|
||||||
|
specifier: ^8.17.1
|
||||||
|
version: 8.17.1
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.0
|
specifier: ^0.7.0
|
||||||
version: 0.7.0
|
version: 0.7.0
|
||||||
|
Loading…
x
Reference in New Issue
Block a user