FEAT: NEW WORKFLOW ENGINE (#3160)

Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: Yeuoly <admin@srmxy.cn>
Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: StyleZhang <jasonapring2015@outlook.com>
Co-authored-by: jyong <jyong@dify.ai>
Co-authored-by: nite-knite <nkCoding@gmail.com>
Co-authored-by: jyong <718720800@qq.com>
This commit is contained in:
takatost
2024-04-08 18:51:46 +08:00
committed by GitHub
parent 2fb9850af5
commit 7753ba2d37
1161 changed files with 103836 additions and 10327 deletions

View File

@@ -0,0 +1,28 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from 'classnames'
import { Plus } from '@/app/components/base/icons/src/vender/line/general'
type Props = {
className?: string
text: string
onClick: () => void
}
const AddButton: FC<Props> = ({
className,
text,
onClick,
}) => {
return (
<div
className={cn(className, 'flex items-center h-7 justify-center bg-gray-100 hover:bg-gray-200 rounded-lg cursor-pointer text-xs font-medium text-gray-700 space-x-1')}
onClick={onClick}
>
<Plus className='w-3.5 h-3.5' />
<div>{text}</div>
</div>
)
}
export default React.memo(AddButton)

View File

@@ -0,0 +1,184 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import produce from 'immer'
import type { InputVar } from '../../../../types'
import { BlockEnum, InputVarType } from '../../../../types'
import CodeEditor from '../editor/code-editor'
import { CodeLanguage } from '../../../code/types'
import Select from '@/app/components/base/select'
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
import { Resolution } from '@/types/app'
import { Trash03 } from '@/app/components/base/icons/src/vender/line/general'
import { useFeatures } from '@/app/components/base/features/hooks'
import { VarBlockIcon } from '@/app/components/workflow/block-icon'
import { Line3 } from '@/app/components/base/icons/src/public/common'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
type Props = {
payload: InputVar
value: any
onChange: (value: any) => void
className?: string
}
const FormItem: FC<Props> = ({
payload,
value,
onChange,
className,
}) => {
const { t } = useTranslation()
const { type } = payload
const fileSettings = useFeatures(s => s.features.file)
const handleContextItemChange = useCallback((index: number) => {
return (newValue: any) => {
const newValues = produce(value, (draft: any) => {
draft[index] = newValue
})
onChange(newValues)
}
}, [value, onChange])
const handleContextItemRemove = useCallback((index: number) => {
return () => {
const newValues = produce(value, (draft: any) => {
draft.splice(index, 1)
})
onChange(newValues)
}
}, [value, onChange])
const nodeKey = (() => {
if (typeof payload.label === 'object') {
const { nodeType, nodeName, variable } = payload.label
return (
<div className='h-full flex items-center'>
<div className='flex items-center'>
<div className='p-[1px]'>
<VarBlockIcon type={nodeType || BlockEnum.Start} />
</div>
<div className='mx-0.5 text-xs font-medium text-gray-700 max-w-[150px] truncate' title={nodeName}>
{nodeName}
</div>
<Line3 className='mr-0.5'></Line3>
</div>
<div className='flex items-center text-primary-600'>
<Variable02 className='w-3.5 h-3.5' />
<div className='ml-0.5 text-xs font-medium max-w-[150px] truncate' title={variable} >
{variable}
</div>
</div>
</div>
)
}
return ''
})()
return (
<div className={`${className}`}>
{type !== InputVarType.contexts && <div className='h-8 leading-8 text-[13px] font-medium text-gray-700 truncate'>{typeof payload.label === 'object' ? nodeKey : payload.label}</div>}
<div className='grow'>
{
type === InputVarType.textInput && (
<input
className="w-full px-3 text-sm leading-8 text-gray-900 border-0 rounded-lg grow h-8 bg-gray-50 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-gray-200"
type="text"
value={value || ''}
onChange={e => onChange(e.target.value)}
placeholder={t('appDebug.variableConig.inputPlaceholder')!}
/>
)
}
{
type === InputVarType.number && (
<input
className="w-full px-3 text-sm leading-8 text-gray-900 border-0 rounded-lg grow h-8 bg-gray-50 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-gray-200"
type="number"
value={value || ''}
onChange={e => onChange(e.target.value)}
placeholder={t('appDebug.variableConig.inputPlaceholder')!}
/>
)
}
{
type === InputVarType.paragraph && (
<textarea
className="w-full px-3 py-1 text-sm leading-[18px] text-gray-900 border-0 rounded-lg grow h-[120px] bg-gray-50 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-gray-200"
value={value || ''}
onChange={e => onChange(e.target.value)}
placeholder={t('appDebug.variableConig.inputPlaceholder')!}
/>
)
}
{
type === InputVarType.select && (
<Select
className="w-full"
defaultValue={value || ''}
items={payload.options?.map(option => ({ name: option, value: option })) || []}
onSelect={i => onChange(i.value)}
allowSearch={false}
/>
)
}
{
type === InputVarType.json && (
<CodeEditor
value={value}
title={<span>JSON</span>}
language={CodeLanguage.json}
onChange={onChange}
/>
)
}
{
type === InputVarType.files && (
<TextGenerationImageUploader
settings={{
...fileSettings.image,
detail: Resolution.high,
}}
onFilesChange={files => onChange(files.filter(file => file.progress !== -1).map(fileItem => ({
type: 'image',
transfer_method: fileItem.type,
url: fileItem.url,
upload_file_id: fileItem.fileId,
})))}
/>
)
}
{
type === InputVarType.contexts && (
<div className='space-y-2'>
{(value || []).map((item: any, index: number) => (
<CodeEditor
key={index}
value={item}
title={<span>JSON</span>}
headerRight={
(value as any).length > 1
? (<Trash03
onClick={handleContextItemRemove(index)}
className='mr-1 w-3.5 h-3.5 text-gray-500 cursor-pointer'
/>)
: undefined
}
language={CodeLanguage.json}
onChange={handleContextItemChange(index)}
/>
))}
</div>
)
}
</div>
</div>
)
}
export default React.memo(FormItem)

View File

@@ -0,0 +1,66 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import produce from 'immer'
import cn from 'classnames'
import type { InputVar } from '../../../../types'
import FormItem from './form-item'
import { InputVarType } from '@/app/components/workflow/types'
import AddButton from '@/app/components/base/button/add-button'
import { RETRIEVAL_OUTPUT_STRUCT } from '@/app/components/workflow/constants'
export type Props = {
className?: string
label?: string
inputs: InputVar[]
values: Record<string, string>
onChange: (newValues: Record<string, any>) => void
}
const Form: FC<Props> = ({
className,
label,
inputs,
values,
onChange,
}) => {
const handleChange = useCallback((key: string) => {
return (value: any) => {
const newValues = produce(values, (draft) => {
draft[key] = value
})
onChange(newValues)
}
}, [values, onChange])
const handleAddContext = useCallback(() => {
const newValues = produce(values, (draft: any) => {
const key = inputs[0].variable
draft[key].push(RETRIEVAL_OUTPUT_STRUCT)
})
onChange(newValues)
}, [values, onChange, inputs])
return (
<div className={cn(className, 'space-y-2')}>
{label && (
<div className='mb-1 flex items-center justify-between'>
<div className='flex items-center h-6 text-xs font-medium text-gray-500 uppercase'>{label}</div>
{inputs[0]?.type === InputVarType.contexts && (
<AddButton onClick={handleAddContext} />
)}
</div>
)}
{inputs.map((input, index) => {
return (
<FormItem
key={index}
payload={input}
value={values[input.variable]}
onChange={handleChange(input.variable)}
/>
)
})}
</div>
)
}
export default React.memo(Form)

View File

@@ -0,0 +1,150 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import type { Props as FormProps } from './form'
import Form from './form'
import Button from '@/app/components/base/button'
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
import { Loading02, XClose } from '@/app/components/base/icons/src/vender/line/general'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import { InputVarType, NodeRunningStatus } from '@/app/components/workflow/types'
import ResultPanel from '@/app/components/workflow/run/result-panel'
import Toast from '@/app/components/base/toast'
const i18nPrefix = 'workflow.singleRun'
type BeforeRunFormProps = {
nodeName: string
onHide: () => void
onRun: (submitData: Record<string, any>) => void
onStop: () => void
runningStatus: NodeRunningStatus
result?: JSX.Element
forms: FormProps[]
}
function formatValue(value: string | any, type: InputVarType) {
if (type === InputVarType.number)
return parseFloat(value)
if (type === InputVarType.json)
return JSON.parse(value)
if (type === InputVarType.contexts) {
return value.map((item: any) => {
return JSON.parse(item)
})
}
return value
}
const BeforeRunForm: FC<BeforeRunFormProps> = ({
nodeName,
onHide,
onRun,
onStop,
runningStatus,
result,
forms,
}) => {
const { t } = useTranslation()
const isFinished = runningStatus === NodeRunningStatus.Succeeded || runningStatus === NodeRunningStatus.Failed
const isRunning = runningStatus === NodeRunningStatus.Running
const handleRun = useCallback(() => {
let errMsg = ''
forms.forEach((form) => {
form.inputs.forEach((input) => {
const value = form.values[input.variable]
if (!errMsg && input.required && (value === '' || value === undefined || value === null || (input.type === InputVarType.files && value.length === 0)))
errMsg = t('workflow.errorMsg.fieldRequired', { field: typeof input.label === 'object' ? input.label.variable : input.label })
})
})
if (errMsg) {
Toast.notify({
message: errMsg,
type: 'error',
})
return
}
const submitData: Record<string, any> = {}
let parseErrorJsonField = ''
forms.forEach((form) => {
form.inputs.forEach((input) => {
try {
const value = formatValue(form.values[input.variable], input.type)
submitData[input.variable] = value
}
catch (e) {
parseErrorJsonField = input.variable
}
})
})
if (parseErrorJsonField) {
Toast.notify({
message: t('workflow.errorMsg.invalidJson', { field: parseErrorJsonField }),
type: 'error',
})
return
}
onRun(submitData)
}, [forms, onRun, t])
return (
<div className='absolute inset-0 z-10 rounded-2xl pt-10' style={{
backgroundColor: 'rgba(16, 24, 40, 0.20)',
}}>
<div className='h-full rounded-2xl bg-white flex flex-col'>
<div className='shrink-0 flex justify-between items-center h-8 pl-4 pr-3 pt-3'>
<div className='text-base font-semibold text-gray-900 truncate'>
{t(`${i18nPrefix}.testRun`)} {nodeName}
</div>
<div className='ml-2 shrink-0 p-1 cursor-pointer' onClick={onHide}>
<XClose className='w-4 h-4 text-gray-500 ' />
</div>
</div>
<div className='h-0 grow overflow-y-auto pb-4'>
<div className='mt-3 px-4 space-y-4'>
{forms.map((form, index) => (
<div key={index}>
<Form
key={index}
className={cn(index < forms.length - 1 && 'mb-4')}
{...form}
/>
{index < forms.length - 1 && <Split />}
</div>
))}
</div>
<div className='mt-4 flex justify-between space-x-2 px-4' >
{isRunning && (
<div
className='p-2 rounded-lg border border-gray-200 bg-white shadow-xs cursor-pointer'
onClick={onStop}
>
<StopCircle className='w-4 h-4 text-gray-500' />
</div>
)}
<Button disabled={isRunning} type='primary' className='w-0 grow !h-8 flex items-center space-x-2 text-[13px]' onClick={handleRun}>
{isRunning && <Loading02 className='animate-spin w-4 h-4 text-white' />}
<div>{t(`${i18nPrefix}.${isRunning ? 'running' : 'startRun'}`)}</div>
</Button>
</div>
{isRunning && (
<ResultPanel status='running' showSteps={false} />
)}
{isFinished && (
<>
{result}
</>
)}
</div>
</div>
</div>
)
}
export default React.memo(BeforeRunForm)

View File

@@ -0,0 +1,81 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useRef, useState } from 'react'
import copy from 'copy-to-clipboard'
import cn from 'classnames'
import PromptEditorHeightResizeWrap from '@/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap'
import { Clipboard, ClipboardCheck } from '@/app/components/base/icons/src/vender/line/files'
import ToggleExpandBtn from '@/app/components/workflow/nodes/_base/components/toggle-expand-btn'
import useToggleExpend from '@/app/components/workflow/nodes/_base/hooks/use-toggle-expend'
type Props = {
className?: string
title: JSX.Element | string
headerRight?: JSX.Element
children: JSX.Element
minHeight?: number
value: string
isFocus: boolean
}
const Base: FC<Props> = ({
className,
title,
headerRight,
children,
minHeight = 120,
value,
isFocus,
}) => {
const ref = useRef<HTMLDivElement>(null)
const {
wrapClassName,
isExpand,
setIsExpand,
editorExpandHeight,
} = useToggleExpend({ ref, hasFooter: false })
const editorContentMinHeight = minHeight - 28
const [editorContentHeight, setEditorContentHeight] = useState(editorContentMinHeight)
const [isCopied, setIsCopied] = React.useState(false)
const handleCopy = useCallback(() => {
copy(value)
setIsCopied(true)
}, [value])
return (
<div className={cn(wrapClassName)}>
<div ref={ref} className={cn(className, isExpand && 'h-full', 'rounded-lg border', isFocus ? 'bg-white border-gray-200' : 'bg-gray-100 border-gray-100 overflow-hidden')}>
<div className='flex justify-between items-center h-7 pt-1 pl-3 pr-2'>
<div className='text-xs font-semibold text-gray-700'>{title}</div>
<div className='flex items-center'>
{headerRight}
{!isCopied
? (
<Clipboard className='mx-1 w-3.5 h-3.5 text-gray-500 cursor-pointer' onClick={handleCopy} />
)
: (
<ClipboardCheck className='mx-1 w-3.5 h-3.5 text-gray-500' />
)
}
<div className='ml-1'>
<ToggleExpandBtn isExpand={isExpand} onExpandChange={setIsExpand} />
</div>
</div>
</div>
<PromptEditorHeightResizeWrap
height={isExpand ? editorExpandHeight : editorContentHeight}
minHeight={editorContentMinHeight}
onHeightChange={setEditorContentHeight}
hideResize={isExpand}
>
<div className='h-full pb-2'>
{children}
</div>
</PromptEditorHeightResizeWrap>
</div>
</div>
)
}
export default React.memo(Base)

View File

@@ -0,0 +1,122 @@
'use client'
import type { FC } from 'react'
import Editor, { loader } from '@monaco-editor/react'
import React, { useRef } from 'react'
import Base from '../base'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import './style.css'
// load file from local instead of cdn https://github.com/suren-atoyan/monaco-react/issues/482
loader.config({ paths: { vs: '/vs' } })
type Props = {
value?: string | object
onChange?: (value: string) => void
title: JSX.Element
language: CodeLanguage
headerRight?: JSX.Element
readOnly?: boolean
isJSONStringifyBeauty?: boolean
height?: number
}
const languageMap = {
[CodeLanguage.javascript]: 'javascript',
[CodeLanguage.python3]: 'python',
[CodeLanguage.json]: 'json',
}
const CodeEditor: FC<Props> = ({
value = '',
onChange = () => { },
title,
headerRight,
language,
readOnly,
isJSONStringifyBeauty,
height,
}) => {
const [isFocus, setIsFocus] = React.useState(false)
const handleEditorChange = (value: string | undefined) => {
onChange(value || '')
}
const editorRef = useRef(null)
const handleEditorDidMount = (editor: any, monaco: any) => {
editorRef.current = editor
editor.onDidFocusEditorText(() => {
setIsFocus(true)
})
editor.onDidBlurEditorText(() => {
setIsFocus(false)
})
monaco.editor.defineTheme('blur-theme', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': '#F2F4F7',
},
})
monaco.editor.defineTheme('focus-theme', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': '#ffffff',
},
})
}
const outPutValue = (() => {
if (!isJSONStringifyBeauty)
return value as string
try {
return JSON.stringify(value as object, null, 2)
}
catch (e) {
return value as string
}
})()
return (
<div>
<Base
title={title}
value={outPutValue}
headerRight={headerRight}
isFocus={isFocus && !readOnly}
minHeight={height || 200}
>
<>
{/* https://www.npmjs.com/package/@monaco-editor/react */}
<Editor
className='h-full'
// language={language === CodeLanguage.javascript ? 'javascript' : 'python'}
language={languageMap[language] || 'javascript'}
theme={isFocus ? 'focus-theme' : 'blur-theme'}
value={outPutValue}
onChange={handleEditorChange}
// https://microsoft.github.io/monaco-editor/typedoc/interfaces/editor.IEditorOptions.html
options={{
readOnly,
domReadOnly: true,
quickSuggestions: false,
minimap: { enabled: false },
lineNumbersMinChars: 1, // would change line num width
wordWrap: 'on', // auto line wrap
// lineNumbers: (num) => {
// return <div>{num}</div>
// }
}}
onMount={handleEditorDidMount}
/>
</>
</Base>
</div>
)
}
export default React.memo(CodeEditor)

View File

@@ -0,0 +1,8 @@
.margin-view-overlays {
padding-left: 10px;
}
/* hide readonly tooltip */
.monaco-editor-overlaymessage {
display: none !important;
}

View File

