dify
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ModelConfig, PromptItem, Variable } from '../../../types'
|
||||
import { EditionType } from '../../../types'
|
||||
import { useWorkflowStore } from '../../../store'
|
||||
import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
|
||||
import TypeSelector from '@/app/components/workflow/nodes/_base/components/selector'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { PromptRole } from '@/models/debug'
|
||||
|
||||
const i18nPrefix = 'workflow.nodes.llm'
|
||||
|
||||
type Props = {
|
||||
instanceId: string
|
||||
className?: string
|
||||
headerClassName?: string
|
||||
canNotChooseSystemRole?: boolean
|
||||
readOnly: boolean
|
||||
id: string
|
||||
nodeId: string
|
||||
canRemove: boolean
|
||||
isChatModel: boolean
|
||||
isChatApp: boolean
|
||||
payload: PromptItem
|
||||
handleChatModeMessageRoleChange: (role: PromptRole) => void
|
||||
onPromptChange: (p: string) => void
|
||||
onEditionTypeChange: (editionType: EditionType) => void
|
||||
onRemove: () => void
|
||||
isShowContext: boolean
|
||||
hasSetBlockStatus: {
|
||||
context: boolean
|
||||
history: boolean
|
||||
query: boolean
|
||||
}
|
||||
availableVars: any
|
||||
availableNodes: any
|
||||
varList: Variable[]
|
||||
handleAddVariable: (payload: any) => void
|
||||
modelConfig?: ModelConfig
|
||||
}
|
||||
|
||||
const roleOptions = [
|
||||
{
|
||||
label: 'system',
|
||||
value: PromptRole.system,
|
||||
},
|
||||
{
|
||||
label: 'user',
|
||||
value: PromptRole.user,
|
||||
},
|
||||
{
|
||||
label: 'assistant',
|
||||
value: PromptRole.assistant,
|
||||
},
|
||||
]
|
||||
|
||||
const roleOptionsWithoutSystemRole = roleOptions.filter(item => item.value !== PromptRole.system)
|
||||
|
||||
const ConfigPromptItem: FC<Props> = ({
|
||||
instanceId,
|
||||
className,
|
||||
headerClassName,
|
||||
canNotChooseSystemRole,
|
||||
readOnly,
|
||||
id,
|
||||
nodeId,
|
||||
canRemove,
|
||||
handleChatModeMessageRoleChange,
|
||||
isChatModel,
|
||||
isChatApp,
|
||||
payload,
|
||||
onPromptChange,
|
||||
onEditionTypeChange,
|
||||
onRemove,
|
||||
isShowContext,
|
||||
hasSetBlockStatus,
|
||||
availableVars,
|
||||
availableNodes,
|
||||
varList,
|
||||
handleAddVariable,
|
||||
modelConfig,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const {
|
||||
setControlPromptEditorRerenderKey,
|
||||
} = workflowStore.getState()
|
||||
|
||||
const handleGenerated = useCallback((prompt: string) => {
|
||||
onPromptChange(prompt)
|
||||
setTimeout(() => setControlPromptEditorRerenderKey(Date.now()))
|
||||
}, [onPromptChange, setControlPromptEditorRerenderKey])
|
||||
|
||||
return (
|
||||
<Editor
|
||||
className={className}
|
||||
headerClassName={headerClassName}
|
||||
instanceId={instanceId}
|
||||
key={instanceId}
|
||||
title={
|
||||
<div className='relative left-1 flex items-center'>
|
||||
{payload.role === PromptRole.system
|
||||
? (<div className='relative left-[-4px] text-xs font-semibold uppercase text-text-secondary'>
|
||||
SYSTEM
|
||||
</div>)
|
||||
: (
|
||||
<TypeSelector
|
||||
value={payload.role as string}
|
||||
allOptions={roleOptions}
|
||||
options={canNotChooseSystemRole ? roleOptionsWithoutSystemRole : roleOptions}
|
||||
onChange={handleChatModeMessageRoleChange}
|
||||
triggerClassName='text-xs font-semibold text-text-secondary uppercase'
|
||||
itemClassName='text-[13px] font-medium text-text-secondary'
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='max-w-[180px]'>{t(`${i18nPrefix}.roleDescription.${payload.role}`)}</div>
|
||||
}
|
||||
triggerClassName='w-4 h-4'
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
value={payload.edition_type === EditionType.jinja2 ? (payload.jinja2_text || '') : payload.text}
|
||||
onChange={onPromptChange}
|
||||
readOnly={readOnly}
|
||||
showRemove={canRemove}
|
||||
onRemove={onRemove}
|
||||
isChatModel={isChatModel}
|
||||
isChatApp={isChatApp}
|
||||
isShowContext={isShowContext}
|
||||
hasSetBlockStatus={hasSetBlockStatus}
|
||||
nodesOutputVars={availableVars}
|
||||
availableNodes={availableNodes}
|
||||
nodeId={nodeId}
|
||||
editorId={id}
|
||||
isSupportPromptGenerator
|
||||
onGenerated={handleGenerated}
|
||||
modelConfig={modelConfig}
|
||||
isSupportJinja
|
||||
editionType={payload.edition_type}
|
||||
onEditionTypeChange={onEditionTypeChange}
|
||||
varList={varList}
|
||||
handleAddVariable={handleAddVariable}
|
||||
isSupportFileVar
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigPromptItem)
|
||||
@@ -0,0 +1,249 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { produce } from 'immer'
|
||||
import { ReactSortable } from 'react-sortablejs'
|
||||
import { v4 as uuid4 } from 'uuid'
|
||||
import type { ModelConfig, PromptItem, ValueSelector, Var, Variable } from '../../../types'
|
||||
import { EditionType, PromptRole } from '../../../types'
|
||||
import useAvailableVarList from '../../_base/hooks/use-available-var-list'
|
||||
import { useWorkflowStore } from '../../../store'
|
||||
import ConfigPromptItem from './config-prompt-item'
|
||||
import cn from '@/utils/classnames'
|
||||
import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
|
||||
import AddButton from '@/app/components/workflow/nodes/_base/components/add-button'
|
||||
import { DragHandle } from '@/app/components/base/icons/src/vender/line/others'
|
||||
|
||||
const i18nPrefix = 'workflow.nodes.llm'
|
||||
|
||||
type Props = {
|
||||
readOnly: boolean
|
||||
nodeId: string
|
||||
filterVar: (payload: Var, selector: ValueSelector) => boolean
|
||||
isChatModel: boolean
|
||||
isChatApp: boolean
|
||||
payload: PromptItem | PromptItem[]
|
||||
onChange: (payload: PromptItem | PromptItem[]) => void
|
||||
isShowContext: boolean
|
||||
hasSetBlockStatus: {
|
||||
context: boolean
|
||||
history: boolean
|
||||
query: boolean
|
||||
}
|
||||
varList?: Variable[]
|
||||
handleAddVariable: (payload: any) => void
|
||||
modelConfig: ModelConfig
|
||||
}
|
||||
|
||||
const ConfigPrompt: FC<Props> = ({
|
||||
readOnly,
|
||||
nodeId,
|
||||
filterVar,
|
||||
isChatModel,
|
||||
isChatApp,
|
||||
payload,
|
||||
onChange,
|
||||
isShowContext,
|
||||
hasSetBlockStatus,
|
||||
varList = [],
|
||||
handleAddVariable,
|
||||
modelConfig,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const {
|
||||
setControlPromptEditorRerenderKey,
|
||||
} = workflowStore.getState()
|
||||
const payloadWithIds = (isChatModel && Array.isArray(payload))
|
||||
? payload.map((item) => {
|
||||
const id = uuid4()
|
||||
return {
|
||||
id: item.id || id,
|
||||
p: {
|
||||
...item,
|
||||
id: item.id || id,
|
||||
},
|
||||
}
|
||||
})
|
||||
: []
|
||||
const {
|
||||
availableVars,
|
||||
availableNodesWithParent,
|
||||
} = useAvailableVarList(nodeId, {
|
||||
onlyLeafNodeVar: false,
|
||||
filterVar,
|
||||
})
|
||||
|
||||
const handleChatModePromptChange = useCallback((index: number) => {
|
||||
return (prompt: string) => {
|
||||
const newPrompt = produce(payload as PromptItem[], (draft) => {
|
||||
draft[index][draft[index].edition_type === EditionType.jinja2 ? 'jinja2_text' : 'text'] = prompt
|
||||
})
|
||||
onChange(newPrompt)
|
||||
}
|
||||
}, [onChange, payload])
|
||||
|
||||
const handleChatModeEditionTypeChange = useCallback((index: number) => {
|
||||
return (editionType: EditionType) => {
|
||||
const newPrompt = produce(payload as PromptItem[], (draft) => {
|
||||
draft[index].edition_type = editionType
|
||||
})
|
||||
onChange(newPrompt)
|
||||
}
|
||||
}, [onChange, payload])
|
||||
|
||||
const handleChatModeMessageRoleChange = useCallback((index: number) => {
|
||||
return (role: PromptRole) => {
|
||||
const newPrompt = produce(payload as PromptItem[], (draft) => {
|
||||
draft[index].role = role
|
||||
})
|
||||
onChange(newPrompt)
|
||||
}
|
||||
}, [onChange, payload])
|
||||
|
||||
const handleAddPrompt = useCallback(() => {
|
||||
const newPrompt = produce(payload as PromptItem[], (draft) => {
|
||||
if (draft.length === 0) {
|
||||
draft.push({ role: PromptRole.system, text: '' })
|
||||
|
||||
return
|
||||
}
|
||||
const isLastItemUser = draft[draft.length - 1].role === PromptRole.user
|
||||
draft.push({ role: isLastItemUser ? PromptRole.assistant : PromptRole.user, text: '' })
|
||||
})
|
||||
onChange(newPrompt)
|
||||
}, [onChange, payload])
|
||||
|
||||
const handleRemove = useCallback((index: number) => {
|
||||
return () => {
|
||||
const newPrompt = produce(payload as PromptItem[], (draft) => {
|
||||
draft.splice(index, 1)
|
||||
})
|
||||
onChange(newPrompt)
|
||||
}
|
||||
}, [onChange, payload])
|
||||
|
||||
const handleCompletionPromptChange = useCallback((prompt: string) => {
|
||||
const newPrompt = produce(payload as PromptItem, (draft) => {
|
||||
draft[draft.edition_type === EditionType.jinja2 ? 'jinja2_text' : 'text'] = prompt
|
||||
})
|
||||
onChange(newPrompt)
|
||||
}, [onChange, payload])
|
||||
|
||||
const handleGenerated = useCallback((prompt: string) => {
|
||||
handleCompletionPromptChange(prompt)
|
||||
setTimeout(() => setControlPromptEditorRerenderKey(Date.now()))
|
||||
}, [handleCompletionPromptChange, setControlPromptEditorRerenderKey])
|
||||
|
||||
const handleCompletionEditionTypeChange = useCallback((editionType: EditionType) => {
|
||||
const newPrompt = produce(payload as PromptItem, (draft) => {
|
||||
draft.edition_type = editionType
|
||||
})
|
||||
onChange(newPrompt)
|
||||
}, [onChange, payload])
|
||||
|
||||
const canChooseSystemRole = (() => {
|
||||
if (isChatModel && Array.isArray(payload))
|
||||
return !payload.find(item => item.role === PromptRole.system)
|
||||
|
||||
return false
|
||||
})()
|
||||
return (
|
||||
<div>
|
||||
{(isChatModel && Array.isArray(payload))
|
||||
? (
|
||||
<div>
|
||||
<div className='space-y-2'>
|
||||
<ReactSortable className="space-y-1"
|
||||
list={payloadWithIds}
|
||||
setList={(list) => {
|
||||
if ((payload as PromptItem[])?.[0]?.role === PromptRole.system && list[0].p?.role !== PromptRole.system)
|
||||
return
|
||||
|
||||
onChange(list.map(item => item.p))
|
||||
}}
|
||||
handle='.handle'
|
||||
ghostClass="opacity-50"
|
||||
animation={150}
|
||||
>
|
||||
{
|
||||
(payload as PromptItem[]).map((item, index) => {
|
||||
const canDrag = (() => {
|
||||
if (readOnly)
|
||||
return false
|
||||
|
||||
if (index === 0 && item.role === PromptRole.system)
|
||||
return false
|
||||
|
||||
return true
|
||||
})()
|
||||
return (
|
||||
<div key={item.id || index} className='group relative'>
|
||||
{canDrag && <DragHandle className='absolute left-[-14px] top-2 hidden h-3.5 w-3.5 text-text-quaternary group-hover:block' />}
|
||||
<ConfigPromptItem
|
||||
instanceId={item.role === PromptRole.system ? `${nodeId}-chat-workflow-llm-prompt-editor` : `${nodeId}-chat-workflow-llm-prompt-editor-${index}`}
|
||||
className={cn(canDrag && 'handle')}
|
||||
headerClassName={cn(canDrag && 'cursor-grab')}
|
||||
canNotChooseSystemRole={!canChooseSystemRole}
|
||||
canRemove={payload.length > 1 && !(index === 0 && item.role === PromptRole.system)}
|
||||
readOnly={readOnly}
|
||||
id={item.id!}
|
||||
nodeId={nodeId}
|
||||
handleChatModeMessageRoleChange={handleChatModeMessageRoleChange(index)}
|
||||
isChatModel={isChatModel}
|
||||
isChatApp={isChatApp}
|
||||
payload={item}
|
||||
onPromptChange={handleChatModePromptChange(index)}
|
||||
onEditionTypeChange={handleChatModeEditionTypeChange(index)}
|
||||
onRemove={handleRemove(index)}
|
||||
isShowContext={isShowContext}
|
||||
hasSetBlockStatus={hasSetBlockStatus}
|
||||
availableVars={availableVars}
|
||||
availableNodes={availableNodesWithParent}
|
||||
varList={varList}
|
||||
handleAddVariable={handleAddVariable}
|
||||
modelConfig={modelConfig}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
</ReactSortable>
|
||||
</div>
|
||||
<AddButton
|
||||
className='mt-2'
|
||||
text={t(`${i18nPrefix}.addMessage`)}
|
||||
onClick={handleAddPrompt}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div>
|
||||
<Editor
|
||||
instanceId={`${nodeId}-chat-workflow-llm-prompt-editor`}
|
||||
title={<span className='capitalize'>{t(`${i18nPrefix}.prompt`)}</span>}
|
||||
value={((payload as PromptItem).edition_type === EditionType.basic || !(payload as PromptItem).edition_type) ? (payload as PromptItem).text : ((payload as PromptItem).jinja2_text || '')}
|
||||
onChange={handleCompletionPromptChange}
|
||||
readOnly={readOnly}
|
||||
isChatModel={isChatModel}
|
||||
isChatApp={isChatApp}
|
||||
isShowContext={isShowContext}
|
||||
hasSetBlockStatus={hasSetBlockStatus}
|
||||
nodesOutputVars={availableVars}
|
||||
availableNodes={availableNodesWithParent}
|
||||
isSupportPromptGenerator
|
||||
isSupportJinja
|
||||
editionType={(payload as PromptItem).edition_type}
|
||||
varList={varList}
|
||||
onEditionTypeChange={handleCompletionEditionTypeChange}
|
||||
handleAddVariable={handleAddVariable}
|
||||
onGenerated={handleGenerated}
|
||||
modelConfig={modelConfig}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigPrompt)
|
||||
@@ -0,0 +1,179 @@
|
||||
import React, { type FC, useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { Theme } from '@/types/app'
|
||||
import classNames from '@/utils/classnames'
|
||||
import { Editor } from '@monaco-editor/react'
|
||||
import { RiClipboardLine, RiIndentIncrease } from '@remixicon/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type CodeEditorProps = {
|
||||
value: string
|
||||
onUpdate?: (value: string) => void
|
||||
showFormatButton?: boolean
|
||||
editorWrapperClassName?: string
|
||||
readOnly?: boolean
|
||||
hideTopMenu?: boolean
|
||||
onFocus?: () => void
|
||||
onBlur?: () => void
|
||||
topContent?: React.ReactNode
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const CodeEditor: FC<CodeEditorProps> = ({
|
||||
value,
|
||||
onUpdate,
|
||||
showFormatButton = true,
|
||||
editorWrapperClassName,
|
||||
readOnly = false,
|
||||
hideTopMenu = false,
|
||||
topContent,
|
||||
className,
|
||||
onFocus,
|
||||
onBlur,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const monacoRef = useRef<any>(null)
|
||||
const editorRef = useRef<any>(null)
|
||||
const [isMounted, setIsMounted] = React.useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (monacoRef.current) {
|
||||
if (theme === Theme.light)
|
||||
monacoRef.current.editor.setTheme('light-theme')
|
||||
else
|
||||
monacoRef.current.editor.setTheme('dark-theme')
|
||||
}
|
||||
}, [theme])
|
||||
|
||||
const handleEditorDidMount = useCallback((editor: any, monaco: any) => {
|
||||
editorRef.current = editor
|
||||
monacoRef.current = monaco
|
||||
|
||||
editor.onDidFocusEditorText(() => {
|
||||
onFocus?.()
|
||||
})
|
||||
editor.onDidBlurEditorText(() => {
|
||||
onBlur?.()
|
||||
})
|
||||
|
||||
monaco.editor.defineTheme('light-theme', {
|
||||
base: 'vs',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.lineHighlightBackground': '#00000000',
|
||||
'focusBorder': '#00000000',
|
||||
},
|
||||
})
|
||||
monaco.editor.defineTheme('dark-theme', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': '#00000000',
|
||||
'editor.lineHighlightBackground': '#00000000',
|
||||
'focusBorder': '#00000000',
|
||||
},
|
||||
})
|
||||
monaco.editor.setTheme('light-theme')
|
||||
setIsMounted(true)
|
||||
}, [])
|
||||
|
||||
const formatJsonContent = useCallback(() => {
|
||||
if (editorRef.current)
|
||||
editorRef.current.getAction('editor.action.formatDocument')?.run()
|
||||
}, [])
|
||||
|
||||
const handleEditorChange = useCallback((value: string | undefined) => {
|
||||
if (value !== undefined)
|
||||
onUpdate?.(value)
|
||||
}, [onUpdate])
|
||||
|
||||
const editorTheme = useMemo(() => {
|
||||
if (theme === Theme.light)
|
||||
return 'light-theme'
|
||||
return 'dark-theme'
|
||||
}, [theme])
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
editorRef.current?.layout()
|
||||
})
|
||||
|
||||
if (containerRef.current)
|
||||
resizeObserver.observe(containerRef.current)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={classNames('flex h-full flex-col overflow-hidden bg-components-input-bg-normal', hideTopMenu && 'pt-2', className)}>
|
||||
{!hideTopMenu && (
|
||||
<div className='flex items-center justify-between pl-2 pr-1 pt-1'>
|
||||
<div className='system-xs-semibold-uppercase py-0.5 text-text-secondary'>
|
||||
<span className='px-1 py-0.5'>JSON</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-x-0.5'>
|
||||
{showFormatButton && (
|
||||
<Tooltip popupContent={t('common.operation.format')}>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-6 w-6 items-center justify-center'
|
||||
onClick={formatJsonContent}
|
||||
>
|
||||
<RiIndentIncrease className='h-4 w-4 text-text-tertiary' />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip popupContent={t('common.operation.copy')}>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-6 w-6 items-center justify-center'
|
||||
onClick={() => copy(value)}>
|
||||
<RiClipboardLine className='h-4 w-4 text-text-tertiary' />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{topContent}
|
||||
<div className={classNames('relative overflow-hidden', editorWrapperClassName)}>
|
||||
<Editor
|
||||
defaultLanguage='json'
|
||||
theme={isMounted ? editorTheme : 'default-theme'} // sometimes not load the default theme
|
||||
value={value}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{
|
||||
readOnly,
|
||||
domReadOnly: true,
|
||||
minimap: { enabled: false },
|
||||
tabSize: 2,
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
wrappingIndent: 'same',
|
||||
overviewRulerBorder: false,
|
||||
hideCursorInOverviewRuler: true,
|
||||
renderLineHighlightOnlyWhenFocus: false,
|
||||
renderLineHighlight: 'none',
|
||||
// Hide scrollbar borders
|
||||
scrollbar: {
|
||||
vertical: 'hidden',
|
||||
horizontal: 'hidden',
|
||||
verticalScrollbarSize: 0,
|
||||
horizontalScrollbarSize: 0,
|
||||
alwaysConsumeMouseWheel: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(CodeEditor)
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import type { FC } from 'react'
|
||||
import { RiErrorWarningFill } from '@remixicon/react'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
type ErrorMessageProps = {
|
||||
message: string
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
const ErrorMessage: FC<ErrorMessageProps> = ({
|
||||
message,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className={classNames(
|
||||
'mt-1 flex gap-x-1 rounded-lg border-[0.5px] border-components-panel-border bg-toast-error-bg p-2',
|
||||
className,
|
||||
)}>
|
||||
<RiErrorWarningFill className='h-4 w-4 shrink-0 text-text-destructive' />
|
||||
<div className='system-xs-medium max-h-12 grow overflow-y-auto whitespace-pre-line break-words text-text-primary'>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ErrorMessage)
|
||||
@@ -0,0 +1,34 @@
|
||||
import React, { type FC } from 'react'
|
||||
import Modal from '../../../../../base/modal'
|
||||
import type { SchemaRoot } from '../../types'
|
||||
import JsonSchemaConfig from './json-schema-config'
|
||||
|
||||
type JsonSchemaConfigModalProps = {
|
||||
isShow: boolean
|
||||
defaultSchema?: SchemaRoot
|
||||
onSave: (schema: SchemaRoot) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const JsonSchemaConfigModal: FC<JsonSchemaConfigModalProps> = ({
|
||||
isShow,
|
||||
defaultSchema,
|
||||
onSave,
|
||||
onClose,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
isShow={isShow}
|
||||
onClose={onClose}
|
||||
className='h-[800px] max-w-[960px] p-0'
|
||||
>
|
||||
<JsonSchemaConfig
|
||||
defaultSchema={defaultSchema}
|
||||
onSave={onSave}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default JsonSchemaConfigModal
|
||||
@@ -0,0 +1,135 @@
|
||||
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 { RiCloseLine } from '@remixicon/react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { checkJsonDepth } from '../../utils'
|
||||
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
|
||||
import CodeEditor from './code-editor'
|
||||
import ErrorMessage from './error-message'
|
||||
import { useVisualEditorStore } from './visual-editor/store'
|
||||
import { useMittContext } from './visual-editor/context'
|
||||
|
||||
type JsonImporterProps = {
|
||||
onSubmit: (schema: any) => 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<HTMLElement>(null)
|
||||
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
|
||||
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
|
||||
const { emit } = useMittContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (importBtnRef.current) {
|
||||
const rect = importBtnRef.current.getBoundingClientRect()
|
||||
updateBtnWidth(rect.width)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTrigger = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
e.stopPropagation()
|
||||
if (advancedEditing || isAddingNewField)
|
||||
emit('quitEditing', {})
|
||||
setOpen(!open)
|
||||
}, [open, advancedEditing, isAddingNewField, emit])
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
try {
|
||||
const parsedJSON = JSON.parse(json)
|
||||
if (typeof parsedJSON !== 'object' || Array.isArray(parsedJSON)) {
|
||||
setParseError(new Error('Root must be an object, not an array or primitive value.'))
|
||||
return
|
||||
}
|
||||
const maxDepth = checkJsonDepth(parsedJSON)
|
||||
if (maxDepth > JSON_SCHEMA_MAX_DEPTH) {
|
||||
setParseError({
|
||||
type: 'error',
|
||||
message: `Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
onSubmit(parsedJSON)
|
||||
setParseError(null)
|
||||
setOpen(false)
|
||||
}
|
||||
catch (e: any) {
|
||||
if (e instanceof Error)
|
||||
setParseError(e)
|
||||
else
|
||||
setParseError(new Error('Invalid JSON'))
|
||||
}
|
||||
}, [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(
|
||||
'system-xs-medium flex shrink-0 rounded-md px-1.5 py-1 text-text-tertiary hover:bg-components-button-ghost-bg-hover',
|
||||
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 w-[400px] flex-col 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 pb-1 pt-3.5'>
|
||||
<div className='absolute bottom-0 right-2.5 flex h-8 w-8 items-center justify-center' onClick={onClose}>
|
||||
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
<div className='system-xl-semibold flex pl-1 pr-8 text-text-primary'>
|
||||
{t('workflow.nodes.llm.jsonSchema.import')}
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className='px-4 py-2'>
|
||||
<CodeEditor
|
||||
className='rounded-lg'
|
||||
editorWrapperClassName='h-[340px]'
|
||||
value={json}
|
||||
onUpdate={setJson}
|
||||
showFormatButton={false}
|
||||
/>
|
||||
{parseError && <ErrorMessage message={parseError.message} />}
|
||||
</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.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default JsonImporter
|
||||
@@ -0,0 +1,292 @@
|
||||
import React, { type FC, useCallback, useState } from 'react'
|
||||
import { type SchemaRoot, Type } from '../../types'
|
||||
import { RiBracesLine, RiCloseLine, RiExternalLinkLine, RiTimelineView } from '@remixicon/react'
|
||||
import { SegmentedControl } from '../../../../../base/segmented-control'
|
||||
import JsonSchemaGenerator from './json-schema-generator'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import JsonImporter from './json-importer'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import VisualEditor from './visual-editor'
|
||||
import SchemaEditor from './schema-editor'
|
||||
import {
|
||||
checkJsonSchemaDepth,
|
||||
getValidationErrorMessage,
|
||||
jsonToSchema,
|
||||
preValidateSchema,
|
||||
validateSchemaAgainstDraft7,
|
||||
} from '../../utils'
|
||||
import { MittProvider, VisualEditorContextProvider, useMittContext } from './visual-editor/context'
|
||||
import ErrorMessage from './error-message'
|
||||
import { useVisualEditorStore } from './visual-editor/store'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
|
||||
type JsonSchemaConfigProps = {
|
||||
defaultSchema?: SchemaRoot
|
||||
onSave: (schema: SchemaRoot) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
enum SchemaView {
|
||||
VisualEditor = 'visualEditor',
|
||||
JsonSchema = 'jsonSchema',
|
||||
}
|
||||
|
||||
const VIEW_TABS = [
|
||||
{ Icon: RiTimelineView, text: 'Visual Editor', value: SchemaView.VisualEditor },
|
||||
{ Icon: RiBracesLine, text: 'JSON Schema', value: SchemaView.JsonSchema },
|
||||
]
|
||||
|
||||
const DEFAULT_SCHEMA: SchemaRoot = {
|
||||
type: Type.object,
|
||||
properties: {},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
||||
const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
|
||||
defaultSchema,
|
||||
onSave,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const [currentTab, setCurrentTab] = useState(SchemaView.VisualEditor)
|
||||
const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA)
|
||||
const [json, setJson] = useState(() => JSON.stringify(jsonSchema, null, 2))
|
||||
const [btnWidth, setBtnWidth] = useState(0)
|
||||
const [parseError, setParseError] = useState<Error | null>(null)
|
||||
const [validationError, setValidationError] = useState<string>('')
|
||||
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
|
||||
const setAdvancedEditing = useVisualEditorStore(state => state.setAdvancedEditing)
|
||||
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
|
||||
const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
|
||||
const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty)
|
||||
const { emit } = useMittContext()
|
||||
|
||||
const updateBtnWidth = useCallback((width: number) => {
|
||||
setBtnWidth(width + 32)
|
||||
}, [])
|
||||
|
||||
const handleTabChange = useCallback((value: SchemaView) => {
|
||||
if (currentTab === value) return
|
||||
if (currentTab === SchemaView.JsonSchema) {
|
||||
try {
|
||||
const schema = JSON.parse(json)
|
||||
setParseError(null)
|
||||
const result = preValidateSchema(schema)
|
||||
if (!result.success) {
|
||||
setValidationError(result.error.message)
|
||||
return
|
||||
}
|
||||
const schemaDepth = checkJsonSchemaDepth(schema)
|
||||
if (schemaDepth > JSON_SCHEMA_MAX_DEPTH) {
|
||||
setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`)
|
||||
return
|
||||
}
|
||||
const validationErrors = validateSchemaAgainstDraft7(schema)
|
||||
if (validationErrors.length > 0) {
|
||||
setValidationError(getValidationErrorMessage(validationErrors))
|
||||
return
|
||||
}
|
||||
setJsonSchema(schema)
|
||||
setValidationError('')
|
||||
}
|
||||
catch (error) {
|
||||
setValidationError('')
|
||||
if (error instanceof Error)
|
||||
setParseError(error)
|
||||
else
|
||||
setParseError(new Error('Invalid JSON'))
|
||||
return
|
||||
}
|
||||
}
|
||||
else if (currentTab === SchemaView.VisualEditor) {
|
||||
if (advancedEditing || isAddingNewField)
|
||||
emit('quitEditing', { callback: (backup: SchemaRoot) => setJson(JSON.stringify(backup || jsonSchema, null, 2)) })
|
||||
else
|
||||
setJson(JSON.stringify(jsonSchema, null, 2))
|
||||
}
|
||||
|
||||
setCurrentTab(value)
|
||||
}, [currentTab, jsonSchema, json, advancedEditing, isAddingNewField, emit])
|
||||
|
||||
const handleApplySchema = useCallback((schema: SchemaRoot) => {
|
||||
if (currentTab === SchemaView.VisualEditor)
|
||||
setJsonSchema(schema)
|
||||
else if (currentTab === SchemaView.JsonSchema)
|
||||
setJson(JSON.stringify(schema, null, 2))
|
||||
}, [currentTab])
|
||||
|
||||
const handleSubmit = useCallback((schema: Record<string, unknown>) => {
|
||||
const jsonSchema = jsonToSchema(schema) as SchemaRoot
|
||||
if (currentTab === SchemaView.VisualEditor)
|
||||
setJsonSchema(jsonSchema)
|
||||
else if (currentTab === SchemaView.JsonSchema)
|
||||
setJson(JSON.stringify(jsonSchema, null, 2))
|
||||
}, [currentTab])
|
||||
|
||||
const handleVisualEditorUpdate = useCallback((schema: SchemaRoot) => {
|
||||
setJsonSchema(schema)
|
||||
}, [])
|
||||
|
||||
const handleSchemaEditorUpdate = useCallback((schema: string) => {
|
||||
setJson(schema)
|
||||
}, [])
|
||||
|
||||
const handleResetDefaults = useCallback(() => {
|
||||
if (currentTab === SchemaView.VisualEditor) {
|
||||
setHoveringProperty(null)
|
||||
if (advancedEditing)
|
||||
setAdvancedEditing(false)
|
||||
if (isAddingNewField)
|
||||
setIsAddingNewField(false)
|
||||
}
|
||||
setJsonSchema(DEFAULT_SCHEMA)
|
||||
setJson(JSON.stringify(DEFAULT_SCHEMA, null, 2))
|
||||
}, [currentTab, advancedEditing, isAddingNewField, setAdvancedEditing, setIsAddingNewField, setHoveringProperty])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
let schema = jsonSchema
|
||||
if (currentTab === SchemaView.JsonSchema) {
|
||||
try {
|
||||
schema = JSON.parse(json)
|
||||
setParseError(null)
|
||||
const result = preValidateSchema(schema)
|
||||
if (!result.success) {
|
||||
setValidationError(result.error.message)
|
||||
return
|
||||
}
|
||||
const schemaDepth = checkJsonSchemaDepth(schema)
|
||||
if (schemaDepth > JSON_SCHEMA_MAX_DEPTH) {
|
||||
setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`)
|
||||
return
|
||||
}
|
||||
const validationErrors = validateSchemaAgainstDraft7(schema)
|
||||
if (validationErrors.length > 0) {
|
||||
setValidationError(getValidationErrorMessage(validationErrors))
|
||||
return
|
||||
}
|
||||
setJsonSchema(schema)
|
||||
setValidationError('')
|
||||
}
|
||||
catch (error) {
|
||||
setValidationError('')
|
||||
if (error instanceof Error)
|
||||
setParseError(error)
|
||||
else
|
||||
setParseError(new Error('Invalid JSON'))
|
||||
return
|
||||
}
|
||||
}
|
||||
else if (currentTab === SchemaView.VisualEditor) {
|
||||
if (advancedEditing || isAddingNewField) {
|
||||
Toast.notify({
|
||||
type: 'warning',
|
||||
message: t('workflow.nodes.llm.jsonSchema.warningTips.saveSchema'),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
onSave(schema)
|
||||
onClose()
|
||||
}, [currentTab, jsonSchema, json, onSave, onClose, advancedEditing, isAddingNewField, t])
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
{/* Header */}
|
||||
<div className='relative flex p-6 pb-3 pr-14'>
|
||||
<div className='title-2xl-semi-bold grow truncate text-text-primary'>
|
||||
{t('workflow.nodes.llm.jsonSchema.title')}
|
||||
</div>
|
||||
<div className='absolute right-5 top-5 flex h-8 w-8 items-center justify-center p-1.5' onClick={onClose}>
|
||||
<RiCloseLine className='h-[18px] w-[18px] text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className='flex items-center justify-between px-6 py-2'>
|
||||
{/* Tab */}
|
||||
<SegmentedControl<SchemaView>
|
||||
options={VIEW_TABS}
|
||||
value={currentTab}
|
||||
onChange={handleTabChange}
|
||||
/>
|
||||
<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='flex grow flex-col gap-y-1 overflow-hidden px-6'>
|
||||
{currentTab === SchemaView.VisualEditor && (
|
||||
<VisualEditor
|
||||
schema={jsonSchema}
|
||||
onChange={handleVisualEditorUpdate}
|
||||
/>
|
||||
)}
|
||||
{currentTab === SchemaView.JsonSchema && (
|
||||
<SchemaEditor
|
||||
schema={json}
|
||||
onUpdate={handleSchemaEditorUpdate}
|
||||
/>
|
||||
)}
|
||||
{parseError && <ErrorMessage message={parseError.message} />}
|
||||
{validationError && <ErrorMessage message={validationError} />}
|
||||
</div>
|
||||
{/* Footer */}
|
||||
<div className='flex items-center gap-x-2 p-6 pt-5'>
|
||||
<a
|
||||
className='flex grow items-center gap-x-1 text-text-accent'
|
||||
href={docLink('/guides/workflow/structured-outputs')}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<span className='system-xs-regular'>{t('workflow.nodes.llm.jsonSchema.doc')}</span>
|
||||
<RiExternalLinkLine className='h-3 w-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='ml-1 mr-0 h-4' />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
const JsonSchemaConfigWrapper: FC<JsonSchemaConfigProps> = (props) => {
|
||||
return (
|
||||
<MittProvider>
|
||||
<VisualEditorContextProvider>
|
||||
<JsonSchemaConfig {...props} />
|
||||
</VisualEditorContextProvider>
|
||||
</MittProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default JsonSchemaConfigWrapper
|
||||
@@ -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,121 @@
|
||||
import React, { type FC, useCallback, useMemo, useState } from 'react'
|
||||
import type { SchemaRoot } from '../../../types'
|
||||
import { RiArrowLeftLine, RiCloseLine, RiSparklingLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import CodeEditor from '../code-editor'
|
||||
import ErrorMessage from '../error-message'
|
||||
import { getValidationErrorMessage, validateSchemaAgainstDraft7 } from '../../../utils'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
|
||||
type GeneratedResultProps = {
|
||||
schema: SchemaRoot
|
||||
isGenerating: boolean
|
||||
onBack: () => void
|
||||
onRegenerate: () => void
|
||||
onClose: () => void
|
||||
onApply: () => void
|
||||
}
|
||||
|
||||
const GeneratedResult: FC<GeneratedResultProps> = ({
|
||||
schema,
|
||||
isGenerating,
|
||||
onBack,
|
||||
onRegenerate,
|
||||
onClose,
|
||||
onApply,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [parseError, setParseError] = useState<Error | null>(null)
|
||||
const [validationError, setValidationError] = useState<string>('')
|
||||
|
||||
const formatJSON = (json: SchemaRoot) => {
|
||||
try {
|
||||
const schema = JSON.stringify(json, null, 2)
|
||||
setParseError(null)
|
||||
return schema
|
||||
}
|
||||
catch (e) {
|
||||
if (e instanceof Error)
|
||||
setParseError(e)
|
||||
else
|
||||
setParseError(new Error('Invalid JSON'))
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const jsonSchema = useMemo(() => formatJSON(schema), [schema])
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
const validationErrors = validateSchemaAgainstDraft7(schema)
|
||||
if (validationErrors.length > 0) {
|
||||
setValidationError(getValidationErrorMessage(validationErrors))
|
||||
return
|
||||
}
|
||||
onApply()
|
||||
setValidationError('')
|
||||
}, [schema, onApply])
|
||||
|
||||
return (
|
||||
<div className='flex w-[480px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9'>
|
||||
{
|
||||
isGenerating ? (
|
||||
<div className='flex h-[600px] flex-col items-center justify-center gap-y-3'>
|
||||
<Loading type='area' />
|
||||
<div className='system-xs-regular text-text-tertiary'>{t('workflow.nodes.llm.jsonSchema.generating')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className='absolute right-2.5 top-2.5 flex h-8 w-8 items-center justify-center' onClick={onClose}>
|
||||
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
{/* Title */}
|
||||
<div className='flex flex-col gap-y-[0.5px] px-3 pb-1 pt-3.5'>
|
||||
<div className='system-xl-semibold flex pl-1 pr-8 text-text-primary'>
|
||||
{t('workflow.nodes.llm.jsonSchema.generatedResult')}
|
||||
</div>
|
||||
<div className='system-xs-regular flex px-1 text-text-tertiary'>
|
||||
{t('workflow.nodes.llm.jsonSchema.resultTip')}
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className='px-4 py-2'>
|
||||
<CodeEditor
|
||||
className='rounded-lg'
|
||||
editorWrapperClassName='h-[424px]'
|
||||
value={jsonSchema}
|
||||
readOnly
|
||||
showFormatButton={false}
|
||||
/>
|
||||
{parseError && <ErrorMessage message={parseError.message} />}
|
||||
{validationError && <ErrorMessage message={validationError} />}
|
||||
</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='h-4 w-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='h-4 w-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 React.memo(GeneratedResult)
|
||||
@@ -0,0 +1,194 @@
|
||||
import React, { type FC, useCallback, useEffect, useState } from 'react'
|
||||
import type { SchemaRoot } from '../../../types'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import type { CompletionParams, Model } from '@/types/app'
|
||||
import { ModelModeType } from '@/types/app'
|
||||
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'
|
||||
import { useGenerateStructuredOutputRules } from '@/service/use-common'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { type FormValue, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { useVisualEditorStore } from '../visual-editor/store'
|
||||
import { useMittContext } from '../visual-editor/context'
|
||||
|
||||
type JsonSchemaGeneratorProps = {
|
||||
onApply: (schema: SchemaRoot) => void
|
||||
crossAxisOffset?: number
|
||||
}
|
||||
|
||||
enum GeneratorView {
|
||||
promptEditor = 'promptEditor',
|
||||
result = 'result',
|
||||
}
|
||||
|
||||
const JsonSchemaGenerator: FC<JsonSchemaGeneratorProps> = ({
|
||||
onApply,
|
||||
crossAxisOffset,
|
||||
}) => {
|
||||
const localModel = localStorage.getItem('auto-gen-model')
|
||||
? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model
|
||||
: null
|
||||
const [open, setOpen] = useState(false)
|
||||
const [view, setView] = useState(GeneratorView.promptEditor)
|
||||
const [model, setModel] = useState<Model>(localModel || {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: ModelModeType.completion,
|
||||
completion_params: {} as CompletionParams,
|
||||
})
|
||||
const [instruction, setInstruction] = useState('')
|
||||
const [schema, setSchema] = useState<SchemaRoot | null>(null)
|
||||
const { theme } = useTheme()
|
||||
const {
|
||||
defaultModel,
|
||||
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
|
||||
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
|
||||
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
|
||||
const { emit } = useMittContext()
|
||||
const SchemaGenerator = theme === Theme.light ? SchemaGeneratorLight : SchemaGeneratorDark
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultModel) {
|
||||
const localModel = localStorage.getItem('auto-gen-model')
|
||||
? JSON.parse(localStorage.getItem('auto-gen-model') || '')
|
||||
: null
|
||||
if (localModel) {
|
||||
setModel(localModel)
|
||||
}
|
||||
else {
|
||||
setModel(prev => ({
|
||||
...prev,
|
||||
name: defaultModel.model,
|
||||
provider: defaultModel.provider.provider,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}, [defaultModel])
|
||||
|
||||
const handleTrigger = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
e.stopPropagation()
|
||||
if (advancedEditing || isAddingNewField)
|
||||
emit('quitEditing', {})
|
||||
setOpen(!open)
|
||||
}, [open, advancedEditing, isAddingNewField, emit])
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleModelChange = useCallback((newValue: { modelId: string; provider: string; mode?: string; features?: string[] }) => {
|
||||
const newModel = {
|
||||
...model,
|
||||
provider: newValue.provider,
|
||||
name: newValue.modelId,
|
||||
mode: newValue.mode as ModelModeType,
|
||||
}
|
||||
setModel(newModel)
|
||||
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
||||
}, [model, setModel])
|
||||
|
||||
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
|
||||
const newModel = {
|
||||
...model,
|
||||
completion_params: newParams as CompletionParams,
|
||||
}
|
||||
setModel(newModel)
|
||||
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
||||
}, [model, setModel])
|
||||
|
||||
const { mutateAsync: generateStructuredOutputRules, isPending: isGenerating } = useGenerateStructuredOutputRules()
|
||||
|
||||
const generateSchema = useCallback(async () => {
|
||||
const { output, error } = await generateStructuredOutputRules({ instruction, model_config: model! })
|
||||
if (error) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: error,
|
||||
})
|
||||
setSchema(null)
|
||||
setView(GeneratorView.promptEditor)
|
||||
return
|
||||
}
|
||||
return output
|
||||
}, [instruction, model, generateStructuredOutputRules])
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
setView(GeneratorView.result)
|
||||
const output = await generateSchema()
|
||||
if (output === undefined) return
|
||||
setSchema(JSON.parse(output))
|
||||
}, [generateSchema])
|
||||
|
||||
const goBackToPromptEditor = () => {
|
||||
setView(GeneratorView.promptEditor)
|
||||
}
|
||||
|
||||
const handleRegenerate = useCallback(async () => {
|
||||
const output = await generateSchema()
|
||||
if (output === undefined) return
|
||||
setSchema(JSON.parse(output))
|
||||
}, [generateSchema])
|
||||
|
||||
const handleApply = () => {
|
||||
onApply(schema!)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: crossAxisOffset ?? 0,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'flex h-6 w-6 items-center justify-center rounded-md p-0.5 hover:bg-state-accent-hover',
|
||||
open && 'bg-state-accent-active',
|
||||
)}
|
||||
>
|
||||
<SchemaGenerator />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[100]'>
|
||||
{view === GeneratorView.promptEditor && (
|
||||
<PromptEditor
|
||||
instruction={instruction}
|
||||
model={model}
|
||||
onInstructionChange={setInstruction}
|
||||
onCompletionParamsChange={handleCompletionParamsChange}
|
||||
onGenerate={handleGenerate}
|
||||
onClose={onClose}
|
||||
onModelChange={handleModelChange}
|
||||
/>
|
||||
)}
|
||||
{view === GeneratorView.result && (
|
||||
<GeneratedResult
|
||||
schema={schema!}
|
||||
isGenerating={isGenerating}
|
||||
onBack={goBackToPromptEditor}
|
||||
onRegenerate={handleRegenerate}
|
||||
onApply={handleApply}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default JsonSchemaGenerator
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import { RiCloseLine, RiSparklingFill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
import type { Model } from '@/types/app'
|
||||
|
||||
export type ModelInfo = {
|
||||
modelId: string
|
||||
provider: string
|
||||
mode?: string
|
||||
features?: string[]
|
||||
}
|
||||
|
||||
type PromptEditorProps = {
|
||||
instruction: string
|
||||
model: Model
|
||||
onInstructionChange: (instruction: string) => void
|
||||
onCompletionParamsChange: (newParams: FormValue) => void
|
||||
onModelChange: (model: ModelInfo) => void
|
||||
onClose: () => void
|
||||
onGenerate: () => void
|
||||
}
|
||||
|
||||
const PromptEditor: FC<PromptEditorProps> = ({
|
||||
instruction,
|
||||
model,
|
||||
onInstructionChange,
|
||||
onCompletionParamsChange,
|
||||
onClose,
|
||||
onGenerate,
|
||||
onModelChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleInstructionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onInstructionChange(e.target.value)
|
||||
}, [onInstructionChange])
|
||||
|
||||
return (
|
||||
<div className='relative flex w-[480px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl shadow-shadow-shadow-9'>
|
||||
<div className='absolute right-2.5 top-2.5 flex h-8 w-8 items-center justify-center' onClick={onClose}>
|
||||
<RiCloseLine className='h-4 w-4 text-text-tertiary'/>
|
||||
</div>
|
||||
{/* Title */}
|
||||
<div className='flex flex-col gap-y-[0.5px] px-3 pb-1 pt-3.5'>
|
||||
<div className='system-xl-semibold flex pl-1 pr-8 text-text-primary'>
|
||||
{t('workflow.nodes.llm.jsonSchema.generateJsonSchema')}
|
||||
</div>
|
||||
<div className='system-xs-regular flex px-1 text-text-tertiary'>
|
||||
{t('workflow.nodes.llm.jsonSchema.generationTip')}
|
||||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className='flex flex-col gap-y-1 px-4 py-2'>
|
||||
<div className='system-sm-semibold-uppercase flex h-6 items-center text-text-secondary'>
|
||||
{t('common.modelProvider.model')}
|
||||
</div>
|
||||
<ModelParameterModal
|
||||
popupClassName='!w-[448px]'
|
||||
portalToFollowElemContentClassName='z-[1000]'
|
||||
isAdvancedMode={true}
|
||||
provider={model.provider}
|
||||
completionParams={model.completion_params}
|
||||
modelId={model.name}
|
||||
setModel={onModelChange}
|
||||
onCompletionParamsChange={onCompletionParamsChange}
|
||||
hideDebugWithMultipleModel
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-y-1 px-4 py-2'>
|
||||
<div className='system-sm-semibold-uppercase flex h-6 items-center text-text-secondary'>
|
||||
<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] resize-none px-2 py-1'
|
||||
value={instruction}
|
||||
placeholder={t('workflow.nodes.llm.jsonSchema.promptPlaceholder')}
|
||||
onChange={handleInstructionChange}
|
||||
/>
|
||||
</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='h-4 w-4' />
|
||||
<span>{t('workflow.nodes.llm.jsonSchema.generate')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(PromptEditor)
|
||||
@@ -0,0 +1,42 @@
|
||||
import React, { type FC } from 'react'
|
||||
import CodeEditor from './code-editor'
|
||||
import cn from '@/utils/classnames'
|
||||
import LargeDataAlert from '@/app/components/workflow/variable-inspect/large-data-alert'
|
||||
|
||||
type SchemaEditorProps = {
|
||||
schema: string
|
||||
onUpdate: (schema: string) => void
|
||||
hideTopMenu?: boolean
|
||||
className?: string
|
||||
readonly?: boolean
|
||||
onFocus?: () => void
|
||||
onBlur?: () => void
|
||||
isTruncated?: boolean
|
||||
}
|
||||
|
||||
const SchemaEditor: FC<SchemaEditorProps> = ({
|
||||
schema,
|
||||
onUpdate,
|
||||
hideTopMenu,
|
||||
className,
|
||||
readonly = false,
|
||||
onFocus,
|
||||
onBlur,
|
||||
isTruncated,
|
||||
}) => {
|
||||
return (
|
||||
<CodeEditor
|
||||
readOnly={readonly}
|
||||
className={cn('grow rounded-xl', className)}
|
||||
editorWrapperClassName='grow'
|
||||
value={schema}
|
||||
onUpdate={onUpdate}
|
||||
hideTopMenu={hideTopMenu}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
topContent={isTruncated && <LargeDataAlert className='mx-1 mb-3 mt-[-4px]' />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default SchemaEditor
|
||||
@@ -0,0 +1,36 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { RiAddCircleFill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useVisualEditorStore } from './store'
|
||||
import { useMittContext } from './context'
|
||||
|
||||
const AddField = () => {
|
||||
const { t } = useTranslation()
|
||||
const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
|
||||
const { emit } = useMittContext()
|
||||
|
||||
const handleAddField = useCallback(() => {
|
||||
setIsAddingNewField(true)
|
||||
// fix: when user change the last property type, the 'hoveringProperty' value will be reset by 'setHoveringPropertyDebounced(null)', that cause the EditCard not showing
|
||||
setTimeout(() => {
|
||||
emit('addField', { path: [] })
|
||||
}, 100)
|
||||
}, [setIsAddingNewField, emit])
|
||||
|
||||
return (
|
||||
<div className='py-2 pl-5'>
|
||||
<Button
|
||||
size='small'
|
||||
variant='secondary-accent'
|
||||
className='flex items-center gap-x-[1px]'
|
||||
onClick={handleAddField}
|
||||
>
|
||||
<RiAddCircleFill className='h-3.5 w-3.5'/>
|
||||
<span className='px-[3px]'>{t('workflow.nodes.llm.jsonSchema.addField')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AddField)
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { type FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type CardProps = {
|
||||
name: string
|
||||
type: string
|
||||
required: boolean
|
||||
description?: string
|
||||
}
|
||||
|
||||
const Card: FC<CardProps> = ({
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
description,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex flex-col py-0.5'>
|
||||
<div className='flex h-6 items-center gap-x-1 pl-1 pr-0.5'>
|
||||
<div className='system-sm-semibold truncate border border-transparent px-1 py-px text-text-primary'>
|
||||
{name}
|
||||
</div>
|
||||
<div className='system-xs-medium px-1 py-0.5 text-text-tertiary'>
|
||||
{type}
|
||||
</div>
|
||||
{
|
||||
required && (
|
||||
<div className='system-2xs-medium-uppercase px-1 py-0.5 text-text-warning'>
|
||||
{t('workflow.nodes.llm.jsonSchema.required')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<div className='system-xs-regular truncate px-2 pb-1 text-text-tertiary'>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Card)
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { createVisualEditorStore } from './store'
|
||||
import { useMitt } from '@/hooks/use-mitt'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
type VisualEditorStore = ReturnType<typeof createVisualEditorStore>
|
||||
|
||||
type VisualEditorContextType = VisualEditorStore | null
|
||||
|
||||
type VisualEditorProviderProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const VisualEditorContext = createContext<VisualEditorContextType>(null)
|
||||
|
||||
export const VisualEditorContextProvider = ({ children }: VisualEditorProviderProps) => {
|
||||
const storeRef = useRef<VisualEditorStore | null>(null)
|
||||
|
||||
if (!storeRef.current)
|
||||
storeRef.current = createVisualEditorStore()
|
||||
|
||||
return (
|
||||
<VisualEditorContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</VisualEditorContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const MittContext = createContext<ReturnType<typeof useMitt>>({
|
||||
emit: noop,
|
||||
useSubscribe: noop,
|
||||
})
|
||||
|
||||
export const MittProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const mitt = useMitt()
|
||||
|
||||
return (
|
||||
<MittContext.Provider value={mitt}>
|
||||
{children}
|
||||
</MittContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useMittContext = () => {
|
||||
return useContext(MittContext)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { RiAddCircleLine, RiDeleteBinLine, RiEditLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type ActionsProps = {
|
||||
disableAddBtn: boolean
|
||||
onAddChildField: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
const Actions: FC<ActionsProps> = ({
|
||||
disableAddBtn,
|
||||
onAddChildField,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-x-0.5'>
|
||||
<Tooltip popupContent={t('workflow.nodes.llm.jsonSchema.addChildField')}>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-6 w-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary disabled:cursor-not-allowed disabled:text-text-disabled'
|
||||
onClick={onAddChildField}
|
||||
disabled={disableAddBtn}
|
||||
>
|
||||
<RiAddCircleLine className='h-4 w-4'/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('common.operation.edit')}>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-6 w-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary'
|
||||
onClick={onEdit}
|
||||
>
|
||||
<RiEditLine className='h-4 w-4' />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip popupContent={t('common.operation.remove')}>
|
||||
<button
|
||||
type='button'
|
||||
className='flex h-6 w-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive'
|
||||
onClick={onDelete}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Actions)
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, { type FC } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
|
||||
type AdvancedActionsProps = {
|
||||
isConfirmDisabled: boolean
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
const Key = (props: { keyName: string }) => {
|
||||
const { keyName } = props
|
||||
return (
|
||||
<kbd className='system-kbd flex h-4 min-w-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-white px-px text-text-primary-on-surface'>
|
||||
{keyName}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
const AdvancedActions: FC<AdvancedActionsProps> = ({
|
||||
isConfirmDisabled,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
useKeyPress([`${getKeyboardKeyCodeBySystem('ctrl')}.enter`], (e) => {
|
||||
e.preventDefault()
|
||||
onConfirm()
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<Button size='small' variant='secondary' onClick={onCancel}>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
className='flex items-center gap-x-1'
|
||||
disabled={isConfirmDisabled}
|
||||
size='small'
|
||||
variant='primary'
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<span>{t('common.operation.confirm')}</span>
|
||||
<div className='flex items-center gap-x-0.5'>
|
||||
<Key keyName={getKeyboardKeyNameBySystem('ctrl')} />
|
||||
<Key keyName='⏎' />
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AdvancedActions)
|
||||
@@ -0,0 +1,77 @@
|
||||
import React, { type FC, useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
export type AdvancedOptionsType = {
|
||||
enum: string
|
||||
}
|
||||
|
||||
type AdvancedOptionsProps = {
|
||||
options: AdvancedOptionsType
|
||||
onChange: (options: AdvancedOptionsType) => void
|
||||
}
|
||||
|
||||
const AdvancedOptions: FC<AdvancedOptionsProps> = ({
|
||||
onChange,
|
||||
options,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
// const [showAdvancedOptions, setShowAdvancedOptions] = useState(false)
|
||||
const [enumValue, setEnumValue] = useState(options.enum)
|
||||
|
||||
const handleEnumChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setEnumValue(e.target.value)
|
||||
}, [])
|
||||
|
||||
const handleEnumBlur = useCallback((e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
onChange({ enum: e.target.value })
|
||||
}, [onChange])
|
||||
|
||||
// const handleToggleAdvancedOptions = useCallback(() => {
|
||||
// setShowAdvancedOptions(prev => !prev)
|
||||
// }, [])
|
||||
|
||||
return (
|
||||
<div className='border-t border-divider-subtle'>
|
||||
{/* {showAdvancedOptions ? ( */}
|
||||
<div className='flex flex-col gap-y-1 px-2 py-1.5'>
|
||||
<div className='flex w-full items-center gap-x-2'>
|
||||
<span className='system-2xs-medium-uppercase text-text-tertiary'>
|
||||
{t('workflow.nodes.llm.jsonSchema.stringValidations')}
|
||||
</span>
|
||||
<div className='grow'>
|
||||
<Divider type='horizontal' className='my-0 h-px bg-line-divider-bg' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col'>
|
||||
<div className='system-xs-medium flex h-6 items-center text-text-secondary'>
|
||||
Enum
|
||||
</div>
|
||||
<Textarea
|
||||
size='small'
|
||||
className='min-h-6'
|
||||
value={enumValue}
|
||||
onChange={handleEnumChange}
|
||||
onBlur={handleEnumBlur}
|
||||
placeholder={'abcd, 1, 1.5, etc.'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* ) : (
|
||||
<button
|
||||
type='button'
|
||||
className='flex items-center gap-x-0.5 pb-1 pl-1.5 pr-2 pt-2'
|
||||
onClick={handleToggleAdvancedOptions}
|
||||
>
|
||||
<RiArrowDownDoubleLine className='h-3 w-3 text-text-tertiary' />
|
||||
<span className='system-xs-regular text-text-tertiary'>
|
||||
{t('workflow.nodes.llm.jsonSchema.showAdvancedOptions')}
|
||||
</span>
|
||||
</button>
|
||||
)} */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AdvancedOptions)
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type AutoWidthInputProps = {
|
||||
value: string
|
||||
placeholder: string
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||
onBlur: () => void
|
||||
minWidth?: number
|
||||
maxWidth?: number
|
||||
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'>
|
||||
|
||||
const AutoWidthInput: FC<AutoWidthInputProps> = ({
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
onBlur,
|
||||
minWidth = 60,
|
||||
maxWidth = 300,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const [width, setWidth] = useState(minWidth)
|
||||
const textRef = React.useRef<HTMLSpanElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (textRef.current) {
|
||||
textRef.current.textContent = value || placeholder
|
||||
const textWidth = textRef.current.offsetWidth
|
||||
const newWidth = Math.max(minWidth, Math.min(textWidth + 16, maxWidth))
|
||||
if (width !== newWidth)
|
||||
setWidth(newWidth)
|
||||
}
|
||||
}, [value, placeholder, minWidth, maxWidth, width])
|
||||
|
||||
// Handle Enter key
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && e.currentTarget.blur)
|
||||
e.currentTarget.blur()
|
||||
if (props.onKeyUp)
|
||||
props.onKeyUp(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative inline-flex items-center'>
|
||||
{/* Hidden measurement span */}
|
||||
<span
|
||||
ref={textRef}
|
||||
className='system-sm-semibold invisible absolute left-0 top-0 -z-10 whitespace-pre px-1'
|
||||
aria-hidden="true"
|
||||
>
|
||||
{value || placeholder}
|
||||
</span>
|
||||
|
||||
{/* Actual input element */}
|
||||
<input
|
||||
value={value}
|
||||
className={cn(
|
||||
'system-sm-semibold placeholder:system-sm-semibold h-5 rounded-[5px] border border-transparent px-1',
|
||||
'py-px text-text-primary caret-[#295EFF] shadow-shadow-shadow-3 outline-none',
|
||||
'placeholder:text-text-placeholder hover:bg-state-base-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
minWidth: `${minWidth}px`,
|
||||
maxWidth: `${maxWidth}px`,
|
||||
transition: 'width 100ms ease-out',
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
onKeyUp={handleKeyUp}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AutoWidthInput)
|
||||
@@ -0,0 +1,280 @@
|
||||
import React, { type FC, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import type { SchemaEnumType } from '../../../../types'
|
||||
import { ArrayType, Type } from '../../../../types'
|
||||
import type { TypeItem } from './type-selector'
|
||||
import TypeSelector from './type-selector'
|
||||
import RequiredSwitch from './required-switch'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Actions from './actions'
|
||||
import AdvancedActions from './advanced-actions'
|
||||
import AdvancedOptions, { type AdvancedOptionsType } from './advanced-options'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import classNames from '@/utils/classnames'
|
||||
import { useVisualEditorStore } from '../store'
|
||||
import { useMittContext } from '../context'
|
||||
import { useUnmount } from 'ahooks'
|
||||
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
|
||||
import AutoWidthInput from './auto-width-input'
|
||||
|
||||
export type EditData = {
|
||||
name: string
|
||||
type: Type | ArrayType
|
||||
required: boolean
|
||||
description?: string
|
||||
enum?: SchemaEnumType
|
||||
}
|
||||
|
||||
type Options = {
|
||||
description?: string
|
||||
enum?: SchemaEnumType
|
||||
}
|
||||
|
||||
type EditCardProps = {
|
||||
fields: EditData
|
||||
depth: number
|
||||
path: string[]
|
||||
parentPath: string[]
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: Type.string, text: 'string' },
|
||||
{ value: Type.number, text: 'number' },
|
||||
{ value: Type.boolean, text: 'boolean' },
|
||||
{ value: Type.object, text: 'object' },
|
||||
{ value: ArrayType.string, text: 'array[string]' },
|
||||
{ value: ArrayType.number, text: 'array[number]' },
|
||||
{ value: ArrayType.object, text: 'array[object]' },
|
||||
]
|
||||
|
||||
const MAXIMUM_DEPTH_TYPE_OPTIONS = [
|
||||
{ value: Type.string, text: 'string' },
|
||||
{ value: Type.number, text: 'number' },
|
||||
{ value: Type.boolean, text: 'boolean' },
|
||||
{ value: ArrayType.string, text: 'array[string]' },
|
||||
{ value: ArrayType.number, text: 'array[number]' },
|
||||
]
|
||||
|
||||
const EditCard: FC<EditCardProps> = ({
|
||||
fields,
|
||||
depth,
|
||||
path,
|
||||
parentPath,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [currentFields, setCurrentFields] = useState(fields)
|
||||
const [backupFields, setBackupFields] = useState<EditData | null>(null)
|
||||
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
|
||||
const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
|
||||
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
|
||||
const setAdvancedEditing = useVisualEditorStore(state => state.setAdvancedEditing)
|
||||
const { emit, useSubscribe } = useMittContext()
|
||||
const blurWithActions = useRef(false)
|
||||
|
||||
const maximumDepthReached = depth === JSON_SCHEMA_MAX_DEPTH
|
||||
const disableAddBtn = maximumDepthReached || (currentFields.type !== Type.object && currentFields.type !== ArrayType.object)
|
||||
const hasAdvancedOptions = currentFields.type === Type.string || currentFields.type === Type.number
|
||||
const isAdvancedEditing = advancedEditing || isAddingNewField
|
||||
|
||||
const advancedOptions = useMemo(() => {
|
||||
let enumValue = ''
|
||||
if (currentFields.type === Type.string || currentFields.type === Type.number)
|
||||
enumValue = (currentFields.enum || []).join(', ')
|
||||
return { enum: enumValue }
|
||||
}, [currentFields.type, currentFields.enum])
|
||||
|
||||
useSubscribe('restorePropertyName', () => {
|
||||
setCurrentFields(prev => ({ ...prev, name: fields.name }))
|
||||
})
|
||||
|
||||
useSubscribe('fieldChangeSuccess', () => {
|
||||
if (isAddingNewField)
|
||||
setIsAddingNewField(false)
|
||||
if (advancedEditing)
|
||||
setAdvancedEditing(false)
|
||||
})
|
||||
|
||||
const emitPropertyNameChange = useCallback(() => {
|
||||
emit('propertyNameChange', { path, parentPath, oldFields: fields, fields: currentFields })
|
||||
}, [fields, currentFields, path, parentPath, emit])
|
||||
|
||||
const emitPropertyTypeChange = useCallback((type: Type | ArrayType) => {
|
||||
emit('propertyTypeChange', { path, parentPath, oldFields: fields, fields: { ...currentFields, type } })
|
||||
}, [fields, currentFields, path, parentPath, emit])
|
||||
|
||||
const emitPropertyRequiredToggle = useCallback(() => {
|
||||
emit('propertyRequiredToggle', { path, parentPath, oldFields: fields, fields: currentFields })
|
||||
}, [emit, path, parentPath, fields, currentFields])
|
||||
|
||||
const emitPropertyOptionsChange = useCallback((options: Options) => {
|
||||
emit('propertyOptionsChange', { path, parentPath, oldFields: fields, fields: { ...currentFields, ...options } })
|
||||
}, [emit, path, parentPath, fields, currentFields])
|
||||
|
||||
const emitPropertyDelete = useCallback(() => {
|
||||
emit('propertyDelete', { path, parentPath, oldFields: fields, fields: currentFields })
|
||||
}, [emit, path, parentPath, fields, currentFields])
|
||||
|
||||
const emitPropertyAdd = useCallback(() => {
|
||||
emit('addField', { path })
|
||||
}, [emit, path])
|
||||
|
||||
const emitFieldChange = useCallback(() => {
|
||||
emit('fieldChange', { path, parentPath, oldFields: fields, fields: currentFields })
|
||||
}, [emit, path, parentPath, fields, currentFields])
|
||||
|
||||
const handlePropertyNameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// fix: when user add name contains space, the variable reference will not work
|
||||
setCurrentFields(prev => ({ ...prev, name: e.target.value?.trim() }))
|
||||
}, [])
|
||||
|
||||
const handlePropertyNameBlur = useCallback(() => {
|
||||
if (isAdvancedEditing) return
|
||||
emitPropertyNameChange()
|
||||
}, [isAdvancedEditing, emitPropertyNameChange])
|
||||
|
||||
const handleTypeChange = useCallback((item: TypeItem) => {
|
||||
setCurrentFields(prev => ({ ...prev, type: item.value }))
|
||||
if (isAdvancedEditing) return
|
||||
emitPropertyTypeChange(item.value)
|
||||
}, [isAdvancedEditing, emitPropertyTypeChange])
|
||||
|
||||
const toggleRequired = useCallback(() => {
|
||||
setCurrentFields(prev => ({ ...prev, required: !prev.required }))
|
||||
if (isAdvancedEditing) return
|
||||
emitPropertyRequiredToggle()
|
||||
}, [isAdvancedEditing, emitPropertyRequiredToggle])
|
||||
|
||||
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCurrentFields(prev => ({ ...prev, description: e.target.value }))
|
||||
}, [])
|
||||
|
||||
const handleDescriptionBlur = useCallback(() => {
|
||||
if (isAdvancedEditing) return
|
||||
emitPropertyOptionsChange({ description: currentFields.description, enum: currentFields.enum })
|
||||
}, [isAdvancedEditing, emitPropertyOptionsChange, currentFields])
|
||||
|
||||
const handleAdvancedOptionsChange = useCallback((options: AdvancedOptionsType) => {
|
||||
let enumValue: SchemaEnumType | undefined
|
||||
if (options.enum === '') {
|
||||
enumValue = undefined
|
||||
}
|
||||
else {
|
||||
const stringArray = options.enum.replace(/\s/g, '').split(',')
|
||||
if (currentFields.type === Type.number)
|
||||
enumValue = stringArray.map(value => Number(value)).filter(num => !Number.isNaN(num))
|
||||
else
|
||||
enumValue = stringArray
|
||||
}
|
||||
setCurrentFields(prev => ({ ...prev, enum: enumValue }))
|
||||
if (isAdvancedEditing) return
|
||||
emitPropertyOptionsChange({ description: currentFields.description, enum: enumValue })
|
||||
}, [isAdvancedEditing, emitPropertyOptionsChange, currentFields])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
blurWithActions.current = true
|
||||
emitPropertyDelete()
|
||||
}, [emitPropertyDelete])
|
||||
|
||||
const handleAdvancedEdit = useCallback(() => {
|
||||
setBackupFields({ ...currentFields })
|
||||
setAdvancedEditing(true)
|
||||
}, [currentFields, setAdvancedEditing])
|
||||
|
||||
const handleAddChildField = useCallback(() => {
|
||||
blurWithActions.current = true
|
||||
emitPropertyAdd()
|
||||
}, [emitPropertyAdd])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
emitFieldChange()
|
||||
}, [emitFieldChange])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (isAddingNewField) {
|
||||
blurWithActions.current = true
|
||||
emit('restoreSchema')
|
||||
setIsAddingNewField(false)
|
||||
return
|
||||
}
|
||||
if (backupFields) {
|
||||
setCurrentFields(backupFields)
|
||||
setBackupFields(null)
|
||||
}
|
||||
setAdvancedEditing(false)
|
||||
}, [isAddingNewField, emit, setIsAddingNewField, setAdvancedEditing, backupFields])
|
||||
|
||||
useUnmount(() => {
|
||||
if (isAdvancedEditing || blurWithActions.current) return
|
||||
emitFieldChange()
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='flex flex-col rounded-lg bg-components-panel-bg py-0.5 shadow-sm shadow-shadow-shadow-4'>
|
||||
<div className='flex h-6 items-center pl-1 pr-0.5'>
|
||||
<div className='flex grow items-center gap-x-1'>
|
||||
<AutoWidthInput
|
||||
value={currentFields.name}
|
||||
placeholder={t('workflow.nodes.llm.jsonSchema.fieldNamePlaceholder')}
|
||||
minWidth={80}
|
||||
maxWidth={300}
|
||||
onChange={handlePropertyNameChange}
|
||||
onBlur={handlePropertyNameBlur}
|
||||
/>
|
||||
<TypeSelector
|
||||
currentValue={currentFields.type}
|
||||
items={maximumDepthReached ? MAXIMUM_DEPTH_TYPE_OPTIONS : TYPE_OPTIONS}
|
||||
onSelect={handleTypeChange}
|
||||
popupClassName={'z-[1000]'}
|
||||
/>
|
||||
{
|
||||
currentFields.required && (
|
||||
<div className='system-2xs-medium-uppercase px-1 py-0.5 text-text-warning'>
|
||||
{t('workflow.nodes.llm.jsonSchema.required')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<RequiredSwitch
|
||||
defaultValue={currentFields.required}
|
||||
toggleRequired={toggleRequired}
|
||||
/>
|
||||
<Divider type='vertical' className='h-3' />
|
||||
{isAdvancedEditing ? (
|
||||
<AdvancedActions
|
||||
isConfirmDisabled={currentFields.name === ''}
|
||||
onCancel={handleCancel}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
) : (
|
||||
<Actions
|
||||
disableAddBtn={disableAddBtn}
|
||||
onAddChildField={handleAddChildField}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleAdvancedEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(fields.description || isAdvancedEditing) && (
|
||||
<div className={classNames('flex', isAdvancedEditing ? 'p-2 pt-1' : 'px-2 pb-1')}>
|
||||
<input
|
||||
value={currentFields.description}
|
||||
className='system-xs-regular placeholder:system-xs-regular h-4 w-full p-0 text-text-tertiary caret-[#295EFF] outline-none placeholder:text-text-placeholder'
|
||||
placeholder={t('workflow.nodes.llm.jsonSchema.descriptionPlaceholder')}
|
||||
onChange={handleDescriptionChange}
|
||||
onBlur={handleDescriptionBlur}
|
||||
onKeyUp={e => e.key === 'Enter' && e.currentTarget.blur()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdvancedEditing && hasAdvancedOptions && (
|
||||
<AdvancedOptions
|
||||
options={advancedOptions}
|
||||
onChange={handleAdvancedOptionsChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditCard
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import type { FC } from 'react'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type RequiredSwitchProps = {
|
||||
defaultValue: boolean
|
||||
toggleRequired: () => void
|
||||
}
|
||||
|
||||
const RequiredSwitch: FC<RequiredSwitchProps> = ({
|
||||
defaultValue,
|
||||
toggleRequired,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-x-1 rounded-[5px] border border-divider-subtle bg-background-default-lighter px-1.5 py-1'>
|
||||
<span className='system-2xs-medium-uppercase text-text-secondary'>{t('workflow.nodes.llm.jsonSchema.required')}</span>
|
||||
<Switch size='xs' defaultValue={defaultValue} onChange={toggleRequired} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(RequiredSwitch)
|
||||
@@ -0,0 +1,69 @@
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
|
||||
import type { ArrayType, Type } from '../../../../types'
|
||||
import type { FC } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
export type TypeItem = {
|
||||
value: Type | ArrayType
|
||||
text: string
|
||||
}
|
||||
|
||||
type TypeSelectorProps = {
|
||||
items: TypeItem[]
|
||||
currentValue: Type | ArrayType
|
||||
onSelect: (item: TypeItem) => void
|
||||
popupClassName?: string
|
||||
}
|
||||
|
||||
const TypeSelector: FC<TypeSelectorProps> = ({
|
||||
items,
|
||||
currentValue,
|
||||
onSelect,
|
||||
popupClassName,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-start'
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<div className={cn(
|
||||
'flex items-center rounded-[5px] p-0.5 pl-1 hover:bg-state-base-hover',
|
||||
open && 'bg-state-base-hover',
|
||||
)}>
|
||||
<span className='system-xs-medium text-text-tertiary'>{currentValue}</span>
|
||||
<RiArrowDownSLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className={popupClassName}>
|
||||
<div className='w-40 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg shadow-shadow-shadow-5'>
|
||||
{items.map((item) => {
|
||||
const isSelected = item.value === currentValue
|
||||
return (<div
|
||||
key={item.value}
|
||||
className={'flex items-center gap-x-1 rounded-lg px-2 py-1 hover:bg-state-base-hover'}
|
||||
onClick={() => {
|
||||
onSelect(item)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className='system-sm-medium px-1 text-text-secondary'>{item.text}</span>
|
||||
{isSelected && <RiCheckLine className='h-4 w-4 text-text-accent' />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default TypeSelector
|
||||
@@ -0,0 +1,446 @@
|
||||
import { produce } from 'immer'
|
||||
import type { VisualEditorProps } from '.'
|
||||
import { useMittContext } from './context'
|
||||
import { useVisualEditorStore } from './store'
|
||||
import type { EditData } from './edit-card'
|
||||
import { ArrayType, type Field, Type } from '../../../types'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { findPropertyWithPath } from '../../../utils'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
type ChangeEventParams = {
|
||||
path: string[],
|
||||
parentPath: string[],
|
||||
oldFields: EditData,
|
||||
fields: EditData,
|
||||
}
|
||||
|
||||
type AddEventParams = {
|
||||
path: string[]
|
||||
}
|
||||
|
||||
export const useSchemaNodeOperations = (props: VisualEditorProps) => {
|
||||
const { schema: jsonSchema, onChange: doOnChange } = props
|
||||
const onChange = doOnChange || noop
|
||||
const backupSchema = useVisualEditorStore(state => state.backupSchema)
|
||||
const setBackupSchema = useVisualEditorStore(state => state.setBackupSchema)
|
||||
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
|
||||
const setIsAddingNewField = useVisualEditorStore(state => state.setIsAddingNewField)
|
||||
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
|
||||
const setAdvancedEditing = useVisualEditorStore(state => state.setAdvancedEditing)
|
||||
const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty)
|
||||
const { emit, useSubscribe } = useMittContext()
|
||||
|
||||
useSubscribe('restoreSchema', () => {
|
||||
if (backupSchema) {
|
||||
onChange(backupSchema)
|
||||
setBackupSchema(null)
|
||||
}
|
||||
})
|
||||
|
||||
useSubscribe('quitEditing', (params) => {
|
||||
const { callback } = params as any
|
||||
callback?.(backupSchema)
|
||||
if (backupSchema) {
|
||||
onChange(backupSchema)
|
||||
setBackupSchema(null)
|
||||
}
|
||||
if (isAddingNewField)
|
||||
setIsAddingNewField(false)
|
||||
if (advancedEditing)
|
||||
setAdvancedEditing(false)
|
||||
setHoveringProperty(null)
|
||||
})
|
||||
|
||||
useSubscribe('propertyNameChange', (params) => {
|
||||
const { parentPath, oldFields, fields } = params as ChangeEventParams
|
||||
const { name: oldName } = oldFields
|
||||
const { name: newName } = fields
|
||||
const newSchema = produce(jsonSchema, (draft) => {
|
||||
if (oldName === newName) return
|
||||
const schema = findPropertyWithPath(draft, parentPath) as Field
|
||||
|
||||
if (schema.type === Type.object) {
|
||||
const properties = schema.properties || {}
|
||||
if (properties[newName]) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Property name already exists',
|
||||
})
|
||||
emit('restorePropertyName')
|
||||
return
|
||||
}
|
||||
|
||||
const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
|
||||
acc[key === oldName ? newName : key] = value
|
||||
return acc
|
||||
}, {} as Record<string, Field>)
|
||||
|
||||
const required = schema.required || []
|
||||
const newRequired = produce(required, (draft) => {
|
||||
const index = draft.indexOf(oldName)
|
||||
if (index !== -1)
|
||||
draft.splice(index, 1, newName)
|
||||
})
|
||||
|
||||
schema.properties = newProperties
|
||||
schema.required = newRequired
|
||||
}
|
||||
|
||||
if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
|
||||
const properties = schema.items.properties || {}
|
||||
if (properties[newName]) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Property name already exists',
|
||||
})
|
||||
emit('restorePropertyName')
|
||||
return
|
||||
}
|
||||
|
||||
const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
|
||||
acc[key === oldName ? newName : key] = value
|
||||
return acc
|
||||
}, {} as Record<string, Field>)
|
||||
const required = schema.items.required || []
|
||||
const newRequired = produce(required, (draft) => {
|
||||
const index = draft.indexOf(oldName)
|
||||
if (index !== -1)
|
||||
draft.splice(index, 1, newName)
|
||||
})
|
||||
|
||||
schema.items.properties = newProperties
|
||||
schema.items.required = newRequired
|
||||
}
|
||||
})
|
||||
onChange(newSchema)
|
||||
})
|
||||
|
||||
useSubscribe('propertyTypeChange', (params) => {
|
||||
const { path, oldFields, fields } = params as ChangeEventParams
|
||||
const { type: oldType } = oldFields
|
||||
const { type: newType } = fields
|
||||
if (oldType === newType) return
|
||||
const newSchema = produce(jsonSchema, (draft) => {
|
||||
const schema = findPropertyWithPath(draft, path) as Field
|
||||
|
||||
if (schema.type === Type.object) {
|
||||
delete schema.properties
|
||||
delete schema.required
|
||||
}
|
||||
if (schema.type === Type.array)
|
||||
delete schema.items
|
||||
switch (newType) {
|
||||
case Type.object:
|
||||
schema.type = Type.object
|
||||
schema.properties = {}
|
||||
schema.required = []
|
||||
schema.additionalProperties = false
|
||||
break
|
||||
case ArrayType.string:
|
||||
schema.type = Type.array
|
||||
schema.items = {
|
||||
type: Type.string,
|
||||
}
|
||||
break
|
||||
case ArrayType.number:
|
||||
schema.type = Type.array
|
||||
schema.items = {
|
||||
type: Type.number,
|
||||
}
|
||||
break
|
||||
// case ArrayType.boolean:
|
||||
// schema.type = Type.array
|
||||
// schema.items = {
|
||||
// type: Type.boolean,
|
||||
// }
|
||||
// break
|
||||
case ArrayType.object:
|
||||
schema.type = Type.array
|
||||
schema.items = {
|
||||
type: Type.object,
|
||||
properties: {},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
}
|
||||
break
|
||||
default:
|
||||
schema.type = newType as Type
|
||||
}
|
||||
})
|
||||
onChange(newSchema)
|
||||
})
|
||||
|
||||
useSubscribe('propertyRequiredToggle', (params) => {
|
||||
const { parentPath, fields } = params as ChangeEventParams
|
||||
const { name } = fields
|
||||
const newSchema = produce(jsonSchema, (draft) => {
|
||||
const schema = findPropertyWithPath(draft, parentPath) as Field
|
||||
|
||||
if (schema.type === Type.object) {
|
||||
const required = schema.required || []
|
||||
const newRequired = required.includes(name)
|
||||
? required.filter(item => item !== name)
|
||||
: [...required, name]
|
||||
schema.required = newRequired
|
||||
}
|
||||
if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
|
||||
const required = schema.items.required || []
|
||||
const newRequired = required.includes(name)
|
||||
? required.filter(item => item !== name)
|
||||
: [...required, name]
|
||||
schema.items.required = newRequired
|
||||
}
|
||||
})
|
||||
onChange(newSchema)
|
||||
})
|
||||
|
||||
useSubscribe('propertyOptionsChange', (params) => {
|
||||
const { path, fields } = params as ChangeEventParams
|
||||
const newSchema = produce(jsonSchema, (draft) => {
|
||||
const schema = findPropertyWithPath(draft, path) as Field
|
||||
schema.description = fields.description
|
||||
schema.enum = fields.enum
|
||||
})
|
||||
onChange(newSchema)
|
||||
})
|
||||
|
||||
useSubscribe('propertyDelete', (params) => {
|
||||
const { parentPath, fields } = params as ChangeEventParams
|
||||
const { name } = fields
|
||||
const newSchema = produce(jsonSchema, (draft) => {
|
||||
const schema = findPropertyWithPath(draft, parentPath) as Field
|
||||
if (schema.type === Type.object && schema.properties) {
|
||||
delete schema.properties[name]
|
||||
schema.required = schema.required?.filter(item => item !== name)
|
||||
}
|
||||
if (schema.type === Type.array && schema.items?.properties && schema.items?.type === Type.object) {
|
||||
delete schema.items.properties[name]
|
||||
schema.items.required = schema.items.required?.filter(item => item !== name)
|
||||
}
|
||||
})
|
||||
onChange(newSchema)
|
||||
})
|
||||
|
||||
useSubscribe('addField', (params) => {
|
||||
if (advancedEditing)
|
||||
setAdvancedEditing(false)
|
||||
setBackupSchema(jsonSchema)
|
||||
const { path } = params as AddEventParams
|
||||
setIsAddingNewField(true)
|
||||
const newSchema = produce(jsonSchema, (draft) => {
|
||||
const schema = findPropertyWithPath(draft, path) as Field
|
||||
if (schema.type === Type.object) {
|
||||
schema.properties = {
|
||||
...schema.properties,
|
||||
'': {
|
||||
type: Type.string,
|
||||
},
|
||||
}
|
||||
setHoveringProperty([...path, 'properties', ''].join('.'))
|
||||
}
|
||||
if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
|
||||
schema.items.properties = {
|
||||
...schema.items.properties,
|
||||
'': {
|
||||
type: Type.string,
|
||||
},
|
||||
}
|
||||
setHoveringProperty([...path, 'items', 'properties', ''].join('.'))
|
||||
}
|
||||
})
|
||||
onChange(newSchema)
|
||||
})
|
||||
|
||||
useSubscribe('fieldChange', (params) => {
|
||||
let samePropertyNameError = false
|
||||
const { parentPath, oldFields, fields } = params as ChangeEventParams
|
||||
const newSchema = produce(jsonSchema, (draft) => {
|
||||
const parentSchema = findPropertyWithPath(draft, parentPath) as Field
|
||||
const { name: oldName, type: oldType, required: oldRequired } = oldFields
|
||||
const { name: newName, type: newType, required: newRequired } = fields
|
||||
if (parentSchema.type === Type.object && parentSchema.properties) {
|
||||
// name change
|
||||
if (oldName !== newName) {
|
||||
const properties = parentSchema.properties
|
||||
if (properties[newName]) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Property name already exists',
|
||||
})
|
||||
samePropertyNameError = true
|
||||
}
|
||||
|
||||
const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
|
||||
acc[key === oldName ? newName : key] = value
|
||||
return acc
|
||||
}, {} as Record<string, Field>)
|
||||
|
||||
const requiredProperties = parentSchema.required || []
|
||||
const newRequiredProperties = produce(requiredProperties, (draft) => {
|
||||
const index = draft.indexOf(oldName)
|
||||
if (index !== -1)
|
||||
draft.splice(index, 1, newName)
|
||||
})
|
||||
|
||||
parentSchema.properties = newProperties
|
||||
parentSchema.required = newRequiredProperties
|
||||
}
|
||||
|
||||
// required change
|
||||
if (oldRequired !== newRequired) {
|
||||
const required = parentSchema.required || []
|
||||
const newRequired = required.includes(newName)
|
||||
? required.filter(item => item !== newName)
|
||||
: [...required, newName]
|
||||
parentSchema.required = newRequired
|
||||
}
|
||||
|
||||
const schema = parentSchema.properties[newName]
|
||||
|
||||
// type change
|
||||
if (oldType !== newType) {
|
||||
if (schema.type === Type.object) {
|
||||
delete schema.properties
|
||||
delete schema.required
|
||||
}
|
||||
if (schema.type === Type.array)
|
||||
delete schema.items
|
||||
switch (newType) {
|
||||
case Type.object:
|
||||
schema.type = Type.object
|
||||
schema.properties = {}
|
||||
schema.required = []
|
||||
schema.additionalProperties = false
|
||||
break
|
||||
case ArrayType.string:
|
||||
schema.type = Type.array
|
||||
schema.items = {
|
||||
type: Type.string,
|
||||
}
|
||||
break
|
||||
case ArrayType.number:
|
||||
schema.type = Type.array
|
||||
schema.items = {
|
||||
type: Type.number,
|
||||
}
|
||||
break
|
||||
// case ArrayType.boolean:
|
||||
// schema.type = Type.array
|
||||
// schema.items = {
|
||||
// type: Type.boolean,
|
||||
// }
|
||||
// break
|
||||
case ArrayType.object:
|
||||
schema.type = Type.array
|
||||
schema.items = {
|
||||
type: Type.object,
|
||||
properties: {},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
}
|
||||
break
|
||||
default:
|
||||
schema.type = newType as Type
|
||||
}
|
||||
}
|
||||
|
||||
// other options change
|
||||
schema.description = fields.description
|
||||
schema.enum = fields.enum
|
||||
}
|
||||
|
||||
if (parentSchema.type === Type.array && parentSchema.items && parentSchema.items.type === Type.object && parentSchema.items.properties) {
|
||||
// name change
|
||||
if (oldName !== newName) {
|
||||
const properties = parentSchema.items.properties || {}
|
||||
if (properties[newName]) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: 'Property name already exists',
|
||||
})
|
||||
samePropertyNameError = true
|
||||
}
|
||||
|
||||
const newProperties = Object.entries(properties).reduce((acc, [key, value]) => {
|
||||
acc[key === oldName ? newName : key] = value
|
||||
return acc
|
||||
}, {} as Record<string, Field>)
|
||||
const required = parentSchema.items.required || []
|
||||
const newRequired = produce(required, (draft) => {
|
||||
const index = draft.indexOf(oldName)
|
||||
if (index !== -1)
|
||||
draft.splice(index, 1, newName)
|
||||
})
|
||||
|
||||
parentSchema.items.properties = newProperties
|
||||
parentSchema.items.required = newRequired
|
||||
}
|
||||
|
||||
// required change
|
||||
if (oldRequired !== newRequired) {
|
||||
const required = parentSchema.items.required || []
|
||||
const newRequired = required.includes(newName)
|
||||
? required.filter(item => item !== newName)
|
||||
: [...required, newName]
|
||||
parentSchema.items.required = newRequired
|
||||
}
|
||||
|
||||
const schema = parentSchema.items.properties[newName]
|
||||
// type change
|
||||
if (oldType !== newType) {
|
||||
if (schema.type === Type.object) {
|
||||
delete schema.properties
|
||||
delete schema.required
|
||||
}
|
||||
if (schema.type === Type.array)
|
||||
delete schema.items
|
||||
switch (newType) {
|
||||
case Type.object:
|
||||
schema.type = Type.object
|
||||
schema.properties = {}
|
||||
schema.required = []
|
||||
schema.additionalProperties = false
|
||||
break
|
||||
case ArrayType.string:
|
||||
schema.type = Type.array
|
||||
schema.items = {
|
||||
type: Type.string,
|
||||
}
|
||||
break
|
||||
case ArrayType.number:
|
||||
schema.type = Type.array
|
||||
schema.items = {
|
||||
type: Type.number,
|
||||
}
|
||||
break
|
||||
// case ArrayType.boolean:
|
||||
// schema.type = Type.array
|
||||
// schema.items = {
|
||||
// type: Type.boolean,
|
||||
// }
|
||||
// break
|
||||
case ArrayType.object:
|
||||
schema.type = Type.array
|
||||
schema.items = {
|
||||
type: Type.object,
|
||||
properties: {},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
}
|
||||
break
|
||||
default:
|
||||
schema.type = newType as Type
|
||||
}
|
||||
}
|
||||
|
||||
// other options change
|
||||
schema.description = fields.description
|
||||
schema.enum = fields.enum
|
||||
}
|
||||
})
|
||||
if (samePropertyNameError) return
|
||||
onChange(newSchema)
|
||||
emit('fieldChangeSuccess')
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { FC } from 'react'
|
||||
import type { SchemaRoot } from '../../../types'
|
||||
import SchemaNode from './schema-node'
|
||||
import { useSchemaNodeOperations } from './hooks'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
export type VisualEditorProps = {
|
||||
className?: string
|
||||
schema: SchemaRoot
|
||||
rootName?: string
|
||||
readOnly?: boolean
|
||||
onChange?: (schema: SchemaRoot) => void
|
||||
}
|
||||
|
||||
const VisualEditor: FC<VisualEditorProps> = (props) => {
|
||||
const { className, schema, readOnly } = props
|
||||
useSchemaNodeOperations(props)
|
||||
|
||||
return (
|
||||
<div className={cn('h-full overflow-auto rounded-xl bg-background-section-burn p-1 pl-2', className)}>
|
||||
<SchemaNode
|
||||
name={props.rootName || 'structured_output'}
|
||||
schema={schema}
|
||||
required={false}
|
||||
path={[]}
|
||||
depth={0}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VisualEditor
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { FC } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { type Field, Type } from '../../../types'
|
||||
import classNames from '@/utils/classnames'
|
||||
import { RiArrowDropDownLine, RiArrowDropRightLine } from '@remixicon/react'
|
||||
import { getFieldType, getHasChildren } from '../../../utils'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import EditCard from './edit-card'
|
||||
import Card from './card'
|
||||
import { useVisualEditorStore } from './store'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import AddField from './add-field'
|
||||
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
|
||||
|
||||
type SchemaNodeProps = {
|
||||
name: string
|
||||
required: boolean
|
||||
schema: Field
|
||||
path: string[]
|
||||
parentPath?: string[]
|
||||
depth: number
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
// Support 10 levels of indentation
|
||||
const indentPadding: Record<number, string> = {
|
||||
0: 'pl-0',
|
||||
1: 'pl-[20px]',
|
||||
2: 'pl-[40px]',
|
||||
3: 'pl-[60px]',
|
||||
4: 'pl-[80px]',
|
||||
5: 'pl-[100px]',
|
||||
6: 'pl-[120px]',
|
||||
7: 'pl-[140px]',
|
||||
8: 'pl-[160px]',
|
||||
9: 'pl-[180px]',
|
||||
10: 'pl-[200px]',
|
||||
}
|
||||
|
||||
const indentLeft: Record<number, string> = {
|
||||
0: 'left-0',
|
||||
1: 'left-[20px]',
|
||||
2: 'left-[40px]',
|
||||
3: 'left-[60px]',
|
||||
4: 'left-[80px]',
|
||||
5: 'left-[100px]',
|
||||
6: 'left-[120px]',
|
||||
7: 'left-[140px]',
|
||||
8: 'left-[160px]',
|
||||
9: 'left-[180px]',
|
||||
10: 'left-[200px]',
|
||||
}
|
||||
|
||||
const SchemaNode: FC<SchemaNodeProps> = ({
|
||||
name,
|
||||
required,
|
||||
schema,
|
||||
path,
|
||||
parentPath,
|
||||
depth,
|
||||
readOnly,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const hoveringProperty = useVisualEditorStore(state => state.hoveringProperty)
|
||||
const setHoveringProperty = useVisualEditorStore(state => state.setHoveringProperty)
|
||||
const isAddingNewField = useVisualEditorStore(state => state.isAddingNewField)
|
||||
const advancedEditing = useVisualEditorStore(state => state.advancedEditing)
|
||||
|
||||
const { run: setHoveringPropertyDebounced } = useDebounceFn((path: string | null) => {
|
||||
setHoveringProperty(path)
|
||||
}, { wait: 50 })
|
||||
|
||||
const hasChildren = useMemo(() => getHasChildren(schema), [schema])
|
||||
const type = useMemo(() => getFieldType(schema), [schema])
|
||||
const isHovering = hoveringProperty === path.join('.')
|
||||
|
||||
const handleExpand = () => {
|
||||
setIsExpanded(!isExpanded)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if(readOnly) return
|
||||
if (advancedEditing || isAddingNewField) return
|
||||
setHoveringPropertyDebounced(path.join('.'))
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if(readOnly) return
|
||||
if (advancedEditing || isAddingNewField) return
|
||||
setHoveringPropertyDebounced(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<div className={classNames('relative z-10', indentPadding[depth])}>
|
||||
{depth > 0 && hasChildren && (
|
||||
<div className={classNames(
|
||||
'absolute top-0 z-10 flex h-7 w-5 items-center bg-background-section-burn px-0.5',
|
||||
indentLeft[depth - 1],
|
||||
)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExpand}
|
||||
className='py-0.5 text-text-tertiary hover:text-text-accent'
|
||||
>
|
||||
{
|
||||
isExpanded
|
||||
? <RiArrowDropDownLine className='h-4 w-4' />
|
||||
: <RiArrowDropRightLine className='h-4 w-4' />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{(isHovering && depth > 0) ? (
|
||||
<EditCard
|
||||
fields={{
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
description: schema.description,
|
||||
enum: schema.enum,
|
||||
}}
|
||||
path={path}
|
||||
parentPath={parentPath!}
|
||||
depth={depth}
|
||||
/>
|
||||
) : (
|
||||
<Card
|
||||
name={name}
|
||||
type={type}
|
||||
required={required}
|
||||
description={schema.description}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={classNames(
|
||||
'absolute z-0 flex w-5 justify-center',
|
||||
schema.description ? 'top-12 h-[calc(100%-3rem)]' : 'top-7 h-[calc(100%-1.75rem)]',
|
||||
indentLeft[depth],
|
||||
)}>
|
||||
<Divider
|
||||
type='vertical'
|
||||
className={classNames('mx-0', isHovering ? 'bg-divider-deep' : 'bg-divider-subtle')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isExpanded && hasChildren && depth < JSON_SCHEMA_MAX_DEPTH && (
|
||||
<>
|
||||
{schema.type === Type.object && schema.properties && (
|
||||
Object.entries(schema.properties).map(([key, childSchema]) => (
|
||||
<SchemaNode
|
||||
key={key}
|
||||
name={key}
|
||||
required={!!schema.required?.includes(key)}
|
||||
schema={childSchema}
|
||||
path={[...path, 'properties', key]}
|
||||
parentPath={path}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{schema.type === Type.array
|
||||
&& schema.items
|
||||
&& schema.items.type === Type.object
|
||||
&& schema.items.properties
|
||||
&& (
|
||||
Object.entries(schema.items.properties).map(([key, childSchema]) => (
|
||||
<SchemaNode
|
||||
key={key}
|
||||
name={key}
|
||||
required={!!schema.items?.required?.includes(key)}
|
||||
schema={childSchema}
|
||||
path={[...path, 'items', 'properties', key]}
|
||||
parentPath={path}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{
|
||||
!readOnly && depth === 0 && !isAddingNewField && (
|
||||
<AddField />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SchemaNode)
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useContext } from 'react'
|
||||
import { createStore, useStore } from 'zustand'
|
||||
import type { SchemaRoot } from '../../../types'
|
||||
import { VisualEditorContext } from './context'
|
||||
|
||||
type VisualEditorStore = {
|
||||
hoveringProperty: string | null
|
||||
setHoveringProperty: (propertyPath: string | null) => void
|
||||
isAddingNewField: boolean
|
||||
setIsAddingNewField: (isAdding: boolean) => void
|
||||
advancedEditing: boolean
|
||||
setAdvancedEditing: (isEditing: boolean) => void
|
||||
backupSchema: SchemaRoot | null
|
||||
setBackupSchema: (schema: SchemaRoot | null) => void
|
||||
}
|
||||
|
||||
export const createVisualEditorStore = () => createStore<VisualEditorStore>(set => ({
|
||||
hoveringProperty: null,
|
||||
setHoveringProperty: (propertyPath: string | null) => set({ hoveringProperty: propertyPath }),
|
||||
isAddingNewField: false,
|
||||
setIsAddingNewField: (isAdding: boolean) => set({ isAddingNewField: isAdding }),
|
||||
advancedEditing: false,
|
||||
setAdvancedEditing: (isEditing: boolean) => set({ advancedEditing: isEditing }),
|
||||
backupSchema: null,
|
||||
setBackupSchema: (schema: SchemaRoot | null) => set({ backupSchema: schema }),
|
||||
}))
|
||||
|
||||
export const useVisualEditorStore = <T>(selector: (state: VisualEditorStore) => T): T => {
|
||||
const store = useContext(VisualEditorContext)
|
||||
if (!store)
|
||||
throw new Error('Missing VisualEditorContext.Provider in the tree')
|
||||
|
||||
return useStore(store, selector)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import cn from 'classnames'
|
||||
import { Generator } from '@/app/components/base/icons/src/vender/other'
|
||||
import { ActionButton } from '@/app/components/base/action-button'
|
||||
import GetAutomaticResModal from '@/app/components/app/configuration/config/automatic/get-automatic-res'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import type { GenRes } from '@/service/debug'
|
||||
import type { ModelConfig } from '@/app/components/workflow/types'
|
||||
import { useHooksStore } from '../../../hooks-store'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
onGenerated?: (prompt: string) => void
|
||||
modelConfig?: ModelConfig
|
||||
nodeId: string
|
||||
editorId?: string
|
||||
currentPrompt?: string
|
||||
}
|
||||
|
||||
const PromptGeneratorBtn: FC<Props> = ({
|
||||
className,
|
||||
onGenerated,
|
||||
nodeId,
|
||||
editorId,
|
||||
currentPrompt,
|
||||
}) => {
|
||||
const [showAutomatic, { setTrue: showAutomaticTrue, setFalse: showAutomaticFalse }] = useBoolean(false)
|
||||
const handleAutomaticRes = useCallback((res: GenRes) => {
|
||||
onGenerated?.(res.modified)
|
||||
showAutomaticFalse()
|
||||
}, [onGenerated, showAutomaticFalse])
|
||||
const configsMap = useHooksStore(s => s.configsMap)
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<ActionButton
|
||||
className='hover:bg-[#155EFF]/8'
|
||||
onClick={showAutomaticTrue}>
|
||||
<Generator className='h-4 w-4 text-primary-600' />
|
||||
</ActionButton>
|
||||
{showAutomatic && (
|
||||
<GetAutomaticResModal
|
||||
mode={AppModeEnum.CHAT}
|
||||
isShow={showAutomatic}
|
||||
onClose={showAutomaticFalse}
|
||||
onFinished={handleAutomaticRes}
|
||||
flowId={configsMap?.flowId || ''}
|
||||
nodeId={nodeId}
|
||||
editorId={editorId}
|
||||
currentPrompt={currentPrompt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(PromptGeneratorBtn)
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Field from '@/app/components/workflow/nodes/_base/components/field'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
|
||||
type ReasoningFormatConfigProps = {
|
||||
value?: 'tagged' | 'separated'
|
||||
onChange: (value: 'tagged' | 'separated') => void
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const ReasoningFormatConfig: FC<ReasoningFormatConfigProps> = ({
|
||||
value = 'tagged',
|
||||
onChange,
|
||||
readonly = false,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Field
|
||||
title={t('workflow.nodes.llm.reasoningFormat.title')}
|
||||
tooltip={t('workflow.nodes.llm.reasoningFormat.tooltip')}
|
||||
operations={
|
||||
// ON = separated, OFF = tagged
|
||||
<Switch
|
||||
defaultValue={value === 'separated'}
|
||||
onChange={enabled => onChange(enabled ? 'separated' : 'tagged')}
|
||||
size='md'
|
||||
disabled={readonly}
|
||||
key={value}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReasoningFormatConfig
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
|
||||
import { Resolution } from '@/types/app'
|
||||
|
||||
const i18nPrefix = 'workflow.nodes.llm'
|
||||
|
||||
type Props = {
|
||||
value: Resolution
|
||||
onChange: (value: Resolution) => void
|
||||
}
|
||||
|
||||
const ResolutionPicker: FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleOnChange = useCallback((value: Resolution) => {
|
||||
return () => {
|
||||
onChange(value)
|
||||
}
|
||||
}, [onChange])
|
||||
return (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='mr-2 text-xs font-medium uppercase text-text-secondary'>{t(`${i18nPrefix}.resolution.name`)}</div>
|
||||
<div className='flex items-center space-x-1'>
|
||||
<OptionCard
|
||||
title={t(`${i18nPrefix}.resolution.high`)}
|
||||
onSelect={handleOnChange(Resolution.high)}
|
||||
selected={value === Resolution.high}
|
||||
/>
|
||||
<OptionCard
|
||||
title={t(`${i18nPrefix}.resolution.low`)}
|
||||
onSelect={handleOnChange(Resolution.low)}
|
||||
selected={value === Resolution.low}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(ResolutionPicker)
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { RiEditLine } from '@remixicon/react'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { type SchemaRoot, type StructuredOutput, Type } from '../types'
|
||||
import ShowPanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import JsonSchemaConfigModal from './json-schema-config-modal'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
value?: StructuredOutput
|
||||
onChange: (value: StructuredOutput) => void,
|
||||
}
|
||||
|
||||
const StructureOutput: FC<Props> = ({
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [showConfig, {
|
||||
setTrue: showConfigModal,
|
||||
setFalse: hideConfigModal,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const handleChange = useCallback((value: SchemaRoot) => {
|
||||
onChange({
|
||||
schema: value,
|
||||
})
|
||||
}, [onChange])
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<div className='flex justify-between'>
|
||||
<div className='flex items-center leading-[18px]'>
|
||||
<div className='code-sm-semibold text-text-secondary'>structured_output</div>
|
||||
<div className='system-xs-regular ml-2 text-text-tertiary'>object</div>
|
||||
</div>
|
||||
<Button
|
||||
size='small'
|
||||
variant='secondary'
|
||||
className='flex'
|
||||
onClick={showConfigModal}
|
||||
>
|
||||
<RiEditLine className='mr-1 size-3.5' />
|
||||
<div className='system-xs-medium text-components-button-secondary-text'>{t('app.structOutput.configure')}</div>
|
||||
</Button>
|
||||
</div>
|
||||
{(value?.schema && value.schema.properties && Object.keys(value.schema.properties).length > 0) ? (
|
||||
<ShowPanel
|
||||
payload={value}
|
||||
/>) : (
|
||||
<div className='system-xs-regular mt-1.5 flex h-10 cursor-pointer items-center justify-center rounded-[10px] bg-background-section text-text-tertiary' onClick={showConfigModal}>{t('app.structOutput.notConfiguredTip')}</div>
|
||||
)}
|
||||
|
||||
{showConfig && (
|
||||
<JsonSchemaConfigModal
|
||||
isShow
|
||||
defaultSchema={(value?.schema || {
|
||||
type: Type.object,
|
||||
properties: {},
|
||||
required: [],
|
||||
additionalProperties: false,
|
||||
}) as any} // wait for types change
|
||||
onSave={handleChange as any} // wait for types change
|
||||
onClose={hideConfigModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(StructureOutput)
|
||||
112
dify/web/app/components/workflow/nodes/llm/default.ts
Normal file
112
dify/web/app/components/workflow/nodes/llm/default.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// import { RETRIEVAL_OUTPUT_STRUCT } from '../../constants'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { BlockEnum, EditionType } from '../../types'
|
||||
import { type NodeDefault, type PromptItem, PromptRole } from '../../types'
|
||||
import type { LLMNodeType } from './types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
|
||||
const RETRIEVAL_OUTPUT_STRUCT = `{
|
||||
"content": "",
|
||||
"title": "",
|
||||
"url": "",
|
||||
"icon": "",
|
||||
"metadata": {
|
||||
"dataset_id": "",
|
||||
"dataset_name": "",
|
||||
"document_id": [],
|
||||
"document_name": "",
|
||||
"document_data_source_type": "",
|
||||
"segment_id": "",
|
||||
"segment_position": "",
|
||||
"segment_word_count": "",
|
||||
"segment_hit_count": "",
|
||||
"segment_index_node_hash": "",
|
||||
"score": ""
|
||||
}
|
||||
}`
|
||||
|
||||
const i18nPrefix = 'workflow.errorMsg'
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
sort: 1,
|
||||
type: BlockEnum.LLM,
|
||||
})
|
||||
const nodeDefault: NodeDefault<LLMNodeType> = {
|
||||
metaData,
|
||||
defaultValue: {
|
||||
model: {
|
||||
provider: '',
|
||||
name: '',
|
||||
mode: AppModeEnum.CHAT,
|
||||
completion_params: {
|
||||
temperature: 0.7,
|
||||
},
|
||||
},
|
||||
prompt_template: [{
|
||||
role: PromptRole.system,
|
||||
text: '',
|
||||
}],
|
||||
context: {
|
||||
enabled: false,
|
||||
variable_selector: [],
|
||||
},
|
||||
vision: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
defaultRunInputData: {
|
||||
'#context#': [RETRIEVAL_OUTPUT_STRUCT],
|
||||
'#files#': [],
|
||||
},
|
||||
checkValid(payload: LLMNodeType, t: any) {
|
||||
let errorMessages = ''
|
||||
if (!errorMessages && !payload.model.provider)
|
||||
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.model`) })
|
||||
|
||||
if (!errorMessages && !payload.memory) {
|
||||
const isChatModel = payload.model.mode === AppModeEnum.CHAT
|
||||
const isPromptEmpty = isChatModel
|
||||
? !(payload.prompt_template as PromptItem[]).some((t) => {
|
||||
if (t.edition_type === EditionType.jinja2)
|
||||
return t.jinja2_text !== ''
|
||||
|
||||
return t.text !== ''
|
||||
})
|
||||
: ((payload.prompt_template as PromptItem).edition_type === EditionType.jinja2 ? (payload.prompt_template as PromptItem).jinja2_text === '' : (payload.prompt_template as PromptItem).text === '')
|
||||
if (isPromptEmpty)
|
||||
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t('workflow.nodes.llm.prompt') })
|
||||
}
|
||||
|
||||
if (!errorMessages && !!payload.memory) {
|
||||
const isChatModel = payload.model.mode === AppModeEnum.CHAT
|
||||
// payload.memory.query_prompt_template not pass is default: {{#sys.query#}}
|
||||
if (isChatModel && !!payload.memory.query_prompt_template && !payload.memory.query_prompt_template.includes('{{#sys.query#}}'))
|
||||
errorMessages = t('workflow.nodes.llm.sysQueryInUser')
|
||||
}
|
||||
|
||||
if (!errorMessages) {
|
||||
const isChatModel = payload.model.mode === AppModeEnum.CHAT
|
||||
const isShowVars = (() => {
|
||||
if (isChatModel)
|
||||
return (payload.prompt_template as PromptItem[]).some(item => item.edition_type === EditionType.jinja2)
|
||||
return (payload.prompt_template as PromptItem).edition_type === EditionType.jinja2
|
||||
})()
|
||||
if (isShowVars && payload.prompt_config?.jinja2_variables) {
|
||||
payload.prompt_config?.jinja2_variables.forEach((i) => {
|
||||
if (!errorMessages && !i.variable)
|
||||
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.variable`) })
|
||||
if (!errorMessages && !i.value_selector.length)
|
||||
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.variableValue`) })
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!errorMessages && payload.vision?.enabled && !payload.vision.configs?.variable_selector?.length)
|
||||
errorMessages = t(`${i18nPrefix}.fieldRequired`, { field: t(`${i18nPrefix}.fields.visionVariable`) })
|
||||
return {
|
||||
isValid: !errorMessages,
|
||||
errorMessage: errorMessages,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default nodeDefault
|
||||
36
dify/web/app/components/workflow/nodes/llm/node.tsx
Normal file
36
dify/web/app/components/workflow/nodes/llm/node.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { LLMNodeType } from './types'
|
||||
import {
|
||||
useTextGenerationCurrentProviderAndModelAndModelList,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
|
||||
import type { NodeProps } from '@/app/components/workflow/types'
|
||||
|
||||
const Node: FC<NodeProps<LLMNodeType>> = ({
|
||||
data,
|
||||
}) => {
|
||||
const { provider, name: modelId } = data.model || {}
|
||||
const {
|
||||
textGenerationModelList,
|
||||
} = useTextGenerationCurrentProviderAndModelAndModelList()
|
||||
const hasSetModel = provider && modelId
|
||||
|
||||
if (!hasSetModel)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className='mb-1 px-3 py-1'>
|
||||
{hasSetModel && (
|
||||
<ModelSelector
|
||||
defaultModel={{ provider, model: modelId }}
|
||||
modelList={textGenerationModelList}
|
||||
triggerClassName='!h-6 !rounded-md'
|
||||
readonly
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Node)
|
||||
320
dify/web/app/components/workflow/nodes/llm/panel.tsx
Normal file
320
dify/web/app/components/workflow/nodes/llm/panel.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MemoryConfig from '../_base/components/memory-config'
|
||||
import VarReferencePicker from '../_base/components/variable/var-reference-picker'
|
||||
import ConfigVision from '../_base/components/config-vision'
|
||||
import useConfig from './use-config'
|
||||
import type { LLMNodeType } from './types'
|
||||
import ConfigPrompt from './components/config-prompt'
|
||||
import VarList from '@/app/components/workflow/nodes/_base/components/variable/var-list'
|
||||
import AddButton2 from '@/app/components/base/button/add-button'
|
||||
import Field from '@/app/components/workflow/nodes/_base/components/field'
|
||||
import Split from '@/app/components/workflow/nodes/_base/components/split'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
|
||||
import type { NodePanelProps } from '@/app/components/workflow/types'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
|
||||
import StructureOutput from './components/structure-output'
|
||||
import ReasoningFormatConfig from './components/reasoning-format-config'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import { RiAlertFill, RiQuestionLine } from '@remixicon/react'
|
||||
import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
const i18nPrefix = 'workflow.nodes.llm'
|
||||
|
||||
const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
id,
|
||||
data,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
readOnly,
|
||||
inputs,
|
||||
isChatModel,
|
||||
isChatMode,
|
||||
isCompletionModel,
|
||||
shouldShowContextTip,
|
||||
isVisionModel,
|
||||
handleModelChanged,
|
||||
hasSetBlockStatus,
|
||||
handleCompletionParamsChange,
|
||||
handleContextVarChange,
|
||||
filterInputVar,
|
||||
filterVar,
|
||||
availableVars,
|
||||
availableNodesWithParent,
|
||||
isShowVars,
|
||||
handlePromptChange,
|
||||
handleAddEmptyVariable,
|
||||
handleAddVariable,
|
||||
handleVarListChange,
|
||||
handleVarNameChange,
|
||||
handleSyeQueryChange,
|
||||
handleMemoryChange,
|
||||
handleVisionResolutionEnabledChange,
|
||||
handleVisionResolutionChange,
|
||||
isModelSupportStructuredOutput,
|
||||
structuredOutputCollapsed,
|
||||
setStructuredOutputCollapsed,
|
||||
handleStructureOutputEnableChange,
|
||||
handleStructureOutputChange,
|
||||
filterJinja2InputVar,
|
||||
handleReasoningFormatChange,
|
||||
} = useConfig(id, data)
|
||||
|
||||
const model = inputs.model
|
||||
|
||||
const handleModelChange = useCallback((model: {
|
||||
provider: string
|
||||
modelId: string
|
||||
mode?: string
|
||||
}) => {
|
||||
(async () => {
|
||||
try {
|
||||
const { params: filtered, removedDetails } = await fetchAndMergeValidCompletionParams(
|
||||
model.provider,
|
||||
model.modelId,
|
||||
inputs.model.completion_params,
|
||||
true,
|
||||
)
|
||||
const keys = Object.keys(removedDetails)
|
||||
if (keys.length)
|
||||
Toast.notify({ type: 'warning', message: `${t('common.modelProvider.parametersInvalidRemoved')}: ${keys.map(k => `${k} (${removedDetails[k]})`).join(', ')}` })
|
||||
handleCompletionParamsChange(filtered)
|
||||
}
|
||||
catch {
|
||||
Toast.notify({ type: 'error', message: t('common.error') })
|
||||
handleCompletionParamsChange({})
|
||||
}
|
||||
finally {
|
||||
handleModelChanged(model)
|
||||
}
|
||||
})()
|
||||
}, [inputs.model.completion_params])
|
||||
return (
|
||||
<div className='mt-2'>
|
||||
<div className='space-y-4 px-4 pb-4'>
|
||||
<Field
|
||||
title={t(`${i18nPrefix}.model`)}
|
||||
required
|
||||
>
|
||||
<ModelParameterModal
|
||||
popupClassName='!w-[387px]'
|
||||
isInWorkflow
|
||||
isAdvancedMode={true}
|
||||
provider={model?.provider}
|
||||
completionParams={model?.completion_params}
|
||||
modelId={model?.name}
|
||||
setModel={handleModelChange}
|
||||
onCompletionParamsChange={handleCompletionParamsChange}
|
||||
hideDebugWithMultipleModel
|
||||
debugWithMultipleModel={false}
|
||||
readonly={readOnly}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* knowledge */}
|
||||
<Field
|
||||
title={t(`${i18nPrefix}.context`)}
|
||||
tooltip={t(`${i18nPrefix}.contextTooltip`)!}
|
||||
>
|
||||
<>
|
||||
<VarReferencePicker
|
||||
readonly={readOnly}
|
||||
nodeId={id}
|
||||
isShowNodeName
|
||||
value={inputs.context?.variable_selector || []}
|
||||
onChange={handleContextVarChange}
|
||||
filterVar={filterVar}
|
||||
/>
|
||||
{shouldShowContextTip && (
|
||||
<div className='text-xs font-normal leading-[18px] text-[#DC6803]'>{t(`${i18nPrefix}.notSetContextInPromptTip`)}</div>
|
||||
)}
|
||||
</>
|
||||
</Field>
|
||||
|
||||
{/* Prompt */}
|
||||
{model.name && (
|
||||
<ConfigPrompt
|
||||
readOnly={readOnly}
|
||||
nodeId={id}
|
||||
filterVar={isShowVars ? filterJinja2InputVar : filterInputVar}
|
||||
isChatModel={isChatModel}
|
||||
isChatApp={isChatMode}
|
||||
isShowContext
|
||||
payload={inputs.prompt_template}
|
||||
onChange={handlePromptChange}
|
||||
hasSetBlockStatus={hasSetBlockStatus}
|
||||
varList={inputs.prompt_config?.jinja2_variables || []}
|
||||
handleAddVariable={handleAddVariable}
|
||||
modelConfig={model}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isShowVars && (
|
||||
<Field
|
||||
title={t('workflow.nodes.templateTransform.inputVars')}
|
||||
operations={
|
||||
!readOnly ? <AddButton2 onClick={handleAddEmptyVariable} /> : undefined
|
||||
}
|
||||
>
|
||||
<VarList
|
||||
nodeId={id}
|
||||
readonly={readOnly}
|
||||
list={inputs.prompt_config?.jinja2_variables || []}
|
||||
onChange={handleVarListChange}
|
||||
onVarNameChange={handleVarNameChange}
|
||||
filterVar={filterJinja2InputVar}
|
||||
isSupportFileVar={false}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Memory put place examples. */}
|
||||
{isChatMode && isChatModel && !!inputs.memory && (
|
||||
<div className='mt-4'>
|
||||
<div className='flex h-8 items-center justify-between rounded-lg bg-components-input-bg-normal pl-3 pr-2'>
|
||||
<div className='flex items-center space-x-1'>
|
||||
<div className='text-xs font-semibold uppercase text-text-secondary'>{t('workflow.nodes.common.memories.title')}</div>
|
||||
<Tooltip
|
||||
popupContent={t('workflow.nodes.common.memories.tip')}
|
||||
triggerClassName='w-4 h-4'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex h-[18px] items-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 text-xs font-semibold uppercase text-text-tertiary'>{t('workflow.nodes.common.memories.builtIn')}</div>
|
||||
</div>
|
||||
{/* Readonly User Query */}
|
||||
<div className='mt-4'>
|
||||
<Editor
|
||||
title={<div className='flex items-center space-x-1'>
|
||||
<div className='text-xs font-semibold uppercase text-text-secondary'>user</div>
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='max-w-[180px]'>{t('workflow.nodes.llm.roleDescription.user')}</div>
|
||||
}
|
||||
triggerClassName='w-4 h-4'
|
||||
/>
|
||||
</div>}
|
||||
value={inputs.memory.query_prompt_template || '{{#sys.query#}}'}
|
||||
onChange={handleSyeQueryChange}
|
||||
readOnly={readOnly}
|
||||
isShowContext={false}
|
||||
isChatApp
|
||||
isChatModel
|
||||
hasSetBlockStatus={hasSetBlockStatus}
|
||||
nodesOutputVars={availableVars}
|
||||
availableNodes={availableNodesWithParent}
|
||||
isSupportFileVar
|
||||
/>
|
||||
|
||||
{inputs.memory.query_prompt_template && !inputs.memory.query_prompt_template.includes('{{#sys.query#}}') && (
|
||||
<div className='text-xs font-normal leading-[18px] text-[#DC6803]'>{t(`${i18nPrefix}.sysQueryInUser`)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Memory */}
|
||||
{isChatMode && (
|
||||
<>
|
||||
<Split />
|
||||
<MemoryConfig
|
||||
readonly={readOnly}
|
||||
config={{ data: inputs.memory }}
|
||||
onChange={handleMemoryChange}
|
||||
canSetRoleName={isCompletionModel}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Vision: GPT4-vision and so on */}
|
||||
<ConfigVision
|
||||
nodeId={id}
|
||||
readOnly={readOnly}
|
||||
isVisionModel={isVisionModel}
|
||||
enabled={inputs.vision?.enabled}
|
||||
onEnabledChange={handleVisionResolutionEnabledChange}
|
||||
config={inputs.vision?.configs}
|
||||
onConfigChange={handleVisionResolutionChange}
|
||||
/>
|
||||
|
||||
{/* Reasoning Format */}
|
||||
<ReasoningFormatConfig
|
||||
// Default to tagged for backward compatibility
|
||||
value={inputs.reasoning_format || 'tagged'}
|
||||
onChange={handleReasoningFormatChange}
|
||||
readonly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
<Split />
|
||||
<OutputVars
|
||||
collapsed={structuredOutputCollapsed}
|
||||
onCollapse={setStructuredOutputCollapsed}
|
||||
operations={
|
||||
<div className='mr-4 flex shrink-0 items-center'>
|
||||
{(!isModelSupportStructuredOutput && !!inputs.structured_output_enabled) && (
|
||||
<Tooltip noDecoration popupContent={
|
||||
<div className='w-[232px] rounded-xl border-[0.5px] border-components-panel-border bg-components-tooltip-bg px-4 py-3.5 shadow-lg backdrop-blur-[5px]'>
|
||||
<div className='title-xs-semi-bold text-text-primary'>{t('app.structOutput.modelNotSupported')}</div>
|
||||
<div className='body-xs-regular mt-1 text-text-secondary'>{t('app.structOutput.modelNotSupportedTip')}</div>
|
||||
</div>
|
||||
}>
|
||||
<div>
|
||||
<RiAlertFill className='mr-1 size-4 text-text-warning-secondary' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className='system-xs-medium-uppercase mr-0.5 text-text-tertiary'>{t('app.structOutput.structured')}</div>
|
||||
<Tooltip popupContent={
|
||||
<div className='max-w-[150px]'>{t('app.structOutput.structuredTip')}</div>
|
||||
}>
|
||||
<div>
|
||||
<RiQuestionLine className='size-3.5 text-text-quaternary' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Switch
|
||||
className='ml-2'
|
||||
defaultValue={!!inputs.structured_output_enabled}
|
||||
onChange={handleStructureOutputEnableChange}
|
||||
size='md'
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<VarItem
|
||||
name='text'
|
||||
type='string'
|
||||
description={t(`${i18nPrefix}.outputVars.output`)}
|
||||
/>
|
||||
<VarItem
|
||||
name='reasoning_content'
|
||||
type='string'
|
||||
description={t(`${i18nPrefix}.outputVars.reasoning_content`)}
|
||||
/>
|
||||
<VarItem
|
||||
name='usage'
|
||||
type='object'
|
||||
description={t(`${i18nPrefix}.outputVars.usage`)}
|
||||
/>
|
||||
{inputs.structured_output_enabled && (
|
||||
<>
|
||||
<Split className='mt-3' />
|
||||
<StructureOutput
|
||||
className='mt-4'
|
||||
value={inputs.structured_output}
|
||||
onChange={handleStructureOutputChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</OutputVars>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Panel)
|
||||
72
dify/web/app/components/workflow/nodes/llm/types.ts
Normal file
72
dify/web/app/components/workflow/nodes/llm/types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { CommonNodeType, Memory, ModelConfig, PromptItem, ValueSelector, Variable, VisionSetting } from '@/app/components/workflow/types'
|
||||
|
||||
export type LLMNodeType = CommonNodeType & {
|
||||
model: ModelConfig
|
||||
prompt_template: PromptItem[] | PromptItem
|
||||
prompt_config?: {
|
||||
jinja2_variables?: Variable[]
|
||||
}
|
||||
memory?: Memory
|
||||
context: {
|
||||
enabled: boolean
|
||||
variable_selector: ValueSelector
|
||||
}
|
||||
vision: {
|
||||
enabled: boolean
|
||||
configs?: VisionSetting
|
||||
}
|
||||
structured_output_enabled?: boolean
|
||||
structured_output?: StructuredOutput
|
||||
reasoning_format?: 'tagged' | 'separated'
|
||||
}
|
||||
|
||||
export enum Type {
|
||||
string = 'string',
|
||||
number = 'number',
|
||||
boolean = 'boolean',
|
||||
object = 'object',
|
||||
array = 'array',
|
||||
arrayString = 'array[string]',
|
||||
arrayNumber = 'array[number]',
|
||||
arrayObject = 'array[object]',
|
||||
file = 'file',
|
||||
enumType = 'enum',
|
||||
}
|
||||
|
||||
export enum ArrayType {
|
||||
string = 'array[string]',
|
||||
number = 'array[number]',
|
||||
boolean = 'array[boolean]',
|
||||
object = 'array[object]',
|
||||
}
|
||||
|
||||
export type TypeWithArray = Type | ArrayType
|
||||
|
||||
type ArrayItemType = Exclude<Type, Type.array>
|
||||
export type ArrayItems = Omit<Field, 'type'> & { type: ArrayItemType }
|
||||
|
||||
export type SchemaEnumType = string[] | number[]
|
||||
|
||||
export type Field = {
|
||||
type: Type
|
||||
properties?: { // Object has properties
|
||||
[key: string]: Field
|
||||
}
|
||||
required?: string[] // Key of required properties in object
|
||||
description?: string
|
||||
items?: ArrayItems // Array has items. Define the item type
|
||||
enum?: SchemaEnumType // Enum values
|
||||
additionalProperties?: false // Required in object by api. Just set false
|
||||
schemaType?: string // an another type defined in backend schemas
|
||||
}
|
||||
|
||||
export type StructuredOutput = {
|
||||
schema: SchemaRoot
|
||||
}
|
||||
|
||||
export type SchemaRoot = {
|
||||
type: Type.object
|
||||
properties: Record<string, Field>
|
||||
required?: string[]
|
||||
additionalProperties: false
|
||||
}
|
||||
374
dify/web/app/components/workflow/nodes/llm/use-config.ts
Normal file
374
dify/web/app/components/workflow/nodes/llm/use-config.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { produce } from 'immer'
|
||||
import { EditionType, VarType } from '../../types'
|
||||
import type { Memory, PromptItem, ValueSelector, Var, Variable } from '../../types'
|
||||
import { useStore } from '../../store'
|
||||
import {
|
||||
useIsChatMode,
|
||||
useNodesReadOnly,
|
||||
} from '../../hooks'
|
||||
import useAvailableVarList from '../_base/hooks/use-available-var-list'
|
||||
import useConfigVision from '../../hooks/use-config-vision'
|
||||
import type { LLMNodeType, StructuredOutput } from './types'
|
||||
import { useModelList, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import {
|
||||
ModelFeatureEnum,
|
||||
ModelTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
|
||||
import { checkHasContextBlock, checkHasHistoryBlock, checkHasQueryBlock } from '@/app/components/base/prompt-editor/constants'
|
||||
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const useConfig = (id: string, payload: LLMNodeType) => {
|
||||
const { nodesReadOnly: readOnly } = useNodesReadOnly()
|
||||
const isChatMode = useIsChatMode()
|
||||
|
||||
const defaultConfig = useStore(s => s.nodesDefaultConfigs)?.[payload.type]
|
||||
const [defaultRolePrefix, setDefaultRolePrefix] = useState<{ user: string; assistant: string }>({ user: '', assistant: '' })
|
||||
const { inputs, setInputs: doSetInputs } = useNodeCrud<LLMNodeType>(id, payload)
|
||||
const inputRef = useRef(inputs)
|
||||
useEffect(() => {
|
||||
inputRef.current = inputs
|
||||
}, [inputs])
|
||||
|
||||
const { deleteNodeInspectorVars } = useInspectVarsCrud()
|
||||
|
||||
const setInputs = useCallback((newInputs: LLMNodeType) => {
|
||||
if (newInputs.memory && !newInputs.memory.role_prefix) {
|
||||
const newPayload = produce(newInputs, (draft) => {
|
||||
draft.memory!.role_prefix = defaultRolePrefix
|
||||
})
|
||||
doSetInputs(newPayload)
|
||||
inputRef.current = newPayload
|
||||
return
|
||||
}
|
||||
doSetInputs(newInputs)
|
||||
inputRef.current = newInputs
|
||||
}, [doSetInputs, defaultRolePrefix])
|
||||
|
||||
// model
|
||||
const model = inputs.model
|
||||
const modelMode = inputs.model?.mode
|
||||
const isChatModel = modelMode === AppModeEnum.CHAT
|
||||
|
||||
const isCompletionModel = !isChatModel
|
||||
|
||||
const hasSetBlockStatus = (() => {
|
||||
const promptTemplate = inputs.prompt_template
|
||||
const hasSetContext = isChatModel ? (promptTemplate as PromptItem[]).some(item => checkHasContextBlock(item.text)) : checkHasContextBlock((promptTemplate as PromptItem).text)
|
||||
if (!isChatMode) {
|
||||
return {
|
||||
history: false,
|
||||
query: false,
|
||||
context: hasSetContext,
|
||||
}
|
||||
}
|
||||
if (isChatModel) {
|
||||
return {
|
||||
history: false,
|
||||
query: (promptTemplate as PromptItem[]).some(item => checkHasQueryBlock(item.text)),
|
||||
context: hasSetContext,
|
||||
}
|
||||
}
|
||||
else {
|
||||
return {
|
||||
history: checkHasHistoryBlock((promptTemplate as PromptItem).text),
|
||||
query: checkHasQueryBlock((promptTemplate as PromptItem).text),
|
||||
context: hasSetContext,
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
const shouldShowContextTip = !hasSetBlockStatus.context && inputs.context.enabled
|
||||
|
||||
const appendDefaultPromptConfig = useCallback((draft: LLMNodeType, defaultConfig: any, passInIsChatMode?: boolean) => {
|
||||
const promptTemplates = defaultConfig.prompt_templates
|
||||
if (passInIsChatMode === undefined ? isChatModel : passInIsChatMode) {
|
||||
draft.prompt_template = promptTemplates.chat_model.prompts
|
||||
}
|
||||
else {
|
||||
draft.prompt_template = promptTemplates.completion_model.prompt
|
||||
|
||||
setDefaultRolePrefix({
|
||||
user: promptTemplates.completion_model.conversation_histories_role.user_prefix,
|
||||
assistant: promptTemplates.completion_model.conversation_histories_role.assistant_prefix,
|
||||
})
|
||||
}
|
||||
}, [isChatModel])
|
||||
useEffect(() => {
|
||||
const isReady = defaultConfig && Object.keys(defaultConfig).length > 0
|
||||
|
||||
if (isReady && !inputs.prompt_template) {
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
appendDefaultPromptConfig(draft, defaultConfig)
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}
|
||||
}, [defaultConfig, isChatModel])
|
||||
|
||||
const [modelChanged, setModelChanged] = useState(false)
|
||||
const {
|
||||
currentProvider,
|
||||
currentModel,
|
||||
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
|
||||
|
||||
const {
|
||||
isVisionModel,
|
||||
handleVisionResolutionEnabledChange,
|
||||
handleVisionResolutionChange,
|
||||
handleModelChanged: handleVisionConfigAfterModelChanged,
|
||||
} = useConfigVision(model, {
|
||||
payload: inputs.vision,
|
||||
onChange: (newPayload) => {
|
||||
const newInputs = produce(inputRef.current, (draft) => {
|
||||
draft.vision = newPayload
|
||||
})
|
||||
setInputs(newInputs)
|
||||
},
|
||||
})
|
||||
|
||||
const handleModelChanged = useCallback((model: { provider: string; modelId: string; mode?: string }) => {
|
||||
const newInputs = produce(inputRef.current, (draft) => {
|
||||
draft.model.provider = model.provider
|
||||
draft.model.name = model.modelId
|
||||
draft.model.mode = model.mode!
|
||||
const isModeChange = model.mode !== inputRef.current.model.mode
|
||||
if (isModeChange && defaultConfig && Object.keys(defaultConfig).length > 0)
|
||||
appendDefaultPromptConfig(draft, defaultConfig, model.mode === AppModeEnum.CHAT)
|
||||
})
|
||||
setInputs(newInputs)
|
||||
setModelChanged(true)
|
||||
}, [setInputs, defaultConfig, appendDefaultPromptConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProvider?.provider && currentModel?.model && !model.provider) {
|
||||
handleModelChanged({
|
||||
provider: currentProvider?.provider,
|
||||
modelId: currentModel?.model,
|
||||
mode: currentModel?.model_properties?.mode as string,
|
||||
})
|
||||
}
|
||||
}, [model.provider, currentProvider, currentModel, handleModelChanged])
|
||||
|
||||
const handleCompletionParamsChange = useCallback((newParams: Record<string, any>) => {
|
||||
const newInputs = produce(inputRef.current, (draft) => {
|
||||
draft.model.completion_params = newParams
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}, [setInputs])
|
||||
|
||||
// change to vision model to set vision enabled, else disabled
|
||||
useEffect(() => {
|
||||
if (!modelChanged)
|
||||
return
|
||||
setModelChanged(false)
|
||||
handleVisionConfigAfterModelChanged()
|
||||
}, [isVisionModel, modelChanged])
|
||||
|
||||
// variables
|
||||
const isShowVars = (() => {
|
||||
if (isChatModel)
|
||||
return (inputs.prompt_template as PromptItem[]).some(item => item.edition_type === EditionType.jinja2)
|
||||
|
||||
return (inputs.prompt_template as PromptItem).edition_type === EditionType.jinja2
|
||||
})()
|
||||
const handleAddEmptyVariable = useCallback(() => {
|
||||
const newInputs = produce(inputRef.current, (draft) => {
|
||||
if (!draft.prompt_config) {
|
||||
draft.prompt_config = {
|
||||
jinja2_variables: [],
|
||||
}
|
||||
}
|
||||
if (!draft.prompt_config.jinja2_variables)
|
||||
draft.prompt_config.jinja2_variables = []
|
||||
|
||||
draft.prompt_config.jinja2_variables.push({
|
||||
variable: '',
|
||||
value_selector: [],
|
||||
})
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}, [setInputs])
|
||||
|
||||
const handleAddVariable = useCallback((payload: Variable) => {
|
||||
const newInputs = produce(inputRef.current, (draft) => {
|
||||
if (!draft.prompt_config) {
|
||||
draft.prompt_config = {
|
||||
jinja2_variables: [],
|
||||
}
|
||||
}
|
||||
if (!draft.prompt_config.jinja2_variables)
|
||||
draft.prompt_config.jinja2_variables = []
|
||||
|
||||
draft.prompt_config.jinja2_variables.push(payload)
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}, [setInputs])
|
||||
|
||||
const handleVarListChange = useCallback((newList: Variable[]) => {
|
||||
const newInputs = produce(inputRef.current, (draft) => {
|
||||
if (!draft.prompt_config) {
|
||||
draft.prompt_config = {
|
||||
jinja2_variables: [],
|
||||
}
|
||||
}
|
||||
if (!draft.prompt_config.jinja2_variables)
|
||||
draft.prompt_config.jinja2_variables = []
|
||||
|
||||
draft.prompt_config.jinja2_variables = newList
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}, [setInputs])
|
||||
|
||||
const handleVarNameChange = useCallback((oldName: string, newName: string) => {
|
||||
const newInputs = produce(inputRef.current, (draft) => {
|
||||
if (isChatModel) {
|
||||
const promptTemplate = draft.prompt_template as PromptItem[]
|
||||
promptTemplate.filter(item => item.edition_type === EditionType.jinja2).forEach((item) => {
|
||||
item.jinja2_text = (item.jinja2_text || '').replaceAll(`{{ ${oldName} }}`, `{{ ${newName} }}`)
|
||||
})
|
||||
}
|
||||
else {
|
||||
if ((draft.prompt_template as PromptItem).edition_type !== EditionType.jinja2)
|
||||
return
|
||||
|
||||
const promptTemplate = draft.prompt_template as PromptItem
|
||||
promptTemplate.jinja2_text = (promptTemplate.jinja2_text || '').replaceAll(`{{ ${oldName} }}`, `{{ ${newName} }}`)
|
||||
}
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}, [isChatModel, setInputs])
|
||||
|
||||
// context
|
||||
const handleContextVarChange = useCallback((newVar: ValueSelector | string) => {
|
||||
const newInputs = produce(inputRef.current, (draft) => {
|
||||
draft.context.variable_selector = newVar as ValueSelector || []
|
||||
draft.context.enabled = !!(newVar && newVar.length > 0)
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}, [setInputs])
|
||||
|
||||
const handlePromptChange = useCallback((newPrompt: PromptItem[] | PromptItem) => {
|
||||
const newInputs = produce(inputRef.current, (draft) => {
|
||||
draft.prompt_template = newPrompt
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}, [setInputs])
|
||||
|
||||
const handleMemoryChange = useCallback((newMemory?: Memory) => {
|
||||
const newInputs = produce(inputRef.current, (draft) => {
|
||||
draft.memory = newMemory
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}, [setInputs])
|
||||
|
||||
const handleSyeQueryChange = useCallback((newQuery: string) => {
|
||||
const newInputs = produce(inputRef.current, (draft) => {
|
||||
if (!draft.memory) {
|
||||
draft.memory = {
|
||||
window: {
|
||||
enabled: false,
|
||||
size: 10,
|
||||
},
|
||||
query_prompt_template: newQuery,
|
||||
}
|
||||
}
|
||||
else {
|
||||
draft.memory.query_prompt_template = newQuery
|
||||
}
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}, [setInputs])
|
||||
|
||||
// structure output
|
||||
const { data: modelList } = useModelList(ModelTypeEnum.textGeneration)
|
||||
const isModelSupportStructuredOutput = modelList
|
||||
?.find(provideItem => provideItem.provider === model?.provider)
|
||||
?.models.find(modelItem => modelItem.model === model?.name)
|
||||
?.features?.includes(ModelFeatureEnum.StructuredOutput)
|
||||
|
||||
const [structuredOutputCollapsed, setStructuredOutputCollapsed] = useState(true)
|
||||
const handleStructureOutputEnableChange = useCallback((enabled: boolean) => {
|
||||
const newInputs = produce(inputRef.current, (draft) => {
|
||||
draft.structured_output_enabled = enabled
|
||||
})
|
||||
setInputs(newInputs)
|
||||
if (enabled)
|
||||
setStructuredOutputCollapsed(false)
|
||||
deleteNodeInspectorVars(id)
|
||||
}, [setInputs, deleteNodeInspectorVars, id])
|
||||
|
||||
const handleStructureOutputChange = useCallback((newOutput: StructuredOutput) => {
|
||||
const newInputs = produce(inputRef.current, (draft) => {
|
||||
draft.structured_output = newOutput
|
||||
})
|
||||
setInputs(newInputs)
|
||||
deleteNodeInspectorVars(id)
|
||||
}, [setInputs, deleteNodeInspectorVars, id])
|
||||
|
||||
const filterInputVar = useCallback((varPayload: Var) => {
|
||||
return [VarType.number, VarType.string, VarType.secret, VarType.arrayString, VarType.arrayNumber, VarType.file, VarType.arrayFile].includes(varPayload.type)
|
||||
}, [])
|
||||
|
||||
const filterJinja2InputVar = useCallback((varPayload: Var) => {
|
||||
return [VarType.number, VarType.string, VarType.secret, VarType.arrayString, VarType.arrayNumber, VarType.arrayBoolean, VarType.arrayObject, VarType.object, VarType.array, VarType.boolean].includes(varPayload.type)
|
||||
}, [])
|
||||
|
||||
const filterMemoryPromptVar = useCallback((varPayload: Var) => {
|
||||
return [VarType.arrayObject, VarType.array, VarType.number, VarType.string, VarType.secret, VarType.arrayString, VarType.arrayNumber, VarType.file, VarType.arrayFile].includes(varPayload.type)
|
||||
}, [])
|
||||
|
||||
// reasoning format
|
||||
const handleReasoningFormatChange = useCallback((reasoningFormat: 'tagged' | 'separated') => {
|
||||
const newInputs = produce(inputRef.current, (draft) => {
|
||||
draft.reasoning_format = reasoningFormat
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}, [setInputs])
|
||||
|
||||
const {
|
||||
availableVars,
|
||||
availableNodesWithParent,
|
||||
} = useAvailableVarList(id, {
|
||||
onlyLeafNodeVar: false,
|
||||
filterVar: filterMemoryPromptVar,
|
||||
})
|
||||
|
||||
return {
|
||||
readOnly,
|
||||
isChatMode,
|
||||
inputs,
|
||||
isChatModel,
|
||||
isCompletionModel,
|
||||
hasSetBlockStatus,
|
||||
shouldShowContextTip,
|
||||
isVisionModel,
|
||||
handleModelChanged,
|
||||
handleCompletionParamsChange,
|
||||
isShowVars,
|
||||
handleVarListChange,
|
||||
handleVarNameChange,
|
||||
handleAddVariable,
|
||||
handleAddEmptyVariable,
|
||||
handleContextVarChange,
|
||||
filterInputVar,
|
||||
filterVar: filterMemoryPromptVar,
|
||||
availableVars,
|
||||
availableNodesWithParent,
|
||||
handlePromptChange,
|
||||
handleMemoryChange,
|
||||
handleSyeQueryChange,
|
||||
handleVisionResolutionEnabledChange,
|
||||
handleVisionResolutionChange,
|
||||
isModelSupportStructuredOutput,
|
||||
handleStructureOutputChange,
|
||||
structuredOutputCollapsed,
|
||||
setStructuredOutputCollapsed,
|
||||
handleStructureOutputEnableChange,
|
||||
filterJinja2InputVar,
|
||||
handleReasoningFormatChange,
|
||||
}
|
||||
}
|
||||
|
||||
export default useConfig
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { RefObject } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/components/before-run-form/form'
|
||||
import type { InputVar, PromptItem, Var, Variable } from '@/app/components/workflow/types'
|
||||
import { InputVarType, VarType } from '@/app/components/workflow/types'
|
||||
import type { LLMNodeType } from './types'
|
||||
import { EditionType } from '../../types'
|
||||
import useNodeCrud from '../_base/hooks/use-node-crud'
|
||||
import { useIsChatMode } from '../../hooks'
|
||||
import { useCallback } from 'react'
|
||||
import useConfigVision from '../../hooks/use-config-vision'
|
||||
import { noop } from 'lodash-es'
|
||||
import { findVariableWhenOnLLMVision } from '../utils'
|
||||
import useAvailableVarList from '../_base/hooks/use-available-var-list'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const i18nPrefix = 'workflow.nodes.llm'
|
||||
type Params = {
|
||||
id: string,
|
||||
payload: LLMNodeType,
|
||||
runInputData: Record<string, any>
|
||||
runInputDataRef: RefObject<Record<string, any>>
|
||||
getInputVars: (textList: string[]) => InputVar[]
|
||||
setRunInputData: (data: Record<string, any>) => void
|
||||
toVarInputs: (variables: Variable[]) => InputVar[]
|
||||
}
|
||||
const useSingleRunFormParams = ({
|
||||
id,
|
||||
payload,
|
||||
runInputData,
|
||||
runInputDataRef,
|
||||
getInputVars,
|
||||
setRunInputData,
|
||||
toVarInputs,
|
||||
}: Params) => {
|
||||
const { t } = useTranslation()
|
||||
const { inputs } = useNodeCrud<LLMNodeType>(id, payload)
|
||||
const getVarInputs = getInputVars
|
||||
const isChatMode = useIsChatMode()
|
||||
|
||||
const contexts = runInputData['#context#']
|
||||
const setContexts = useCallback((newContexts: string[]) => {
|
||||
setRunInputData?.({
|
||||
...runInputDataRef.current,
|
||||
'#context#': newContexts,
|
||||
})
|
||||
}, [runInputDataRef, setRunInputData])
|
||||
|
||||
const visionFiles = runInputData['#files#']
|
||||
const setVisionFiles = useCallback((newFiles: any[]) => {
|
||||
setRunInputData?.({
|
||||
...runInputDataRef.current,
|
||||
'#files#': newFiles,
|
||||
})
|
||||
}, [runInputDataRef, setRunInputData])
|
||||
|
||||
// model
|
||||
const model = inputs.model
|
||||
const modelMode = inputs.model?.mode
|
||||
const isChatModel = modelMode === AppModeEnum.CHAT
|
||||
const {
|
||||
isVisionModel,
|
||||
} = useConfigVision(model, {
|
||||
payload: inputs.vision,
|
||||
onChange: noop,
|
||||
})
|
||||
|
||||
const isShowVars = (() => {
|
||||
if (isChatModel)
|
||||
return (inputs.prompt_template as PromptItem[]).some(item => item.edition_type === EditionType.jinja2)
|
||||
|
||||
return (inputs.prompt_template as PromptItem).edition_type === EditionType.jinja2
|
||||
})()
|
||||
|
||||
const filterMemoryPromptVar = useCallback((varPayload: Var) => {
|
||||
return [VarType.arrayObject, VarType.array, VarType.number, VarType.string, VarType.secret, VarType.arrayString, VarType.arrayNumber, VarType.file, VarType.arrayFile].includes(varPayload.type)
|
||||
}, [])
|
||||
|
||||
const {
|
||||
availableVars,
|
||||
} = useAvailableVarList(id, {
|
||||
onlyLeafNodeVar: false,
|
||||
filterVar: filterMemoryPromptVar,
|
||||
})
|
||||
|
||||
const allVarStrArr = (() => {
|
||||
const arr = isChatModel ? (inputs.prompt_template as PromptItem[]).filter(item => item.edition_type !== EditionType.jinja2).map(item => item.text) : [(inputs.prompt_template as PromptItem).text]
|
||||
if (isChatMode && isChatModel && !!inputs.memory) {
|
||||
arr.push('{{#sys.query#}}')
|
||||
arr.push(inputs.memory.query_prompt_template)
|
||||
}
|
||||
|
||||
return arr
|
||||
})()
|
||||
const varInputs = (() => {
|
||||
const vars = getVarInputs(allVarStrArr) || []
|
||||
if (isShowVars)
|
||||
return [...vars, ...(toVarInputs ? (toVarInputs(inputs.prompt_config?.jinja2_variables || [])) : [])]
|
||||
|
||||
return vars
|
||||
})()
|
||||
|
||||
const inputVarValues = (() => {
|
||||
const vars: Record<string, any> = {}
|
||||
Object.keys(runInputData)
|
||||
.filter(key => !['#context#', '#files#'].includes(key))
|
||||
.forEach((key) => {
|
||||
vars[key] = runInputData[key]
|
||||
})
|
||||
return vars
|
||||
})()
|
||||
|
||||
const setInputVarValues = useCallback((newPayload: Record<string, any>) => {
|
||||
const newVars = {
|
||||
...newPayload,
|
||||
'#context#': runInputDataRef.current['#context#'],
|
||||
'#files#': runInputDataRef.current['#files#'],
|
||||
}
|
||||
setRunInputData?.(newVars)
|
||||
}, [runInputDataRef, setRunInputData])
|
||||
|
||||
const forms = (() => {
|
||||
const forms: FormProps[] = []
|
||||
|
||||
if (varInputs.length > 0) {
|
||||
forms.push(
|
||||
{
|
||||
label: t(`${i18nPrefix}.singleRun.variable`)!,
|
||||
inputs: varInputs,
|
||||
values: inputVarValues,
|
||||
onChange: setInputVarValues,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (inputs.context?.variable_selector && inputs.context?.variable_selector.length > 0) {
|
||||
forms.push(
|
||||
{
|
||||
label: t(`${i18nPrefix}.context`)!,
|
||||
inputs: [{
|
||||
label: '',
|
||||
variable: '#context#',
|
||||
type: InputVarType.contexts,
|
||||
required: false,
|
||||
}],
|
||||
values: { '#context#': contexts },
|
||||
onChange: keyValue => setContexts(keyValue['#context#']),
|
||||
},
|
||||
)
|
||||
}
|
||||
if (isVisionModel && payload.vision?.enabled && payload.vision?.configs?.variable_selector) {
|
||||
const currentVariable = findVariableWhenOnLLMVision(payload.vision.configs.variable_selector, availableVars)
|
||||
|
||||
forms.push(
|
||||
{
|
||||
label: t(`${i18nPrefix}.vision`)!,
|
||||
inputs: [{
|
||||
label: currentVariable?.variable as any,
|
||||
variable: '#files#',
|
||||
type: currentVariable?.formType as any,
|
||||
required: false,
|
||||
}],
|
||||
values: { '#files#': visionFiles },
|
||||
onChange: keyValue => setVisionFiles((keyValue as any)['#files#']),
|
||||
},
|
||||
)
|
||||
}
|
||||
return forms
|
||||
})()
|
||||
|
||||
const getDependentVars = () => {
|
||||
const promptVars = varInputs.map((item) => {
|
||||
// Guard against null/undefined variable to prevent app crash
|
||||
if (!item.variable || typeof item.variable !== 'string')
|
||||
return []
|
||||
|
||||
return item.variable.slice(1, -1).split('.')
|
||||
}).filter(arr => arr.length > 0)
|
||||
const contextVar = payload.context.variable_selector
|
||||
const vars = [...promptVars, contextVar]
|
||||
if (isVisionModel && payload.vision?.enabled && payload.vision?.configs?.variable_selector) {
|
||||
const visionVar = payload.vision.configs.variable_selector
|
||||
vars.push(visionVar)
|
||||
}
|
||||
return vars
|
||||
}
|
||||
|
||||
const getDependentVar = (variable: string) => {
|
||||
if(variable === '#context#')
|
||||
return payload.context.variable_selector
|
||||
|
||||
if(variable === '#files#')
|
||||
return payload.vision.configs?.variable_selector
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
forms,
|
||||
getDependentVars,
|
||||
getDependentVar,
|
||||
}
|
||||
}
|
||||
|
||||
export default useSingleRunFormParams
|
||||
169
dify/web/app/components/workflow/nodes/llm/utils.ts
Normal file
169
dify/web/app/components/workflow/nodes/llm/utils.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { z } from 'zod'
|
||||
import { ArrayType, Type } from './types'
|
||||
import type { ArrayItems, Field, LLMNodeType } from './types'
|
||||
import { draft07Validator, forbidBooleanProperties } from '@/utils/validators'
|
||||
import type { ValidationError } from 'jsonschema'
|
||||
|
||||
export const checkNodeValid = (_payload: LLMNodeType) => {
|
||||
return true
|
||||
}
|
||||
|
||||
export const getFieldType = (field: Field) => {
|
||||
const { type, items, enum: enums } = field
|
||||
if (field.schemaType === 'file') return Type.file
|
||||
if (enums && enums.length > 0) return Type.enumType
|
||||
if (type !== Type.array || !items)
|
||||
return type
|
||||
|
||||
return ArrayType[items.type as keyof typeof ArrayType]
|
||||
}
|
||||
|
||||
export const getHasChildren = (schema: Field) => {
|
||||
const complexTypes = [Type.object, Type.array]
|
||||
if (!complexTypes.includes(schema.type))
|
||||
return false
|
||||
if (schema.type === Type.object)
|
||||
return schema.properties && Object.keys(schema.properties).length > 0
|
||||
if (schema.type === Type.array)
|
||||
return schema.items && schema.items.type === Type.object && schema.items.properties && Object.keys(schema.items.properties).length > 0
|
||||
}
|
||||
|
||||
export const getTypeOf = (target: any) => {
|
||||
if (target === null) return 'null'
|
||||
if (typeof target !== 'object') {
|
||||
return typeof target
|
||||
}
|
||||
else {
|
||||
return Object.prototype.toString
|
||||
.call(target)
|
||||
.slice(8, -1)
|
||||
.toLocaleLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
export const inferType = (value: any): Type => {
|
||||
const type = getTypeOf(value)
|
||||
if (type === 'array') return Type.array
|
||||
// type boolean will be treated as string
|
||||
if (type === 'boolean') return Type.string
|
||||
if (type === 'number') return Type.number
|
||||
if (type === 'string') return Type.string
|
||||
if (type === 'object') return Type.object
|
||||
return Type.string
|
||||
}
|
||||
|
||||
export const jsonToSchema = (json: any): Field => {
|
||||
const schema: Field = {
|
||||
type: inferType(json),
|
||||
}
|
||||
|
||||
if (schema.type === Type.object) {
|
||||
schema.properties = {}
|
||||
schema.required = []
|
||||
schema.additionalProperties = false
|
||||
|
||||
Object.entries(json).forEach(([key, value]) => {
|
||||
schema.properties![key] = jsonToSchema(value)
|
||||
schema.required!.push(key)
|
||||
})
|
||||
}
|
||||
else if (schema.type === Type.array) {
|
||||
schema.items = jsonToSchema(json[0]) as ArrayItems
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
export const checkJsonDepth = (json: any) => {
|
||||
if (!json || getTypeOf(json) !== 'object')
|
||||
return 0
|
||||
|
||||
let maxDepth = 0
|
||||
|
||||
if (getTypeOf(json) === 'array') {
|
||||
if (json[0] && getTypeOf(json[0]) === 'object')
|
||||
maxDepth = checkJsonDepth(json[0])
|
||||
}
|
||||
else if (getTypeOf(json) === 'object') {
|
||||
const propertyDepths = Object.values(json).map(value => checkJsonDepth(value))
|
||||
maxDepth = propertyDepths.length ? Math.max(...propertyDepths) + 1 : 1
|
||||
}
|
||||
|
||||
return maxDepth
|
||||
}
|
||||
|
||||
export const checkJsonSchemaDepth = (schema: Field) => {
|
||||
if (!schema || getTypeOf(schema) !== 'object')
|
||||
return 0
|
||||
|
||||
let maxDepth = 0
|
||||
|
||||
if (schema.type === Type.object && schema.properties) {
|
||||
const propertyDepths = Object.values(schema.properties).map(value => checkJsonSchemaDepth(value))
|
||||
maxDepth = propertyDepths.length ? Math.max(...propertyDepths) + 1 : 1
|
||||
}
|
||||
else if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
|
||||
maxDepth = checkJsonSchemaDepth(schema.items) + 1
|
||||
}
|
||||
|
||||
return maxDepth
|
||||
}
|
||||
|
||||
export const findPropertyWithPath = (target: any, path: string[]) => {
|
||||
let current = target
|
||||
for (const key of path)
|
||||
current = current[key]
|
||||
return current
|
||||
}
|
||||
|
||||
export const validateSchemaAgainstDraft7 = (schemaToValidate: any) => {
|
||||
// First check against Draft-07
|
||||
const result = draft07Validator(schemaToValidate)
|
||||
// Then apply custom rule
|
||||
const customErrors = forbidBooleanProperties(schemaToValidate)
|
||||
|
||||
return [...result.errors, ...customErrors]
|
||||
}
|
||||
|
||||
export const getValidationErrorMessage = (errors: Array<ValidationError | string>) => {
|
||||
const message = errors.map((error) => {
|
||||
if (typeof error === 'string')
|
||||
return error
|
||||
else
|
||||
return `Error: ${error.stack}\n`
|
||||
}).join('')
|
||||
return message
|
||||
}
|
||||
|
||||
// Previous Not support boolean type, so transform boolean to string when paste it into schema editor
|
||||
export const convertBooleanToString = (schema: any) => {
|
||||
if (schema.type === Type.boolean)
|
||||
schema.type = Type.string
|
||||
if (schema.type === Type.array && schema.items && schema.items.type === Type.boolean)
|
||||
schema.items.type = Type.string
|
||||
if (schema.type === Type.object) {
|
||||
schema.properties = Object.entries(schema.properties).reduce((acc, [key, value]) => {
|
||||
acc[key] = convertBooleanToString(value)
|
||||
return acc
|
||||
}, {} as any)
|
||||
}
|
||||
if (schema.type === Type.array && schema.items && schema.items.type === Type.object) {
|
||||
schema.items.properties = Object.entries(schema.items.properties).reduce((acc, [key, value]) => {
|
||||
acc[key] = convertBooleanToString(value)
|
||||
return acc
|
||||
}, {} as any)
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
const schemaRootObject = z.object({
|
||||
type: z.literal('object'),
|
||||
properties: z.record(z.string(), z.any()),
|
||||
required: z.array(z.string()),
|
||||
additionalProperties: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const preValidateSchema = (schema: any) => {
|
||||
const result = schemaRootObject.safeParse(schema)
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user