From fefd7819e6ecbed2f5ea8a4e816849eb7d7616ba Mon Sep 17 00:00:00 2001 From: twwu Date: Wed, 12 Mar 2025 12:14:01 +0800 Subject: [PATCH] feat: add JSON Schema generator and support enum values in types --- .../base/segmented-control/index.tsx | 68 ++++++ .../json-schema-config-modal/index.tsx | 147 +++++++++++++ .../json-importer.tsx | 197 ++++++++++++++++++ .../json-schema-generator/assets/index.tsx | 7 + .../assets/schema-generator-dark.tsx | 15 ++ .../assets/schema-generator-light.tsx | 15 ++ .../generated-result.tsx | 159 ++++++++++++++ .../json-schema-generator/index.tsx | 134 ++++++++++++ .../json-schema-generator/prompt-editor.tsx | 88 ++++++++ .../components/workflow/nodes/llm/types.ts | 1 + web/i18n/en-US/workflow.ts | 17 ++ web/i18n/zh-Hans/workflow.ts | 17 ++ 12 files changed, 865 insertions(+) create mode 100644 web/app/components/base/segmented-control/index.tsx create mode 100644 web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx create mode 100644 web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx create mode 100644 web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/index.tsx create mode 100644 web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-dark.tsx create mode 100644 web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-light.tsx create mode 100644 web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/generated-result.tsx create mode 100644 web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx create mode 100644 web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx diff --git a/web/app/components/base/segmented-control/index.tsx b/web/app/components/base/segmented-control/index.tsx new file mode 100644 index 0000000000..e3e0f7f6ca --- /dev/null +++ b/web/app/components/base/segmented-control/index.tsx @@ -0,0 +1,68 @@ +import React from 'react' +import classNames from '@/utils/classnames' +import type { RemixiconComponentType } from '@remixicon/react' +import Divider from '../divider' + +// Updated generic type to allow enum values +type SegmentedControlProps = { + options: { Icon: RemixiconComponentType, text: string, value: T }[] + value: T + onChange: (value: T) => void + className?: string +} + +export const SegmentedControl = ({ + options, + value, + onChange, + className, +}: SegmentedControlProps): JSX.Element => { + const selectedOptionIndex = options.findIndex(option => option.value === value) + + return ( +
+ {options.map((option, index) => { + const { Icon } = option + const isSelected = index === selectedOptionIndex + const isNextSelected = index === selectedOptionIndex - 1 + const isLast = index === options.length - 1 + return ( + + ) + })} +
+ ) +} + +export default React.memo(SegmentedControl) as typeof SegmentedControl 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 new file mode 100644 index 0000000000..39bafb9abb --- /dev/null +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx @@ -0,0 +1,147 @@ +import React, { type FC, useCallback, useState } from 'react' +import Modal from '../../../../../base/modal' +import { type StructuredOutput, 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' + +type JsonSchemaConfigModalProps = { + isShow: boolean + defaultSchema: StructuredOutput + onSave: (schema: StructuredOutput) => void + onClose: () => void +} + +enum SchemaView { + VisualEditor = 'visualEditor', + JsonSchema = 'jsonSchema', +} + +const options = [ + { Icon: RiTimelineView, text: 'Visual Editor', value: SchemaView.VisualEditor }, + { Icon: RiBracesLine, text: 'JSON Schema', value: SchemaView.JsonSchema }, +] + +const DEFAULT_SCHEMA = { + schema: { + 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 [btnWidth, setBtnWidth] = useState(0) + + const updateBtnWidth = useCallback((width: number) => { + setBtnWidth(width + 32) + }, []) + + const handleApplySchema = useCallback(() => {}, []) + + const handleSubmit = useCallback(() => {}, []) + + 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={options} + value={currentTab} + onChange={(value: SchemaView) => { + setCurrentTab(value) + }} + /> +
+ {/* JSON Schema Generator */} + + + {/* JSON Schema Importer */} + +
+
+
+ {currentTab === SchemaView.VisualEditor &&
Visual Editor
} + {currentTab === SchemaView.JsonSchema &&
JSON Schema
} +
+ {/* Footer */} +
+ + {t('workflow.nodes.llm.jsonSchema.doc')} + + +
+
+ + +
+
+ + +
+
+
+
+
+ ) +} + +export default JsonSchemaConfigModal diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx new file mode 100644 index 0000000000..3741ed8c37 --- /dev/null +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-importer.tsx @@ -0,0 +1,197 @@ +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 Button from '@/app/components/base/button' + +type JsonImporterProps = { + onSubmit: (schema: string) => void + updateBtnWidth: (width: number) => void +} + +const JsonImporter: FC = ({ + onSubmit, + updateBtnWidth, +}) => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [json, setJson] = useState('') + const [parseError, setParseError] = useState(null) + const importBtnRef = useRef(null) + const monacoRef = useRef(null) + const editorRef = useRef(null) + + useEffect(() => { + if (importBtnRef.current) { + const rect = importBtnRef.current.getBoundingClientRect() + updateBtnWidth(rect.width) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const handleEditorDidMount = useCallback((editor: any, monaco: any) => { + editorRef.current = editor + monacoRef.current = monaco + monaco.editor.defineTheme('light-theme', { + base: 'vs', + inherit: true, + rules: [], + colors: { + 'editor.background': '#00000000', + 'editor.lineHighlightBackground': '#00000000', + 'focusBorder': '#00000000', + }, + }) + monaco.editor.setTheme('light-theme') + }, []) + + const handleEditorChange = useCallback((value: string | undefined) => { + if (!value) + return + setJson(value) + }, []) + + const handleTrigger = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + setOpen(!open) + }, [open]) + + const onClose = useCallback(() => { + setOpen(false) + }, []) + + const formatJsonContent = useCallback(() => { + if (editorRef.current) + editorRef.current.getAction('editor.action.formatDocument')?.run() + }, []) + + const handleSubmit = useCallback(() => { + try { + const parsedJSON = JSON.parse(json) + onSubmit(parsedJSON) + setParseError(null) + } + catch (e: any) { + if (e instanceof SyntaxError) + setParseError(e) + else + setParseError(new Error('Unknown error')) + } + }, [onSubmit, json]) + + return ( + + + + + +
+ {/* Title */} +
+
+ +
+
+ {t('workflow.nodes.llm.jsonSchema.import')} +
+
+ {/* Content */} +
+
+
+
+ JSON +
+
+ + +
+
+
+ +
+
+ {parseError && ( +
+ +
+ {parseError.message} +
+
+ )} +
+ {/* Footer */} +
+ + +
+
+
+
+ ) +} + +export default JsonImporter diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/index.tsx new file mode 100644 index 0000000000..5f1f117086 --- /dev/null +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/index.tsx @@ -0,0 +1,7 @@ +import SchemaGeneratorLight from './schema-generator-light' +import SchemaGeneratorDark from './schema-generator-dark' + +export { + SchemaGeneratorLight, + SchemaGeneratorDark, +} diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-dark.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-dark.tsx new file mode 100644 index 0000000000..ac4793b1e3 --- /dev/null +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-dark.tsx @@ -0,0 +1,15 @@ +const SchemaGeneratorDark = () => { + return ( + + + + + + + + + + ) +} + +export default SchemaGeneratorDark diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-light.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-light.tsx new file mode 100644 index 0000000000..8b898bde68 --- /dev/null +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/assets/schema-generator-light.tsx @@ -0,0 +1,15 @@ +const SchemaGeneratorLight = () => { + return ( + + + + + + + + + + ) +} + +export default SchemaGeneratorLight diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/generated-result.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/generated-result.tsx new file mode 100644 index 0000000000..f068b9f25b --- /dev/null +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/generated-result.tsx @@ -0,0 +1,159 @@ +import React, { type FC, useCallback, useRef, useState } from 'react' +import type { StructuredOutput } from '../../../types' +import { RiArrowLeftLine, RiClipboardLine, 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' + +type GeneratedResultProps = { + schema: StructuredOutput + onBack: () => void + onRegenerate: () => void + onClose: () => void + onApply: (schema: any) => void +} + +const GeneratedResult: FC = ({ + schema, + onBack, + onRegenerate, + onClose, + onApply, +}) => { + const { t } = useTranslation() + const monacoRef = useRef(null) + const editorRef = useRef(null) + + const formatJSON = (json: any): string => { + try { + if (typeof json === 'string') { + const parsed = JSON.parse(json) + return JSON.stringify(parsed, null, 2) + } + return JSON.stringify(json, null, 2) + } + catch (e) { + console.error('Failed to format JSON:', e) + return typeof json === 'string' ? json : JSON.stringify(json) + } + } + + 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 handleApply = useCallback(() => { + try { + // Parse the JSON to ensure it's valid before applying + const parsedJSON = JSON.parse(jsonSchema) + onApply(parsedJSON) + } + catch { + // TODO: Handle invalid JSON error + } + }, [jsonSchema, onApply]) + + return ( +
+
+ +
+ {/* Title */} +
+
+ {t('workflow.nodes.llm.jsonSchema.generatedResult')} +
+
+ {t('workflow.nodes.llm.jsonSchema.resultTip')} +
+
+ {/* Content */} +
+
+
+
+ JSON +
+ +
+
+ +
+
+
+ {/* Footer */} +
+ +
+ + +
+
+
+ ) +} + +export default GeneratedResult diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx new file mode 100644 index 0000000000..de5343ff3d --- /dev/null +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/index.tsx @@ -0,0 +1,134 @@ +import React, { type FC, useCallback, useState } from 'react' +import { type StructuredOutput, Type } from '../../../types' +import { + PortalToFollowElem, + PortalToFollowElemContent, + PortalToFollowElemTrigger, +} from '@/app/components/base/portal-to-follow-elem' +import useTheme from '@/hooks/use-theme' +import { Theme } from '@/types/app' +import { SchemaGeneratorDark, SchemaGeneratorLight } from './assets' +import cn from '@/utils/classnames' +import PromptEditor from './prompt-editor' +import GeneratedResult from './generated-result' + +type JsonSchemaGeneratorProps = { + onApply: (schema: StructuredOutput) => void + crossAxisOffset?: number +} + +enum GeneratorView { + promptEditor = 'promptEditor', + result = 'result', +} + +export const JsonSchemaGenerator: FC = ({ + onApply, + crossAxisOffset, +}) => { + const [open, setOpen] = useState(false) + const { theme } = useTheme() + const [view, setView] = useState(GeneratorView.promptEditor) + const [instruction, setInstruction] = useState('') + const [schema, setSchema] = useState(null) + const SchemaGenerator = theme === Theme.light ? SchemaGeneratorLight : SchemaGeneratorDark + + const handleTrigger = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + setOpen(!open) + }, [open]) + + const onClose = useCallback(() => { + setOpen(false) + }, []) + + const generateSchema = useCallback(async () => { + // todo: fetch schema, delete mock data + await new Promise((resolve) => { + setTimeout(() => { + setSchema({ + schema: { + type: Type.object, + properties: { + string_field_1: { + type: Type.string, + description: '可为空可为空可为空可为空可为空可为空可为空可为空可为空可为空', + }, + string_field_2: { + type: Type.string, + description: '可为空可为空可为空可为空可为空可为空可为空可为空可为空可为空', + }, + }, + required: [ + 'string_field_1', + ], + additionalProperties: false, + }, + }) + resolve() + }, 1000) + }) + }, []) + + const handleGenerate = useCallback(async () => { + await generateSchema() + setView(GeneratorView.result) + }, [generateSchema]) + + const goBackToPromptEditor = () => { + setView(GeneratorView.promptEditor) + } + + const handleRegenerate = useCallback(async () => { + await generateSchema() + }, [generateSchema]) + + const handleApply = () => { + onApply(schema!) + } + + return ( + + + + + + {view === GeneratorView.promptEditor && ( + + )} + {view === GeneratorView.result && ( + + )} + + + ) +} + +export default JsonSchemaGenerator diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx new file mode 100644 index 0000000000..345bb99755 --- /dev/null +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/json-schema-generator/prompt-editor.tsx @@ -0,0 +1,88 @@ +import type { FC } from 'react' +import { RiCloseLine, RiSparklingFill } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' +import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' +import Textarea from '@/app/components/base/textarea' +import Tooltip from '@/app/components/base/tooltip' +import Button from '@/app/components/base/button' + +type PromptEditorProps = { + instruction: string + onInstructionChange: (instruction: string) => void + onClose: () => void + onGenerate: () => void +} + +const PromptEditor: FC = ({ + instruction, + onInstructionChange, + onClose, + onGenerate, +}) => { + const { t } = useTranslation() + + const { + activeTextGenerationModelList, + } = useTextGenerationCurrentProviderAndModelAndModelList() + + const handleChangeModel = () => { + } + + return ( +
+
+ +
+ {/* Title */} +
+
+ {t('workflow.nodes.llm.jsonSchema.generateJsonSchema')} +
+
+ {t('workflow.nodes.llm.jsonSchema.generationTip')} +
+
+ {/* Content */} +
+
+ {t('common.modelProvider.model')} +
+ +
+
+
+ {t('workflow.nodes.llm.jsonSchema.instruction')} + +
+
+