@@ -0,0 +1,60 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useBoolean } from 'ahooks'
import Base from './base'
type Props = {
value: string
onChange: (value: string) => void
title: JSX.Element | string
headerRight?: JSX.Element
minHeight?: number
onBlur?: () => void
placeholder?: string
readonly?: boolean
}
const TextEditor: FC<Props> = ({
value,
onChange,
title,
headerRight,
minHeight,
onBlur,
placeholder,
readonly,
}) => {
const [isFocus, {
setTrue: setIsFocus,
setFalse: setIsNotFocus,
}] = useBoolean(false)
const handleBlur = useCallback(() => {
setIsNotFocus()
onBlur?.()
}, [setIsNotFocus, onBlur])
return (
<div>
<Base
title={title}
value={value}
headerRight={headerRight}
isFocus={isFocus}
minHeight={minHeight}
>
<textarea
value={value}
onChange={e => onChange(e.target.value)}
onFocus={setIsFocus}
onBlur={handleBlur}
className='w-full h-full px-3 resize-none bg-transparent border-none focus:outline-none leading-[18px] text-[13px] font-normal text-gray-900 placeholder:text-gray-300'
placeholder={placeholder}
readOnly={readonly}
/>
</Base>
</div>
)
}
export default React.memo(TextEditor)

View File

@@ -0,0 +1,57 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from 'classnames'
import { useBoolean } from 'ahooks'
import { HelpCircle } from '@/app/components/base/icons/src/vender/line/general'
import TooltipPlus from '@/app/components/base/tooltip-plus'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
type Props = {
title: string
tooltip?: string
supportFold?: boolean
children?: JSX.Element | string | null
operations?: JSX.Element
inline?: boolean
}
const Filed: FC<Props> = ({
title,
tooltip,
children,
operations,
inline,
supportFold,
}) => {
const [fold, {
toggle: toggleFold,
}] = useBoolean(true)
return (
<div className={cn(inline && 'flex justify-between items-center', supportFold && 'cursor-pointer')}>
<div
onClick={() => supportFold && toggleFold()}
className='flex justify-between items-center'>
<div className='flex items-center h-6'>
<div className='text-[13px] font-medium text-gray-700 uppercase'>{title}</div>
{tooltip && (
<TooltipPlus popupContent={
<div className='w-[120px]'>
{tooltip}
</div>}>
<HelpCircle className='w-3.5 h-3.5 ml-0.5 text-gray-400' />
</TooltipPlus>
)}
</div>
<div className='flex'>
{operations && <div>{operations}</div>}
{supportFold && (
<ChevronRight className='w-3.5 h-3.5 text-gray-500 cursor-pointer transform transition-transform' style={{ transform: fold ? 'rotate(0deg)' : 'rotate(90deg)' }} />
)}
</div>
</div>
{children && (!supportFold || (supportFold && !fold)) && <div className={cn(!inline && 'mt-1')}>{children}</div>}
</div>
)
}
export default React.memo(Filed)

View File

@@ -0,0 +1,27 @@
'use client'
import type { FC } from 'react'
import React from 'react'
type Props = {
title: string
content: string | JSX.Element
}
const InfoPanel: FC<Props> = ({
title,
content,
}) => {
return (
<div>
<div className='px-[5px] py-[3px] bg-gray-100 rounded-md'>
<div className='leading-4 text-[10px] font-medium text-gray-500 uppercase'>
{title}
</div>
<div className='leading-4 text-xs font-normal text-gray-700'>
{content}
</div>
</div>
</div>
)
}
export default React.memo(InfoPanel)

View File

@@ -0,0 +1,123 @@
'use client'
import type { FC } from 'react'
import React, { useEffect } from 'react'
import cn from 'classnames'
import { useBoolean } from 'ahooks'
import { useTranslation } from 'react-i18next'
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import PromptEditor from '@/app/components/base/prompt-editor'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import TooltipPlus from '@/app/components/base/tooltip-plus'
type Props = {
instanceId?: string
className?: string
placeholder?: string
placeholderClassName?: string
promptMinHeightClassName?: string
value: string
onChange: (value: string) => void
onFocusChange?: (value: boolean) => void
readOnly?: boolean
justVar?: boolean
nodesOutputVars?: NodeOutPutVar[]
availableNodes?: Node[]
}
const Editor: FC<Props> = ({
instanceId,
className,
placeholder,
placeholderClassName,
promptMinHeightClassName = 'min-h-[20px]',
value,
onChange,
onFocusChange,
readOnly,
nodesOutputVars,
availableNodes = [],
}) => {
const { t } = useTranslation()
const [isFocus, {
setTrue: setFocus,
setFalse: setBlur,
}] = useBoolean(false)
useEffect(() => {
onFocusChange?.(isFocus)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isFocus])
return (
<div className={cn(className, 'relative')}>
<>
<PromptEditor
instanceId={instanceId}
className={cn(promptMinHeightClassName, '!leading-[18px]')}
placeholder={placeholder}
placeholderClassName={placeholderClassName}
value={value}
contextBlock={{
show: false,
selectable: false,
datasets: [],
onAddContext: () => { },
}}
historyBlock={{
show: false,
selectable: false,
history: {
user: 'Human',
assistant: 'Assistant',
},
onEditRole: () => { },
}}
queryBlock={{
show: false,
selectable: false,
}}
workflowVariableBlock={{
show: true,
variables: nodesOutputVars || [],
workflowNodesMap: availableNodes.reduce((acc, node) => {
acc[node.id] = {
title: node.data.title,
type: node.data.type,
}
if (node.data.type === BlockEnum.Start) {
acc.sys = {
title: t('workflow.blocks.start'),
type: BlockEnum.Start,
}
}
return acc
}, {} as any),
}}
onChange={onChange}
editable={!readOnly}
onBlur={setBlur}
onFocus={setFocus}
/>
{/* to patch Editor not support dynamic change editable status */}
{readOnly && <div className='absolute inset-0 z-10'></div>}
{isFocus && (
<div className='absolute z-10 top-[-9px] right-1'>
<TooltipPlus
popupContent={`${t('workflow.common.insertVarTip')}`}
>
<div className='p-0.5 rounded-[5px] shadow-lg cursor-pointer bg-white hover:bg-gray-100 border-[0.5px] border-black/5'>
<Variable02 className='w-3.5 h-3.5 text-gray-500' />
</div>
</TooltipPlus>
</div>
)}
</>
</div >
)
}
export default React.memo(Editor)

View File

@@ -0,0 +1,31 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { InputVarType } from '../../../types'
import { AlignLeft, LetterSpacing01 } from '@/app/components/base/icons/src/vender/line/editor'
import { CheckDone01, Hash02 } from '@/app/components/base/icons/src/vender/line/general'
type Props = {
className?: string
type: InputVarType
}
const getIcon = (type: InputVarType) => {
return ({
[InputVarType.textInput]: LetterSpacing01,
[InputVarType.paragraph]: AlignLeft,
[InputVarType.select]: CheckDone01,
[InputVarType.number]: Hash02,
} as any)[type] || LetterSpacing01
}
const InputVarTypeIcon: FC<Props> = ({
className,
type,
}) => {
const Icon = getIcon(type)
return (
<Icon className={className} />
)
}
export default React.memo(InputVarTypeIcon)

View File

@@ -0,0 +1,202 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import produce from 'immer'
import cn from 'classnames'
import type { Memory } from '../../../types'
import { MemoryRole } from '../../../types'
import Field from '@/app/components/workflow/nodes/_base/components/field'
import Switch from '@/app/components/base/switch'
import Slider from '@/app/components/base/slider'
const i18nPrefix = 'workflow.nodes.common.memory'
const WINDOW_SIZE_MIN = 1
const WINDOW_SIZE_MAX = 100
const WINDOW_SIZE_DEFAULT = 50
type RoleItemProps = {
readonly: boolean
title: string
value: string
onChange: (value: string) => void
}
const RoleItem: FC<RoleItemProps> = ({
readonly,
title,
value,
onChange,
}) => {
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value)
}, [onChange])
return (
<div className='flex items-center justify-between'>
<div className='text-[13px] font-normal text-gray-700'>{title}</div>
<input
readOnly={readonly}
value={value}
onChange={handleChange}
className='w-[200px] h-8 leading-8 px-2.5 rounded-lg border-0 bg-gray-100 text-gray-900 text-[13px] placeholder:text-gray-400 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-gray-200'
type='text' />
</div>
)
}
type Props = {
className?: string
readonly: boolean
config: { data?: Memory }
onChange: (memory?: Memory) => void
canSetRoleName?: boolean
}
const MEMORY_DEFAULT: Memory = { window: { enabled: false, size: WINDOW_SIZE_DEFAULT } }
const MemoryConfig: FC<Props> = ({
className,
readonly,
config = { data: MEMORY_DEFAULT },
onChange,
canSetRoleName = false,
}) => {
const { t } = useTranslation()
const payload = config.data
const handleMemoryEnabledChange = useCallback((enabled: boolean) => {
onChange(enabled ? MEMORY_DEFAULT : undefined)
}, [onChange])
const handleWindowEnabledChange = useCallback((enabled: boolean) => {
const newPayload = produce(config.data || MEMORY_DEFAULT, (draft) => {
if (!draft.window)
draft.window = { enabled: false, size: WINDOW_SIZE_DEFAULT }
draft.window.enabled = enabled
})
onChange(newPayload)
}, [config, onChange])
const handleWindowSizeChange = useCallback((size: number | string) => {
const newPayload = produce(payload || MEMORY_DEFAULT, (draft) => {
if (!draft.window)
draft.window = { enabled: true, size: WINDOW_SIZE_DEFAULT }
let limitedSize: null | string | number = size
if (limitedSize === '') {
limitedSize = null
}
else {
limitedSize = parseInt(limitedSize as string, 10)
if (isNaN(limitedSize))
limitedSize = WINDOW_SIZE_DEFAULT
if (limitedSize < WINDOW_SIZE_MIN)
limitedSize = WINDOW_SIZE_MIN
if (limitedSize > WINDOW_SIZE_MAX)
limitedSize = WINDOW_SIZE_MAX
}
draft.window.size = limitedSize as number
})
onChange(newPayload)
}, [payload, onChange])
const handleBlur = useCallback(() => {
const payload = config.data
if (!payload)
return
if (payload.window.size === '' || payload.window.size === null)
handleWindowSizeChange(WINDOW_SIZE_DEFAULT)
}, [handleWindowSizeChange, config])
const handleRolePrefixChange = useCallback((role: MemoryRole) => {
return (value: string) => {
const newPayload = produce(config.data || MEMORY_DEFAULT, (draft) => {
if (!draft.role_prefix) {
draft.role_prefix = {
user: '',
assistant: '',
}
}
draft.role_prefix[role] = value
})
onChange(newPayload)
}
}, [config, onChange])
return (
<div className={cn(className)}>
<Field
title={t(`${i18nPrefix}.memory`)}
tooltip={t(`${i18nPrefix}.memoryTip`)!}
operations={
<Switch
defaultValue={!!payload}
onChange={handleMemoryEnabledChange}
size='md'
disabled={readonly}
/>
}
>
{payload && (
<>
{/* window size */}
<div className='flex justify-between'>
<div className='flex items-center h-8 space-x-1'>
<Switch
defaultValue={payload?.window?.enabled}
onChange={handleWindowEnabledChange}
size='md'
disabled={readonly}
/>
<div className='leading-[18px] text-xs font-medium text-gray-500 uppercase'>{t(`${i18nPrefix}.windowSize`)}</div>
</div>
<div className='flex items-center h-8 space-x-2'>
<Slider
className='w-[144px]'
value={(payload.window?.size || WINDOW_SIZE_DEFAULT) as number}
min={WINDOW_SIZE_MIN}
max={WINDOW_SIZE_MAX}
step={1}
onChange={handleWindowSizeChange}
disabled={readonly || !payload.window?.enabled}
/>
<input
value={(payload.window?.size || WINDOW_SIZE_DEFAULT) as number}
className='shrink-0 block ml-4 pl-3 w-12 h-8 appearance-none outline-none rounded-lg bg-gray-100 text-[13px] text-gra-900'
type='number'
min={WINDOW_SIZE_MIN}
max={WINDOW_SIZE_MAX}
step={1}
onChange={e => handleWindowSizeChange(e.target.value)}
onBlur={handleBlur}
disabled={readonly}
/>
</div>
</div>
{canSetRoleName && (
<div className='mt-4'>
<div className='leading-6 text-xs font-medium text-gray-500 uppercase'>{t(`${i18nPrefix}.conversationRoleName`)}</div>
<div className='mt-1 space-y-2'>
<RoleItem
readonly={readonly}
title={t(`${i18nPrefix}.user`)}
value={payload.role_prefix?.user || ''}
onChange={handleRolePrefixChange(MemoryRole.user)}
/>
<RoleItem
readonly={readonly}
title={t(`${i18nPrefix}.assistant`)}
value={payload.role_prefix?.assistant || ''}
onChange={handleRolePrefixChange(MemoryRole.assistant)}
/>
</div>
</div>
)}
</>
)}
</Field>
</div>
)
}
export default React.memo(MemoryConfig)

View File

@@ -0,0 +1,90 @@
import {
memo,
useCallback,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
useNodesExtraData,
useNodesInteractions,
useNodesReadOnly,
} from '@/app/components/workflow/hooks'
import BlockSelector from '@/app/components/workflow/block-selector'
import { Plus } from '@/app/components/base/icons/src/vender/line/general'
import type {
BlockEnum,
OnSelectBlock,
} from '@/app/components/workflow/types'
type AddProps = {
nodeId: string
nodeType: BlockEnum
sourceHandle: string
branchName?: string
}
const Add = ({
nodeId,
nodeType,
sourceHandle,
branchName,
}: AddProps) => {
const { t } = useTranslation()
const { handleNodeAdd } = useNodesInteractions()
const nodesExtraData = useNodesExtraData()
const { nodesReadOnly } = useNodesReadOnly()
const availableNextNodes = nodesExtraData[nodeType].availableNextNodes
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
handleNodeAdd(
{
nodeType: type,
toolDefaultValue,
},
{
prevNodeId: nodeId,
prevNodeSourceHandle: sourceHandle,
},
)
}, [nodeId, sourceHandle, handleNodeAdd])
const renderTrigger = useCallback((open: boolean) => {
return (
<div
className={`
relative flex items-center px-2 h-9 rounded-lg border border-dashed border-gray-200 bg-gray-50
hover:bg-gray-100 text-xs text-gray-500 cursor-pointer
${open && '!bg-gray-100'}
${nodesReadOnly && '!cursor-not-allowed'}
`}
>
{
branchName && (
<div
className='absolute left-1 right-1 -top-[7.5px] flex items-center h-3 text-[10px] text-gray-500 font-semibold'
title={branchName.toLocaleUpperCase()}
>
<div className='inline-block px-0.5 rounded-[5px] bg-white truncate'>{branchName.toLocaleUpperCase()}</div>
</div>
)
}
<div className='flex items-center justify-center mr-1.5 w-5 h-5 rounded-[5px] bg-gray-200'>
<Plus className='w-3 h-3' />
</div>
{t('workflow.panel.selectNextStep')}
</div>
)
}, [branchName, t, nodesReadOnly])
return (
<BlockSelector
disabled={nodesReadOnly}
onSelect={handleSelect}
placement='top'
offset={0}
trigger={renderTrigger}
popupClassName='!w-[328px]'
availableBlocksTypes={availableNextNodes}
/>
)
}
export default memo(Add)

View File

@@ -0,0 +1,104 @@
import { memo } from 'react'
import {
getConnectedEdges,
getOutgoers,
useEdges,
useStoreApi,
} from 'reactflow'
import { useToolIcon } from '../../../../hooks'
import BlockIcon from '../../../../block-icon'
import type {
Branch,
Node,
} from '../../../../types'
import { BlockEnum } from '../../../../types'
import Add from './add'
import Item from './item'
import Line from './line'
type NextStepProps = {
selectedNode: Node
}
const NextStep = ({
selectedNode,
}: NextStepProps) => {
const data = selectedNode.data
const toolIcon = useToolIcon(data)
const store = useStoreApi()
const branches = data._targetBranches || []
const nodeWithBranches = data.type === BlockEnum.IfElse || data.type === BlockEnum.QuestionClassifier
const edges = useEdges()
const outgoers = getOutgoers(selectedNode as Node, store.getState().getNodes(), edges)
const connectedEdges = getConnectedEdges([selectedNode] as Node[], edges).filter(edge => edge.source === selectedNode!.id)
return (
<div className='flex py-1'>
<div className='shrink-0 relative flex items-center justify-center w-9 h-9 bg-white rounded-lg border-[0.5px] border-gray-200 shadow-xs'>
<BlockIcon
type={selectedNode!.data.type}
toolIcon={toolIcon}
/>
</div>
<Line linesNumber={nodeWithBranches ? branches.length : 1} />
<div className='grow'>
{
!nodeWithBranches && !!outgoers.length && (
<Item
nodeId={outgoers[0].id}
data={outgoers[0].data}
sourceHandle='source'
/>
)
}
{
!nodeWithBranches && !outgoers.length && (
<Add
nodeId={selectedNode!.id}
nodeType={selectedNode!.data.type}
sourceHandle='source'
/>
)
}
{
!!branches?.length && nodeWithBranches && (
branches.map((branch: Branch) => {
const connected = connectedEdges.find(edge => edge.sourceHandle === branch.id)
const target = outgoers.find(outgoer => outgoer.id === connected?.target)
return (
<div
key={branch.id}
className='mb-3 last-of-type:mb-0'
>
{
connected && (
<Item
data={target!.data!}
nodeId={target!.id}
sourceHandle={branch.id}
branchName={branch.name}
/>
)
}
{
!connected && (
<Add
key={branch.id}
nodeId={selectedNode!.id}
nodeType={selectedNode!.data.type}
sourceHandle={branch.id}
branchName={branch.name}
/>
)
}
</div>
)
})
)
}
</div>
</div>
)
}
export default memo(NextStep)

