From 136eca79e6da48d24850ba2ff8eac99050fa167d Mon Sep 17 00:00:00 2001 From: chanx <1243304602@qq.com> Date: Thu, 20 Nov 2025 11:21:46 +0800 Subject: [PATCH] Fix: Introducing a new JSON editor --- web/package.json | 1 + .../json-edit/css/cloud9_night.less | 132 +++++++ web/src/components/json-edit/css/index.less | 83 +++++ web/src/components/json-edit/index.tsx | 142 ++++++++ web/src/components/json-edit/interface.ts | 339 ++++++++++++++++++ .../hooks/use-object-fields.tsx | 80 ++++- 6 files changed, 763 insertions(+), 14 deletions(-) create mode 100644 web/src/components/json-edit/css/cloud9_night.less create mode 100644 web/src/components/json-edit/css/index.less create mode 100644 web/src/components/json-edit/index.tsx create mode 100644 web/src/components/json-edit/interface.ts diff --git a/web/package.json b/web/package.json index a9e13a117..ab24212c5 100644 --- a/web/package.json +++ b/web/package.json @@ -79,6 +79,7 @@ "input-otp": "^1.4.1", "js-base64": "^3.7.5", "jsencrypt": "^3.3.2", + "jsoneditor": "^10.4.2", "lexical": "^0.23.1", "lodash": "^4.17.21", "lucide-react": "^0.546.0", diff --git a/web/src/components/json-edit/css/cloud9_night.less b/web/src/components/json-edit/css/cloud9_night.less new file mode 100644 index 000000000..c9e95ed87 --- /dev/null +++ b/web/src/components/json-edit/css/cloud9_night.less @@ -0,0 +1,132 @@ +.ace-tomorrow-night .ace_gutter { + background: var(--bg-card); + color: rgb(var(--text-primary)); +} +.ace-tomorrow-night .ace_print-margin { + width: 1px; + background: #25282c; +} + +.ace-tomorrow-night { + background: var(--bg-card); + color: rgb(var(--text-primary)); + .ace_editor { + background: var(--bg-card); + } +} + +.ace-tomorrow-night .ace_cursor { + color: #aeafad; +} + +.ace-tomorrow-night .ace_marker-layer .ace_selection { + background: #373b41; +} + +.ace-tomorrow-night.ace_multiselect .ace_selection.ace_start { + box-shadow: 0 0 3px 0px #1d1f21; +} + +.ace-tomorrow-night .ace_marker-layer .ace_step { + background: rgb(102, 82, 0); +} + +.ace-tomorrow-night .ace_marker-layer .ace_bracket { + margin: -1px 0 0 -1px; + border: 1px solid #4b4e55; +} + +.ace-tomorrow-night .ace_marker-layer .ace_active-line { + background: var(--bg-card); +} + +.ace-tomorrow-night .ace_gutter-active-line { + background-color: var(--bg-card); +} + +.ace-tomorrow-night .ace_marker-layer .ace_selected-word { + border: 1px solid #373b41; +} + +.ace-tomorrow-night .ace_invisible { + color: #4b4e55; +} + +.ace-tomorrow-night .ace_keyword, +.ace-tomorrow-night .ace_meta, +.ace-tomorrow-night .ace_storage, +.ace-tomorrow-night .ace_storage.ace_type, +.ace-tomorrow-night .ace_support.ace_type { + color: #b294bb; +} + +.ace-tomorrow-night .ace_keyword.ace_operator { + color: #8abeb7; +} + +.ace-tomorrow-night .ace_constant.ace_character, +.ace-tomorrow-night .ace_constant.ace_language, +.ace-tomorrow-night .ace_constant.ace_numeric, +.ace-tomorrow-night .ace_keyword.ace_other.ace_unit, +.ace-tomorrow-night .ace_support.ace_constant, +.ace-tomorrow-night .ace_variable.ace_parameter { + color: #de935f; +} + +.ace-tomorrow-night .ace_constant.ace_other { + color: #ced1cf; +} + +.ace-tomorrow-night .ace_invalid { + color: #ced2cf; + background-color: #df5f5f; +} + +.ace-tomorrow-night .ace_invalid.ace_deprecated { + color: #ced2cf; + background-color: #b798bf; +} + +.ace-tomorrow-night .ace_fold { + background-color: #81a2be; + border-color: #c5c8c6; +} + +.ace-tomorrow-night .ace_entity.ace_name.ace_function, +.ace-tomorrow-night .ace_support.ace_function, +.ace-tomorrow-night .ace_variable { + color: #81a2be; +} + +.ace-tomorrow-night .ace_support.ace_class, +.ace-tomorrow-night .ace_support.ace_type { + color: #f0c674; +} + +.ace-tomorrow-night .ace_heading, +.ace-tomorrow-night .ace_markup.ace_heading, +.ace-tomorrow-night .ace_string { + color: #b5bd68; +} + +.ace-tomorrow-night .ace_entity.ace_name.ace_tag, +.ace-tomorrow-night .ace_entity.ace_other.ace_attribute-name, +.ace-tomorrow-night .ace_meta.ace_tag, +.ace-tomorrow-night .ace_string.ace_regexp, +.ace-tomorrow-night .ace_variable { + color: #cc6666; +} + +.ace-tomorrow-night .ace_comment { + color: #969896; +} + +.ace-tomorrow-night .ace_indent-guide { + background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQImWNgYGBgYHB3d/8PAAOIAdULw8qMAAAAAElFTkSuQmCC) + right repeat-y; +} + +.ace-tomorrow-night .ace_indent-guide-active { + background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQIW2PQ1dX9zzBz5sz/ABCcBFFentLlAAAAAElFTkSuQmCC) + right repeat-y; +} diff --git a/web/src/components/json-edit/css/index.less b/web/src/components/json-edit/css/index.less new file mode 100644 index 000000000..62564616b --- /dev/null +++ b/web/src/components/json-edit/css/index.less @@ -0,0 +1,83 @@ +.jsoneditor { + border: none; + color: rgb(var(--text-primary)); + overflow: auto; + scrollbar-width: none; + background-color: var(--bg-base); + .jsoneditor-menu { + background-color: var(--bg-base); + // border-color: var(--border-button); + border-bottom: thin solid var(--border-button); + } + .jsoneditor-navigation-bar { + border-bottom: 1px solid var(--border-button); + background-color: var(--bg-input); + } + .jsoneditor-tree { + background: var(--bg-base); + } + .jsoneditor-highlight { + background-color: var(--bg-card); + } +} +.jsoneditor-popover, +.jsoneditor-schema-error, +div.jsoneditor td, +div.jsoneditor textarea, +div.jsoneditor th, +div.jsoneditor-field, +div.jsoneditor-value, +pre.jsoneditor-preview { + font-family: consolas, menlo, monaco, 'Ubuntu Mono', source-code-pro, + monospace; + font-size: 14px; + color: rgb(var(--text-primary)); +} + +div.jsoneditor-field.jsoneditor-highlight, +div.jsoneditor-field[contenteditable='true']:focus, +div.jsoneditor-field[contenteditable='true']:hover, +div.jsoneditor-value.jsoneditor-highlight, +div.jsoneditor-value[contenteditable='true']:focus, +div.jsoneditor-value[contenteditable='true']:hover { + background-color: var(--bg-input); + border: 1px solid var(--border-button); + border-radius: 2px; +} + +.jsoneditor-selected, +.jsoneditor-contextmenu .jsoneditor-menu li ul { + background: var(--bg-base); +} + +.jsoneditor-contextmenu .jsoneditor-menu button { + color: rgb(var(--text-secondary)); +} +.jsoneditor-menu a.jsoneditor-poweredBy { + display: none; +} +.ace-jsoneditor .ace_scroller { + background-color: var(--bg-base); +} +.jsoneditor-statusbar { + border-top: 1px solid var(--border-button); + background-color: var(--bg-base); + color: rgb(var(--text-primary)); +} +.jsoneditor-menu > .jsoneditor-modes > button, +.jsoneditor-menu > button { + // color: rgb(var(--text-secondary)); + background-color: var(--text-disabled); +} + +.jsoneditor-menu > .jsoneditor-modes > button:active, +.jsoneditor-menu > .jsoneditor-modes > button:focus, +.jsoneditor-menu > button:active, +.jsoneditor-menu > button:focus { + background-color: rgb(var(--text-secondary)); +} +.jsoneditor-menu > .jsoneditor-modes > button:hover, +.jsoneditor-menu > button:hover { + background-color: rgb(var(--text-secondary)); + border: 1px solid var(--border-button); +} diff --git a/web/src/components/json-edit/index.tsx b/web/src/components/json-edit/index.tsx new file mode 100644 index 000000000..2ab49c4e2 --- /dev/null +++ b/web/src/components/json-edit/index.tsx @@ -0,0 +1,142 @@ +import React, { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import './css/cloud9_night.less'; +import './css/index.less'; +import { JsonEditorOptions, JsonEditorProps } from './interface'; +const defaultConfig: JsonEditorOptions = { + mode: 'code', + modes: ['tree', 'code'], + history: false, + search: false, + mainMenuBar: false, + navigationBar: false, + enableSort: false, + enableTransform: false, + indentation: 2, +}; +const JsonEditor: React.FC = ({ + value, + onChange, + height = '400px', + className = '', + options = {}, +}) => { + const containerRef = useRef(null); + const editorRef = useRef(null); + const { i18n } = useTranslation(); + const currentLanguageRef = useRef(i18n.language); + + useEffect(() => { + if (typeof window !== 'undefined') { + const JSONEditor = require('jsoneditor'); + import('jsoneditor/dist/jsoneditor.min.css'); + + if (containerRef.current) { + // Default configuration options + const defaultOptions: JsonEditorOptions = { + ...defaultConfig, + language: i18n.language === 'zh' ? 'zh-CN' : 'en', + onChange: () => { + if (editorRef.current && onChange) { + try { + const updatedJson = editorRef.current.get(); + onChange(updatedJson); + } catch (err) { + // Do not trigger onChange when parsing error occurs + console.error(err); + } + } + }, + ...options, // Merge user provided options with defaults + }; + + editorRef.current = new JSONEditor( + containerRef.current, + defaultOptions, + ); + + if (value) { + editorRef.current.set(value); + } + } + } + + return () => { + if (editorRef.current) { + if (typeof editorRef.current.destroy === 'function') { + editorRef.current.destroy(); + } + editorRef.current = null; + } + }; + }, []); + + useEffect(() => { + // Update language when i18n language changes + // Since JSONEditor doesn't have a setOptions method, we need to recreate the editor + if (editorRef.current && currentLanguageRef.current !== i18n.language) { + currentLanguageRef.current = i18n.language; + + // Save current data + let currentData; + try { + currentData = editorRef.current.get(); + } catch (e) { + // If there's an error getting data, use the passed value or empty object + currentData = value || {}; + } + + // Destroy the current editor + if (typeof editorRef.current.destroy === 'function') { + editorRef.current.destroy(); + } + + // Recreate the editor with new language + const JSONEditor = require('jsoneditor'); + + const newOptions: JsonEditorOptions = { + ...defaultConfig, + language: i18n.language === 'zh' ? 'zh-CN' : 'en', + onChange: () => { + if (editorRef.current && onChange) { + try { + const updatedJson = editorRef.current.get(); + onChange(updatedJson); + } catch (err) { + // Do not trigger onChange when parsing error occurs + } + } + }, + ...options, // Merge user provided options with defaults + }; + + editorRef.current = new JSONEditor(containerRef.current, newOptions); + editorRef.current.set(currentData); + } + }, [i18n.language, value, onChange, options]); + + useEffect(() => { + if (editorRef.current && value !== undefined) { + try { + // Only update the editor when the value actually changes + const currentJson = editorRef.current.get(); + if (JSON.stringify(currentJson) !== JSON.stringify(value)) { + editorRef.current.set(value); + } + } catch (err) { + // Skip update if there is a syntax error in the current editor + editorRef.current.set(value); + } + } + }, [value]); + + return ( +
+ ); +}; + +export default JsonEditor; diff --git a/web/src/components/json-edit/interface.ts b/web/src/components/json-edit/interface.ts new file mode 100644 index 000000000..9807411c9 --- /dev/null +++ b/web/src/components/json-edit/interface.ts @@ -0,0 +1,339 @@ +// JSONEditor configuration options interface see: https://github.com/josdejong/jsoneditor/blob/master/docs/api.md +export interface JsonEditorOptions { + /** + * Editor mode. Available values: 'tree' (default), 'view', 'form', 'text', and 'code'. + */ + mode?: 'tree' | 'view' | 'form' | 'text' | 'code'; + + /** + * Array of available modes + */ + modes?: Array<'tree' | 'view' | 'form' | 'text' | 'code'>; + + /** + * Field name for the root node. Only applicable for modes 'tree', 'view', and 'form' + */ + name?: string; + + /** + * Theme for the editor + */ + theme?: string; + + /** + * Enable history (undo/redo). True by default. Only applicable for modes 'tree', 'view', and 'form' + */ + history?: boolean; + + /** + * Enable search box. True by default. Only applicable for modes 'tree', 'view', and 'form' + */ + search?: boolean; + + /** + * Main menu bar visibility + */ + mainMenuBar?: boolean; + + /** + * Navigation bar visibility + */ + navigationBar?: boolean; + + /** + * Status bar visibility + */ + statusBar?: boolean; + + /** + * If true, object keys are sorted before display. false by default. + */ + sortObjectKeys?: boolean; + + /** + * Enable transform functionality + */ + enableTransform?: boolean; + + /** + * Enable sort functionality + */ + enableSort?: boolean; + + /** + * Limit dragging functionality + */ + limitDragging?: boolean; + + /** + * A JSON schema object + */ + schema?: any; + + /** + * Schemas that are referenced using the `$ref` property from the JSON schema + */ + schemaRefs?: Record; + + /** + * Array of template objects + */ + templates?: Array<{ + text: string; + title?: string; + className?: string; + field?: string; + value: any; + }>; + + /** + * Ace editor instance + */ + ace?: any; + + /** + * An instance of Ajv JSON schema validator + */ + ajv?: any; + + /** + * Switch to enable/disable autocomplete + */ + autocomplete?: { + confirmKey?: string | string[]; + caseSensitive?: boolean; + getOptions?: ( + text: string, + path: Array, + input: string, + editor: any, + ) => string[] | Promise | null; + }; + + /** + * Number of indentation spaces. 4 by default. Only applicable for modes 'text' and 'code' + */ + indentation?: number; + + /** + * Available languages + */ + languages?: string[]; + + /** + * Language of the editor + */ + language?: string; + + /** + * Callback method, triggered on change of contents. Does not pass the contents itself. + * See also onChangeJSON and onChangeText. + */ + onChange?: () => void; + + /** + * Callback method, triggered in modes on change of contents, passing the changed contents as JSON. + * Only applicable for modes 'tree', 'view', and 'form'. + */ + onChangeJSON?: (json: any) => void; + + /** + * Callback method, triggered in modes on change of contents, passing the changed contents as stringified JSON. + */ + onChangeText?: (text: string) => void; + + /** + * Callback method, triggered when an error occurs + */ + onError?: (error: Error) => void; + + /** + * Callback method, triggered when node is expanded + */ + onExpand?: (node: any) => void; + + /** + * Callback method, triggered when node is collapsed + */ + onCollapse?: (node: any) => void; + + /** + * Callback method, determines if a node is editable + */ + onEditable?: (node: any) => boolean | { field: boolean; value: boolean }; + + /** + * Callback method, triggered when an event occurs in a JSON field or value. + * Only applicable for modes 'form', 'tree' and 'view' + */ + onEvent?: (node: any, event: Event) => void; + + /** + * Callback method, triggered when the editor comes into focus, passing an object {type, target}. + * Applicable for all modes + */ + onFocus?: (node: any) => void; + + /** + * Callback method, triggered when the editor goes out of focus, passing an object {type, target}. + * Applicable for all modes + */ + onBlur?: (node: any) => void; + + /** + * Callback method, triggered when creating menu items + */ + onCreateMenu?: (menuItems: any[], node: any) => any[]; + + /** + * Callback method, triggered on node selection change. Only applicable for modes 'tree', 'view', and 'form' + */ + onSelectionChange?: (selection: any) => void; + + /** + * Callback method, triggered on text selection change. Only applicable for modes 'text' and 'code' + */ + onTextSelectionChange?: (selection: any) => void; + + /** + * Callback method, triggered when a Node DOM is rendered. Function returns a css class name to be set on a node. + * Only applicable for modes 'form', 'tree' and 'view' + */ + onClassName?: (node: any) => string | undefined; + + /** + * Callback method, triggered when validating nodes + */ + onValidate?: ( + json: any, + ) => + | Array<{ path: Array; message: string }> + | Promise; message: string }>>; + + /** + * Callback method, triggered when node name is determined + */ + onNodeName?: (parentNode: any, childNode: any, name: string) => string; + + /** + * Callback method, triggered when mode changes + */ + onModeChange?: (newMode: string, oldMode: string) => void; + + /** + * Color picker options + */ + colorPicker?: boolean; + + /** + * Callback method for color picker + */ + onColorPicker?: ( + callback: (color: string) => void, + parent: HTMLElement, + ) => void; + + /** + * If true, shows timestamp tag + */ + timestampTag?: boolean; + + /** + * Format for timestamps + */ + timestampFormat?: string; + + /** + * If true, unicode characters are escaped. false by default. + */ + escapeUnicode?: boolean; + + /** + * Number of children allowed for a node in 'tree', 'view', or 'form' mode before + * the "show more/show all" buttons appear. 100 by default. + */ + maxVisibleChilds?: number; + + /** + * Callback method for validation errors + */ + onValidationError?: ( + errors: Array<{ path: Array; message: string }>, + ) => void; + + /** + * Callback method for validation warnings + */ + onValidationWarning?: ( + warnings: Array<{ path: Array; message: string }>, + ) => void; + + /** + * The anchor element to apply an overlay and display the modals in a centered location. Defaults to document.body + */ + modalAnchor?: HTMLElement | null; + + /** + * Anchor element for popups + */ + popupAnchor?: HTMLElement | null; + + /** + * Function to create queries + */ + createQuery?: () => void; + + /** + * Function to execute queries + */ + executeQuery?: () => void; + + /** + * Query description + */ + queryDescription?: string; + + /** + * Allow schema suggestions + */ + allowSchemaSuggestions?: boolean; + + /** + * Show error table + */ + showErrorTable?: boolean; + + /** + * Validate current JSON object against the configured JSON schema + * Must be implemented by tree mode and text mode + */ + validate?: () => Promise; + + /** + * Refresh the rendered contents + * Can be implemented by tree mode and text mode + */ + refresh?: () => void; + + /** + * Callback method triggered when schema changes + */ + _onSchemaChange?: (schema: any, schemaRefs: any) => void; +} + +export interface JsonEditorProps { + // JSON data to be displayed in the editor + value?: any; + + // Callback function triggered when the JSON data changes + onChange?: (value: any) => void; + + // Height of the editor + height?: string; + + // Additional CSS class names + className?: string; + + // Configuration options for the JSONEditor + options?: JsonEditorOptions; +} diff --git a/web/src/pages/agent/gobal-variable-sheet/hooks/use-object-fields.tsx b/web/src/pages/agent/gobal-variable-sheet/hooks/use-object-fields.tsx index d8600d568..7e60a7aec 100644 --- a/web/src/pages/agent/gobal-variable-sheet/hooks/use-object-fields.tsx +++ b/web/src/pages/agent/gobal-variable-sheet/hooks/use-object-fields.tsx @@ -1,7 +1,7 @@ +import JsonEditor from '@/components/json-edit'; import { BlockButton, Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Segmented } from '@/components/ui/segmented'; -import { Editor } from '@monaco-editor/react'; import { t } from 'i18next'; import { Trash2, X } from 'lucide-react'; import { useCallback } from 'react'; @@ -31,32 +31,80 @@ export const useObjectFields = () => { }, [], ); + const validateKeys = ( + obj: any, + path: (string | number)[] = [], + ): Array<{ path: (string | number)[]; message: string }> => { + const errors: Array<{ path: (string | number)[]; message: string }> = []; + if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) { + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + if (!/^[a-zA-Z_]+$/.test(key)) { + errors.push({ + path: [...path, key], + message: `Key "${key}" is invalid. Keys can only contain letters and underscores.`, + }); + } + const nestedErrors = validateKeys(obj[key], [...path, key]); + errors.push(...nestedErrors); + } + } + } else if (Array.isArray(obj)) { + obj.forEach((item, index) => { + const nestedErrors = validateKeys(item, [...path, index]); + errors.push(...nestedErrors); + }); + } + + return errors; + }; const objectRender = useCallback((field: FieldValues) => { - const fieldValue = - typeof field.value === 'object' - ? JSON.stringify(field.value, null, 2) - : JSON.stringify({}, null, 2); - console.log('object-render-field', field, fieldValue); + // const fieldValue = + // typeof field.value === 'object' + // ? JSON.stringify(field.value, null, 2) + // : JSON.stringify({}, null, 2); + // console.log('object-render-field', field, fieldValue); return ( - + { + return validateKeys(json); + }, + }} /> ); }, []); const objectValidate = useCallback((value: any) => { try { - if (!JSON.parse(value)) { - throw new Error(t('knowledgeDetails.formatTypeError')); + if (validateKeys(value, [])?.length > 0) { + throw new Error(t('flow.formatTypeError')); + } + if (!z.object({}).safeParse(value).success) { + throw new Error(t('flow.formatTypeError')); + } + if (value && typeof value === 'string' && !JSON.parse(value)) { + throw new Error(t('flow.formatTypeError')); } return true; } catch (e) { - throw new Error(t('knowledgeDetails.formatTypeError')); + console.log('object-render-error', e, value); + throw new Error(t('flow.formatTypeError')); } }, []); @@ -219,6 +267,10 @@ export const useObjectFields = () => { }; const handleCustomSchema = (value: TypesWithArray) => { switch (value) { + case TypesWithArray.Object: + return z.object({}); + case TypesWithArray.ArrayObject: + return z.array(z.object({})); case TypesWithArray.ArrayString: return z.array(z.string()); case TypesWithArray.ArrayNumber: