feat: add AJV for JSON schema validation and improve error handling

This commit is contained in:
twwu 2025-03-18 22:34:40 +08:00
parent 80a928a7b1
commit 44be94d5b5
11 changed files with 316 additions and 279 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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<JsonImporterProps> = ({
const [json, setJson] = useState('')
const [parseError, setParseError] = useState<any>(null)
const importBtnRef = useRef<HTMLButtonElement>(null)
const monacoRef = useRef<any>(null)
const editorRef = useRef<any>(null)
useEffect(() => {
if (importBtnRef.current) {
@ -34,28 +32,6 @@ const JsonImporter: FC<JsonImporterProps> = ({
// 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>) => {
e.stopPropagation()
setOpen(!open)
@ -65,11 +41,6 @@ const JsonImporter: FC<JsonImporterProps> = ({
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<JsonImporterProps> = ({
</div>
{/* Content */}
<div className='px-4 py-2'>
<div className='flex flex-col h-full rounded-lg bg-components-input-bg-normal overflow-hidden'>
<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'>
<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(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>
)}
<CodeEditor
className='rounded-lg'
editorWrapperClassName='h-[340px]'
value={json}
onUpdate={setJson}
showFormatButton={false}
/>
{parseError && <ErrorMessage message={parseError.message} />}
</div>
{/* Footer */}
<div className='flex items-center justify-end gap-x-2 p-4 pt-2'>

View File

@ -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<JsonSchemaConfigProps> = ({
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<Error | null>(null)
const [validationError, setValidationError] = useState<string>('')
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<JsonSchemaConfigProps> = ({
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<JsonSchemaConfigProps> = ({
}, [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 (
<div className='flex flex-col h-full'>
@ -97,9 +157,7 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
<SegmentedControl<SchemaView>
options={VIEW_TABS}
value={currentTab}
onChange={(value: SchemaView) => {
setCurrentTab(value)
}}
onChange={handleTabChange}
/>
<div className='flex items-center gap-x-0.5'>
{/* JSON Schema Generator */}
@ -115,7 +173,7 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
/>
</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 && (
<MittProvider>
<VisualEditorContextProvider>
@ -132,6 +190,8 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
onUpdate={handleSchemaEditorUpdate}
/>
)}
{parseError && <ErrorMessage message={parseError.message} />}
{validationError && <ErrorMessage message={validationError} />}
</div>
{/* Footer */}
<div className='flex items-center p-6 pt-5 gap-x-2'>

View File

@ -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<GeneratedResultProps> = ({
@ -22,57 +23,36 @@ const GeneratedResult: FC<GeneratedResultProps> = ({
onApply,
}) => {
const { t } = useTranslation()
const monacoRef = useRef<any>(null)
const editorRef = useRef<any>(null)
const [parseError, setParseError] = useState<Error | null>(null)
const [validationError, setValidationError] = useState<string>('')
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 (
<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>
{/* Content */}
<div className='w-full h-[468px] px-4 py-2'>
<div className='flex flex-col h-full rounded-lg bg-components-input-bg-normal overflow-hidden'>
<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>
<button
type='button'
className='flex items-center justify-center h-6 w-6'
onClick={() => copy(jsonSchema)}>
<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 className='px-4 py-2'>
<CodeEditor
className='rounded-lg'
editorWrapperClassName='h-[424px]'
value={jsonSchema}
readOnly
showFormatButton={false}
/>
{parseError && <ErrorMessage message={parseError.message} />}
{validationError && <ErrorMessage message={validationError} />}
</div>
{/* Footer */}
<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)

View File

@ -83,6 +83,7 @@ export const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
const handleApply = () => {
onApply(schema!)
setOpen(false)
}
return (

View File

@ -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<PromptEditorProps> = ({
)
}
export default PromptEditor
export default React.memo(PromptEditor)

View File

@ -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<SchemaEditorProps> = ({
schema,
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 (
<div className='flex flex-col h-full rounded-xl bg-components-input-bg-normal overflow-hidden'>
<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'>
<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>
<CodeEditor
className='rounded-xl'
editorWrapperClassName='grow'
value={schema}
onUpdate={onUpdate}
/>
)
}

View File

@ -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
}

View File

@ -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",

3
web/pnpm-lock.yaml generated
View File

@ -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