View File

@@ -0,0 +1,95 @@
import {
memo,
useCallback,
} from 'react'
import { useTranslation } from 'react-i18next'
import { intersection } from 'lodash-es'
import type {
CommonNodeType,
OnSelectBlock,
} from '@/app/components/workflow/types'
import BlockIcon from '@/app/components/workflow/block-icon'
import BlockSelector from '@/app/components/workflow/block-selector'
import {
useNodesExtraData,
useNodesInteractions,
useNodesReadOnly,
useToolIcon,
} from '@/app/components/workflow/hooks'
import Button from '@/app/components/base/button'
type ItemProps = {
nodeId: string
sourceHandle: string
branchName?: string
data: CommonNodeType
}
const Item = ({
nodeId,
sourceHandle,
branchName,
data,
}: ItemProps) => {
const { t } = useTranslation()
const { handleNodeChange } = useNodesInteractions()
const { nodesReadOnly } = useNodesReadOnly()
const nodesExtraData = useNodesExtraData()
const toolIcon = useToolIcon(data)
const availablePrevNodes = nodesExtraData[data.type].availablePrevNodes
const availableNextNodes = nodesExtraData[data.type].availableNextNodes
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
handleNodeChange(nodeId, type, sourceHandle, toolDefaultValue)
}, [nodeId, sourceHandle, handleNodeChange])
const renderTrigger = useCallback((open: boolean) => {
return (
<Button
className={`
hidden group-hover:flex px-2 py-0 h-6 bg-white text-xs text-gray-700 font-medium rounded-md
${open && '!bg-gray-100 !flex'}
`}
>
{t('workflow.panel.change')}
</Button>
)
}, [t])
return (
<div
className='relative group flex items-center mb-3 last-of-type:mb-0 px-2 h-9 rounded-lg border-[0.5px] border-gray-200 bg-white hover:bg-gray-50 shadow-xs text-xs text-gray-700 cursor-pointer'
>
{
branchName && (
<div
className='absolute left-1 right-1 -top-[7.5px] flex items-center h-3 text-[10px] text-gray-500 font-semibold'
title={branchName.toLocaleUpperCase()}
>
<div className='inline-block px-0.5 rounded-[5px] bg-white truncate'>{branchName.toLocaleUpperCase()}</div>
</div>
)
}
<BlockIcon
type={data.type}
toolIcon={toolIcon}
className='shrink-0 mr-1.5'
/>
<div className='grow'>{data.title}</div>
{
!nodesReadOnly && (
<BlockSelector
onSelect={handleSelect}
placement='top-end'
offset={{
mainAxis: 6,
crossAxis: 8,
}}
trigger={renderTrigger}
popupClassName='!w-[328px]'
availableBlocksTypes={intersection(availablePrevNodes, availableNextNodes)}
/>
)
}
</div>
)
}
export default memo(Item)

View File

@@ -0,0 +1,59 @@
import { memo } from 'react'
type LineProps = {
linesNumber: number
}
const Line = ({
linesNumber,
}: LineProps) => {
const svgHeight = linesNumber * 36 + (linesNumber - 1) * 12
return (
<svg className='shrink-0 w-6' style={{ height: svgHeight }}>
{
Array(linesNumber).fill(0).map((_, index) => (
<g key={index}>
{
index === 0 && (
<>
<rect
x={0}
y={16}
width={1}
height={4}
fill='#98A2B3'
/>
<path
d='M0,18 L24,18'
strokeWidth={1}
stroke='#D0D5DD'
fill='none'
/>
</>
)
}
{
index > 0 && (
<path
d={`M0,18 Q12,18 12,28 L12,${index * 48 + 18 - 10} Q12,${index * 48 + 18} 24,${index * 48 + 18}`}
strokeWidth={1}
stroke='#D0D5DD'
fill='none'
/>
)
}
<rect
x={23}
y={index * 48 + 18 - 2}
width={1}
height={4}
fill='#98A2B3'
/>
</g>
))
}
</svg>
)
}
export default memo(Line)

View File

@@ -0,0 +1,91 @@
import type { FC } from 'react'
import {
memo,
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
useNodeDataUpdate,
useNodesInteractions,
useNodesSyncDraft,
} from '../../../hooks'
import type { Node } from '../../../types'
import { canRunBySingle } from '../../../utils'
import PanelOperator from './panel-operator'
import {
Play,
Stop,
} from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import TooltipPlus from '@/app/components/base/tooltip-plus'
type NodeControlProps = Pick<Node, 'id' | 'data'>
const NodeControl: FC<NodeControlProps> = ({
id,
data,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const { handleNodeDataUpdate } = useNodeDataUpdate()
const { handleNodeSelect } = useNodesInteractions()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const handleOpenChange = useCallback((newOpen: boolean) => {
setOpen(newOpen)
}, [])
return (
<div
className={`
hidden group-hover:flex pb-1 absolute right-0 -top-7 h-7
${data.selected && '!flex'}
${open && '!flex'}
`}
>
<div
className='flex items-center px-0.5 h-6 bg-white rounded-lg border-[0.5px] border-gray-100 shadow-xs text-gray-500'
onClick={e => e.stopPropagation()}
>
{
canRunBySingle(data.type) && (
<div
className='flex items-center justify-center w-5 h-5 rounded-md cursor-pointer hover:bg-black/5'
onClick={() => {
handleNodeDataUpdate({
id,
data: {
_isSingleRun: !data._isSingleRun,
},
})
handleNodeSelect(id)
if (!data._isSingleRun)
handleSyncWorkflowDraft(true)
}}
>
{
data._isSingleRun
? <Stop className='w-3 h-3' />
: (
<TooltipPlus
popupContent={t('workflow.panel.runThisStep')}
>
<Play className='w-3 h-3' />
</TooltipPlus>
)
}
</div>
)
}
<PanelOperator
id={id}
data={data}
offset={0}
onOpenChange={handleOpenChange}
triggerClassName='!w-5 !h-5'
/>
</div>
</div>
)
}
export default memo(NodeControl)

View File

@@ -0,0 +1,183 @@
import type { MouseEvent } from 'react'
import {
memo,
useCallback,
useEffect,
useState,
} from 'react'
import {
Handle,
Position,
} from 'reactflow'
import { BlockEnum } from '../../../types'
import type { Node } from '../../../types'
import BlockSelector from '../../../block-selector'
import type { ToolDefaultValue } from '../../../block-selector/types'
import {
useNodesExtraData,
useNodesInteractions,
} from '../../../hooks'
import { useStore } from '../../../store'
type NodeHandleProps = {
handleId: string
handleClassName?: string
nodeSelectorClassName?: string
} & Pick<Node, 'id' | 'data'>
export const NodeTargetHandle = memo(({
id,
data,
handleId,
handleClassName,
nodeSelectorClassName,
}: NodeHandleProps) => {
const [open, setOpen] = useState(false)
const { handleNodeAdd } = useNodesInteractions()
const nodesExtraData = useNodesExtraData()
const connected = data._connectedTargetHandleIds?.includes(handleId)
const availablePrevNodes = nodesExtraData[data.type].availablePrevNodes
const isConnectable = !!availablePrevNodes.length
const handleOpenChange = useCallback((v: boolean) => {
setOpen(v)
}, [])
const handleHandleClick = useCallback((e: MouseEvent) => {
e.stopPropagation()
if (!connected)
setOpen(v => !v)
}, [connected])
const handleSelect = useCallback((type: BlockEnum, toolDefaultValue?: ToolDefaultValue) => {
handleNodeAdd(
{
nodeType: type,
toolDefaultValue,
},
{
nextNodeId: id,
nextNodeTargetHandle: handleId,
},
)
}, [handleNodeAdd, id, handleId])
return (
<>
<Handle
id={handleId}
type='target'
position={Position.Left}
className={`
!w-4 !h-4 !bg-transparent !rounded-none !outline-none !border-none z-[1]
after:absolute after:w-0.5 after:h-2 after:left-1.5 after:top-1 after:bg-primary-500
hover:scale-125 transition-all
${!connected && 'after:opacity-0'}
${data.type === BlockEnum.Start && 'opacity-0'}
${handleClassName}
`}
isConnectable={isConnectable}
onClick={handleHandleClick}
>
{
!connected && isConnectable && !data._isInvalidConnection && (
<BlockSelector
open={open}
onOpenChange={handleOpenChange}
onSelect={handleSelect}
asChild
placement='left'
triggerClassName={open => `
hidden absolute left-0 top-0 pointer-events-none
${nodeSelectorClassName}
group-hover:!flex
${data.selected && '!flex'}
${open && '!flex'}
`}
availableBlocksTypes={availablePrevNodes}
/>
)
}
</Handle>
</>
)
})
NodeTargetHandle.displayName = 'NodeTargetHandle'
export const NodeSourceHandle = memo(({
id,
data,
handleId,
handleClassName,
nodeSelectorClassName,
}: NodeHandleProps) => {
const notInitialWorkflow = useStore(s => s.notInitialWorkflow)
const [open, setOpen] = useState(false)
const { handleNodeAdd } = useNodesInteractions()
const nodesExtraData = useNodesExtraData()
const availableNextNodes = nodesExtraData[data.type].availableNextNodes
const isConnectable = !!availableNextNodes.length
const connected = data._connectedSourceHandleIds?.includes(handleId)
const handleOpenChange = useCallback((v: boolean) => {
setOpen(v)
}, [])
const handleHandleClick = useCallback((e: MouseEvent) => {
e.stopPropagation()
if (!connected)
setOpen(v => !v)
}, [connected])
const handleSelect = useCallback((type: BlockEnum, toolDefaultValue?: ToolDefaultValue) => {
handleNodeAdd(
{
nodeType: type,
toolDefaultValue,
},
{
prevNodeId: id,
prevNodeSourceHandle: handleId,
},
)
}, [handleNodeAdd, id, handleId])
useEffect(() => {
if (notInitialWorkflow && data.type === BlockEnum.Start)
setOpen(true)
}, [notInitialWorkflow, data.type])
return (
<>
<Handle
id={handleId}
type='source'
position={Position.Right}
className={`
!w-4 !h-4 !bg-transparent !rounded-none !outline-none !border-none z-[1]
after:absolute after:w-0.5 after:h-2 after:right-1.5 after:top-1 after:bg-primary-500
hover:scale-125 transition-all
${!connected && 'after:opacity-0'}
${handleClassName}
`}
isConnectable={isConnectable}
onClick={handleHandleClick}
>
{
!connected && isConnectable && !data._isInvalidConnection && (
<BlockSelector
open={open}
onOpenChange={handleOpenChange}
onSelect={handleSelect}
asChild
triggerClassName={open => `
hidden absolute top-0 left-0 pointer-events-none
${nodeSelectorClassName}
group-hover:!flex
${data.selected && '!flex'}
${open && '!flex'}
`}
availableBlocksTypes={availableNextNodes}
/>
)
}
</Handle>
</>
)
})
NodeSourceHandle.displayName = 'NodeSourceHandle'

View File

@@ -0,0 +1,81 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import { useBoolean } from 'ahooks'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
type Props = {
className?: string
title?: string
children: JSX.Element
}
const OutputVars: FC<Props> = ({
className,
title,
children,
}) => {
const { t } = useTranslation()
const [isFold, {
toggle: toggleFold,
}] = useBoolean(true)
return (
<div>
<div
onClick={toggleFold}
className={cn(className, 'flex justify-between leading-[18px] text-[13px] font-semibold text-gray-700 uppercase cursor-pointer')}>
<div>{title || t('workflow.nodes.common.outputVars')}</div>
<ChevronRight className='w-4 h-4 text-gray-500 transform transition-transform' style={{ transform: isFold ? 'rotate(0deg)' : 'rotate(90deg)' }} />
</div>
{!isFold && (
<div className='mt-2 space-y-1'>
{children}
</div>
)}
</div>
)
}
type VarItemProps = {
name: string
type: string
description: string
subItems?: {
name: string
type: string
description: string
}[]
}
export const VarItem: FC<VarItemProps> = ({
name,
type,
description,
subItems,
}) => {
return (
<div className='py-1'>
<div className='flex leading-[18px] items-center'>
<div className='text-[13px] font-medium text-gray-900 font-mono'>{name}</div>
<div className='ml-2 text-xs font-normal text-gray-500 capitalize'>{type}</div>
</div>
<div className='mt-0.5 leading-[18px] text-xs font-normal text-gray-600'>
{description}
{subItems && (
<div className='ml-2 border-l border-gray-200 pl-2'>
{subItems.map((item, index) => (
<VarItem
key={index}
name={item.name}
type={item.type}
description={item.description}
/>
))}
</div>
)}
</div>
</div>
)
}
export default React.memo(OutputVars)

View File

@@ -0,0 +1,70 @@
import {
memo,
useCallback,
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import { intersection } from 'lodash-es'
import BlockSelector from '@/app/components/workflow/block-selector'
import {
useNodesExtraData,
useNodesInteractions,
} from '@/app/components/workflow/hooks'
import type {
BlockEnum,
OnSelectBlock,
} from '@/app/components/workflow/types'
type ChangeBlockProps = {
nodeId: string
nodeType: BlockEnum
sourceHandle: string
}
const ChangeBlock = ({
nodeId,
nodeType,
sourceHandle,
}: ChangeBlockProps) => {
const { t } = useTranslation()
const { handleNodeChange } = useNodesInteractions()
const nodesExtraData = useNodesExtraData()
const availablePrevNodes = nodesExtraData[nodeType].availablePrevNodes
const availableNextNodes = nodesExtraData[nodeType].availableNextNodes
const availableNodes = useMemo(() => {
if (availableNextNodes.length && availableNextNodes.length)
return intersection(availablePrevNodes, availableNextNodes)
else if (availablePrevNodes.length)
return availablePrevNodes
else
return availableNextNodes
}, [availablePrevNodes, availableNextNodes])
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
handleNodeChange(nodeId, type, sourceHandle, toolDefaultValue)
}, [handleNodeChange, nodeId, sourceHandle])
const renderTrigger = useCallback(() => {
return (
<div className='flex items-center px-3 w-[232px] h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'>
{t('workflow.panel.changeBlock')}
</div>
)
}, [t])
return (
<BlockSelector
placement='bottom-end'
offset={{
mainAxis: -36,
crossAxis: 4,
}}
onSelect={handleSelect}
trigger={renderTrigger}
popupClassName='min-w-[240px]'
availableBlocksTypes={availableNodes}
/>
)
}
export default memo(ChangeBlock)

View File

