feat: add JSON Schema generator and support enum values in types

This commit is contained in:
twwu 2025-03-12 12:14:01 +08:00
parent 6a76e27b05
commit fefd7819e6
12 changed files with 865 additions and 0 deletions

View File

@ -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<T extends string | number | symbol> = {
options: { Icon: RemixiconComponentType, text: string, value: T }[]
value: T
onChange: (value: T) => void
className?: string
}
export const SegmentedControl = <T extends string | number | symbol>({
options,
value,
onChange,
className,
}: SegmentedControlProps<T>): JSX.Element => {
const selectedOptionIndex = options.findIndex(option => option.value === value)
return (
<div className={classNames(
'flex items-center rounded-lg bg-components-segmented-control-bg-normal gap-x-[1px] p-0.5',
className,
)}>
{options.map((option, index) => {
const { Icon } = option
const isSelected = index === selectedOptionIndex
const isNextSelected = index === selectedOptionIndex - 1
const isLast = index === options.length - 1
return (
<button
type='button'
key={String(option.value)}
className={classNames(
'flex items-center justify-center relative px-2 py-1 rounded-lg gap-x-0.5 group border-0.5 border-transparent',
isSelected
? 'border-components-segmented-control-item-active-border bg-components-segmented-control-item-active-bg shadow-xs shadow-shadow-shadow-3'
: 'hover:bg-state-base-hover',
)}
onClick={() => onChange(option.value)}
>
<span className='flex items-center justify-center w-5 h-5'>
<Icon className={classNames(
'w-4 h-4 text-text-tertiary',
isSelected ? 'text-text-accent-light-mode-only' : 'group-hover:text-text-secondary',
)} />
</span>
<span className={classNames(
'p-0.5 text-text-tertiary system-sm-medium',
isSelected ? 'text-text-accent-light-mode-only' : 'group-hover:text-text-secondary',
)}>
{option.text}
</span>
{!isLast && !isSelected && !isNextSelected && (
<div className='absolute top-0 right-[-1px] h-full flex items-center'>
<Divider type='vertical' className='h-3.5 mx-0' />
</div>
)}
</button>
)
})}
</div>
)
}
export default React.memo(SegmentedControl) as typeof SegmentedControl

View File

@ -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<JsonSchemaConfigModalProps> = ({
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 (
<Modal
isShow={isShow}
onClose={onClose}
className='max-w-[960px] h-[800px] p-0'
>
<div className='flex flex-col'>
{/* Header */}
<div className='relative flex p-6 pr-14 pb-3'>
<div className='text-text-primary title-2xl-semi-bold grow truncate'>
{t('workflow.nodes.llm.jsonSchema.title')}
</div>
<div className='absolute right-5 top-5 w-8 h-8 flex justify-center items-center p-1.5' onClick={() => onClose()}>
<RiCloseLine className='w-[18px] h-[18px] text-text-tertiary' />
</div>
</div>
{/* Content */}
<div className='flex items-center justify-between px-6 py-2'>
{/* Tab */}
<SegmentedControl<SchemaView>
options={options}
value={currentTab}
onChange={(value: SchemaView) => {
setCurrentTab(value)
}}
/>
<div className='flex items-center gap-x-0.5'>
{/* JSON Schema Generator */}
<JsonSchemaGenerator
crossAxisOffset={btnWidth}
onApply={handleApplySchema}
/>
<Divider type='vertical' className='h-3' />
{/* JSON Schema Importer */}
<JsonImporter
updateBtnWidth={updateBtnWidth}
onSubmit={handleSubmit}
/>
</div>
</div>
<div className='px-6 grow'>
{currentTab === SchemaView.VisualEditor && <div className='h-full bg-components-input-bg-normal'>Visual Editor</div>}
{currentTab === SchemaView.JsonSchema && <div className='h-full bg-components-input-bg-normal'>JSON Schema</div>}
</div>
{/* Footer */}
<div className='flex items-center p-6 pt-5 gap-x-2'>
<a
className='flex items-center gap-x-1 grow text-text-accent'
href='https://json-schema.org/'
target='_blank'
rel='noopener noreferrer'
>
<span className='system-xs-regular'>{t('workflow.nodes.llm.jsonSchema.doc')}</span>
<RiExternalLinkLine className='w-3 h-3' />
</a>
<div className='flex items-center gap-x-3'>
<div className='flex items-center gap-x-2'>
<Button variant='secondary' onClick={handleResetDefaults}>
{t('workflow.nodes.llm.jsonSchema.resetDefaults')}
</Button>
<Divider type='vertical' className='h-4 ml-1 mr-0' />
</div>
<div className='flex items-center gap-x-2'>
<Button variant='secondary' onClick={handleCancel}>
{t('common.operation.cancel')}
</Button>
<Button variant='primary' onClick={handleSave}>
{t('common.operation.save')}
</Button>
</div>
</div>
</div>
</div>
</Modal>
)
}
export default JsonSchemaConfigModal

View File

@ -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<JsonImporterProps> = ({
onSubmit,
updateBtnWidth,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
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) {
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<HTMLElement, 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 (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 4,
crossAxis: 16,
}}
>
<PortalToFollowElemTrigger ref={importBtnRef} onClick={handleTrigger}>
<button
type='button'
className={cn(
'flex shrink-0 px-1.5 py-1 rounded-md hover:bg-components-button-ghost-bg-hover text-text-tertiary system-xs-medium',
open && 'bg-components-button-ghost-bg-hover',
)}
>
<span className='px-0.5'>{t('workflow.nodes.llm.jsonSchema.import')}</span>
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[100]'>
<div className='flex flex-col w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9'>
{/* Title */}
<div className='relative px-3 pt-3.5 pb-1'>
<div className='flex items-center justify-center absolute right-2.5 bottom-0 w-8 h-8' onClick={onClose}>
<RiCloseLine className='w-4 h-4 text-text-tertiary' />
</div>
<div className='flex pl-1 pr-8 text-text-primary system-xl-semibold'>
{t('workflow.nodes.llm.jsonSchema.import')}
</div>
</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>
)}
</div>
{/* Footer */}
<div className='flex items-center justify-end gap-x-2 p-4 pt-2'>
<Button variant='secondary' onClick={onClose}>
{t('common.operation.cancel')}
</Button>
<Button variant='primary' onClick={handleSubmit}>
{t('common.operation.confirm')}
</Button>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default JsonImporter

