From d4185c2d9181515a9bf493ca7b43f37700f897f7 Mon Sep 17 00:00:00 2001 From: twwu Date: Mon, 17 Mar 2025 16:42:05 +0800 Subject: [PATCH] feat: implement JSON Schema configuration context and store for advanced editing options --- .../json-schema-config-modal/context.tsx | 49 ++ .../json-schema-config-modal/index.tsx | 155 +---- .../json-schema-config.tsx | 572 ++++++++++++++++++ .../json-schema-config-modal/store.ts | 34 ++ .../visual-editor/add-field.tsx | 34 +- .../edit-card/advanced-actions.tsx | 9 +- .../edit-card/advanced-options.tsx | 5 - .../visual-editor/edit-card/index.tsx | 196 ++++-- .../visual-editor/index.tsx | 6 +- .../visual-editor/schema-node.tsx | 264 ++------ web/app/dev-preview/page.tsx | 28 +- web/hooks/use-mitt.ts | 18 +- 12 files changed, 896 insertions(+), 474 deletions(-) create mode 100644 web/app/components/workflow/nodes/llm/components/json-schema-config-modal/context.tsx create mode 100644 web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx create mode 100644 web/app/components/workflow/nodes/llm/components/json-schema-config-modal/store.ts diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/context.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/context.tsx new file mode 100644 index 0000000000..787e4354d2 --- /dev/null +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/context.tsx @@ -0,0 +1,49 @@ +import { + createContext, + useContext, + useRef, +} from 'react' +import { createJsonSchemaConfigStore } from './store' +import { useMitt } from '@/hooks/use-mitt' + +type JsonSchemaConfigStore = ReturnType + +type JsonSchemaConfigContextType = JsonSchemaConfigStore | null + +type JsonSchemaConfigProviderProps = { + children: React.ReactNode +} + +export const JsonSchemaConfigContext = createContext(null) + +export const JsonSchemaConfigContextProvider = ({ children }: JsonSchemaConfigProviderProps) => { + const storeRef = useRef() + + if (!storeRef.current) + storeRef.current = createJsonSchemaConfigStore() + + return ( + + {children} + + ) +} + +export const MittContext = createContext>({ + emit: () => {}, + useSubscribe: () => {}, +}) + +export const MittProvider = ({ children }: { children: React.ReactNode }) => { + const mitt = useMitt() + + return ( + + {children} + + ) +} + +export const useMittContext = () => { + return useContext(MittContext) +} diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx index 2f04afe84a..485fe80e24 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx @@ -1,164 +1,37 @@ -import React, { type FC, useCallback, useState } from 'react' +import React, { type FC } from 'react' import Modal from '../../../../../base/modal' -import { type Field, Type } from '../../types' -import { RiBracesLine, RiCloseLine, RiExternalLinkLine, RiTimelineView } from '@remixicon/react' -import { SegmentedControl } from '../../../../../base/segmented-control' -import JsonSchemaGenerator from './json-schema-generator' -import Divider from '@/app/components/base/divider' -import JsonImporter from './json-importer' -import { useTranslation } from 'react-i18next' -import Button from '@/app/components/base/button' -import VisualEditor from './visual-editor' -import SchemaEditor from './schema-editor' +import type { SchemaRoot } from '../../types' +import JsonSchemaConfig from './json-schema-config' +import { JsonSchemaConfigContextProvider, MittProvider } from './context' type JsonSchemaConfigModalProps = { isShow: boolean - defaultSchema?: Field - onSave: (schema: Field) => void + defaultSchema?: SchemaRoot + onSave: (schema: SchemaRoot) => void onClose: () => void } -enum SchemaView { - VisualEditor = 'visualEditor', - JsonSchema = 'jsonSchema', -} - -const VIEW_TABS = [ - { Icon: RiTimelineView, text: 'Visual Editor', value: SchemaView.VisualEditor }, - { Icon: RiBracesLine, text: 'JSON Schema', value: SchemaView.JsonSchema }, -] - -const DEFAULT_SCHEMA: Field = { - type: Type.object, - properties: {}, - required: [], - additionalProperties: false, -} - const JsonSchemaConfigModal: FC = ({ isShow, defaultSchema, onSave, onClose, }) => { - const { t } = useTranslation() - const [currentTab, setCurrentTab] = useState(SchemaView.VisualEditor) - const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA) - const [json, setJson] = useState(JSON.stringify(jsonSchema, null, 2)) - const [btnWidth, setBtnWidth] = useState(0) - - const updateBtnWidth = useCallback((width: number) => { - setBtnWidth(width + 32) - }, []) - - const handleApplySchema = useCallback(() => {}, []) - - const handleSubmit = useCallback(() => {}, []) - - const handleUpdateSchema = useCallback((schema: Field) => { - setJsonSchema(schema) - }, []) - - const handleSchemaEditorUpdate = useCallback((schema: string) => { - setJson(schema) - }, []) - - const handleResetDefaults = useCallback(() => { - setJsonSchema(defaultSchema || DEFAULT_SCHEMA) - }, [defaultSchema]) - - const handleCancel = useCallback(() => { - onClose() - }, [onClose]) - - const handleSave = useCallback(() => { - onSave(jsonSchema) - onClose() - }, [jsonSchema, onSave, onClose]) - return ( -
- {/* Header */} -
-
- {t('workflow.nodes.llm.jsonSchema.title')} -
-
onClose()}> - -
-
- {/* Content */} -
- {/* Tab */} - - options={VIEW_TABS} - value={currentTab} - onChange={(value: SchemaView) => { - setCurrentTab(value) - }} + + + -
- {/* JSON Schema Generator */} - - - {/* JSON Schema Importer */} - -
-
-
- {currentTab === SchemaView.VisualEditor && ( - - )} - {currentTab === SchemaView.JsonSchema && ( - - )} -
- {/* Footer */} -
- - {t('workflow.nodes.llm.jsonSchema.doc')} - - -
-
- - -
-
- - -
-
-
-
+ +
) } 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 new file mode 100644 index 0000000000..669ec07c55 --- /dev/null +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-config.tsx @@ -0,0 +1,572 @@ +import React, { type FC, useCallback, useState } from 'react' +import { ArrayType, type Field, type SchemaRoot, Type } from '../../types' +import { RiBracesLine, RiCloseLine, RiExternalLinkLine, RiTimelineView } from '@remixicon/react' +import { SegmentedControl } from '../../../../../base/segmented-control' +import JsonSchemaGenerator from './json-schema-generator' +import Divider from '@/app/components/base/divider' +import JsonImporter from './json-importer' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' +import VisualEditor from './visual-editor' +import SchemaEditor from './schema-editor' +import { useJsonSchemaConfigStore } from './store' +import { useMittContext } from './context' +import type { EditData } from './visual-editor/edit-card' +import produce from 'immer' +import Toast from '@/app/components/base/toast' + +type JsonSchemaConfigProps = { + defaultSchema?: SchemaRoot + onSave: (schema: SchemaRoot) => void + onClose: () => void +} + +enum SchemaView { + VisualEditor = 'visualEditor', + JsonSchema = 'jsonSchema', +} + +const VIEW_TABS = [ + { Icon: RiTimelineView, text: 'Visual Editor', value: SchemaView.VisualEditor }, + { Icon: RiBracesLine, text: 'JSON Schema', value: SchemaView.JsonSchema }, +] + +const DEFAULT_SCHEMA: SchemaRoot = { + type: Type.object, + properties: {}, + required: [], + additionalProperties: false, +} + +type ChangeEventParams = { + path: string[], + parentPath: string[], + oldFields: EditData, + fields: EditData, +} + +type AddEventParams = { + path: string[] +} + +const findPropertyWithPath = (target: any, path: string[]) => { + let current = target + for (const key of path) + current = current[key] + return current +} + +const JsonSchemaConfig: FC = ({ + defaultSchema, + onSave, + onClose, +}) => { + const { t } = useTranslation() + const backupSchema = useJsonSchemaConfigStore(state => state.backupSchema) + const setBackupSchema = useJsonSchemaConfigStore(state => state.setBackupSchema) + const setIsAddingNewField = useJsonSchemaConfigStore(state => state.setIsAddingNewField) + const setHoveringProperty = useJsonSchemaConfigStore(state => state.setHoveringProperty) + const { emit, useSubscribe } = useMittContext() + const [currentTab, setCurrentTab] = useState(SchemaView.VisualEditor) + const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA) + const [json, setJson] = useState(JSON.stringify(jsonSchema, null, 2)) + const [btnWidth, setBtnWidth] = useState(0) + + useSubscribe('restoreSchema', () => { + if (backupSchema) { + setJsonSchema(backupSchema) + setBackupSchema(null) + } + }) + + useSubscribe('propertyNameChange', (params) => { + const { parentPath, oldFields, fields } = params as ChangeEventParams + const { name: oldName } = oldFields + const { name: newName } = fields + const newSchema = produce(jsonSchema, (draft) => { + if (oldName === newName) return + const schema = findPropertyWithPath(draft, parentPath) as Field + + if (schema.type === Type.object) { + const properties = schema.properties || {} + if (properties[newName]) { + Toast.notify({ + type: 'error', + message: 'Property name already exists', + }) + emit('restorePropertyName') + return + } + + const newProperties = Object.entries(properties).reduce((acc, [key, value]) => { + acc[key === oldName ? newName : key] = value + return acc + }, {} as Record) + + const required = schema.required || [] + const newRequired = produce(required, (draft) => { + const index = draft.indexOf(oldName) + if (index !== -1) + draft.splice(index, 1, newName) + }) + + schema.properties = newProperties + schema.required = newRequired + } + + if (schema.type === Type.array && schema.items && schema.items.type === Type.object) { + const properties = schema.items.properties || {} + if (properties[newName]) { + Toast.notify({ + type: 'error', + message: 'Property name already exists', + }) + emit('restorePropertyName') + return + } + + const newProperties = Object.entries(properties).reduce((acc, [key, value]) => { + acc[key === oldName ? newName : key] = value + return acc + }, {} as Record) + const required = schema.items.required || [] + const newRequired = produce(required, (draft) => { + const index = draft.indexOf(oldName) + if (index !== -1) + draft.splice(index, 1, newName) + }) + + schema.items.properties = newProperties + schema.items.required = newRequired + } + }) + setJsonSchema(newSchema) + }) + + useSubscribe('propertyTypeChange', (params) => { + const { path, oldFields, fields } = params as ChangeEventParams + const { type: oldType } = oldFields + const { type: newType } = fields + if (oldType === newType) return + const newSchema = produce(jsonSchema, (draft) => { + const schema = findPropertyWithPath(draft, path) as Field + + if (schema.type === Type.object) { + delete schema.properties + delete schema.required + } + if (schema.type === Type.array) + delete schema.items + switch (newType) { + case Type.object: + schema.type = Type.object + schema.properties = {} + schema.required = [] + break + case ArrayType.string: + schema.type = Type.array + schema.items = { + type: Type.string, + } + break + case ArrayType.number: + schema.type = Type.array + schema.items = { + type: Type.number, + } + break + case ArrayType.boolean: + schema.type = Type.array + schema.items = { + type: Type.boolean, + } + break + case ArrayType.object: + schema.type = Type.array + schema.items = { + type: Type.object, + properties: {}, + required: [], + } + break + default: + schema.type = newType as Type + } + }) + setJsonSchema(newSchema) + }) + + useSubscribe('propertyRequiredToggle', (params) => { + const { parentPath, fields } = params as ChangeEventParams + const { name } = fields + const newSchema = produce(jsonSchema, (draft) => { + const schema = findPropertyWithPath(draft, parentPath) as Field + + if (schema.type === Type.object) { + const required = schema.required || [] + const newRequired = required.includes(name) + ? required.filter(item => item !== name) + : [...required, name] + schema.required = newRequired + } + if (schema.type === Type.array && schema.items && schema.items.type === Type.object) { + const required = schema.items.required || [] + const newRequired = required.includes(name) + ? required.filter(item => item !== name) + : [...required, name] + schema.items.required = newRequired + } + }) + setJsonSchema(newSchema) + }) + + useSubscribe('propertyOptionsChange', (params) => { + const { path, fields } = params as ChangeEventParams + const newSchema = produce(jsonSchema, (draft) => { + const schema = findPropertyWithPath(draft, path) as Field + schema.description = fields.description + schema.enum = fields.enum + }) + setJsonSchema(newSchema) + }) + + useSubscribe('propertyDelete', (params) => { + const { parentPath, fields } = params as ChangeEventParams + const { name } = fields + const newSchema = produce(jsonSchema, (draft) => { + const schema = findPropertyWithPath(draft, parentPath) as Field + if (schema.type === Type.object && schema.properties) { + delete schema.properties[name] + schema.required = schema.required?.filter(item => item !== name) + } + if (schema.type === Type.array && schema.items?.properties && schema.items?.type === Type.object) { + delete schema.items.properties[name] + schema.items.required = schema.items.required?.filter(item => item !== name) + } + }) + setJsonSchema(newSchema) + }) + + useSubscribe('addField', (params) => { + setBackupSchema(jsonSchema) + const { path } = params as AddEventParams + setIsAddingNewField(true) + const newSchema = produce(jsonSchema, (draft) => { + const schema = findPropertyWithPath(draft, path) as Field + if (schema.type === Type.object) { + schema.properties = { + ...(schema.properties || {}), + '': { + type: Type.string, + description: '', + enum: [], + }, + } + setHoveringProperty([...path, 'properties', ''].join('.')) + } + if (schema.type === Type.array && schema.items && schema.items.type === Type.object) { + schema.items.properties = { + ...(schema.items.properties || {}), + '': { + type: Type.string, + description: '', + enum: [], + }, + } + setHoveringProperty([...path, 'items', 'properties', ''].join('.')) + } + }) + setJsonSchema(newSchema) + }) + + useSubscribe('fieldChange', (params) => { + const { parentPath, oldFields, fields } = params as ChangeEventParams + const newSchema = produce(jsonSchema, (draft) => { + const parentSchema = findPropertyWithPath(draft, parentPath) as Field + const { name: oldName, type: oldType, required: oldRequired } = oldFields + const { name: newName, type: newType, required: newRequired } = fields + if (parentSchema.type === Type.object && parentSchema.properties) { + // name change + if (oldName !== newName) { + const properties = parentSchema.properties + if (properties[newName]) { + Toast.notify({ + type: 'error', + message: 'Property name already exists', + }) + return + } + + const newProperties = Object.entries(properties).reduce((acc, [key, value]) => { + acc[key === oldName ? newName : key] = value + return acc + }, {} as Record) + + const requiredProperties = parentSchema.required || [] + const newRequiredProperties = produce(requiredProperties, (draft) => { + const index = draft.indexOf(oldName) + if (index !== -1) + draft.splice(index, 1, newName) + }) + + parentSchema.properties = newProperties + parentSchema.required = newRequiredProperties + } + + // required change + if (oldRequired !== newRequired) { + const required = parentSchema.required || [] + const newRequired = required.includes(newName) + ? required.filter(item => item !== newName) + : [...required, newName] + parentSchema.required = newRequired + } + + const schema = parentSchema.properties[newName] + + // type change + if (oldType !== newType) { + if (schema.type === Type.object) { + delete schema.properties + delete schema.required + } + if (schema.type === Type.array) + delete schema.items + switch (newType) { + case Type.object: + schema.type = Type.object + schema.properties = {} + schema.required = [] + break + case ArrayType.string: + schema.type = Type.array + schema.items = { + type: Type.string, + } + break + case ArrayType.number: + schema.type = Type.array + schema.items = { + type: Type.number, + } + break + case ArrayType.boolean: + schema.type = Type.array + schema.items = { + type: Type.boolean, + } + break + case ArrayType.object: + schema.type = Type.array + schema.items = { + type: Type.object, + properties: {}, + required: [], + } + break + default: + schema.type = newType as Type + } + } + + // other options change + schema.description = fields.description + schema.enum = fields.enum + } + + if (parentSchema.type === Type.array && parentSchema.items && parentSchema.items.type === Type.object && parentSchema.items.properties) { + // name change + if (oldName !== newName) { + const properties = parentSchema.items.properties || {} + if (properties[newName]) { + Toast.notify({ + type: 'error', + message: 'Property name already exists', + }) + emit('restorePropertyName') + return + } + + const newProperties = Object.entries(properties).reduce((acc, [key, value]) => { + acc[key === oldName ? newName : key] = value + return acc + }, {} as Record) + const required = parentSchema.items.required || [] + const newRequired = produce(required, (draft) => { + const index = draft.indexOf(oldName) + if (index !== -1) + draft.splice(index, 1, newName) + }) + + parentSchema.items.properties = newProperties + parentSchema.items.required = newRequired + } + + // required change + if (oldRequired !== newRequired) { + const required = parentSchema.items.required || [] + const newRequired = required.includes(newName) + ? required.filter(item => item !== newName) + : [...required, newName] + parentSchema.items.required = newRequired + } + + const schema = parentSchema.items.properties[newName] + // type change + if (oldType !== newType) { + if (schema.type === Type.object) { + delete schema.properties + delete schema.required + } + if (schema.type === Type.array) + delete schema.items + switch (newType) { + case Type.object: + schema.type = Type.object + schema.properties = {} + schema.required = [] + break + case ArrayType.string: + schema.type = Type.array + schema.items = { + type: Type.string, + } + break + case ArrayType.number: + schema.type = Type.array + schema.items = { + type: Type.number, + } + break + case ArrayType.boolean: + schema.type = Type.array + schema.items = { + type: Type.boolean, + } + break + case ArrayType.object: + schema.type = Type.array + schema.items = { + type: Type.object, + properties: {}, + required: [], + } + break + default: + schema.type = newType as Type + } + } + + // other options change + schema.description = fields.description + schema.enum = fields.enum + } + }) + setJsonSchema(newSchema) + emit('fieldChangeSuccess') + }) + + const updateBtnWidth = useCallback((width: number) => { + setBtnWidth(width + 32) + }, []) + + const handleApplySchema = useCallback(() => {}, []) + + const handleSubmit = useCallback(() => {}, []) + + const handleSchemaEditorUpdate = useCallback((schema: string) => { + setJson(schema) + }, []) + + const handleResetDefaults = useCallback(() => { + setJsonSchema(defaultSchema || DEFAULT_SCHEMA) + }, [defaultSchema]) + + const handleCancel = useCallback(() => { + onClose() + }, [onClose]) + + const handleSave = useCallback(() => { + onSave(jsonSchema) + onClose() + }, [jsonSchema, onSave, onClose]) + + return ( +
+ {/* Header */} +
+
+ {t('workflow.nodes.llm.jsonSchema.title')} +
+
+ +
+
+ {/* Content */} +
+ {/* Tab */} + + options={VIEW_TABS} + value={currentTab} + onChange={(value: SchemaView) => { + setCurrentTab(value) + }} + /> +
+ {/* JSON Schema Generator */} + + + {/* JSON Schema Importer */} + +
+
+
+ {currentTab === SchemaView.VisualEditor && ( + + )} + {currentTab === SchemaView.JsonSchema && ( + + )} +
+ {/* Footer */} +
+ + {t('workflow.nodes.llm.jsonSchema.doc')} + + +
+
+ + +
+
+ + +
+
+
+
+ ) +} + +export default JsonSchemaConfig diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/store.ts b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/store.ts new file mode 100644 index 0000000000..3036dbc6cc --- /dev/null +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/store.ts @@ -0,0 +1,34 @@ +import { useContext } from 'react' +import { createStore, useStore } from 'zustand' +import type { SchemaRoot } from '../../types' +import { JsonSchemaConfigContext } from './context' + +type JsonSchemaConfigStore = { + hoveringProperty: string | '' + setHoveringProperty: (propertyPath: string) => void + isAddingNewField: boolean + setIsAddingNewField: (isAdding: boolean) => void + advancedEditing: boolean + setAdvancedEditing: (isEditing: boolean) => void + backupSchema: SchemaRoot | null + setBackupSchema: (schema: SchemaRoot | null) => void +} + +export const createJsonSchemaConfigStore = () => createStore(set => ({ + hoveringProperty: '', + setHoveringProperty: (propertyPath: string) => set({ hoveringProperty: propertyPath }), + isAddingNewField: false, + setIsAddingNewField: (isAdding: boolean) => set({ isAddingNewField: isAdding }), + advancedEditing: false, + setAdvancedEditing: (isEditing: boolean) => set({ advancedEditing: isEditing }), + backupSchema: null, + setBackupSchema: (schema: SchemaRoot | null) => set({ backupSchema: schema }), +})) + +export const useJsonSchemaConfigStore = (selector: (state: JsonSchemaConfigStore) => T): T => { + const store = useContext(JsonSchemaConfigContext) + if (!store) + throw new Error('Missing JsonSchemaConfigContext.Provider in the tree') + + return useStore(store, selector) +} diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/add-field.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/add-field.tsx index 62a5153160..3bd3eec1ff 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/add-field.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/add-field.tsx @@ -1,30 +1,32 @@ -import { type FC, useState } from 'react' -import type { Field } from '../../../types' import Button from '@/app/components/base/button' import { RiAddCircleFill } from '@remixicon/react' import { useTranslation } from 'react-i18next' +import { useJsonSchemaConfigStore } from '../store' +import { useCallback } from 'react' +import { useMittContext } from '../context' -type AddToRootProps = { - addField: (path: string[], updates: Field) => void -} - -const AddField: FC = ({ - addField, -}) => { +const AddField = () => { const { t } = useTranslation() - const [isEditing, setIsEditing] = useState(false) + const setIsAddingNewField = useJsonSchemaConfigStore(state => state.setIsAddingNewField) + const { emit } = useMittContext() - const handleAddField = () => { - setIsEditing(true) - } + const handleAddField = useCallback(() => { + setIsAddingNewField(true) + emit('addField', { path: [] }) + }, [setIsAddingNewField, emit]) return ( - <> - - + ) } diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx index 709b9f9bc0..2d76bff793 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-actions.tsx @@ -3,11 +3,13 @@ import Button from '@/app/components/base/button' import { useTranslation } from 'react-i18next' type AdvancedActionsProps = { + isConfirmDisabled: boolean onCancel: () => void onConfirm: () => void } const AdvancedActions: FC = ({ + isConfirmDisabled, onCancel, onConfirm, }) => { @@ -18,7 +20,12 @@ const AdvancedActions: FC = ({ - diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-options.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-options.tsx index 3d9bbbafbe..9baad1a6a5 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-options.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/advanced-options.tsx @@ -29,11 +29,6 @@ const AdvancedOptions: FC = ({ onChange({ enum: e.target.value }) }, [onChange]) - // const handleEnumChange = useCallback((e: React.ChangeEvent) => { - // const value = e.target.value - // onChange({ enum: value }) - // }, [onChange]) - const handleToggleAdvancedOptions = useCallback(() => { setShowAdvancedOptions(prev => !prev) }, []) diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/index.tsx index a678ad1297..f26e0758e2 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/index.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/index.tsx @@ -9,8 +9,10 @@ import Actions from './actions' import AdvancedActions from './advanced-actions' import AdvancedOptions, { type AdvancedOptionsType } from './advanced-options' import { useTranslation } from 'react-i18next' -import { useUnmount } from 'ahooks' import classNames from '@/utils/classnames' +import { useJsonSchemaConfigStore } from '../../store' +import { useMittContext } from '../../context' +import produce from 'immer' export type EditData = { name: string @@ -20,15 +22,16 @@ export type EditData = { enum?: SchemaEnumType } +type Options = { + description: string + enum?: SchemaEnumType +} + type EditCardProps = { fields: EditData - onPropertyNameChange: (name: string) => void - onTypeChange: (type: Type | ArrayType) => void - onRequiredChange: (name: string) => void - onDescriptionChange: (description: string) => void - onAdvancedOptionsChange: (options: AdvancedOptionsType) => void - onDelete: (name: string) => void - onCancel: () => void + depth: number + path: string[] + parentPath: string[] } const TYPE_OPTIONS = [ @@ -42,93 +45,165 @@ const TYPE_OPTIONS = [ { value: ArrayType.object, text: 'array[object]' }, ] +const DEPTH_LIMIT = 10 + const EditCard: FC = ({ fields, - onPropertyNameChange, - onTypeChange, - onRequiredChange, - onDescriptionChange, - onAdvancedOptionsChange, - onDelete, - onCancel, + depth, + path, + parentPath, }) => { const { t } = useTranslation() - const [propertyName, setPropertyName] = useState(fields.name) - const [description, setDescription] = useState(fields.description) - const [AdvancedEditing, setAdvancedEditing] = useState(!fields) + const [currentFields, setCurrentFields] = useState(fields) + const [backupFields, setBackupFields] = useState(null) + const isAddingNewField = useJsonSchemaConfigStore(state => state.isAddingNewField) + const setIsAddingNewField = useJsonSchemaConfigStore(state => state.setIsAddingNewField) + const advancedEditing = useJsonSchemaConfigStore(state => state.advancedEditing) + const setAdvancedEditing = useJsonSchemaConfigStore(state => state.setAdvancedEditing) + const { emit, useSubscribe } = useMittContext() + + const disableAddBtn = fields.type !== Type.object && fields.type !== ArrayType.object && depth < DEPTH_LIMIT + const hasAdvancedOptions = fields.type === Type.string || fields.type === Type.number + const isAdvancedEditing = advancedEditing || isAddingNewField + + const advancedOptions = useMemo(() => { + return { enum: (currentFields.enum || []).join(', ') } + }, [currentFields.enum]) + + useSubscribe('restorePropertyName', () => { + setCurrentFields(prev => ({ ...prev, name: fields.name })) + }) + + useSubscribe('fieldChangeSuccess', () => { + if (isAddingNewField) { + setIsAddingNewField(false) + return + } + setAdvancedEditing(false) + }) + + const emitPropertyNameChange = useCallback((name: string) => { + const newFields = produce(fields, (draft) => { + draft.name = name + }) + emit('propertyNameChange', { path, parentPath, oldFields: fields, fields: newFields }) + }, [fields, path, parentPath, emit]) + + const emitPropertyTypeChange = useCallback((type: Type | ArrayType) => { + const newFields = produce(fields, (draft) => { + draft.type = type + }) + emit('propertyTypeChange', { path, parentPath, oldFields: fields, fields: newFields }) + }, [fields, path, parentPath, emit]) + + const emitPropertyRequiredToggle = useCallback(() => { + emit('propertyRequiredToggle', { path, parentPath, oldFields: fields, fields: currentFields }) + }, [emit, path, parentPath, fields, currentFields]) + + const emitPropertyOptionsChange = useCallback((options: Options) => { + emit('propertyOptionsChange', { path, parentPath, oldFields: fields, fields: { ...currentFields, ...options } }) + }, [emit, path, parentPath, fields, currentFields]) + + const emitPropertyDelete = useCallback(() => { + emit('propertyDelete', { path, parentPath, oldFields: fields, fields: currentFields }) + }, [emit, path, parentPath, fields, currentFields]) + + const emitPropertyAdd = useCallback(() => { + emit('addField', { path }) + }, [emit, path]) + + const emitFieldChange = useCallback(() => { + emit('fieldChange', { path, parentPath, oldFields: fields, fields: currentFields }) + }, [emit, path, parentPath, fields, currentFields]) const handlePropertyNameChange = useCallback((e: React.ChangeEvent) => { - setPropertyName(e.target.value) + setCurrentFields(prev => ({ ...prev, name: e.target.value })) }, []) const handlePropertyNameBlur = useCallback((e: React.FocusEvent) => { - onPropertyNameChange(e.target.value) - }, [onPropertyNameChange]) + if (isAdvancedEditing) return + emitPropertyNameChange(e.target.value) + }, [isAdvancedEditing, emitPropertyNameChange]) const handleTypeChange = useCallback((item: TypeItem) => { - onTypeChange(item.value) - }, [onTypeChange]) + setCurrentFields(prev => ({ ...prev, type: item.value })) + if (isAdvancedEditing) return + emitPropertyTypeChange(item.value) + }, [isAdvancedEditing, emitPropertyTypeChange]) const toggleRequired = useCallback(() => { - onRequiredChange(propertyName) - }, [onRequiredChange, propertyName]) + setCurrentFields(prev => ({ ...prev, required: !prev.required })) + if (isAdvancedEditing) return + emitPropertyRequiredToggle() + }, [isAdvancedEditing, emitPropertyRequiredToggle]) const handleDescriptionChange = useCallback((e: React.ChangeEvent) => { - setDescription(e.target.value) + setCurrentFields(prev => ({ ...prev, description: e.target.value })) }, []) const handleDescriptionBlur = useCallback((e: React.FocusEvent) => { - onDescriptionChange(e.target.value) - }, [onDescriptionChange]) - - const advancedOptions = useMemo(() => { - return { enum: (fields.enum || []).join(', ') } - }, [fields.enum]) + if (isAdvancedEditing) return + emitPropertyOptionsChange({ description: e.target.value, enum: fields.enum }) + }, [isAdvancedEditing, emitPropertyOptionsChange, fields]) const handleAdvancedOptionsChange = useCallback((options: AdvancedOptionsType) => { - onAdvancedOptionsChange(options) - }, [onAdvancedOptionsChange]) - - const handleConfirm = useCallback(() => { - setAdvancedEditing(false) - }, []) + if (isAdvancedEditing) return + const enumValue = options.enum.replace(' ', '').split(',') + emitPropertyOptionsChange({ description: fields.description, enum: enumValue }) + }, [isAdvancedEditing, emitPropertyOptionsChange, fields]) const handleDelete = useCallback(() => { - onDelete(propertyName) - }, [onDelete, propertyName]) + emitPropertyDelete() + }, [emitPropertyDelete]) - const handleEdit = useCallback(() => { + const handleAdvancedEdit = useCallback(() => { + setBackupFields({ ...currentFields }) setAdvancedEditing(true) - }, []) + }, [currentFields, setAdvancedEditing]) - useUnmount(() => { - onPropertyNameChange(propertyName) - }) + const handleAddChildField = useCallback(() => { + emitPropertyAdd() + }, [emitPropertyAdd]) - const disableAddBtn = fields.type !== Type.object && fields.type !== ArrayType.object - const hasAdvancedOptions = fields.type === Type.string || fields.type === Type.number + const handleConfirm = useCallback(() => { + emitFieldChange() + }, [emitFieldChange]) + + const handleCancel = useCallback(() => { + if (isAddingNewField) { + emit('restoreSchema') + setIsAddingNewField(false) + return + } + if (backupFields) { + setCurrentFields(backupFields) + setBackupFields(null) + } + setAdvancedEditing(false) + }, [isAddingNewField, emit, setIsAddingNewField, setAdvancedEditing, backupFields]) return (
e.key === 'Enter' && e.currentTarget.blur()} /> { - fields.required && ( + currentFields.required && (
{t('workflow.nodes.llm.jsonSchema.required')}
@@ -136,37 +211,40 @@ const EditCard: FC = ({ }
- {AdvancedEditing ? ( + {isAdvancedEditing ? ( { }} + isConfirmDisabled={currentFields.name === ''} + onCancel={handleCancel} onConfirm={handleConfirm} /> ) : ( { }} + onAddChildField={handleAddChildField} onDelete={handleDelete} - onEdit={handleEdit}/> + onEdit={handleAdvancedEdit} + /> )}
- {(description || AdvancedEditing) && ( -
+ {(currentFields.description || isAdvancedEditing) && ( +
e.key === 'Enter' && e.currentTarget.blur()} />
)} - {AdvancedEditing && hasAdvancedOptions && ( + {isAdvancedEditing && hasAdvancedOptions && ( void } const VisualEditor: FC = ({ schema, - onChange, }) => { return ( -
+
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx index 055a2f178c..85c08df17d 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/schema-node.tsx @@ -1,25 +1,23 @@ import type { FC } from 'react' -import React, { useCallback, useRef, useState } from 'react' -import { ArrayType } from '../../../types' -import { type ArrayItems, type Field, Type } from '../../../types' -import type { AdvancedOptionsType } from './edit-card/advanced-options' +import React, { useState } from 'react' +import { type Field, Type } from '../../../types' import classNames from '@/utils/classnames' import { RiArrowDropDownLine, RiArrowDropRightLine } from '@remixicon/react' import { getFieldType, getHasChildren } from '../../../utils' import Divider from '@/app/components/base/divider' import EditCard from './edit-card' import Card from './card' -import produce from 'immer' +import { useJsonSchemaConfigStore } from '../store' +import { useDebounceFn } from 'ahooks' +import AddField from './add-field' type SchemaNodeProps = { name: string required: boolean schema: Field + path: string[] + parentPath?: string[] depth: number - onChange: (schema: Field) => void - onPropertyNameChange?: (name: string) => void - onRequiredChange?: (name: string) => void - onNodeDelete?: (name: string) => void } // Support 10 levels of indentation @@ -52,222 +50,38 @@ const SchemaNode: FC = ({ name, required, schema, - onChange, - onPropertyNameChange, - onRequiredChange, - onNodeDelete, + path, + parentPath, depth, }) => { const [isExpanded, setIsExpanded] = useState(true) - const [isHovering, setIsHovering] = useState(false) - const hoverTimer = useRef(null) + const hoveringProperty = useJsonSchemaConfigStore(state => state.hoveringProperty) + const setHoveringProperty = useJsonSchemaConfigStore(state => state.setHoveringProperty) + const isAddingNewField = useJsonSchemaConfigStore(state => state.isAddingNewField) + const advancedEditing = useJsonSchemaConfigStore(state => state.advancedEditing) + + const { run: setHoveringPropertyDebounced } = useDebounceFn((path: string) => { + setHoveringProperty(path) + }, { wait: 50 }) const hasChildren = getHasChildren(schema) - const isEditing = isHovering && depth > 0 const type = getFieldType(schema) + const isHovering = hoveringProperty === path.join('.') && depth > 0 const handleExpand = () => { setIsExpanded(!isExpanded) } const handleMouseEnter = () => { - hoverTimer.current = setTimeout(() => { - setIsHovering(true) - }, 100) + if (advancedEditing || isAddingNewField) return + setHoveringPropertyDebounced(path.join('.')) } const handleMouseLeave = () => { - clearTimeout(hoverTimer.current) - setIsHovering(false) + if (advancedEditing || isAddingNewField) return + setHoveringPropertyDebounced('') } - const handlePropertyNameChange = useCallback((oldName: string, newName: string) => { - if (oldName === newName) return - - if (schema.type === Type.object) { - const properties = schema.properties || {} - if (properties[newName]) { - // TODO: Show error message - return - } - - const newProperties = Object.entries(properties).reduce((acc, [key, value]) => { - acc[key === oldName ? newName : key] = value - return acc - }, {} as Record) - - const required = schema.required || [] - const newRequired = produce(required, (draft) => { - const index = draft.indexOf(oldName) - if (index !== -1) - draft.splice(index, 1, newName) - }) - - onChange({ - ...schema, - properties: newProperties, - required: newRequired, - }) - return - } - - if (schema.type === Type.array && schema.items && schema.items.type === Type.object) { - const properties = schema.items.properties || {} - if (properties[newName]) { - // TODO: Show error message - return - } - - const newProperties = Object.entries(properties).reduce((acc, [key, value]) => { - acc[key === oldName ? newName : key] = value - return acc - }, {} as Record) - const required = schema.items.required || [] - const newRequired = produce(required, (draft) => { - const index = draft.indexOf(oldName) - if (index !== -1) - draft.splice(index, 1, newName) - }) - onChange({ - ...schema, - items: { - ...schema.items, - properties: newProperties, - required: newRequired, - }, - }) - } - }, [onChange, schema]) - - const handleTypeChange = useCallback((newType: Type | ArrayType) => { - if (schema.type === newType) return - const newSchema = produce(schema, (draft) => { - if (draft.type === Type.object) { - delete draft.properties - delete draft.required - } - if (draft.type === Type.array) - delete draft.items - switch (newType) { - case Type.object: - draft.type = Type.object - draft.properties = {} - draft.required = [] - break - case ArrayType.string: - draft.type = Type.array - draft.items = { - type: Type.string, - } - break - case ArrayType.number: - draft.type = Type.array - draft.items = { - type: Type.number, - } - break - case ArrayType.boolean: - draft.type = Type.array - draft.items = { - type: Type.boolean, - } - break - case ArrayType.object: - draft.type = Type.array - draft.items = { - type: Type.object, - properties: {}, - required: [], - } - break - default: - draft.type = newType as Type - } - }) - onChange(newSchema) - }, [onChange, schema]) - - const toggleRequired = useCallback((name: string) => { - if (schema.type === Type.object) { - const required = schema.required || [] - const newRequired = required.includes(name) - ? required.filter(item => item !== name) - : [...required, name] - onChange({ - ...schema, - required: newRequired, - }) - return - } - if (schema.type === Type.array && schema.items && schema.items.type === Type.object) { - const required = schema.items.required || [] - const newRequired = required.includes(name) - ? required.filter(item => item !== name) - : [...required, name] - onChange({ - ...schema, - items: { - ...schema.items, - required: newRequired, - }, - }) - } - }, [onChange, schema]) - - const handleDescriptionChange = useCallback((description: string) => { - onChange({ - ...schema, - description, - }) - }, [onChange, schema]) - - const handleAdvancedOptionsChange = useCallback((advancedOptions: AdvancedOptionsType) => { - const newAdvancedOptions = { - enum: advancedOptions.enum.replace(' ', '').split(','), - } - onChange({ - ...schema, - ...newAdvancedOptions, - }) - }, [onChange, schema]) - - const handleNodeDelete = useCallback((name: string) => { - const newSchema = produce(schema, (draft) => { - if (draft.type === Type.object && draft.properties) { - delete draft.properties[name] - draft.required = draft.required?.filter(item => item !== name) - } - if (draft.type === Type.array && draft.items?.properties && draft.items?.type === Type.object) { - delete draft.items.properties[name] - draft.items.required = draft.items.required?.filter(item => item !== name) - } - }) - onChange(newSchema) - }, [onChange, schema]) - - const handlePropertyChange = useCallback((name: string, propertySchema: Field) => { - onChange({ - ...schema, - properties: { - ...(schema.properties || {}), - [name]: propertySchema, - }, - }) - }, [onChange, schema]) - - const handleItemsPropertyChange = useCallback((name: string, itemsSchema: Field) => { - onChange({ - ...schema, - items: { - ...schema.items, - properties: { - ...(schema.items?.properties || {}), - [name]: itemsSchema as ArrayItems, - }, - } as ArrayItems, - }) - }, [onChange, schema]) - return (
@@ -293,7 +107,7 @@ const SchemaNode: FC = ({ onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} > - {isEditing ? ( + {isHovering ? ( = ({ description: schema.description || '', enum: schema.enum || [], }} - onPropertyNameChange={onPropertyNameChange!} - onTypeChange={handleTypeChange} - onRequiredChange={onRequiredChange!} - onDescriptionChange={handleDescriptionChange} - onAdvancedOptionsChange={handleAdvancedOptionsChange} - onDelete={onNodeDelete!} - onCancel={() => {}} + path={path} + parentPath={parentPath!} + depth={depth} /> ) : ( = ({ /> )}
-
@@ -338,10 +148,8 @@ const SchemaNode: FC = ({ name={key} required={!!schema.required?.includes(key)} schema={childSchema} - onChange={handlePropertyChange.bind(null, key)} - onPropertyNameChange={handlePropertyNameChange.bind(null, key)} - onRequiredChange={toggleRequired} - onNodeDelete={handleNodeDelete} + path={[...path, 'properties', key]} + parentPath={path} depth={depth + 1} /> )) @@ -358,16 +166,20 @@ const SchemaNode: FC = ({ name={key} required={!!schema.items?.required?.includes(key)} schema={childSchema} - onChange={handleItemsPropertyChange.bind(null, key)} - onPropertyNameChange={handlePropertyNameChange.bind(null, key)} - onRequiredChange={toggleRequired} - onNodeDelete={handleNodeDelete} + path={[...path, 'items', 'properties', key]} + parentPath={path} depth={depth + 1} /> )) )} )} + + { + depth === 0 && !isAddingNewField && ( + + ) + }
) } diff --git a/web/app/dev-preview/page.tsx b/web/app/dev-preview/page.tsx index 7c9e1c1ecf..49a8fe747b 100644 --- a/web/app/dev-preview/page.tsx +++ b/web/app/dev-preview/page.tsx @@ -1,12 +1,12 @@ 'use client' import { useState } from 'react' -import { type Field, Type } from '../components/workflow/nodes/llm/types' +import { type SchemaRoot, Type } from '../components/workflow/nodes/llm/types' import JsonSchemaConfigModal from '../components/workflow/nodes/llm/components/json-schema-config-modal' export default function Page() { const [show, setShow] = useState(false) - const [schema, setSchema] = useState({ + const [schema, setSchema] = useState({ type: Type.object, properties: { userId: { @@ -19,9 +19,6 @@ export default function Page() { title: { type: Type.string, }, - completed: { - type: Type.boolean, - }, locations: { type: Type.array, items: { @@ -51,6 +48,9 @@ export default function Page() { ], }, }, + completed: { + type: Type.boolean, + }, }, required: [ 'userId', @@ -62,14 +62,16 @@ export default function Page() { return
- {show && { - setSchema(schema) - }} - onClose={() => setShow(false)} - />} + {show && ( + { + setSchema(schema) + }} + onClose={() => setShow(false)} + /> + )}
       {JSON.stringify(schema, null, 2)}
     
diff --git a/web/hooks/use-mitt.ts b/web/hooks/use-mitt.ts index b9094bc262..90c23992dc 100644 --- a/web/hooks/use-mitt.ts +++ b/web/hooks/use-mitt.ts @@ -10,7 +10,7 @@ const merge = >( export type _Events = Record -export type UseSubcribeOption = { +export type UseSubscribeOption = { /** * Whether the subscription is enabled. * @default true @@ -22,21 +22,21 @@ export type ExtendedOn = { ( type: Key, handler: Handler, - options?: UseSubcribeOption, + options?: UseSubscribeOption, ): void; ( type: '*', handler: WildcardHandler, - option?: UseSubcribeOption, + option?: UseSubscribeOption, ): void; } export type UseMittReturn = { - useSubcribe: ExtendedOn; + useSubscribe: ExtendedOn; emit: Emitter['emit']; } -const defaultSubcribeOption: UseSubcribeOption = { +const defaultSubscribeOption: UseSubscribeOption = { enabled: true, } @@ -52,12 +52,12 @@ function useMitt( emitterRef.current = mitt } const emitter = emitterRef.current - const useSubcribe: ExtendedOn = ( + const useSubscribe: ExtendedOn = ( type: string, handler: any, - option?: UseSubcribeOption, + option?: UseSubscribeOption, ) => { - const { enabled } = merge(defaultSubcribeOption, option) + const { enabled } = merge(defaultSubscribeOption, option) useEffect(() => { if (enabled) { emitter.on(type, handler) @@ -67,7 +67,7 @@ function useMitt( } return { emit: emitter.emit, - useSubcribe, + useSubscribe, } }