@@ -0,0 +1,162 @@
import {
memo,
useCallback,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useEdges } from 'reactflow'
import type { OffsetOptions } from '@floating-ui/react'
import ChangeBlock from './change-block'
import { useStore } from '@/app/components/workflow/store'
import {
useNodesExtraData,
useNodesInteractions,
useNodesReadOnly,
} from '@/app/components/workflow/hooks'
import { DotsHorizontal } from '@/app/components/base/icons/src/vender/line/general'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import type { Node } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { useGetLanguage } from '@/context/i18n'
type PanelOperatorProps = {
id: string
data: Node['data']
triggerClassName?: string
offset?: OffsetOptions
onOpenChange?: (open: boolean) => void
inNode?: boolean
}
const PanelOperator = ({
id,
data,
triggerClassName,
offset = {
mainAxis: 4,
crossAxis: 53,
},
onOpenChange,
inNode,
}: PanelOperatorProps) => {
const { t } = useTranslation()
const language = useGetLanguage()
const edges = useEdges()
const { handleNodeDelete } = useNodesInteractions()
const { nodesReadOnly } = useNodesReadOnly()
const nodesExtraData = useNodesExtraData()
const buildInTools = useStore(s => s.buildInTools)
const customTools = useStore(s => s.customTools)
const [open, setOpen] = useState(false)
const edge = edges.find(edge => edge.target === id)
const author = useMemo(() => {
if (data.type !== BlockEnum.Tool)
return nodesExtraData[data.type].author
if (data.provider_type === 'builtin')
return buildInTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author
return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author
}, [data, nodesExtraData, buildInTools, customTools])
const about = useMemo(() => {
if (data.type !== BlockEnum.Tool)
return nodesExtraData[data.type].about
if (data.provider_type === 'builtin')
return buildInTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language]
return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language]
}, [data, nodesExtraData, language, buildInTools, customTools])
const handleOpenChange = useCallback((newOpen: boolean) => {
setOpen(newOpen)
if (onOpenChange)
onOpenChange(newOpen)
}, [onOpenChange])
return (
<PortalToFollowElem
placement='bottom-end'
offset={offset}
open={open}
onOpenChange={handleOpenChange}
>
<PortalToFollowElemTrigger onClick={() => handleOpenChange(!open)}>
<div
className={`
flex items-center justify-center w-6 h-6 rounded-md cursor-pointer
hover:bg-black/5
${open && 'bg-black/5'}
${triggerClassName}
`}
>
<DotsHorizontal className={`w-4 h-4 ${inNode ? 'text-gray-500' : 'text-gray-700'}`} />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[11]'>
<div className='w-[240px] border-[0.5px] border-gray-200 rounded-lg shadow-xl bg-white'>
<div className='p-1'>
{
data.type !== BlockEnum.Start && !nodesReadOnly && (
<ChangeBlock
nodeId={id}
nodeType={data.type}
sourceHandle={edge?.sourceHandle || 'source'}
/>
)
}
<a
href={
language === 'zh_Hans'
? 'https://docs.dify.ai/v/zh-hans/guides/workflow'
: 'https://docs.dify.ai/features/workflow'
}
target='_blank'
className='flex items-center px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
>
{t('workflow.panel.helpLink')}
</a>
</div>
{
data.type !== BlockEnum.Start && !nodesReadOnly && (
<>
<div className='h-[1px] bg-gray-100'></div>
<div className='p-1'>
<div
className={`
flex items-center px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer
hover:bg-rose-50 hover:text-red-500
`}
onClick={() => handleNodeDelete(id)}
>
{t('common.operation.delete')}
</div>
</div>
</>
)
}
<div className='h-[1px] bg-gray-100'></div>
<div className='p-1'>
<div className='px-3 py-2 text-xs text-gray-500'>
<div className='flex items-center mb-1 h-[22px] font-medium'>
{t('workflow.panel.about').toLocaleUpperCase()}
</div>
<div className='mb-1 text-gray-700 leading-[18px]'>{about}</div>
<div className='leading-[18px]'>
{t('workflow.panel.createdBy')} {author}
</div>
</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(PanelOperator)

View File

@@ -0,0 +1,210 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import cn from 'classnames'
import copy from 'copy-to-clipboard'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import {
BlockEnum,
type Node,
type NodeOutPutVar,
} from '../../../../types'
import ToggleExpandBtn from '@/app/components/workflow/nodes/_base/components/toggle-expand-btn'
import useToggleExpend from '@/app/components/workflow/nodes/_base/hooks/use-toggle-expend'
import PromptEditorHeightResizeWrap from '@/app/components/app/configuration/config-prompt/prompt-editor-height-resize-wrap'
import PromptEditor from '@/app/components/base/prompt-editor'
import { Clipboard, ClipboardCheck } from '@/app/components/base/icons/src/vender/line/files'
import s from '@/app/components/app/configuration/config-prompt/style.module.css'
import { Trash03 } from '@/app/components/base/icons/src/vender/line/general'
import TooltipPlus from '@/app/components/base/tooltip-plus'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { PROMPT_EDITOR_INSERT_QUICKLY } from '@/app/components/base/prompt-editor/plugins/update-block'
type Props = {
instanceId?: string
title: string | JSX.Element
value: string
onChange: (value: string) => void
readOnly?: boolean
showRemove?: boolean
onRemove?: () => void
justVar?: boolean
isChatModel?: boolean
isChatApp?: boolean
isShowContext?: boolean
hasSetBlockStatus?: {
context: boolean
history: boolean
query: boolean
}
nodesOutputVars?: NodeOutPutVar[]
availableNodes?: Node[]
}
const Editor: FC<Props> = ({
instanceId,
title,
value,
onChange,
readOnly,
showRemove,
onRemove,
justVar,
isChatModel,
isChatApp,
isShowContext,
hasSetBlockStatus,
nodesOutputVars,
availableNodes = [],
}) => {
const { t } = useTranslation()
const { eventEmitter } = useEventEmitterContextContext()
const isShowHistory = !isChatModel && isChatApp
const ref = useRef<HTMLDivElement>(null)
const {
wrapClassName,
isExpand,
setIsExpand,
editorExpandHeight,
} = useToggleExpend({ ref })
const minHeight = 98
const [editorHeight, setEditorHeight] = React.useState(minHeight)
const [isCopied, setIsCopied] = React.useState(false)
const handleCopy = useCallback(() => {
copy(value)
setIsCopied(true)
}, [value])
const [isFocus, {
setTrue: setFocus,
setFalse: setBlur,
}] = useBoolean(false)
const hideTooltipRunId = useRef(0)
const [isShowInsertToolTip, setIsShowInsertTooltip] = useState(false)
useEffect(() => {
if (isFocus) {
clearTimeout(hideTooltipRunId.current)
setIsShowInsertTooltip(true)
}
else {
hideTooltipRunId.current = setTimeout(() => {
setIsShowInsertTooltip(false)
}, 100) as any
}
}, [isFocus])
const handleInsertVariable = () => {
setFocus()
eventEmitter?.emit({ type: PROMPT_EDITOR_INSERT_QUICKLY, instanceId } as any)
}
return (
<div className={cn(wrapClassName)}>
<div ref={ref} className={cn(isFocus ? s.gradientBorder : 'bg-gray-100', isExpand && 'h-full', '!rounded-[9px] p-0.5')}>
<div className={cn(isFocus ? 'bg-gray-50' : 'bg-gray-100', isExpand && 'h-full flex flex-col', 'rounded-lg')}>
<div className='pt-1 pl-3 pr-2 flex justify-between h-6 items-center'>
<div className='leading-4 text-xs font-semibold text-gray-700 uppercase'>{title}</div>
<div className='flex items-center'>
<div className='leading-[18px] text-xs font-medium text-gray-500'>{value?.length || 0}</div>
<div className='w-px h-3 ml-2 mr-2 bg-gray-200'></div>
{/* Operations */}
<div className='flex items-center space-x-2'>
{showRemove && (
<Trash03 className='w-3.5 h-3.5 text-gray-500 cursor-pointer' onClick={onRemove} />
)}
{!isCopied
? (
<Clipboard className='w-3.5 h-3.5 text-gray-500 cursor-pointer' onClick={handleCopy} />
)
: (
<ClipboardCheck className='mx-1 w-3.5 h-3.5 text-gray-500' />
)
}
<ToggleExpandBtn isExpand={isExpand} onExpandChange={setIsExpand} />
</div>
</div>
</div>
<PromptEditorHeightResizeWrap
className={cn(isExpand && 'h-0 grow', 'px-3 min-h-[102px] overflow-y-auto text-sm text-gray-700')}
height={isExpand ? editorExpandHeight : editorHeight}
minHeight={minHeight}
onHeightChange={setEditorHeight}
footer={(
<div className='pl-3 pb-2 flex'>
{(isFocus || isShowInsertToolTip)
? (
<TooltipPlus
popupContent={`${t('workflow.common.insertVarTip')}`}
>
<div
className="h-[18px] leading-[18px] px-1 rounded-md bg-gray-100 text-xs text-gray-500"
onClick={handleInsertVariable}
>{'{x} '}{t('workflow.nodes.common.insertVarTip')}</div>
</TooltipPlus>)
: <div className='h-[18px]'></div>}
</div>
)}
hideResize={isExpand}
>
<>
<PromptEditor
instanceId={instanceId}
className={cn('min-h-[84px]')}
compact
style={isExpand ? { height: editorExpandHeight - 5 } : {}}
value={value}
contextBlock={{
show: justVar ? false : isShowContext,
selectable: !hasSetBlockStatus?.context,
canNotAddContext: true,
}}
historyBlock={{
show: justVar ? false : isShowHistory,
selectable: !hasSetBlockStatus?.history,
history: {
user: 'Human',
assistant: 'Assistant',
},
}}
queryBlock={{
show: false, // use [sys.query] instead of query block
selectable: false,
}}
workflowVariableBlock={{
show: true,
variables: nodesOutputVars || [],
workflowNodesMap: availableNodes.reduce((acc, node) => {
acc[node.id] = {
title: node.data.title,
type: node.data.type,
}
if (node.data.type === BlockEnum.Start) {
acc.sys = {
title: t('workflow.blocks.start'),
type: BlockEnum.Start,
}
}
return acc
}, {} as any),
}}
onChange={onChange}
onBlur={setBlur}
onFocus={setFocus}
editable={!readOnly}
/>
{/* to patch Editor not support dynamic change editable status */}
{readOnly && <div className='absolute inset-0 z-10'></div>}
</>
</PromptEditorHeightResizeWrap>
</div>
</div>
</div>
)
}
export default React.memo(Editor)

View File

@@ -0,0 +1,72 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useWorkflow } from '../../../hooks'
import { BlockEnum } from '../../../types'
import { VarBlockIcon } from '../../../block-icon'
import { getNodeInfoById, isSystemVar } from './variable/utils'
import { Line3 } from '@/app/components/base/icons/src/public/common'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
type Props = {
nodeId: string
value: string
}
const VAR_PLACEHOLDER = '@#!@#!'
const ReadonlyInputWithSelectVar: FC<Props> = ({
nodeId,
value,
}) => {
const { getBeforeNodesInSameBranch } = useWorkflow()
const availableNodes = getBeforeNodesInSameBranch(nodeId)
const startNode = availableNodes.find((node: any) => {
return node.data.type === BlockEnum.Start
})
const res = (() => {
const vars: string[] = []
const strWithVarPlaceholder = value.replaceAll(/{{#([^#]*)#}}/g, (_match, p1) => {
vars.push(p1)
return VAR_PLACEHOLDER
})
const html: JSX.Element[] = strWithVarPlaceholder.split(VAR_PLACEHOLDER).map((str, index) => {
if (!vars[index])
return <span className='relative top-[-3px] leading-[16px]' key={index}>{str}</span>
const value = vars[index].split('.')
const isSystem = isSystemVar(value)
const node = (isSystem ? startNode : getNodeInfoById(availableNodes, value[0]))?.data
const varName = `${isSystem ? 'sys.' : ''}${value[value.length - 1]}`
return (<span key={index}>
<span className='relative top-[-3px] leading-[16px]'>{str}</span>
<div className=' inline-flex h-[16px] items-center px-1.5 rounded-[5px] bg-white'>
<div className='flex items-center'>
<div className='p-[1px]'>
<VarBlockIcon
className='!text-gray-900'
type={node?.type || BlockEnum.Start}
/>
</div>
<div className='max-w-[60px] mx-0.5 text-xs font-medium text-gray-700 truncate' title={node?.title}>{node?.title}</div>
<Line3 className='mr-0.5'></Line3>
</div>
<div className='flex items-center text-primary-600'>
<Variable02 className='w-3.5 h-3.5' />
<div className='max-w-[50px] ml-0.5 text-xs font-medium truncate' title={varName}>{varName}</div>
</div>
</div>
</span>)
})
return html
})()
return (
<div className='break-all text-xs'>
{res}
</div>
)
}
export default React.memo(ReadonlyInputWithSelectVar)

View File

@@ -0,0 +1,25 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from 'classnames'
import { Trash03 } from '@/app/components/base/icons/src/vender/line/general'
type Props = {
className?: string
onClick: (e: React.MouseEvent) => void
}
const Remove: FC<Props> = ({
className,
onClick,
}) => {
return (
<div
className={cn(className, 'p-1 cursor-pointer rounded-md hover:bg-black/5 text-gray-500 hover:text-gray-800')}
onClick={onClick}
>
<Trash03 className='w-4 h-4' />
</div>
)
}
export default React.memo(Remove)

View File

@@ -0,0 +1,32 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Confirm from '@/app/components/base/confirm'
type Props = {
isShow: boolean
onConfirm: () => void
onCancel: () => void
}
const i18nPrefix = 'workflow.common.effectVarConfirm'
const RemoveVarConfirm: FC<Props> = ({
isShow,
onConfirm,
onCancel,
}) => {
const { t } = useTranslation()
return (
<Confirm
isShow={isShow}
title={t(`${i18nPrefix}.title`)}
content={t(`${i18nPrefix}.content`)}
onConfirm={onConfirm}
onCancel={onCancel}
onClose={onCancel}
/>
)
}
export default React.memo(RemoveVarConfirm)

View File

@@ -0,0 +1,91 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useBoolean, useClickAway } from 'ahooks'
import cn from 'classnames'
import { ChevronSelectorVertical } from '@/app/components/base/icons/src/vender/line/arrows'
import { Check } from '@/app/components/base/icons/src/vender/line/general'
type Item = {
value: string
label: string
}
type Props = {
trigger?: JSX.Element
DropDownIcon?: any
noLeft?: boolean
options: Item[]
value: string
placeholder?: string
onChange: (value: any) => void
uppercase?: boolean
popupClassName?: string
triggerClassName?: string
itemClassName?: string
readonly?: boolean
showChecked?: boolean
}
const TypeSelector: FC<Props> = ({
trigger,
DropDownIcon = ChevronSelectorVertical,
noLeft,
options: list,
value,
placeholder = '',
onChange,
uppercase,
triggerClassName,
popupClassName,
itemClassName,
readonly,
showChecked,
}) => {
const noValue = value === '' || value === undefined || value === null
const item = list.find(item => item.value === value)
const [showOption, { setFalse: setHide, toggle: toggleShow }] = useBoolean(false)
const ref = React.useRef(null)
useClickAway(() => {
setHide()
}, ref)
return (
<div className={cn(!trigger && !noLeft && 'left-[-8px]', 'relative')} ref={ref}>
{trigger
? (
<div
onClick={toggleShow}
>
{trigger}
</div>
)
: (
<div
onClick={toggleShow}
className={cn(showOption && 'bg-black/5', 'flex items-center h-5 pl-1 pr-0.5 rounded-md text-xs font-semibold text-gray-700 cursor-pointer hover:bg-black/5')}>
<div className={cn(triggerClassName, 'text-sm font-semibold', uppercase && 'uppercase', noValue && 'text-gray-400')}>{!noValue ? item?.label : placeholder}</div>
{!readonly && <DropDownIcon className='w-3 h-3 ' />}
</div>
)}
{(showOption && !readonly) && (
<div className={cn(popupClassName, 'absolute z-10 top-[24px] w-[120px] p-1 border border-gray-200 shadow-lg rounded-lg bg-white')}>
{list.map(item => (
<div
key={item.value}
onClick={() => {
setHide()
onChange(item.value)
}}
className={cn(itemClassName, uppercase && 'uppercase', 'flex items-center h-[30px] justify-between min-w-[44px] px-3 rounded-lg cursor-pointer text-[13px] font-medium text-gray-700 hover:bg-gray-50')}
>
<div>{item.label}</div>
{showChecked && item.value === value && <Check className='text-primary-600 w-4 h-4' />}
</div>
))
}
</div>
)
}
</div>
)
}
export default React.memo(TypeSelector)

View File

@@ -0,0 +1,18 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from 'classnames'
type Props = {
className?: string
}
const Split: FC<Props> = ({
className,
}) => {
return (
<div className={cn(className, 'h-[0.5px] bg-black/5')}>
</div>
)
}
export default React.memo(Split)

View File

@@ -0,0 +1,52 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from 'classnames'
import { varHighlightHTML } from '@/app/components/app/configuration/base/var-highlight'
type Props = {
isFocus?: boolean
onFocus?: () => void
value: string
children?: React.ReactNode
wrapClassName?: string
textClassName?: string
readonly?: boolean
}
const SupportVarInput: FC<Props> = ({
isFocus,
onFocus,
children,
value,
wrapClassName,
textClassName,
readonly,
}) => {
const withHightContent = (value || '')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\{\{([^}]+)\}\}/g, varHighlightHTML({ name: '$1', className: '!mb-0' })) // `<span class="${highLightClassName}">{{$1}}</span>`
.replace(/\n/g, '<br />')
return (
<div
className={
cn(wrapClassName, 'flex w-full h-full')
} onClick={onFocus}
>
{(isFocus && !readonly && children)
? (
children
)
: (
<div
className={cn(textClassName, 'w-0 grow h-full whitespace-nowrap truncate')}
title={value}
dangerouslySetInnerHTML={{
__html: withHightContent,
}}></div>
)}
</div>
)
}
export default React.memo(SupportVarInput)

View File

@@ -0,0 +1,89 @@
import {
memo,
useCallback,
useState,
} from 'react'
import Textarea from 'rc-textarea'
import { useTranslation } from 'react-i18next'
type TitleInputProps = {
value: string
onBlur: (value: string) => void
}
export const TitleInput = memo(({
value,
onBlur,
}: TitleInputProps) => {
const { t } = useTranslation()
const [localValue, setLocalValue] = useState(value)
const handleBlur = () => {
if (!localValue) {
setLocalValue(value)
onBlur(value)
return
}
onBlur(localValue)
}
return (
<input
value={localValue}
onChange={e => setLocalValue(e.target.value)}
className={`
grow mr-2 px-1 h-6 text-base text-gray-900 font-semibold rounded-lg border border-transparent appearance-none outline-none
hover:bg-gray-50
focus:border-gray-300 focus:shadow-xs focus:bg-white caret-[#295EFF]
`}
placeholder={t('workflow.common.addTitle') || ''}
onBlur={handleBlur}
/>
)
})
TitleInput.displayName = 'TitleInput'
type DescriptionInputProps = {
value: string
onChange: (value: string) => void
}
export const DescriptionInput = memo(({
value,
onChange,
}: DescriptionInputProps) => {
const { t } = useTranslation()
const [focus, setFocus] = useState(false)
const handleFocus = useCallback(() => {
setFocus(true)
}, [])
const handleBlur = useCallback(() => {
setFocus(false)
}, [])
return (
<div
className={`
group flex px-2 py-[5px] max-h-[60px] rounded-lg overflow-y-auto
border border-transparent hover:bg-gray-50 leading-0
${focus && '!border-gray-300 shadow-xs !bg-gray-50'}
`}
>
<Textarea
value={value}
onChange={e => onChange(e.target.value)}
rows={1}
onFocus={handleFocus}
onBlur={handleBlur}
className={`
w-full text-xs text-gray-900 leading-[18px] bg-transparent
appearance-none outline-none resize-none
placeholder:text-gray-400 caret-[#295EFF]
`}
placeholder={t('workflow.common.addDescription') || ''}
autoSize
/>
</div>
)
})
DescriptionInput.displayName = 'DescriptionInput'