View File

@ -0,0 +1,7 @@
import SchemaGeneratorLight from './schema-generator-light'
import SchemaGeneratorDark from './schema-generator-dark'
export {
SchemaGeneratorLight,
SchemaGeneratorDark,
}

View File

@ -0,0 +1,15 @@
const SchemaGeneratorDark = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M9.33329 2.95825C10.2308 2.95825 10.9583 2.23071 10.9583 1.33325H11.7083C11.7083 2.23071 12.4358 2.95825 13.3333 2.95825V3.70825C12.4358 3.70825 11.7083 4.43579 11.7083 5.33325H10.9583C10.9583 4.43579 10.2308 3.70825 9.33329 3.70825V2.95825ZM0.666626 7.33325C2.87577 7.33325 4.66663 5.54239 4.66663 3.33325H5.99996C5.99996 5.54239 7.79083 7.33325 9.99996 7.33325V8.66659C7.79083 8.66659 5.99996 10.4575 5.99996 12.6666H4.66663C4.66663 10.4575 2.87577 8.66659 0.666626 8.66659V7.33325ZM11.5 9.33325C11.5 10.5299 10.5299 11.4999 9.33329 11.4999V12.4999C10.5299 12.4999 11.5 13.47 11.5 14.6666H12.5C12.5 13.47 13.47 12.4999 14.6666 12.4999V11.4999C13.47 11.4999 12.5 10.5299 12.5 9.33325H11.5Z" fill="url(#paint0_linear_13059_32065)" fillOpacity="0.95" />
<defs>
<linearGradient id="paint0_linear_13059_32065" x1="14.9996" y1="15" x2="-2.55847" y2="16.6207" gradientUnits="userSpaceOnUse">
<stop stopColor="#36BFFA" />
<stop offset="1" stopColor="#296DFF" />
</linearGradient>
</defs>
</svg>
)
}
export default SchemaGeneratorDark

View File

