mirror of
https://git.mirrors.martin98.com/https://github.com/langgenius/dify.git
synced 2025-08-16 12:25:57 +08:00
feat: add JSON Schema generator and support enum values in types
This commit is contained in:
parent
6a76e27b05
commit
fefd7819e6
68
web/app/components/base/segmented-control/index.tsx
Normal file
68
web/app/components/base/segmented-control/index.tsx
Normal 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
|
@ -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
|
@ -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
|
@ -0,0 +1,7 @@
|
||||
import SchemaGeneratorLight from './schema-generator-light'
|
||||
import SchemaGeneratorDark from './schema-generator-dark'
|
||||
|
||||
export {
|
||||
SchemaGeneratorLight,
|
||||
SchemaGeneratorDark,
|
||||
}
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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: '查询变量',
|
||||
|
Loading…
x
Reference in New Issue
Block a user