View File

@@ -0,0 +1,25 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { Expand04 } from '@/app/components/base/icons/src/vender/solid/arrows'
import { Collapse04 } from '@/app/components/base/icons/src/vender/line/arrows'
type Props = {
isExpand: boolean
onExpandChange: (isExpand: boolean) => void
}
const ExpandBtn: FC<Props> = ({
isExpand,
onExpandChange,
}) => {
const handleToggle = useCallback(() => {
onExpandChange(!isExpand)
}, [isExpand])
const Icon = isExpand ? Collapse04 : Expand04
return (
<Icon className='w-3.5 h-3.5 text-gray-500 cursor-pointer' onClick={handleToggle} />
)
}
export default React.memo(ExpandBtn)

View File

@@ -0,0 +1,108 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import produce from 'immer'
import { useTranslation } from 'react-i18next'
import type { OutputVar } from '../../../code/types'
import RemoveButton from '../remove-button'
import VarTypePicker from './var-type-picker'
import type { VarType } from '@/app/components/workflow/types'
import { checkKeys } from '@/utils/var'
import Toast from '@/app/components/base/toast'
type Props = {
readonly: boolean
outputs: OutputVar
outputKeyOrders: string[]
onChange: (payload: OutputVar, changedIndex?: number, newKey?: string) => void
onRemove: (index: number) => void
}
const OutputVarList: FC<Props> = ({
readonly,
outputs,
outputKeyOrders,
onChange,
onRemove,
}) => {
const { t } = useTranslation()
const list = outputKeyOrders.map((key) => {
return {
variable: key,
variable_type: outputs[key]?.type,
}
})
const handleVarNameChange = useCallback((index: number) => {
return (e: React.ChangeEvent<HTMLInputElement>) => {
const oldKey = list[index].variable
const newKey = e.target.value
const { isValid, errorKey, errorMessageKey } = checkKeys([newKey], true)
if (!isValid) {
Toast.notify({
type: 'error',
message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: errorKey }),
})
return
}
if (list.map(item => item.variable?.trim()).includes(newKey.trim())) {
Toast.notify({
type: 'error',
message: t('appDebug.varKeyError.keyAlreadyExists', { key: newKey }),
})
return
}
const newOutputs = produce(outputs, (draft) => {
draft[newKey] = draft[oldKey]
delete draft[oldKey]
})
onChange(newOutputs, index, newKey)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [list, onChange, outputs, outputKeyOrders])
const handleVarTypeChange = useCallback((index: number) => {
return (value: string) => {
const key = list[index].variable
const newOutputs = produce(outputs, (draft) => {
draft[key].type = value as VarType
})
onChange(newOutputs)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [list, onChange, outputs, outputKeyOrders])
const handleVarRemove = useCallback((index: number) => {
return () => {
onRemove(index)
}
}, [onRemove])
return (
<div className='space-y-2'>
{list.map((item, index) => (
<div className='flex items-center space-x-1' key={index}>
<input
readOnly={readonly}
value={item.variable}
onChange={handleVarNameChange(index)}
className='w-0 grow h-8 leading-8 px-2.5 rounded-lg border-0 bg-gray-100 text-gray-900 text-[13px] placeholder:text-gray-400 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-gray-200'
type='text' />
<VarTypePicker
readonly={readonly}
value={item.variable_type}
onChange={handleVarTypeChange(index)}
/>
<RemoveButton
className='!p-2 !bg-gray-100 hover:!bg-gray-200'
onClick={handleVarRemove(index)}
/>
</div>
))}
</div>
)
}
export default React.memo(OutputVarList)

View File

@@ -0,0 +1,542 @@
import produce from 'immer'
import { isArray, uniq } from 'lodash-es'
import type { CodeNodeType } from '../../../code/types'
import type { EndNodeType } from '../../../end/types'
import type { AnswerNodeType } from '../../../answer/types'
import type { LLMNodeType } from '../../../llm/types'
import type { KnowledgeRetrievalNodeType } from '../../../knowledge-retrieval/types'
import type { IfElseNodeType } from '../../../if-else/types'
import type { TemplateTransformNodeType } from '../../../template-transform/types'
import type { QuestionClassifierNodeType } from '../../../question-classifier/types'
import type { HttpNodeType } from '../../../http/types'
import { VarType as ToolVarType } from '../../../tool/types'
import type { ToolNodeType } from '../../../tool/types'
import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types'
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import type { VariableAssignerNodeType } from '@/app/components/workflow/nodes/variable-assigner/types'
import {
CHAT_QUESTION_CLASSIFIER_OUTPUT_STRUCT,
COMPLETION_QUESTION_CLASSIFIER_OUTPUT_STRUCT,
HTTP_REQUEST_OUTPUT_STRUCT,
KNOWLEDGE_RETRIEVAL_OUTPUT_STRUCT,
LLM_OUTPUT_STRUCT,
SUPPORT_OUTPUT_VARS_NODE,
TEMPLATE_TRANSFORM_OUTPUT_STRUCT,
TOOL_OUTPUT_STRUCT,
} from '@/app/components/workflow/constants'
import type { PromptItem } from '@/models/debug'
import { VAR_REGEX } from '@/config'
const inputVarTypeToVarType = (type: InputVarType): VarType => {
if (type === InputVarType.number)
return VarType.number
return VarType.string
}
const findExceptVarInObject = (obj: any, filterVar: (payload: Var, selector: ValueSelector) => boolean, value_selector: ValueSelector): Var => {
const { children } = obj
const res: Var = {
variable: obj.variable,
type: VarType.object,
children: children.filter((item: Var) => {
const { children } = item
const currSelector = [...value_selector, item.variable]
if (!children)
return filterVar(item, currSelector)
const obj = findExceptVarInObject(item, filterVar, currSelector)
return obj.children && obj.children?.length > 0
}),
}
return res
}
const formatItem = (item: any, isChatMode: boolean, filterVar: (payload: Var, selector: ValueSelector) => boolean): NodeOutPutVar => {
const { id, data } = item
const res: NodeOutPutVar = {
nodeId: id,
title: data.title,
vars: [],
}
switch (data.type) {
case BlockEnum.Start: {
const {
variables,
} = data as StartNodeType
res.vars = variables.map((v) => {
return {
variable: v.variable,
type: inputVarTypeToVarType(v.type),
isParagraph: v.type === InputVarType.paragraph,
isSelect: v.type === InputVarType.select,
options: v.options,
required: v.required,
}
})
if (isChatMode) {
res.vars.push({
variable: 'sys.query',
type: VarType.string,
})
}
res.vars.push({
variable: 'sys.files',
type: VarType.arrayFile,
})
break
}
case BlockEnum.LLM: {
res.vars = LLM_OUTPUT_STRUCT
break
}
case BlockEnum.KnowledgeRetrieval: {
res.vars = KNOWLEDGE_RETRIEVAL_OUTPUT_STRUCT
break
}
case BlockEnum.Code: {
const {
outputs,
} = data as CodeNodeType
res.vars = Object.keys(outputs).map((key) => {
return {
variable: key,
type: outputs[key].type,
}
})
break
}
case BlockEnum.TemplateTransform: {
res.vars = TEMPLATE_TRANSFORM_OUTPUT_STRUCT
break
}
case BlockEnum.QuestionClassifier: {
res.vars = isChatMode ? CHAT_QUESTION_CLASSIFIER_OUTPUT_STRUCT : COMPLETION_QUESTION_CLASSIFIER_OUTPUT_STRUCT
break
}
case BlockEnum.HttpRequest: {
res.vars = HTTP_REQUEST_OUTPUT_STRUCT
break
}
case BlockEnum.VariableAssigner: {
const {
output_type,
} = data as VariableAssignerNodeType
res.vars = [
{
variable: 'output',
type: output_type,
},
]
break
}
case BlockEnum.Tool: {
res.vars = TOOL_OUTPUT_STRUCT
break
}
}
const selector = [id]
res.vars = res.vars.filter((v) => {
const { children } = v
if (!children)
return filterVar(v, selector)
const obj = findExceptVarInObject(v, filterVar, selector)
return obj?.children && obj?.children.length > 0
}).map((v) => {
const { children } = v
if (!children)
return v
return findExceptVarInObject(v, filterVar, selector)
})
return res
}
export const toNodeOutputVars = (nodes: any[], isChatMode: boolean, filterVar = (_payload: Var, _selector: ValueSelector) => true): NodeOutPutVar[] => {
const res = nodes
.filter(node => SUPPORT_OUTPUT_VARS_NODE.includes(node.data.type))
.map((node) => {
return {
...formatItem(node, isChatMode, filterVar),
isStartNode: node.data.type === BlockEnum.Start,
}
})
.filter(item => item.vars.length > 0)
return res
}
export const isSystemVar = (valueSelector: ValueSelector) => {
return valueSelector[0] === 'sys' || valueSelector[1] === 'sys'
}
export const getNodeInfoById = (nodes: any, id: string) => {
if (!isArray(nodes))
return
return nodes.find((node: any) => node.id === id)
}
export const getVarType = (value: ValueSelector, availableNodes: any[], isChatMode: boolean): VarType | undefined => {
const isSystem = isSystemVar(value)
const startNode = availableNodes.find((node: any) => {
return node.data.type === BlockEnum.Start
})
const allOutputVars = toNodeOutputVars(availableNodes, isChatMode)
const targetVarNodeId = isSystem ? startNode?.id : value[0]
const targetVar = allOutputVars.find(v => v.nodeId === targetVarNodeId)
if (!targetVar)
return undefined
let type: VarType = VarType.string
let curr: any = targetVar.vars
if (isSystem) {
return curr.find((v: any) => v.variable === (value as ValueSelector).join('.'))?.type
}
else {
(value as ValueSelector).slice(1).forEach((key, i) => {
const isLast = i === value.length - 2
curr = curr.find((v: any) => v.variable === key)
if (isLast) {
type = curr?.type
}
else {
if (curr.type === VarType.object)
curr = curr.children
}
})
return type
}
}
const matchNotSystemVars = (prompts: string[]) => {
if (!prompts)
return []
const allVars: string[] = []
prompts.forEach((prompt) => {
VAR_REGEX.lastIndex = 0
allVars.push(...(prompt.match(VAR_REGEX) || []))
})
const uniqVars = uniq(allVars).map(v => v.replaceAll('{{#', '').replace('#}}', '').split('.'))
return uniqVars
}
export const getNodeUsedVars = (node: Node): ValueSelector[] => {
const { data } = node
const { type } = data
let res: ValueSelector[] = []
switch (type) {
case BlockEnum.End: {
res = (data as EndNodeType).outputs?.map((output) => {
return output.value_selector
})
break
}
case BlockEnum.Answer: {
res = (data as AnswerNodeType).variables?.map((v) => {
return v.value_selector
})
break
}
case BlockEnum.LLM: {
const payload = (data as LLMNodeType)
const isChatModel = payload.model?.mode === 'chat'
let prompts: string[] = []
if (isChatModel)
prompts = (payload.prompt_template as PromptItem[])?.map(p => p.text) || []
else
prompts = [(payload.prompt_template as PromptItem).text]
const inputVars: ValueSelector[] = matchNotSystemVars(prompts)
const contextVar = (data as LLMNodeType).context?.variable_selector ? [(data as LLMNodeType).context?.variable_selector] : []
res = [...inputVars, ...contextVar]
break
}
case BlockEnum.KnowledgeRetrieval: {
res = [(data as KnowledgeRetrievalNodeType).query_variable_selector]
break
}
case BlockEnum.IfElse: {
res = (data as IfElseNodeType).conditions?.map((c) => {
return c.variable_selector
})
break
}
case BlockEnum.Code: {
res = (data as CodeNodeType).variables?.map((v) => {
return v.value_selector
})
break
}
case BlockEnum.TemplateTransform: {
res = (data as TemplateTransformNodeType).variables?.map((v: any) => {
return v.value_selector
})
break
}
case BlockEnum.QuestionClassifier: {
res = [(data as QuestionClassifierNodeType).query_variable_selector]
break
}
case BlockEnum.HttpRequest: {
const payload = (data as HttpNodeType)
res = matchNotSystemVars([payload.url, payload.headers, payload.params, payload.body.data])
break
}
case BlockEnum.Tool: {
const payload = (data as ToolNodeType)
const mixVars = matchNotSystemVars(Object.keys(payload.tool_parameters)?.filter(key => payload.tool_parameters[key].type === ToolVarType.mixed).map(key => payload.tool_parameters[key].value) as string[])
const vars = Object.keys(payload.tool_parameters).filter(key => payload.tool_parameters[key].type === ToolVarType.variable).map(key => payload.tool_parameters[key].value as string) || []
res = [...(mixVars as ValueSelector[]), ...(vars as any)]
break
}
case BlockEnum.VariableAssigner: {
res = (data as VariableAssignerNodeType)?.variables
}
}
return res || []
}
export const findUsedVarNodes = (varSelector: ValueSelector, availableNodes: Node[]): Node[] => {
const res: Node[] = []
availableNodes.forEach((node) => {
const vars = getNodeUsedVars(node)
if (vars.find(v => v.join('.') === varSelector.join('.')))
res.push(node)
})
return res
}
export const updateNodeVars = (oldNode: Node, oldVarSelector: ValueSelector, newVarSelector: ValueSelector): Node => {
const newNode = produce(oldNode, (draft: any) => {
const { data } = draft
const { type } = data
switch (type) {
case BlockEnum.End: {
const payload = data as EndNodeType
if (payload.outputs) {
payload.outputs = payload.outputs.map((output) => {
if (output.value_selector.join('.') === oldVarSelector.join('.'))
output.value_selector = newVarSelector
return output
})
}
break
}
case BlockEnum.Answer: {
const payload = data as AnswerNodeType
if (payload.variables) {
payload.variables = payload.variables.map((v) => {
if (v.value_selector.join('.') === oldVarSelector.join('.'))
v.value_selector = newVarSelector
return v
})
}
break
}
case BlockEnum.LLM: {
const payload = data as LLMNodeType
// TODO: update in inputs
// if (payload.variables) {
// payload.variables = payload.variables.map((v) => {
// if (v.value_selector.join('.') === oldVarSelector.join('.'))
// v.value_selector = newVarSelector
// return v
// })
// }
if (payload.context?.variable_selector?.join('.') === oldVarSelector.join('.'))
payload.context.variable_selector = newVarSelector
break
}
case BlockEnum.KnowledgeRetrieval: {
const payload = data as KnowledgeRetrievalNodeType
if (payload.query_variable_selector.join('.') === oldVarSelector.join('.'))
payload.query_variable_selector = newVarSelector
break
}
case BlockEnum.IfElse: {
const payload = data as IfElseNodeType
if (payload.conditions) {
payload.conditions = payload.conditions.map((c) => {
if (c.variable_selector.join('.') === oldVarSelector.join('.'))
c.variable_selector = newVarSelector
return c
})
}
break
}
case BlockEnum.Code: {
const payload = data as CodeNodeType
if (payload.variables) {
payload.variables = payload.variables.map((v) => {
if (v.value_selector.join('.') === oldVarSelector.join('.'))
v.value_selector = newVarSelector
return v
})
}
break
}
case BlockEnum.TemplateTransform: {
const payload = data as TemplateTransformNodeType
if (payload.variables) {
payload.variables = payload.variables.map((v: any) => {
if (v.value_selector.join('.') === oldVarSelector.join('.'))
v.value_selector = newVarSelector
return v
})
}
break
}
case BlockEnum.QuestionClassifier: {
const payload = data as QuestionClassifierNodeType
if (payload.query_variable_selector.join('.') === oldVarSelector.join('.'))
payload.query_variable_selector = newVarSelector
break
}
case BlockEnum.HttpRequest: {
// TODO: update in inputs
// const payload = data as HttpNodeType
// if (payload.variables) {
// payload.variables = payload.variables.map((v) => {
// if (v.value_selector.join('.') === oldVarSelector.join('.'))
// v.value_selector = newVarSelector
// return v
// })
// }
break
}
case BlockEnum.Tool: {
// TODO: update in inputs
// const payload = data as ToolNodeType
// if (payload.tool_parameters) {
// payload.tool_parameters = payload.tool_parameters.map((v) => {
// if (v.type === VarKindType.static)
// return v
// if (v.value_selector?.join('.') === oldVarSelector.join('.'))
// v.value_selector = newVarSelector
// return v
// })
// }
break
}
case BlockEnum.VariableAssigner: {
const payload = data as VariableAssignerNodeType
if (payload.variables) {
payload.variables = payload.variables.map((v) => {
if (v.join('.') === oldVarSelector.join('.'))
v = newVarSelector
return v
})
}
break
}
}
})
return newNode
}
const varToValueSelectorList = (v: Var, parentValueSelector: ValueSelector, res: ValueSelector[]) => {
if (!v.variable)
return
res.push([...parentValueSelector, v.variable])
if (v.children && v.children.length > 0) {
v.children.forEach((child) => {
varToValueSelectorList(child, [...parentValueSelector, v.variable], res)
})
}
}
const varsToValueSelectorList = (vars: Var | Var[], parentValueSelector: ValueSelector, res: ValueSelector[]) => {
if (Array.isArray(vars)) {
vars.forEach((v) => {
varToValueSelectorList(v, parentValueSelector, res)
})
}
varToValueSelectorList(vars as Var, parentValueSelector, res)
}
export const getNodeOutputVars = (node: Node, isChatMode: boolean): ValueSelector[] => {
const { data, id } = node
const { type } = data
let res: ValueSelector[] = []
switch (type) {
case BlockEnum.Start: {
const {
variables,
} = data as StartNodeType
res = variables.map((v) => {
return [id, v.variable]
})
if (isChatMode) {
res.push([id, 'sys', 'query'])
res.push([id, 'sys', 'files'])
}
break
}
case BlockEnum.LLM: {
varsToValueSelectorList(LLM_OUTPUT_STRUCT, [id], res)
break
}
case BlockEnum.KnowledgeRetrieval: {
varsToValueSelectorList(KNOWLEDGE_RETRIEVAL_OUTPUT_STRUCT, [id], res)
break
}
case BlockEnum.Code: {
const {
outputs,
} = data as CodeNodeType
Object.keys(outputs).forEach((key) => {
res.push([id, key])
})
break
}
case BlockEnum.TemplateTransform: {
varsToValueSelectorList(TEMPLATE_TRANSFORM_OUTPUT_STRUCT, [id], res)
break
}
case BlockEnum.QuestionClassifier: {
varsToValueSelectorList(isChatMode ? CHAT_QUESTION_CLASSIFIER_OUTPUT_STRUCT : COMPLETION_QUESTION_CLASSIFIER_OUTPUT_STRUCT, [id], res)
break
}
case BlockEnum.HttpRequest: {
varsToValueSelectorList(HTTP_REQUEST_OUTPUT_STRUCT, [id], res)
break
}
case BlockEnum.VariableAssigner: {
res.push([id, 'output'])
break
}
case BlockEnum.Tool: {
varsToValueSelectorList(TOOL_OUTPUT_STRUCT, [id], res)
break
}
}
return res
}

View File

@@ -0,0 +1,106 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import produce from 'immer'
import RemoveButton from '../remove-button'
import VarReferencePicker from './var-reference-picker'
import type { ValueSelector, Var, Variable } from '@/app/components/workflow/types'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
type Props = {
nodeId: string
readonly: boolean
list: Variable[]
onChange: (list: Variable[]) => void
isSupportConstantValue?: boolean
onlyLeafNodeVar?: boolean
filterVar?: (payload: Var, valueSelector: ValueSelector) => boolean
}
const VarList: FC<Props> = ({
nodeId,
readonly,
list,
onChange,
isSupportConstantValue,
onlyLeafNodeVar,
filterVar,
}) => {
const { t } = useTranslation()
const handleVarNameChange = useCallback((index: number) => {
return (e: React.ChangeEvent<HTMLInputElement>) => {
const newList = produce(list, (draft) => {
draft[index].variable = e.target.value
})
onChange(newList)
}
}, [list, onChange])
const handleVarReferenceChange = useCallback((index: number) => {
return (value: ValueSelector | string, varKindType: VarKindType) => {
const newList = produce(list, (draft) => {
if (!isSupportConstantValue || varKindType === VarKindType.variable) {
draft[index].value_selector = value as ValueSelector
if (isSupportConstantValue)
draft[index].variable_type = VarKindType.variable
if (!draft[index].variable)
draft[index].variable = value[value.length - 1]
}
else {
draft[index].variable_type = VarKindType.constant
draft[index].value_selector = value as ValueSelector
draft[index].value = value as string
}
})
onChange(newList)
}
}, [isSupportConstantValue, list, onChange])
const handleVarRemove = useCallback((index: number) => {
return () => {
const newList = produce(list, (draft) => {
draft.splice(index, 1)
})
onChange(newList)
}
}, [list, onChange])
return (
<div className='space-y-2'>
{list.map((item, index) => (
<div className='flex items-center space-x-1' key={index}>
<input
readOnly={readonly}
value={list[index].variable}
onChange={handleVarNameChange(index)}
placeholder={t('workflow.common.variableNamePlaceholder')!}
className='w-[120px] h-8 leading-8 px-2.5 rounded-lg border-0 bg-gray-100 text-gray-900 text-[13px] placeholder:text-gray-400 focus:outline-none focus:ring-1 focus:ring-inset focus:ring-gray-200'
type='text'
/>
<VarReferencePicker
nodeId={nodeId}
readonly={readonly}
isShowNodeName
className='grow'
value={item.variable_type === VarKindType.constant ? (item.value || '') : (item.value_selector || [])}
isSupportConstantValue={isSupportConstantValue}
onChange={handleVarReferenceChange(index)}
defaultVarKindType={item.variable_type}
onlyLeafNodeVar={onlyLeafNodeVar}
filterVar={filterVar}
/>
{!readonly && (
<RemoveButton
className='!p-2 !bg-gray-100 hover:!bg-gray-200'
onClick={handleVarRemove(index)}
/>
)}
</div>
))}
</div>
)
}
export default React.memo(VarList)

View File

@@ -0,0 +1,292 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import produce from 'immer'
import VarReferencePopup from './var-reference-popup'
import { getNodeInfoById, isSystemVar, toNodeOutputVars } from './utils'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { VarBlockIcon } from '@/app/components/workflow/block-icon'
import { Line3 } from '@/app/components/base/icons/src/public/common'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import {
useIsChatMode,
useWorkflow,
} from '@/app/components/workflow/hooks'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
import TypeSelector from '@/app/components/workflow/nodes/_base/components/selector'
import { ChevronDown } from '@/app/components/base/icons/src/vender/line/arrows'
import { XClose } from '@/app/components/base/icons/src/vender/line/general'
const TRIGGER_DEFAULT_WIDTH = 227
type Props = {
className?: string
nodeId: string
isShowNodeName: boolean
readonly: boolean
value: ValueSelector | string
onChange: (value: ValueSelector | string, varKindType: VarKindType) => void
onOpen?: () => void
isSupportConstantValue?: boolean
defaultVarKindType?: VarKindType
onlyLeafNodeVar?: boolean
filterVar?: (payload: Var, valueSelector: ValueSelector) => boolean
}
const VarReferencePicker: FC<Props> = ({
nodeId,
readonly,
className,
isShowNodeName,
value,
onOpen = () => { },
onChange,
isSupportConstantValue,
defaultVarKindType = VarKindType.constant,
onlyLeafNodeVar,
filterVar = () => true,
}) => {
const { t } = useTranslation()
const triggerRef = useRef<HTMLDivElement>(null)
const [triggerWidth, setTriggerWidth] = useState(TRIGGER_DEFAULT_WIDTH)
useEffect(() => {
if (triggerRef.current)
setTriggerWidth(triggerRef.current.clientWidth)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [triggerRef.current])
const isChatMode = useIsChatMode()
const [varKindType, setVarKindType] = useState<VarKindType>(defaultVarKindType)
const isConstant = isSupportConstantValue && varKindType === VarKindType.constant
const { getTreeLeafNodes, getBeforeNodesInSameBranch } = useWorkflow()
const availableNodes = onlyLeafNodeVar ? getTreeLeafNodes(nodeId) : getBeforeNodesInSameBranch(nodeId)
const allOutputVars = toNodeOutputVars(availableNodes, isChatMode)
const outputVars = toNodeOutputVars(availableNodes, isChatMode, filterVar)
const [open, setOpen] = useState(false)
useEffect(() => {
onOpen()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const hasValue = !isConstant && value.length > 0
const startNode = availableNodes.find((node: any) => {
return node.data.type === BlockEnum.Start
})
const outputVarNodeId = hasValue ? value[0] : ''
const outputVarNode = (() => {
if (!hasValue || isConstant)
return null
if (isSystemVar(value as ValueSelector))
return startNode?.data
return getNodeInfoById(availableNodes, outputVarNodeId)?.data
})()
const varName = hasValue ? `${isSystemVar(value as ValueSelector) ? 'sys.' : ''}${value[value.length - 1]}` : ''
const getVarType = () => {
if (isConstant)
return 'undefined'
const isSystem = isSystemVar(value as ValueSelector)
const targetVarNodeId = isSystem ? startNode?.id : outputVarNodeId
const targetVar = allOutputVars.find(v => v.nodeId === targetVarNodeId)
if (!targetVar)
return 'undefined'
let type: VarType = VarType.string
let curr: any = targetVar.vars
if (isSystem) {
return curr.find((v: any) => v.variable === (value as ValueSelector).join('.'))?.type
}
else {
(value as ValueSelector).slice(1).forEach((key, i) => {
const isLast = i === value.length - 2
curr = curr.find((v: any) => v.variable === key)
if (isLast) {
type = curr?.type
}
else {
if (curr.type === VarType.object)
curr = curr.children
}
})
return type
}
}
const varKindTypes = [
{
label: 'Variable',
value: VarKindType.variable,
},
{
label: 'Constant',
value: VarKindType.constant,
},
]
const handleVarKindTypeChange = useCallback((value: VarKindType) => {
setVarKindType(value)
if (value === VarKindType.constant)
onChange('', value)
else
onChange([], value)
}, [onChange])
const inputRef = useRef<HTMLInputElement>(null)
const [isFocus, setIsFocus] = useState(false)
const [controlFocus, setControlFocus] = useState(0)
useEffect(() => {
if (controlFocus && inputRef.current) {
inputRef.current.focus()
setIsFocus(true)
}
}, [controlFocus])
const handleVarReferenceChange = useCallback((value: ValueSelector) => {
// sys var not passed to backend
const newValue = produce(value, (draft) => {
if (draft[1] && draft[1].startsWith('sys')) {
draft.shift()
const paths = draft[0].split('.')
paths.forEach((p, i) => {
draft[i] = p
})
}
})
onChange(newValue, varKindType)
setOpen(false)
}, [onChange, varKindType])
const handleStaticChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value as string, varKindType)
}, [onChange, varKindType])
const handleClearVar = useCallback(() => {
if (varKindType === VarKindType.constant)
onChange('', varKindType)
else
onChange([], varKindType)
}, [onChange, varKindType])
const type = getVarType()
// 8(left/right-padding) + 14(icon) + 4 + 14 + 2 = 42 + 17 buff
const availableWidth = triggerWidth - 56
const [maxNodeNameWidth, maxVarNameWidth, maxTypeWidth] = (() => {
const totalTextLength = ((outputVarNode?.title || '') + (varName || '') + (type || '')).length
const PRIORITY_WIDTH = 15
const maxNodeNameWidth = PRIORITY_WIDTH + Math.floor((outputVarNode?.title?.length || 0) / totalTextLength * availableWidth)
const maxVarNameWidth = -PRIORITY_WIDTH + Math.floor((varName?.length || 0) / totalTextLength * availableWidth)
const maxTypeWidth = Math.floor((type?.length || 0) / totalTextLength * availableWidth)
return [maxNodeNameWidth, maxVarNameWidth, maxTypeWidth]
})()
return (
<div className={cn(className, !readonly && 'cursor-pointer')}>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
>
<PortalToFollowElemTrigger onClick={() => {
if (readonly)
return
!isConstant ? setOpen(!open) : setControlFocus(Date.now())
}} className='!flex'>
<div ref={triggerRef} className={cn((open || isFocus) ? 'border-gray-300' : 'border-gray-100', 'relative group/wrap flex items-center w-full h-8 p-1 rounded-lg bg-gray-100 border')}>
{isSupportConstantValue
? <div onClick={(e) => {
e.stopPropagation()
setOpen(false)
setControlFocus(Date.now())
}} className='mr-1 flex items-center space-x-1'>
<TypeSelector
noLeft
triggerClassName='!text-xs'
readonly={readonly}
DropDownIcon={ChevronDown}
value={varKindType}
options={varKindTypes}
onChange={handleVarKindTypeChange}
/>
<div className='h-4 w-px bg-black/5'></div>
</div>
: (!hasValue && <div className='ml-1.5 mr-1'>
<Variable02 className='w-3.5 h-3.5 text-gray-400' />
</div>)}
{isConstant
? (
<input
type='text'
className='w-full h-8 leading-8 pl-0.5 bg-transparent text-[13px] font-normal text-gray-900 placeholder:text-gray-400 focus:outline-none overflow-hidden'
value={isConstant ? value : ''}
onChange={handleStaticChange}
onFocus={() => setIsFocus(true)}
onBlur={() => setIsFocus(false)}
readOnly={readonly}
/>
)
: (
<div className={cn('inline-flex h-full items-center px-1.5 rounded-[5px]', hasValue && 'bg-white')}>
{hasValue
? (
<>
{isShowNodeName && (
<div className='flex items-center'>
<div className='p-[1px]'>
<VarBlockIcon
className='!text-gray-900'
type={outputVarNode?.type || BlockEnum.Start}
/>
</div>
<div className='mx-0.5 text-xs font-medium text-gray-700 truncate' title={outputVarNode?.title} style={{
maxWidth: maxNodeNameWidth,
}}>{outputVarNode?.title}</div>
<Line3 className='mr-0.5'></Line3>
</div>
)}
<div className='flex items-center text-primary-600'>
{!hasValue && <Variable02 className='w-3.5 h-3.5' />}
<div className='ml-0.5 text-xs font-medium truncate' title={varName} style={{
maxWidth: maxVarNameWidth,
}}>{varName}</div>
</div>
<div className='ml-0.5 text-xs font-normal text-gray-500 capitalize truncate' title={type} style={{
maxWidth: maxTypeWidth,
}}>{type}</div>
</>
)
: <div className='text-[13px] font-normal text-gray-400'>{t('workflow.common.setVarValuePlaceholder')}</div>}
</div>
)}
{(hasValue && !readonly) && (<div
className='invisible group-hover/wrap:visible absolute h-5 right-1 top-[50%] translate-y-[-50%] group p-1 rounded-md hover:bg-black/5 cursor-pointer'
onClick={handleClearVar}
>
<XClose className='w-3.5 h-3.5 text-gray-500 group-hover:text-gray-800' />
</div>)}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{
zIndex: 100,
}}>
{!isConstant && (
<VarReferencePopup
vars={outputVars}
onChange={handleVarReferenceChange}
itemWidth={triggerWidth}
/>
)}
</PortalToFollowElemContent>
</PortalToFollowElem>
</div >
)
}
export default React.memo(VarReferencePicker)

View File

@@ -0,0 +1,30 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import VarReferenceVars from './var-reference-vars'
import { type NodeOutPutVar, type ValueSelector } from '@/app/components/workflow/types'
type Props = {
vars: NodeOutPutVar[]
onChange: (value: ValueSelector) => void
itemWidth?: number
}
const VarReferencePopup: FC<Props> = ({
vars,
onChange,
itemWidth,
}) => {
// max-h-[300px] overflow-y-auto todo: use portal to handle long list
return (
<div className='p-1 bg-white rounded-lg border border-gray-200 shadow-lg space-y-1' style={{
width: itemWidth || 228,
}}>
<VarReferenceVars
searchBoxClassName='mt-1'
vars={vars}
onChange={onChange}
itemWidth={itemWidth} />
</div >
)
}
export default React.memo(VarReferencePopup)

View File

@@ -0,0 +1,296 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { useBoolean, useHover } from 'ahooks'
import cn from 'classnames'
import { useTranslation } from 'react-i18next'
import { type NodeOutPutVar, type ValueSelector, type Var, VarType } from '@/app/components/workflow/types'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import {
SearchLg,
} from '@/app/components/base/icons/src/vender/line/general'
import { XCircle } from '@/app/components/base/icons/src/vender/solid/general'
import { checkKeys } from '@/utils/var'
type ObjectChildrenProps = {
nodeId: string
title: string
data: Var[]
objPath: string[]
onChange: (value: ValueSelector, item: Var) => void
onHovering?: (value: boolean) => void
itemWidth?: number
}
type ItemProps = {
nodeId: string
title: string
objPath: string[]
itemData: Var
onChange: (value: ValueSelector, item: Var) => void
onHovering?: (value: boolean) => void
itemWidth?: number
}
const Item: FC<ItemProps> = ({
nodeId,
title,
objPath,
itemData,
onChange,
onHovering,
itemWidth,
}) => {
const isObj = itemData.type === VarType.object && itemData.children && itemData.children.length > 0
const itemRef = useRef(null)
const [isItemHovering, setIsItemHovering] = useState(false)
const _ = useHover(itemRef, {
onChange: (hovering) => {
if (hovering) {
setIsItemHovering(true)
}
else {
if (isObj) {
setTimeout(() => {
setIsItemHovering(false)
}, 100)
}
else {
setIsItemHovering(false)
}
}
},
})
const [isChildrenHovering, setIsChildrenHovering] = useState(false)
const isHovering = isItemHovering || isChildrenHovering
const open = isObj && isHovering
useEffect(() => {
onHovering && onHovering(isHovering)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isHovering])
const handleChosen = (e: React.MouseEvent) => {
e.stopPropagation()
if (itemData.variable.startsWith('sys.')) { // system variable
onChange([...objPath, ...itemData.variable.split('.')], itemData)
}
else {
onChange([nodeId, ...objPath, itemData.variable], itemData)
}
}
return (
<PortalToFollowElem
open={open}
onOpenChange={() => { }}
placement='left-start'
>
<PortalToFollowElemTrigger className='w-full'>
<div
ref={itemRef}
className={cn(
isObj ? ' pr-1' : 'pr-[18px]',
isHovering && (isObj ? 'bg-primary-50' : 'bg-gray-50'),
'relative w-full flex items-center h-6 pl-3 rounded-md cursor-pointer')
}
// style={{ width: itemWidth || 252 }}
onClick={handleChosen}
>
<div className='flex items-center w-0 grow'>
<Variable02 className='shrink-0 w-3.5 h-3.5 text-primary-500' />
<div title={itemData.variable} className='ml-1 w-0 grow truncate text-[13px] font-normal text-gray-900'>{itemData.variable}</div>
</div>
<div className='ml-1 shrink-0 text-xs font-normal text-gray-500 capitalize'>{itemData.type}</div>
{isObj && (
<ChevronRight className='ml-0.5 w-3 h-3 text-gray-500' />
)}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{
zIndex: 100,
}}>
{isObj && (
// eslint-disable-next-line @typescript-eslint/no-use-before-define
<ObjectChildren
nodeId={nodeId}
title={title}
objPath={[...objPath, itemData.variable]}
data={itemData.children as Var[]}
onChange={onChange}
onHovering={setIsChildrenHovering}
itemWidth={itemWidth}
/>
)}
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
const ObjectChildren: FC<ObjectChildrenProps> = ({
title,
nodeId,
objPath,
data,
onChange,
onHovering,
itemWidth,
}) => {
const currObjPath = objPath
const itemRef = useRef(null)
const [isItemHovering, setIsItemHovering] = useState(false)
const _ = useHover(itemRef, {
onChange: (hovering) => {
if (hovering) {
setIsItemHovering(true)
}
else {
setTimeout(() => {
setIsItemHovering(false)
}, 100)
}
},
})
const [isChildrenHovering, setIsChildrenHovering] = useState(false)
const isHovering = isItemHovering || isChildrenHovering
useEffect(() => {
onHovering && onHovering(isHovering)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isHovering])
useEffect(() => {
onHovering && onHovering(isItemHovering)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isItemHovering])
// absolute top-[-2px]
return (
<div ref={itemRef} className=' bg-white rounded-lg border border-gray-200 shadow-lg space-y-1' style={{
right: itemWidth ? itemWidth - 10 : 215,
minWidth: 252,
}}>
<div className='flex items-center h-[22px] px-3 text-xs font-normal text-gray-700'><span className='text-gray-500'>{title}.</span>{currObjPath.join('.')}</div>
{
(data && data.length > 0)
&& data.map((v, i) => (
<Item
key={i}
nodeId={nodeId}
title={title}
objPath={objPath}
itemData={v}
onChange={onChange}
onHovering={setIsChildrenHovering}
/>
))
}
</div>
)
}
type Props = {
hideSearch?: boolean
searchBoxClassName?: string
vars: NodeOutPutVar[]
onChange: (value: ValueSelector, item: Var) => void
itemWidth?: number
}
const VarReferenceVars: FC<Props> = ({
hideSearch,
searchBoxClassName,
vars,
onChange,
itemWidth,
}) => {
const { t } = useTranslation()
const [searchText, setSearchText] = useState('')
const filteredVars = vars.filter((v) => {
const children = v.vars.filter(v => checkKeys([v.variable], false).isValid || v.variable.startsWith('sys.'))
return children.length > 0
}).filter((v) => {
if (!searchText)
return v
const children = v.vars.filter(v => v.variable.toLowerCase().includes(searchText.toLowerCase()))
return children.length > 0
}).map((v) => {
let vars = v.vars.filter(v => checkKeys([v.variable], false).isValid || v.variable.startsWith('sys.'))
if (searchText)
vars = vars.filter(v => v.variable.toLowerCase().includes(searchText.toLowerCase()))
return {
...v,
vars,
}
})
const [isFocus, {
setFalse: setBlur,
setTrue: setFocus,
}] = useBoolean(false)
return (
<>
{
!hideSearch && (
<>
<div
className={cn(searchBoxClassName, isFocus && 'shadow-sm bg-white', 'mb-2 mx-1 flex items-center px-2 rounded-lg bg-gray-100 ')}
onClick={e => e.stopPropagation()}
>
<SearchLg className='shrink-0 ml-[1px] mr-[5px] w-3.5 h-3.5 text-gray-400' />
<input
value={searchText}
className='grow px-0.5 py-[7px] text-[13px] text-gray-700 bg-transparent appearance-none outline-none caret-primary-600 placeholder:text-gray-400'
placeholder={t('workflow.common.searchVar') || ''}
onChange={e => setSearchText(e.target.value)}
onFocus={setFocus}
onBlur={setBlur}
autoFocus
/>
{
searchText && (
<div
className='flex items-center justify-center ml-[5px] w-[18px] h-[18px] cursor-pointer'
onClick={() => setSearchText('')}
>
<XCircle className='w-[14px] h-[14px] text-gray-400' />
</div>
)
}
</div>
<div className='h-[0.5px] bg-black/5 relative left-[-4px]' style={{
width: 'calc(100% + 8px)',
}}></div>
</>
)
}
{filteredVars.length > 0
? <div>
{
filteredVars.map((item, i) => (
<div key={i}>
<div
className='leading-[22px] px-3 text-xs font-medium text-gray-500 uppercase truncate'
title={item.title}
>{item.title}</div>
{item.vars.map((v, j) => (
<Item
key={j}
title={item.title}
nodeId={item.nodeId}
objPath={[]}
itemData={v}
onChange={onChange}
itemWidth={itemWidth}
/>
))}
</div>))
}
</div>
: <div className='pl-3 leading-[18px] text-xs font-medium text-gray-500 uppercase'>{t('workflow.common.noVar')}</div>}
</ >
)
}
export default React.memo(VarReferenceVars)

View File

@@ -0,0 +1,71 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useState } from 'react'
import cn from 'classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { Check } from '@/app/components/base/icons/src/vender/line/general'
import { ChevronDown } from '@/app/components/base/icons/src/vender/line/arrows'
import { VarType } from '@/app/components/workflow/types'
type Props = {
className?: string
readonly: boolean
value: string
onChange: (value: string) => void
}
const TYPES = [VarType.string, VarType.number, VarType.arrayNumber, VarType.arrayString, VarType.arrayObject, VarType.object]
const VarReferencePicker: FC<Props> = ({
readonly,
className,
value,
onChange,
}) => {
const [open, setOpen] = useState(false)
const handleChange = useCallback((type: string) => {
return () => {
setOpen(false)
onChange(type)
}
}, [onChange])
return (
<div className={cn(className, !readonly && 'cursor-pointer select-none')}>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={4}
>
<PortalToFollowElemTrigger onClick={() => setOpen(!open)} className='w-[120px] cursor-pointer'>
<div className='flex items-center h-8 justify-between px-2.5 rounded-lg border-0 bg-gray-100 text-gray-900 text-[13px]'>
<div className='capitalize grow w-0 truncate' title={value}>{value}</div>
<ChevronDown className='shrink-0 w-3.5 h-3.5 text-gray-700' />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{
zIndex: 100,
}}>
<div className='w-[120px] p-1 bg-white rounded-lg shadow-sm'>
{TYPES.map(type => (
<div
key={type}
className='flex items-center h-[30px] justify-between pl-3 pr-2 rounded-lg hover:bg-gray-100 text-gray-900 text-[13px] cursor-pointer'
onClick={handleChange(type)}
>
<div className='w-0 grow capitalize truncate'>{type}</div>
{type === value && <Check className='shrink-0 w-4 h-4 text-primary-600' />}
</div>
))}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
)
}
export default React.memo(VarReferencePicker)