@ -0,0 +1,15 @@
const SchemaGeneratorLight = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M9.33329 2.95837C10.2308 2.95837 10.9583 2.23083 10.9583 1.33337H11.7083C11.7083 2.23083 12.4358 2.95837 13.3333 2.95837V3.70837C12.4358 3.70837 11.7083 4.43591 11.7083 5.33337H10.9583C10.9583 4.43591 10.2308 3.70837 9.33329 3.70837V2.95837ZM0.666626 7.33337C2.87577 7.33337 4.66663 5.54251 4.66663 3.33337H5.99996C5.99996 5.54251 7.79083 7.33337 9.99996 7.33337V8.66671C7.79083 8.66671 5.99996 10.4576 5.99996 12.6667H4.66663C4.66663 10.4576 2.87577 8.66671 0.666626 8.66671V7.33337ZM11.5 9.33337C11.5 10.53 10.5299 11.5 9.33329 11.5V12.5C10.5299 12.5 11.5 13.4701 11.5 14.6667H12.5C12.5 13.4701 13.47 12.5 14.6666 12.5V11.5C13.47 11.5 12.5 10.53 12.5 9.33337H11.5Z" fill="url(#paint0_linear_13059_18704)" fillOpacity="0.95" />
<defs>
<linearGradient id="paint0_linear_13059_18704" x1="14.9996" y1="15.0001" x2="-2.55847" y2="16.6209" gradientUnits="userSpaceOnUse">
<stop stopColor="#0BA5EC" />
<stop offset="1" stopColor="#155AEF" />
</linearGradient>
</defs>
</svg>
)
}
export default SchemaGeneratorLight

View File