View File

@@ -0,0 +1,30 @@
import {
useIsChatMode,
useWorkflow,
} from '@/app/components/workflow/hooks'
import { toNodeOutputVars } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
type Params = {
onlyLeafNodeVar?: boolean
filterVar: (payload: Var, selector: ValueSelector) => boolean
}
const useAvailableVarList = (nodeId: string, {
onlyLeafNodeVar,
filterVar,
}: Params = {
onlyLeafNodeVar: false,
filterVar: () => true,
}) => {
const { getTreeLeafNodes, getBeforeNodesInSameBranch } = useWorkflow()
const isChatMode = useIsChatMode()
const availableNodes = onlyLeafNodeVar ? getTreeLeafNodes(nodeId) : getBeforeNodesInSameBranch(nodeId)
const availableVars = toNodeOutputVars(availableNodes, isChatMode, filterVar)
return {
availableVars,
availableNodes,
}
}
export default useAvailableVarList

View File

@@ -0,0 +1,19 @@
import { useNodeDataUpdate } from '@/app/components/workflow/hooks'
import type { CommonNodeType } from '@/app/components/workflow/types'
const useNodeCrud = <T>(id: string, data: CommonNodeType<T>) => {
const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
const setInputs = (newInputs: CommonNodeType<T>) => {
handleNodeDataUpdateWithSyncDraft({
id,
data: newInputs,
})
}
return {
inputs: data,
setInputs,
}
}
export default useNodeCrud

View File

@@ -0,0 +1,275 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { unionBy } from 'lodash-es'
import {
useIsChatMode,
useNodeDataUpdate,
useWorkflow,
} from '@/app/components/workflow/hooks'
import { getNodeInfoById, isSystemVar, toNodeOutputVars } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import type { CommonNodeType, InputVar, ValueSelector, Var, Variable } from '@/app/components/workflow/types'
import { BlockEnum, InputVarType, NodeRunningStatus, VarType } from '@/app/components/workflow/types'
import { useStore as useAppStore } from '@/app/components/app/store'
import { singleNodeRun } from '@/service/workflow'
import Toast from '@/app/components/base/toast'
import LLMDefault from '@/app/components/workflow/nodes/llm/default'
import KnowledgeRetrievalDefault from '@/app/components/workflow/nodes/knowledge-retrieval/default'
import IfElseDefault from '@/app/components/workflow/nodes/if-else/default'
import CodeDefault from '@/app/components/workflow/nodes/code/default'
import TemplateTransformDefault from '@/app/components/workflow/nodes/template-transform/default'
import QuestionClassifyDefault from '@/app/components/workflow/nodes/question-classifier/default'
import HTTPDefault from '@/app/components/workflow/nodes/http/default'
import ToolDefault from '@/app/components/workflow/nodes/tool/default'
import VariableAssigner from '@/app/components/workflow/nodes/variable-assigner/default'
import { getInputVars as doGetInputVars } from '@/app/components/base/prompt-editor/constants'
const { checkValid: checkLLMValid } = LLMDefault
const { checkValid: checkKnowledgeRetrievalValid } = KnowledgeRetrievalDefault
const { checkValid: checkIfElseValid } = IfElseDefault
const { checkValid: checkCodeValid } = CodeDefault
const { checkValid: checkTemplateTransformValid } = TemplateTransformDefault
const { checkValid: checkQuestionClassifyValid } = QuestionClassifyDefault
const { checkValid: checkHttpValid } = HTTPDefault
const { checkValid: checkToolValid } = ToolDefault
const { checkValid: checkVariableAssignerValid } = VariableAssigner
const checkValidFns: Record<BlockEnum, Function> = {
[BlockEnum.LLM]: checkLLMValid,
[BlockEnum.KnowledgeRetrieval]: checkKnowledgeRetrievalValid,
[BlockEnum.IfElse]: checkIfElseValid,
[BlockEnum.Code]: checkCodeValid,
[BlockEnum.TemplateTransform]: checkTemplateTransformValid,
[BlockEnum.QuestionClassifier]: checkQuestionClassifyValid,
[BlockEnum.HttpRequest]: checkHttpValid,
[BlockEnum.Tool]: checkToolValid,
[BlockEnum.VariableAssigner]: checkVariableAssignerValid,
} as any
type Params<T> = {
id: string
data: CommonNodeType<T>
defaultRunInputData: Record<string, any>
moreDataForCheckValid?: any
}
const varTypeToInputVarType = (type: VarType, {
isSelect,
isParagraph,
}: {
isSelect: boolean
isParagraph: boolean
}) => {
if (isSelect)
return InputVarType.select
if (isParagraph)
return InputVarType.paragraph
if (type === VarType.number)
return InputVarType.number
if ([VarType.object, VarType.array, VarType.arrayNumber, VarType.arrayString, VarType.arrayObject].includes(type))
return InputVarType.json
if (type === VarType.arrayFile)
return InputVarType.files
return InputVarType.textInput
}
const useOneStepRun = <T>({
id,
data,
defaultRunInputData,
moreDataForCheckValid,
}: Params<T>) => {
const { t } = useTranslation()
const { getBeforeNodesInSameBranch } = useWorkflow() as any
const isChatMode = useIsChatMode()
const availableNodes = getBeforeNodesInSameBranch(id)
const allOutputVars = toNodeOutputVars(getBeforeNodesInSameBranch(id), isChatMode)
const getVar = (valueSelector: ValueSelector): Var | undefined => {
let res: Var | undefined
const isSystem = valueSelector[0] === 'sys'
const targetVar = isSystem ? allOutputVars.find(item => !!item.isStartNode) : allOutputVars.find(v => v.nodeId === valueSelector[0])
if (!targetVar)
return undefined
if (isSystem)
return targetVar.vars.find(item => item.variable.split('.')[1] === valueSelector[1])
let curr: any = targetVar.vars
valueSelector.slice(1).forEach((key, i) => {
const isLast = i === valueSelector.length - 2
curr = curr.find((v: any) => v.variable === key)
if (isLast) {
res = curr
}
else {
if (curr.type === VarType.object)
curr = curr.children
}
})
return res
}
const checkValid = checkValidFns[data.type]
const appId = useAppStore.getState().appDetail?.id
const [runInputData, setRunInputData] = useState<Record<string, any>>(defaultRunInputData || {})
const [runResult, setRunResult] = useState<any>(null)
const { handleNodeDataUpdate }: { handleNodeDataUpdate: (data: any) => void } = useNodeDataUpdate()
const [canShowSingleRun, setCanShowSingleRun] = useState(false)
const isShowSingleRun = data._isSingleRun && canShowSingleRun
useEffect(() => {
if (!checkValid) {
setCanShowSingleRun(true)
return
}
if (data._isSingleRun) {
const { isValid, errorMessage } = checkValid(data, t, moreDataForCheckValid)
setCanShowSingleRun(isValid)
if (!isValid) {
handleNodeDataUpdate({
id,
data: {
...data,
_isSingleRun: false,
},
})
Toast.notify({
type: 'error',
message: errorMessage,
})
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data._isSingleRun])
const hideSingleRun = () => {
handleNodeDataUpdate({
id,
data: {
...data,
_isSingleRun: false,
},
})
}
const runningStatus = data._singleRunningStatus || NodeRunningStatus.NotStart
const isCompleted = runningStatus === NodeRunningStatus.Succeeded || runningStatus === NodeRunningStatus.Failed
const handleRun = async (submitData: Record<string, any>) => {
handleNodeDataUpdate({
id,
data: {
...data,
_singleRunningStatus: NodeRunningStatus.Running,
},
})
let res: any
try {
res = await singleNodeRun(appId!, id, { inputs: submitData }) as any
if (res.error)
throw new Error(res.error)
}
catch (e: any) {
handleNodeDataUpdate({
id,
data: {
...data,
_singleRunningStatus: NodeRunningStatus.Failed,
},
})
return false
}
finally {
setRunResult({
...res,
created_by: res.created_by_account?.name || '',
})
}
handleNodeDataUpdate({
id,
data: {
...data,
_singleRunningStatus: NodeRunningStatus.Succeeded,
},
})
}
const handleStop = () => {
handleNodeDataUpdate({
id,
data: {
...data,
_singleRunningStatus: NodeRunningStatus.NotStart,
},
})
}
const toVarInputs = (variables: Variable[]): InputVar[] => {
if (!variables)
return []
const varInputs = variables.map((item) => {
const originalVar = getVar(item.value_selector)
if (!originalVar) {
return {
label: item.label || item.variable,
variable: item.variable,
type: InputVarType.textInput,
required: true,
}
}
return {
label: item.label || item.variable,
variable: item.variable,
type: varTypeToInputVarType(originalVar.type, {
isSelect: !!originalVar.isSelect,
isParagraph: !!originalVar.isParagraph,
}),
required: item.required !== false,
options: originalVar.options,
}
})
return varInputs
}
const getInputVars = (textList: string[]) => {
const valueSelectors: ValueSelector[] = []
textList.forEach((text) => {
valueSelectors.push(...doGetInputVars(text))
})
const variables = unionBy(valueSelectors, item => item.join('.')).map((item) => {
const varInfo = getNodeInfoById(availableNodes, item[0])?.data
return {
label: {
nodeType: varInfo?.type,
nodeName: varInfo?.title || availableNodes[0]?.data.title, // default start node title
variable: isSystemVar(item) ? item.join('.') : item[item.length - 1],
},
variable: `#${item.join('.')}#`,
value_selector: item,
}
})
const varInputs = toVarInputs(variables)
return varInputs
}
return {
isShowSingleRun,
hideSingleRun,
toVarInputs,
getInputVars,
runningStatus,
isCompleted,
handleRun,
handleStop,
runInputData,
setRunInputData,
runResult,
}
}
export default useOneStepRun

View File

@@ -0,0 +1,96 @@
import { useCallback, useState } from 'react'
import produce from 'immer'
import { useBoolean } from 'ahooks'
import { type OutputVar } from '../../code/types'
import type { ValueSelector } from '@/app/components/workflow/types'
import { VarType } from '@/app/components/workflow/types'
import {
useWorkflow,
} from '@/app/components/workflow/hooks'
type Params<T> = {
id: string
inputs: T
setInputs: (newInputs: T) => void
varKey?: string
outputKeyOrders: string[]
onOutputKeyOrdersChange: (newOutputKeyOrders: string[]) => void
}
function useOutputVarList<T>({
id,
inputs,
setInputs,
varKey = 'outputs',
outputKeyOrders = [],
onOutputKeyOrdersChange,
}: Params<T>) {
const { handleOutVarRenameChange, isVarUsedInNodes, removeUsedVarInNodes } = useWorkflow()
const handleVarsChange = useCallback((newVars: OutputVar, changedIndex?: number, newKey?: string) => {
const newInputs = produce(inputs, (draft: any) => {
draft[varKey] = newVars
})
setInputs(newInputs)
if (changedIndex !== undefined) {
const newOutputKeyOrders = produce(outputKeyOrders, (draft) => {
draft[changedIndex] = newKey!
})
onOutputKeyOrdersChange(newOutputKeyOrders)
}
if (newKey)
handleOutVarRenameChange(id, [id, outputKeyOrders[changedIndex!]], [id, newKey])
}, [inputs, setInputs, handleOutVarRenameChange, id, outputKeyOrders, varKey, onOutputKeyOrdersChange])
const handleAddVariable = useCallback(() => {
const newKey = `var_${Object.keys((inputs as any)[varKey]).length + 1}`
const newInputs = produce(inputs, (draft: any) => {
draft[varKey] = {
...draft[varKey],
[newKey]: {
type: VarType.string,
children: null,
},
}
})
setInputs(newInputs)
onOutputKeyOrdersChange([...outputKeyOrders, newKey])
}, [inputs, setInputs, varKey, outputKeyOrders, onOutputKeyOrdersChange])
const [isShowRemoveVarConfirm, {
setTrue: showRemoveVarConfirm,
setFalse: hideRemoveVarConfirm,
}] = useBoolean(false)
const [removedVar, setRemovedVar] = useState<ValueSelector>([])
const removeVarInNode = useCallback(() => {
removeUsedVarInNodes(removedVar)
hideRemoveVarConfirm()
}, [hideRemoveVarConfirm, removeUsedVarInNodes, removedVar])
const handleRemoveVariable = useCallback((index: number) => {
const key = outputKeyOrders[index]
if (isVarUsedInNodes([id, key])) {
showRemoveVarConfirm()
setRemovedVar([id, key])
return
}
const newInputs = produce(inputs, (draft: any) => {
delete draft[varKey][key]
})
setInputs(newInputs)
onOutputKeyOrdersChange(outputKeyOrders.filter((_, i) => i !== index))
}, [outputKeyOrders, isVarUsedInNodes, id, inputs, setInputs, onOutputKeyOrdersChange, showRemoveVarConfirm, varKey])
return {
handleVarsChange,
handleAddVariable,
handleRemoveVariable,
isShowRemoveVarConfirm,
hideRemoveVarConfirm,
onRemoveVarConfirm: removeVarInNode,
}
}
export default useOutputVarList

View File

@@ -0,0 +1,116 @@
import {
useCallback,
useEffect,
useRef,
useState,
} from 'react'
export type UseResizePanelPrarams = {
direction?: 'horizontal' | 'vertical' | 'both'
triggerDirection?: 'top' | 'right' | 'bottom' | 'left' | 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
minWidth?: number
maxWidth?: number
minHeight?: number
maxHeight?: number
onResized?: (width: number, height: number) => void
}
export const useResizePanel = (params?: UseResizePanelPrarams) => {
const {
direction = 'both',
triggerDirection = 'bottom-right',
minWidth = -Infinity,
maxWidth = Infinity,
minHeight = -Infinity,
maxHeight = Infinity,
onResized,
} = params || {}
const triggerRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const initXRef = useRef(0)
const initYRef = useRef(0)
const initContainerWidthRef = useRef(0)
const initContainerHeightRef = useRef(0)
const isResizingRef = useRef(false)
const [prevUserSelectStyle, setPrevUserSelectStyle] = useState(getComputedStyle(document.body).userSelect)
const handleStartResize = useCallback((e: MouseEvent) => {
initXRef.current = e.clientX
initYRef.current = e.clientY
initContainerWidthRef.current = containerRef.current?.offsetWidth || minWidth
initContainerHeightRef.current = containerRef.current?.offsetHeight || minHeight
isResizingRef.current = true
setPrevUserSelectStyle(getComputedStyle(document.body).userSelect)
document.body.style.userSelect = 'none'
}, [minWidth, minHeight])
const handleResize = useCallback((e: MouseEvent) => {
if (!isResizingRef.current)
return
if (!containerRef.current)
return
if (direction === 'horizontal' || direction === 'both') {
const offsetX = e.clientX - initXRef.current
let width = 0
if (triggerDirection === 'left' || triggerDirection === 'top-left' || triggerDirection === 'bottom-left')
width = initContainerWidthRef.current - offsetX
else if (triggerDirection === 'right' || triggerDirection === 'top-right' || triggerDirection === 'bottom-right')
width = initContainerWidthRef.current + offsetX
if (width < minWidth)
width = minWidth
if (width > maxWidth)
width = maxWidth
containerRef.current.style.width = `${width}px`
}
if (direction === 'vertical' || direction === 'both') {
const offsetY = e.clientY - initYRef.current
let height = 0
if (triggerDirection === 'top' || triggerDirection === 'top-left' || triggerDirection === 'top-right')
height = initContainerHeightRef.current - offsetY
else if (triggerDirection === 'bottom' || triggerDirection === 'bottom-left' || triggerDirection === 'bottom-right')
height = initContainerHeightRef.current + offsetY
if (height < minHeight)
height = minHeight
if (height > maxHeight)
height = maxHeight
containerRef.current.style.height = `${height}px`
}
}, [
direction,
triggerDirection,
minWidth,
maxWidth,
minHeight,
maxHeight,
])
const handleStopResize = useCallback(() => {
isResizingRef.current = false
document.body.style.userSelect = prevUserSelectStyle
if (onResized && containerRef.current)
onResized(containerRef.current.offsetWidth, containerRef.current.offsetHeight)
}, [prevUserSelectStyle, onResized])
useEffect(() => {
const element = triggerRef.current
element?.addEventListener('mousedown', handleStartResize)
document.addEventListener('mousemove', handleResize)
document.addEventListener('mouseup', handleStopResize)
return () => {
if (element)
element.removeEventListener('mousedown', handleStartResize)
document.removeEventListener('mousemove', handleResize)
}
}, [handleStartResize, handleResize, handleStopResize])
return {
triggerRef,
containerRef,
}
}

View File

@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
type Params = {
ref: React.RefObject<HTMLDivElement>
hasFooter?: boolean
}
const useToggleExpend = ({ ref, hasFooter = true }: Params) => {
const [isExpand, setIsExpand] = useState(false)
const [wrapHeight, setWrapHeight] = useState(ref.current?.clientHeight)
const editorExpandHeight = isExpand ? wrapHeight! - (hasFooter ? 56 : 29) : 0
useEffect(() => {
setWrapHeight(ref.current?.clientHeight)
}, [isExpand])
const wrapClassName = isExpand && 'absolute z-10 left-4 right-6 top-[52px] bottom-0 pb-4 bg-white'
return {
wrapClassName,
editorExpandHeight,
isExpand,
setIsExpand,
}
}
export default useToggleExpend

View File

@@ -0,0 +1,37 @@
import { useCallback } from 'react'
import produce from 'immer'
import type { Variable } from '@/app/components/workflow/types'
type Params<T> = {
inputs: T
setInputs: (newInputs: T) => void
varKey?: string
}
function useVarList<T>({
inputs,
setInputs,
varKey = 'variables',
}: Params<T>) {
const handleVarListChange = useCallback((newList: Variable[] | string) => {
const newInputs = produce(inputs, (draft: any) => {
draft[varKey] = newList as Variable[]
})
setInputs(newInputs)
}, [inputs, setInputs, varKey])
const handleAddVariable = useCallback(() => {
const newInputs = produce(inputs, (draft: any) => {
draft[varKey].push({
variable: '',
value_selector: [],
})
})
setInputs(newInputs)
}, [inputs, setInputs, varKey])
return {
handleVarListChange,
handleAddVariable,
}
}
export default useVarList

View File

@@ -0,0 +1,130 @@
import type {
FC,
ReactElement,
} from 'react'
import {
cloneElement,
memo,
} from 'react'
import type { NodeProps } from '../../types'
import {
BlockEnum,
NodeRunningStatus,
} from '../../types'
import {
useNodesReadOnly,
useToolIcon,
} from '../../hooks'
import {
NodeSourceHandle,
NodeTargetHandle,
} from './components/node-handle'
import NodeControl from './components/node-control'
import BlockIcon from '@/app/components/workflow/block-icon'
import {
CheckCircle,
Loading02,
} from '@/app/components/base/icons/src/vender/line/general'
import { AlertCircle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
type BaseNodeProps = {
children: ReactElement
} & NodeProps
const BaseNode: FC<BaseNodeProps> = ({
id,
data,
children,
}) => {
const { nodesReadOnly } = useNodesReadOnly()
const toolIcon = useToolIcon(data)
return (
<div
className={`
flex border-[2px] rounded-2xl
${(data.selected && !data._runningStatus && !data._isInvalidConnection) ? 'border-primary-600' : 'border-transparent'}
`}
>
<div
className={`
group relative pb-1 w-[240px] bg-[#fcfdff] shadow-xs
border border-transparent rounded-[15px]
${!data._runningStatus && 'hover:shadow-lg'}
${data._runningStatus === NodeRunningStatus.Running && '!border-primary-500'}
${data._runningStatus === NodeRunningStatus.Succeeded && '!border-[#12B76A]'}
${data._runningStatus === NodeRunningStatus.Failed && '!border-[#F04438]'}
${data._runningStatus === NodeRunningStatus.Waiting && 'opacity-70'}
${data._isInvalidConnection && '!border-[#F04438]'}
`}
>
{
data.type !== BlockEnum.VariableAssigner && !data._runningStatus && !nodesReadOnly && (
<NodeTargetHandle
id={id}
data={data}
handleClassName='!top-4 !-left-[9px] !translate-y-0'
handleId='target'
/>
)
}
{
data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && !data._runningStatus && !nodesReadOnly && (
<NodeSourceHandle
id={id}
data={data}
handleClassName='!top-4 !-right-[9px] !translate-y-0'
handleId='source'
/>
)
}
{
!data._runningStatus && !nodesReadOnly && (
<NodeControl
id={id}
data={data}
/>
)
}
<div className='flex items-center px-3 pt-3 pb-2'>
<BlockIcon
className='shrink-0 mr-2'
type={data.type}
size='md'
toolIcon={toolIcon}
/>
<div
title={data.title}
className='grow mr-1 text-[13px] font-semibold text-gray-700 truncate'
>
{data.title}
</div>
{
(data._runningStatus === NodeRunningStatus.Running || data._singleRunningStatus === NodeRunningStatus.Running) && (
<Loading02 className='w-3.5 h-3.5 text-primary-600 animate-spin' />
)
}
{
data._runningStatus === NodeRunningStatus.Succeeded && (
<CheckCircle className='w-3.5 h-3.5 text-[#12B76A]' />
)
}
{
data._runningStatus === NodeRunningStatus.Failed && (
<AlertCircle className='w-3.5 h-3.5 text-[#F04438]' />
)
}
</div>
{cloneElement(children, { id, data })}
{
data.desc && (
<div className='px-3 pt-1 pb-2 text-xs leading-[18px] text-gray-500 whitespace-pre-line break-words'>
{data.desc}
</div>
)
}
</div>
</div>
)
}
export default memo(BaseNode)

View File

@@ -0,0 +1,162 @@
import type {
FC,
ReactElement,
} from 'react'
import {
cloneElement,
memo,
useCallback,
} from 'react'
import { useTranslation } from 'react-i18next'
import NextStep from './components/next-step'
import PanelOperator from './components/panel-operator'
import {
DescriptionInput,
TitleInput,
} from './components/title-description-input'
import { useResizePanel } from './hooks/use-resize-panel'
import {
XClose,
} from '@/app/components/base/icons/src/vender/line/general'
import BlockIcon from '@/app/components/workflow/block-icon'
import {
useNodeDataUpdate,
useNodesExtraData,
useNodesInteractions,
useNodesReadOnly,
useNodesSyncDraft,
useToolIcon,
} from '@/app/components/workflow/hooks'
import { canRunBySingle } from '@/app/components/workflow/utils'
import { Play } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import TooltipPlus from '@/app/components/base/tooltip-plus'
import type { Node } from '@/app/components/workflow/types'
type BasePanelProps = {
children: ReactElement
} & Node
const BasePanel: FC<BasePanelProps> = ({
id,
data,
children,
}) => {
const { t } = useTranslation()
const initPanelWidth = localStorage.getItem('workflow-node-panel-width') || 420
const { handleNodeSelect } = useNodesInteractions()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { nodesReadOnly } = useNodesReadOnly()
const nodesExtraData = useNodesExtraData()
const availableNextNodes = nodesExtraData[data.type].availableNextNodes
const toolIcon = useToolIcon(data)
const handleResized = useCallback((width: number) => {
localStorage.setItem('workflow-node-panel-width', `${width}`)
}, [])
const {
triggerRef,
containerRef,
} = useResizePanel({
direction: 'horizontal',
triggerDirection: 'left',
minWidth: 420,
maxWidth: 720,
onResized: handleResized,
})
const {
handleNodeDataUpdate,
handleNodeDataUpdateWithSyncDraft,
} = useNodeDataUpdate()
const handleTitleBlur = useCallback((title: string) => {
handleNodeDataUpdateWithSyncDraft({ id, data: { title } })
}, [handleNodeDataUpdateWithSyncDraft, id])
const handleDescriptionChange = useCallback((desc: string) => {
handleNodeDataUpdateWithSyncDraft({ id, data: { desc } })
}, [handleNodeDataUpdateWithSyncDraft, id])
return (
<div className='relative mr-2 h-full'>
<div
ref={triggerRef}
className='absolute top-1/2 -translate-y-1/2 -left-2 w-3 h-6 cursor-col-resize resize-x'>
<div className='w-1 h-6 bg-gray-300 rounded-sm'></div>
</div>
<div
ref={containerRef}
className='relative h-full bg-white shadow-lg border-[0.5px] border-gray-200 rounded-2xl overflow-y-auto'
style={{
width: `${initPanelWidth}px`,
}}
>
<div className='sticky top-0 bg-white border-b-[0.5px] border-black/5 z-10'>
<div className='flex items-center px-4 pt-4 pb-1'>
<BlockIcon
className='shrink-0 mr-1'
type={data.type}
toolIcon={toolIcon}
size='md'
/>
<TitleInput
value={data.title || ''}
onBlur={handleTitleBlur}
/>
<div className='shrink-0 flex items-center text-gray-500'>
{
canRunBySingle(data.type) && !nodesReadOnly && (
<TooltipPlus
popupContent={t('workflow.panel.runThisStep')}
>
<div
className='flex items-center justify-center mr-1 w-6 h-6 rounded-md hover:bg-black/5 cursor-pointer'
onClick={() => {
handleNodeDataUpdate({ id, data: { _isSingleRun: true } })
handleSyncWorkflowDraft(true)
}}
>
<Play className='w-4 h-4 text-gray-500' />
</div>
</TooltipPlus>
)
}
<PanelOperator id={id} data={data} />
<div className='mx-3 w-[1px] h-3.5 bg-gray-200' />
<div
className='flex items-center justify-center w-6 h-6 cursor-pointer'
onClick={() => handleNodeSelect(id, true)}
>
<XClose className='w-4 h-4' />
</div>
</div>
</div>
<div className='p-2'>
<DescriptionInput
value={data.desc || ''}
onChange={handleDescriptionChange}
/>
</div>
</div>
<div className='py-2'>
{cloneElement(children, { id, data })}
</div>
{
!!availableNextNodes.length && (
<div className='p-4 border-t-[0.5px] border-t-black/5'>
<div className='flex items-center mb-1 text-gray-700 text-[13px] font-semibold'>
{t('workflow.panel.nextStep').toLocaleUpperCase()}
</div>
<div className='mb-2 text-xs text-gray-400'>
{t('workflow.panel.addNextStep')}
</div>
<NextStep selectedNode={{ id, data } as Node} />
</div>
)
}
</div>
</div>
)
}
export default memo(BasePanel)