@ -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<GeneratedResultProps> = ({
schema,
onBack,
onRegenerate,
onClose,
onApply,
}) => {
const { t } = useTranslation()
const monacoRef = useRef<any>(null)
const editorRef = useRef<any>(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 (
<div className='flex flex-col w-[480px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9'>
<div className='flex items-center justify-center absolute top-2.5 right-2.5 w-8 h-8' onClick={onClose}>
<RiCloseLine className='w-4 h-4 text-text-tertiary' />
</div>
{/* Title */}
<div className='flex flex-col gap-y-[0.5px] px-3 pt-3.5 pb-1'>
<div className='flex pl-1 pr-8 text-text-primary system-xl-semibold'>
{t('workflow.nodes.llm.jsonSchema.generatedResult')}
</div>
<div className='flex px-1 text-text-tertiary system-xs-regular'>
{t('workflow.nodes.llm.jsonSchema.resultTip')}
</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>
{/* Footer */}
<div className='flex items-center justify-between p-4 pt-2'>
<Button variant='secondary' className='flex items-center gap-x-0.5' onClick={onBack}>
<RiArrowLeftLine className='w-4 h-4' />
<span>{t('workflow.nodes.llm.jsonSchema.back')}</span>
</Button>
<div className='flex items-center gap-x-2'>
<Button variant='secondary' className='flex items-center gap-x-0.5' onClick={onRegenerate}>
<RiSparklingLine className='w-4 h-4' />
<span>{t('workflow.nodes.llm.jsonSchema.regenerate')}</span>
</Button>
<Button variant='primary' onClick={handleApply}>
{t('workflow.nodes.llm.jsonSchema.apply')}
</Button>
</div>
</div>
</div>
)
}
export default GeneratedResult

View File

@ -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<JsonSchemaGeneratorProps> = ({
onApply,
crossAxisOffset,
}) => {
const [open, setOpen] = useState(false)
const { theme } = useTheme()
const [view, setView] = useState(GeneratorView.promptEditor)
const [instruction, setInstruction] = useState('')
const [schema, setSchema] = useState<StructuredOutput | null>(null)
const SchemaGenerator = theme === Theme.light ? SchemaGeneratorLight : SchemaGeneratorDark
const handleTrigger = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
e.stopPropagation()
setOpen(!open)
}, [open])
const onClose = useCallback(() => {
setOpen(false)
}, [])
const generateSchema = useCallback(async () => {
// todo: fetch schema, delete mock data
await new Promise<void>((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 (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 4,
crossAxis: crossAxisOffset ?? 0,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<button
type='button'
className={cn(
'w-6 h-6 flex items-center justify-center p-0.5 rounded-md hover:bg-state-accent-hover',
open && 'bg-state-accent-active',
)}
>
<SchemaGenerator />
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[100]'>
{view === GeneratorView.promptEditor && (
<PromptEditor
instruction={instruction}
onInstructionChange={setInstruction}
onGenerate={handleGenerate}
onClose={onClose}
/>
)}
{view === GeneratorView.result && (
<GeneratedResult
schema={schema!}
onBack={goBackToPromptEditor}
onRegenerate={handleRegenerate}
onApply={handleApply}
onClose={onClose}
/>
)}
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default JsonSchemaGenerator

View File

@ -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<PromptEditorProps> = ({
instruction,
onInstructionChange,
onClose,
onGenerate,
}) => {
const { t } = useTranslation()
const {
activeTextGenerationModelList,
} = useTextGenerationCurrentProviderAndModelAndModelList()
const handleChangeModel = () => {
}
return (
<div className='flex flex-col relative w-[480px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9'>
<div className='flex items-center justify-center absolute top-2.5 right-2.5 w-8 h-8' onClick={onClose}>
<RiCloseLine className='w-4 h-4 text-text-tertiary'/>
</div>
{/* Title */}
<div className='flex flex-col gap-y-[0.5px] px-3 pt-3.5 pb-1'>
<div className='flex pl-1 pr-8 text-text-primary system-xl-semibold'>
{t('workflow.nodes.llm.jsonSchema.generateJsonSchema')}
</div>
<div className='flex px-1 text-text-tertiary system-xs-regular'>
{t('workflow.nodes.llm.jsonSchema.generationTip')}
</div>
</div>
{/* Content */}
<div className='flex flex-col gap-y-1 px-4 py-2'>
<div className='flex items-center h-6 text-text-secondary system-sm-semibold-uppercase'>
{t('common.modelProvider.model')}
</div>
<ModelSelector
modelList={activeTextGenerationModelList}
onSelect={handleChangeModel}
/>
</div>
<div className='flex flex-col gap-y-1 px-4 py-2'>
<div className='flex items-center h-6 text-text-secondary system-sm-semibold-uppercase'>
<span>{t('workflow.nodes.llm.jsonSchema.instruction')}</span>
<Tooltip popupContent={t('workflow.nodes.llm.jsonSchema.promptTooltip')} />
</div>
<div className='flex items-center'>
<Textarea
className='h-[364px] px-2 py-1 resize-none'
value={instruction}
placeholder={t('workflow.nodes.llm.jsonSchema.promptPlaceholder')}
onChange={e => onInstructionChange(e.target.value)}
/>
</div>
</div>
{/* Footer */}
<div className='flex justify-end gap-x-2 p-4 pt-2'>
<Button variant='secondary' onClick={onClose}>
{t('common.operation.cancel')}
</Button>
<Button
variant='primary'
className='flex items-center gap-x-0.5'
onClick={onGenerate}
>
<RiSparklingFill className='w-4 h-4' />
<span>{t('workflow.nodes.llm.jsonSchema.generate')}</span>
</Button>
</div>
</div>
)
}
export default PromptEditor

View File

@ -46,6 +46,7 @@ export type Field = {
items?: { // Array has items. Define the item type
type: ArrayItemType
}
enum?: string[] // Enum values
additionalProperties?: false // Required in object by api. Just set false
}

View File

@ -410,6 +410,23 @@ const translation = {
variable: 'Variable',
},
sysQueryInUser: 'sys.query in user message is required',
jsonSchema: {
title: 'Structured Output Schema',
instruction: 'Instruction',
promptTooltip: 'Convert the text description into a standardized JSON Schema structure.',
promptPlaceholder: 'Describe your JSON Schema...',
generate: 'Generate',
import: 'Import from JSON',
generateJsonSchema: 'Generate JSON Schema',
generationTip: 'You can use natural language to quickly create a JSON Schema.',
generatedResult: 'Generated Result',
resultTip: 'Here is the generated result. If you\'re not satisfied, you can go back and modify your prompt.',
back: 'Back',
regenerate: 'Regenerate',
apply: 'Apply',
doc: 'Learn more about structured output',
resetDefaults: 'Reset Defaults',
},
},
knowledgeRetrieval: {
queryVariable: 'Query Variable',

View File

@ -410,6 +410,23 @@ const translation = {
variable: '变量',
},
sysQueryInUser: 'user message 中必须包含 sys.query',
jsonSchema: {
title: '结构化输出 Schema',
instruction: '指令',
promptTooltip: '将文本描述转换为标准化的 JSON Schema 结构',
promptPlaceholder: '描述你的 JSON Schema...',
generate: '生成',
import: '从 JSON 导入',
generateJsonSchema: '生成 JSON Schema',
generationTip: '可以使用自然语言快速创建 JSON Schema。',
generatedResult: '生成结果',
resultTip: '以下是生成的结果。如果你对这个结果不满意,可以返回并修改你的提示词。',
back: '返回',
regenerate: '重新生成',
apply: '应用',
doc: '了解有关结构化输出的更多信息',
resetDefaults: '恢复默认值',
},
},
knowledgeRetrieval: {
queryVariable: '查询变量',