feat: Human Input Node (#32060)

The frontend and backend implementation for the human input node.

Co-authored-by: twwu <twwu@dify.ai>
Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: zhsama <torvalds@linux.do>
This commit is contained in:
QuantumGhost
2026-02-09 14:57:23 +08:00
committed by GitHub
parent 56e3a55023
commit a1fc280102
474 changed files with 32667 additions and 2050 deletions

View File

@ -4,6 +4,7 @@ import { SupportUploadFileTypes } from '../../workflow/types'
export const CONTEXT_PLACEHOLDER_TEXT = '{{#context#}}'
export const HISTORY_PLACEHOLDER_TEXT = '{{#histories#}}'
export const QUERY_PLACEHOLDER_TEXT = '{{#query#}}'
export const REQUEST_URL_PLACEHOLDER_TEXT = '{{#url#}}'
export const CURRENT_PLACEHOLDER_TEXT = '{{#current#}}'
export const ERROR_MESSAGE_PLACEHOLDER_TEXT = '{{#error_message#}}'
export const LAST_RUN_PLACEHOLDER_TEXT = '{{#last_run#}}'
@ -30,6 +31,12 @@ export const checkHasQueryBlock = (text: string) => {
return text.includes(QUERY_PLACEHOLDER_TEXT)
}
export const checkHasRequestURLBlock = (text: string) => {
if (!text)
return false
return text.includes(REQUEST_URL_PLACEHOLDER_TEXT)
}
/*
* {{#1711617514996.name#}} => [1711617514996, name]
* {{#1711617514996.sys.query#}} => [sys, query]

View File

@ -2,16 +2,20 @@
import type {
EditorState,
LexicalCommand,
} from 'lexical'
import type { FC } from 'react'
import type { Hotkey } from './plugins/shortcuts-popup-plugin'
import type {
ContextBlockType,
CurrentBlockType,
ErrorMessageBlockType,
ExternalToolBlockType,
HistoryBlockType,
HITLInputBlockType,
LastRunBlockType,
QueryBlockType,
RequestURLBlockType,
VariableBlockType,
WorkflowVariableBlockType,
} from './types'
@ -27,7 +31,7 @@ import {
TextNode,
} from 'lexical'
import * as React from 'react'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { cn } from '@/utils/classnames'
import {
@ -46,17 +50,23 @@ import {
CurrentBlockReplacementBlock,
} from './plugins/current-block'
import { CustomTextNode } from './plugins/custom-text/node'
import DraggableBlockPlugin from './plugins/draggable-plugin'
import {
ErrorMessageBlock,
ErrorMessageBlockNode,
ErrorMessageBlockReplacementBlock,
} from './plugins/error-message-block'
import {
HistoryBlock,
HistoryBlockNode,
HistoryBlockReplacementBlock,
} from './plugins/history-block'
import {
HITLInputBlock,
HITLInputBlockReplacementBlock,
HITLInputNode,
} from './plugins/hitl-input-block'
import {
LastRunBlock,
LastRunBlockNode,
@ -70,6 +80,12 @@ import {
QueryBlockNode,
QueryBlockReplacementBlock,
} from './plugins/query-block'
import {
RequestURLBlock,
RequestURLBlockNode,
RequestURLBlockReplacementBlock,
} from './plugins/request-url-block'
import ShortcutsPopupPlugin from './plugins/shortcuts-popup-plugin'
import UpdateBlock from './plugins/update-block'
import VariableBlock from './plugins/variable-block'
import VariableValueBlock from './plugins/variable-value-block'
@ -96,14 +112,17 @@ export type PromptEditorProps = {
onFocus?: () => void
contextBlock?: ContextBlockType
queryBlock?: QueryBlockType
requestURLBlock?: RequestURLBlockType
historyBlock?: HistoryBlockType
variableBlock?: VariableBlockType
externalToolBlock?: ExternalToolBlockType
workflowVariableBlock?: WorkflowVariableBlockType
hitlInputBlock?: HITLInputBlockType
currentBlock?: CurrentBlockType
errorMessageBlock?: ErrorMessageBlockType
lastRunBlock?: LastRunBlockType
isSupportFileVar?: boolean
shortcutPopups?: Array<{ hotkey: Hotkey, Popup: React.ComponentType<{ onClose: () => void, onInsert: (command: LexicalCommand<unknown>, params: any[]) => void }> }>
}
const PromptEditor: FC<PromptEditorProps> = ({
@ -121,14 +140,17 @@ const PromptEditor: FC<PromptEditorProps> = ({
onFocus,
contextBlock,
queryBlock,
requestURLBlock,
historyBlock,
variableBlock,
externalToolBlock,
workflowVariableBlock,
hitlInputBlock,
currentBlock,
errorMessageBlock,
lastRunBlock,
isSupportFileVar,
shortcutPopups = [],
}) => {
const { eventEmitter } = useEventEmitterContextContext()
const initialConfig = {
@ -143,8 +165,10 @@ const PromptEditor: FC<PromptEditorProps> = ({
ContextBlockNode,
HistoryBlockNode,
QueryBlockNode,
RequestURLBlockNode,
WorkflowVariableBlockNode,
VariableValueBlockNode,
HITLInputNode,
CurrentBlockNode,
ErrorMessageBlockNode,
LastRunBlockNode, // LastRunBlockNode is used for error message block replacement
@ -176,9 +200,16 @@ const PromptEditor: FC<PromptEditorProps> = ({
} as any)
}, [eventEmitter, historyBlock?.history])
const [floatingAnchorElem, setFloatingAnchorElem] = useState(null)
const onRef = (_floatingAnchorElem: any) => {
if (_floatingAnchorElem !== null)
setFloatingAnchorElem(_floatingAnchorElem)
}
return (
<LexicalComposer initialConfig={{ ...initialConfig, editable }}>
<div className={cn('relative', wrapperClassName)}>
<div className={cn('relative', wrapperClassName)} ref={onRef}>
<RichTextPlugin
contentEditable={(
<ContentEditable
@ -199,11 +230,17 @@ const PromptEditor: FC<PromptEditorProps> = ({
)}
ErrorBoundary={LexicalErrorBoundary}
/>
{shortcutPopups?.map(({ hotkey, Popup }, idx) => (
<ShortcutsPopupPlugin key={idx} hotkey={hotkey}>
{(closePortal, onInsert) => <Popup onClose={closePortal} onInsert={onInsert} />}
</ShortcutsPopupPlugin>
))}
<ComponentPickerBlock
triggerString="/"
contextBlock={contextBlock}
historyBlock={historyBlock}
queryBlock={queryBlock}
requestURLBlock={requestURLBlock}
variableBlock={variableBlock}
externalToolBlock={externalToolBlock}
workflowVariableBlock={workflowVariableBlock}
@ -217,6 +254,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
contextBlock={contextBlock}
historyBlock={historyBlock}
queryBlock={queryBlock}
requestURLBlock={requestURLBlock}
variableBlock={variableBlock}
externalToolBlock={externalToolBlock}
workflowVariableBlock={workflowVariableBlock}
@ -265,6 +303,14 @@ const PromptEditor: FC<PromptEditorProps> = ({
</>
)
}
{
hitlInputBlock?.show && (
<>
<HITLInputBlock {...hitlInputBlock} />
<HITLInputBlockReplacementBlock {...hitlInputBlock} />
</>
)
}
{
currentBlock?.show && (
<>
@ -273,6 +319,14 @@ const PromptEditor: FC<PromptEditorProps> = ({
</>
)
}
{
requestURLBlock?.show && (
<>
<RequestURLBlock {...requestURLBlock} />
<RequestURLBlockReplacementBlock {...requestURLBlock} />
</>
)
}
{
errorMessageBlock?.show && (
<>
@ -298,6 +352,9 @@ const PromptEditor: FC<PromptEditorProps> = ({
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
<UpdateBlock instanceId={instanceId} />
<HistoryPlugin />
{floatingAnchorElem && (
<DraggableBlockPlugin anchorElem={floatingAnchorElem} />
)}
{/* <TreeView /> */}
</div>
</LexicalComposer>

View File

@ -6,10 +6,12 @@ import type {
HistoryBlockType,
LastRunBlockType,
QueryBlockType,
RequestURLBlockType,
VariableBlockType,
WorkflowVariableBlockType,
} from '../../types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { RiGlobalLine } from '@remixicon/react'
import { $insertNodes } from 'lexical'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -27,6 +29,7 @@ import { INSERT_CONTEXT_BLOCK_COMMAND } from '../context-block'
import { $createCustomTextNode } from '../custom-text/node'
import { INSERT_HISTORY_BLOCK_COMMAND } from '../history-block'
import { INSERT_QUERY_BLOCK_COMMAND } from '../query-block'
import { INSERT_REQUEST_URL_BLOCK_COMMAND } from '../request-url-block'
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '../variable-block'
import { PickerBlockMenuOption } from './menu'
import { PromptMenuItem } from './prompt-option'
@ -36,6 +39,7 @@ export const usePromptOptions = (
contextBlock?: ContextBlockType,
queryBlock?: QueryBlockType,
historyBlock?: HistoryBlockType,
requestURLBlock?: RequestURLBlockType,
) => {
const { t } = useTranslation()
const [editor] = useLexicalComposerContext()
@ -91,6 +95,30 @@ export const usePromptOptions = (
)
}
if (requestURLBlock?.show) {
promptOptions.push(new PickerBlockMenuOption({
key: t('promptEditor.requestURL.item.title', { ns: 'common' }),
group: 'request URL',
render: ({ isSelected, onSelect, onSetHighlight }) => {
return (
<PromptMenuItem
title={t('promptEditor.requestURL.item.title', { ns: 'common' })}
icon={<RiGlobalLine className="h-4 w-4 text-util-colors-violet-violet-600" />}
disabled={!requestURLBlock.selectable}
isSelected={isSelected}
onClick={onSelect}
onMouseEnter={onSetHighlight}
/>
)
},
onSelect: () => {
if (!requestURLBlock?.selectable)
return
editor.dispatchCommand(INSERT_REQUEST_URL_BLOCK_COMMAND, undefined)
},
}))
}
if (historyBlock?.show) {
promptOptions.push(
new PickerBlockMenuOption({
@ -272,12 +300,13 @@ export const useOptions = (
variableBlock?: VariableBlockType,
externalToolBlockType?: ExternalToolBlockType,
workflowVariableBlockType?: WorkflowVariableBlockType,
requestURLBlock?: RequestURLBlockType,
currentBlockType?: CurrentBlockType,
errorMessageBlockType?: ErrorMessageBlockType,
lastRunBlockType?: LastRunBlockType,
queryString?: string,
) => {
const promptOptions = usePromptOptions(contextBlock, queryBlock, historyBlock)
const promptOptions = usePromptOptions(contextBlock, queryBlock, historyBlock, requestURLBlock)
const variableOptions = useVariableOptions(variableBlock, queryString)
const externalToolOptions = useExternalToolOptions(externalToolBlockType, queryString)

View File

@ -8,6 +8,7 @@ import type {
HistoryBlockType,
LastRunBlockType,
QueryBlockType,
RequestURLBlockType,
VariableBlockType,
WorkflowVariableBlockType,
} from '../../types'
@ -44,6 +45,7 @@ type ComponentPickerProps = {
triggerString: string
contextBlock?: ContextBlockType
queryBlock?: QueryBlockType
requestURLBlock?: RequestURLBlockType
historyBlock?: HistoryBlockType
variableBlock?: VariableBlockType
externalToolBlock?: ExternalToolBlockType
@ -57,6 +59,7 @@ const ComponentPicker = ({
triggerString,
contextBlock,
queryBlock,
requestURLBlock,
historyBlock,
variableBlock,
externalToolBlock,
@ -100,6 +103,7 @@ const ComponentPicker = ({
variableBlock,
externalToolBlock,
workflowVariableBlock,
requestURLBlock,
currentBlock,
errorMessageBlock,
lastRunBlock,

View File

@ -0,0 +1,86 @@
import type { JSX } from 'react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { DraggableBlockPlugin_EXPERIMENTAL } from '@lexical/react/LexicalDraggableBlockPlugin'
import { RiDraggable } from '@remixicon/react'
import { useEffect, useRef, useState } from 'react'
import { cn } from '@/utils/classnames'
const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu'
function isOnMenu(element: HTMLElement): boolean {
return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`)
}
const SUPPORT_DRAG_CLASS = 'support-drag'
function checkSupportDrag(element: Element | null): boolean {
if (!element)
return false
if (element.classList.contains(SUPPORT_DRAG_CLASS))
return true
if (element.querySelector(`.${SUPPORT_DRAG_CLASS}`))
return true
return !!(element.closest(`.${SUPPORT_DRAG_CLASS}`))
}
export default function DraggableBlockPlugin({
anchorElem = document.body,
}: {
anchorElem?: HTMLElement
}): JSX.Element {
const menuRef = useRef<HTMLDivElement>(null)
const targetLineRef = useRef<HTMLDivElement>(null)
const [, setDraggableElement] = useState<HTMLElement | null>(
null,
)
const [editor] = useLexicalComposerContext()
const [isSupportDrag, setIsSupportDrag] = useState(false)
useEffect(() => {
const root = editor.getRootElement()
if (!root)
return
const onMove = (e: MouseEvent) => {
const isSupportDrag = checkSupportDrag(e.target as Element)
setIsSupportDrag(isSupportDrag)
}
root.addEventListener('mousemove', onMove)
return () => root.removeEventListener('mousemove', onMove)
}, [editor])
return (
<DraggableBlockPlugin_EXPERIMENTAL
anchorElem={anchorElem}
menuRef={menuRef as any}
targetLineRef={targetLineRef as any}
menuComponent={
isSupportDrag
? (
<div ref={menuRef} className={cn(DRAGGABLE_BLOCK_MENU_CLASSNAME, 'absolute right-2.5 top-4 cursor-grab opacity-0 will-change-transform active:cursor-move')}>
<RiDraggable className="size-3.5 text-text-tertiary" />
</div>
)
: null
}
targetLineComponent={(
<div
ref={targetLineRef}
className="pointer-events-none absolute left-[-21px] top-0 opacity-0 will-change-transform"
// style={{ width: 500 }} // width not worked here
>
<div
className="absolute -right-10 left-0 top-0 h-[2px] bg-text-accent-secondary"
>
</div>
</div>
)}
isOnMenu={isOnMenu}
onElementChanged={setDraggableElement}
/>
)
}

View File

@ -0,0 +1,170 @@
'use client'
import type { FC } from 'react'
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { Type } from '@/app/components/workflow/nodes/llm/types'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { RiDeleteBinLine, RiEditLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { InputVarType } from '@/app/components/workflow/types'
import ActionButton from '../../../action-button'
import { VariableX } from '../../../icons/src/vender/workflow'
import Modal from '../../../modal'
import InputField from './input-field'
import VariableBlock from './variable-block'
type HITLInputComponentUIProps = {
nodeId: string
varName: string
formInput?: FormInputItem
onChange: (input: FormInputItem) => void
onRename: (payload: FormInputItem, oldName: string) => void
onRemove: (varName: string) => void
workflowNodesMap: WorkflowNodesMap
environmentVariables?: Var[]
conversationVariables?: Var[]
ragVariables?: Var[]
getVarType?: (payload: {
nodeId: string
valueSelector: ValueSelector
}) => Type
readonly?: boolean
}
const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
nodeId,
varName,
formInput = {
type: InputVarType.paragraph,
output_variable_name: varName,
default: {
type: 'constant',
selector: [],
value: '',
},
},
onChange,
onRename,
onRemove,
workflowNodesMap = {},
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
readonly,
}) => {
const [isShowEditModal, {
setTrue: showEditModal,
setFalse: hideEditModal,
}] = useBoolean(false)
// Lexical delegate the click make it unable to add click by the method of react
const editBtnRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const editBtn = editBtnRef.current
if (editBtn)
editBtn.addEventListener('click', showEditModal)
return () => {
if (editBtn)
editBtn.removeEventListener('click', showEditModal)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const removeBtnRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const removeBtn = removeBtnRef.current
const removeHandler = () => onRemove(varName)
if (removeBtn)
removeBtn.addEventListener('click', removeHandler)
return () => {
if (removeBtn)
removeBtn.removeEventListener('click', removeHandler)
}
}, [onRemove, varName])
const handleChange = useCallback((newPayload: FormInputItem) => {
if (varName === newPayload.output_variable_name)
onChange(newPayload)
else
onRename(newPayload, varName)
hideEditModal()
}, [hideEditModal, onChange, onRename, varName])
const isDefaultValueVariable = useMemo(() => {
return formInput.default?.type === 'variable'
}, [formInput.default?.type])
return (
<div
className="group relative flex h-8 w-full select-none items-center rounded-[8px] border-[1.5px] border-components-input-border-active bg-background-default-hover pl-1.5 pr-0.5"
>
<div className="absolute left-2.5 top-[-12px]">
<div className="absolute bottom-1 h-[1.5px] w-full bg-background-default-subtle"></div>
<div className="relative flex items-center space-x-0.5 px-1 text-text-accent-light-mode-only">
<VariableX className="size-3" />
<div className="system-xs-medium">{varName}</div>
</div>
</div>
<div className="flex w-full items-center gap-x-0.5 pr-5">
<div className="min-w-0 grow">
{/* Default Value Info */}
{isDefaultValueVariable && (
<VariableBlock
variables={formInput.default?.selector}
workflowNodesMap={workflowNodesMap}
getVarType={getVarType}
environmentVariables={environmentVariables}
conversationVariables={conversationVariables}
ragVariables={ragVariables}
/>
)}
{!isDefaultValueVariable && (
<div className="system-xs-medium max-w-full truncate text-components-input-text-filled">{formInput.default?.value}</div>
)}
</div>
{/* Actions */}
{!readonly && (
<div className="hidden h-full shrink-0 items-center space-x-1 group-hover:flex">
<div className="flex h-full items-center" ref={editBtnRef}>
<ActionButton size="s">
<RiEditLine className="size-4 text-text-tertiary" />
</ActionButton>
</div>
<div className="flex h-full items-center" ref={removeBtnRef}>
<ActionButton size="s">
<RiDeleteBinLine className="size-4 text-text-tertiary" />
</ActionButton>
</div>
</div>
)}
</div>
{isShowEditModal && (
<Modal
isShow
onClose={hideEditModal}
wrapperClassName="z-[999]"
className="max-w-[372px] !p-0"
>
<InputField
nodeId={nodeId}
isEdit
payload={formInput}
onChange={handleChange}
onCancel={hideEditModal}
/>
</Modal>
)}
</div>
)
}
export default React.memo(HITLInputComponentUI)

View File

@ -0,0 +1,86 @@
import type { FC } from 'react'
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { Type } from '@/app/components/workflow/nodes/llm/types'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { produce } from 'immer'
import { useCallback } from 'react'
import { useSelectOrDelete } from '../../hooks'
import { DELETE_HITL_INPUT_BLOCK_COMMAND } from './'
import ComponentUi from './component-ui'
type HITLInputComponentProps = {
nodeKey: string
nodeId: string
varName: string
formInputs?: FormInputItem[]
onChange: (inputs: FormInputItem[]) => void
onRename: (payload: FormInputItem, oldName: string) => void
onRemove: (varName: string) => void
workflowNodesMap: WorkflowNodesMap
environmentVariables?: Var[]
conversationVariables?: Var[]
ragVariables?: Var[]
getVarType?: (payload: {
nodeId: string
valueSelector: ValueSelector
}) => Type
readonly?: boolean
}
const HITLInputComponent: FC<HITLInputComponentProps> = ({
nodeKey,
nodeId,
varName,
formInputs = [],
onChange,
onRename,
onRemove,
workflowNodesMap = {},
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
readonly,
}) => {
const [ref] = useSelectOrDelete(nodeKey, DELETE_HITL_INPUT_BLOCK_COMMAND)
const payload = formInputs.find(item => item.output_variable_name === varName)
const handleChange = useCallback((newPayload: FormInputItem) => {
if (!payload) {
onChange([...formInputs, newPayload])
return
}
if (payload?.output_variable_name !== newPayload.output_variable_name) {
onChange(produce(formInputs, (draft) => {
draft.splice(draft.findIndex(item => item.output_variable_name === payload?.output_variable_name), 1, newPayload)
}))
return
}
onChange(formInputs.map(item => item.output_variable_name === varName ? newPayload : item))
}, [formInputs, onChange, payload, varName])
return (
<div
ref={ref}
className="w-full pb-1 pt-3"
>
<ComponentUi
nodeId={nodeId}
varName={varName}
formInput={payload}
onChange={handleChange}
onRename={onRename}
onRemove={onRemove}
workflowNodesMap={workflowNodesMap}
getVarType={getVarType}
environmentVariables={environmentVariables}
conversationVariables={conversationVariables}
ragVariables={ragVariables}
readonly={readonly}
/>
</div>
)
}
export default HITLInputComponent

View File

@ -0,0 +1,89 @@
import type { TextNode } from 'lexical'
import type { HITLInputBlockType } from '../../types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { $applyNodeReplacement } from 'lexical'
import {
memo,
useCallback,
useEffect,
useMemo,
} from 'react'
import { HITL_INPUT_REG } from '@/config'
import { decoratorTransform } from '../../utils'
import { CustomTextNode } from '../custom-text/node'
import { $createHITLInputNode, HITLInputNode } from './node'
const REGEX = new RegExp(HITL_INPUT_REG)
const HITLInputReplacementBlock = ({
nodeId,
formInputs,
onFormInputsChange,
onFormInputItemRename,
onFormInputItemRemove,
workflowNodesMap,
getVarType,
variables,
readonly,
}: HITLInputBlockType) => {
const [editor] = useLexicalComposerContext()
const environmentVariables = useMemo(() => variables?.find(o => o.nodeId === 'env')?.vars || [], [variables])
const conversationVariables = useMemo(() => variables?.find(o => o.nodeId === 'conversation')?.vars || [], [variables])
const ragVariables = useMemo(() => variables?.reduce<any[]>((acc, curr) => {
if (curr.nodeId === 'rag')
acc.push(...curr.vars)
else
acc.push(...curr.vars.filter(v => v.isRagVariable))
return acc
}, []), [variables])
useEffect(() => {
if (!editor.hasNodes([HITLInputNode]))
throw new Error('HITLInputNodePlugin: HITLInputNode not registered on editor')
}, [editor])
const createHITLInputBlockNode = useCallback((textNode: TextNode): HITLInputNode => {
const varName = textNode.getTextContent().split('.')[1].replace(/#\}\}$/, '')
return $applyNodeReplacement($createHITLInputNode(
varName,
nodeId,
formInputs || [],
onFormInputsChange!,
onFormInputItemRename,
onFormInputItemRemove!,
workflowNodesMap,
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
readonly,
))
}, [nodeId, formInputs, onFormInputsChange, onFormInputItemRename, onFormInputItemRemove, workflowNodesMap, getVarType, environmentVariables, conversationVariables, ragVariables, readonly])
const getMatch = useCallback((text: string) => {
const matchArr = REGEX.exec(text)
if (matchArr === null)
return null
const startOffset = matchArr.index
const endOffset = startOffset + matchArr[0].length
return {
end: endOffset,
start: startOffset,
}
}, [])
useEffect(() => {
REGEX.lastIndex = 0
return mergeRegister(
editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createHITLInputBlockNode)),
)
}, [])
return null
}
export default memo(HITLInputReplacementBlock)

View File

@ -0,0 +1,106 @@
import type { HITLInputBlockType } from '../../types'
import type {
HITLNodeProps,
} from './node'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import {
$insertNodes,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from 'lexical'
import {
memo,
useEffect,
} from 'react'
import { CustomTextNode } from '../custom-text/node'
import {
$createHITLInputNode,
HITLInputNode,
} from './node'
export const INSERT_HITL_INPUT_BLOCK_COMMAND = createCommand('INSERT_HITL_INPUT_BLOCK_COMMAND')
export const DELETE_HITL_INPUT_BLOCK_COMMAND = createCommand('DELETE_HITL_INPUT_BLOCK_COMMAND')
export const UPDATE_WORKFLOW_NODES_MAP = createCommand('UPDATE_WORKFLOW_NODES_MAP')
export type HITLInputProps = {
onInsert?: () => void
onDelete?: () => void
}
const HITLInputBlock = memo(({
onInsert,
onDelete,
workflowNodesMap,
getVarType,
readonly,
}: HITLInputBlockType) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
editor.update(() => {
editor.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, workflowNodesMap)
})
}, [editor, workflowNodesMap])
useEffect(() => {
if (!editor.hasNodes([HITLInputNode]))
throw new Error('HITLInputBlockPlugin: HITLInputBlock not registered on editor')
return mergeRegister(
editor.registerCommand(
INSERT_HITL_INPUT_BLOCK_COMMAND,
(nodeProps: HITLNodeProps) => {
const {
variableName,
nodeId,
formInputs,
onFormInputsChange,
onFormInputItemRename,
onFormInputItemRemove,
} = nodeProps
const currentHITLNode = $createHITLInputNode(
variableName,
nodeId,
formInputs,
onFormInputsChange,
onFormInputItemRename,
onFormInputItemRemove,
workflowNodesMap,
getVarType,
undefined,
undefined,
undefined,
readonly,
)
const prev = new CustomTextNode('\n')
$insertNodes([prev])
$insertNodes([currentHITLNode])
const next = new CustomTextNode('\n')
$insertNodes([next])
if (onInsert)
onInsert()
return true
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
DELETE_HITL_INPUT_BLOCK_COMMAND,
() => {
if (onDelete)
onDelete()
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor, onInsert, onDelete])
return null
})
HITLInputBlock.displayName = 'HITLInputBlock'
export { HITLInputBlock }
export { default as HITLInputBlockReplacementBlock } from './hitl-input-block-replacement-block'
export { HITLInputNode } from './node'

View File

@ -0,0 +1,153 @@
import type { FormInputItem, FormInputItemDefault } from '@/app/components/workflow/nodes/human-input/types'
import type { ValueSelector } from '@/app/components/workflow/types'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { InputVarType } from '@/app/components/workflow/types'
import { getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
import Button from '../../../button'
import PrePopulate from './pre-populate'
const i18nPrefix = 'nodes.humanInput.insertInputField'
type InputFieldProps = {
nodeId: string
isEdit: boolean
payload?: FormInputItem
onChange: (newPayload: FormInputItem) => void
onCancel: () => void
}
const defaultPayload: FormInputItem = {
type: InputVarType.paragraph,
output_variable_name: '',
default: { type: 'constant', selector: [], value: '' },
}
const InputField: React.FC<InputFieldProps> = ({
nodeId,
isEdit,
payload,
onChange,
onCancel,
}) => {
const { t } = useTranslation()
const [tempPayload, setTempPayload] = useState(payload || defaultPayload)
const nameValid = useMemo(() => {
const name = tempPayload.output_variable_name.trim()
if (!name)
return false
if (name.includes(' '))
return false
return /^[a-z_]\w{0,29}$/.test(name)
}, [tempPayload.output_variable_name])
const handleSave = useCallback(() => {
if (!nameValid)
return
onChange(tempPayload)
}, [nameValid, onChange, tempPayload])
const defaultValueConfig = tempPayload.default
const handleDefaultValueChange = useCallback((key: keyof FormInputItemDefault) => {
return (value: ValueSelector | string) => {
const nextValue = produce(tempPayload, (draft) => {
if (!draft.default)
draft.default = { type: 'constant', selector: [], value: '' }
if (key === 'selector') {
draft.default.type = 'variable'
draft.default.selector = value as ValueSelector
}
else if (key === 'value') {
draft.default.type = 'constant'
draft.default.value = value as string
}
else if (key === 'type') {
draft.default.type = value as 'constant' | 'variable'
}
})
setTempPayload(nextValue)
}
}, [tempPayload])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
handleSave()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [handleSave])
return (
<div className="w-[372px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-[5px]">
<div className="system-md-semibold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'workflow' })}</div>
<div className="mt-3">
<div className="system-xs-medium text-text-secondary">
{t(`${i18nPrefix}.saveResponseAs`, { ns: 'workflow' })}
<span className="system-xs-regular relative text-text-destructive-secondary">*</span>
</div>
<Input
className="mt-1.5"
placeholder={t(`${i18nPrefix}.saveResponseAsPlaceholder`, { ns: 'workflow' })}
value={tempPayload.output_variable_name}
onChange={(e) => {
setTempPayload(prev => ({ ...prev, output_variable_name: e.target.value }))
}}
autoFocus
/>
{tempPayload.output_variable_name && !nameValid && (
<div className="system-xs-regular mt-1 px-1 text-text-destructive-secondary">
{t(`${i18nPrefix}.variableNameInvalid`, { ns: 'workflow' })}
</div>
)}
</div>
<div className="mt-4">
<div className="system-xs-medium mb-1.5 text-text-secondary">
{t(`${i18nPrefix}.prePopulateField`, { ns: 'workflow' })}
</div>
<PrePopulate
isVariable={defaultValueConfig?.type === 'variable'}
onIsVariableChange={(isVariable) => {
handleDefaultValueChange('type')(isVariable ? 'variable' : 'constant')
}}
nodeId={nodeId}
valueSelector={defaultValueConfig?.selector}
onValueSelectorChange={handleDefaultValueChange('selector')}
value={defaultValueConfig?.value}
onValueChange={handleDefaultValueChange('value')}
/>
</div>
<div className="mt-4 flex justify-end space-x-2">
<Button onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button>
{isEdit
? (
<Button
variant="primary"
onClick={handleSave}
disabled={!nameValid}
>
{t('operation.save', { ns: 'common' })}
</Button>
)
: (
<Button
className="flex"
variant="primary"
disabled={!nameValid}
onClick={handleSave}
>
<span className="mr-1">{t(`${i18nPrefix}.insert`, { ns: 'workflow' })}</span>
<span className="system-kbd mr-0.5 flex h-4 items-center rounded-[4px] bg-components-kbd-bg-white px-1">{getKeyboardKeyNameBySystem('ctrl')}</span>
<span className=" system-kbd flex h-4 items-center rounded-[4px] bg-components-kbd-bg-white px-1"></span>
</Button>
)}
</div>
</div>
)
}
export default InputField

View File

@ -0,0 +1,272 @@
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
import type { GetVarType } from '../../types'
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
import type { Var } from '@/app/components/workflow/types'
import { DecoratorNode } from 'lexical'
import HILTInputBlockComponent from './component'
export type HITLNodeProps = {
variableName: string
nodeId: string
formInputs: FormInputItem[]
onFormInputsChange: (inputs: FormInputItem[]) => void
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void
onFormInputItemRemove: (varName: string) => void
workflowNodesMap: WorkflowNodesMap
getVarType?: GetVarType
environmentVariables?: Var[]
conversationVariables?: Var[]
ragVariables?: Var[]
readonly?: boolean
}
export type SerializedNode = SerializedLexicalNode & HITLNodeProps
export class HITLInputNode extends DecoratorNode<React.JSX.Element> {
__variableName: string
__nodeId: string
__formInputs?: FormInputItem[]
__onFormInputsChange: (inputs: FormInputItem[]) => void
__onFormInputItemRename: (payload: FormInputItem, oldName: string) => void
__onFormInputItemRemove: (varName: string) => void
__workflowNodesMap: WorkflowNodesMap
__getVarType?: GetVarType
__environmentVariables?: Var[]
__conversationVariables?: Var[]
__ragVariables?: Var[]
__readonly?: boolean
isIsolated(): boolean {
return true // This is necessary for drag-and-drop to work correctly
}
isTopLevel(): boolean {
return true // This is necessary for drag-and-drop to work correctly
}
static getType(): string {
return 'hitl-input-block'
}
getVariableName(): string {
const self = this.getLatest()
return self.__variableName
}
getNodeId(): string {
const self = this.getLatest()
return self.__nodeId
}
getFormInputs(): FormInputItem[] {
const self = this.getLatest()
return self.__formInputs || []
}
getOnFormInputsChange(): (inputs: FormInputItem[]) => void {
const self = this.getLatest()
return self.__onFormInputsChange
}
getOnFormInputItemRename(): (payload: FormInputItem, oldName: string) => void {
const self = this.getLatest()
return self.__onFormInputItemRename
}
getOnFormInputItemRemove(): (varName: string) => void {
const self = this.getLatest()
return self.__onFormInputItemRemove
}
getWorkflowNodesMap(): WorkflowNodesMap {
const self = this.getLatest()
return self.__workflowNodesMap
}
getGetVarType(): GetVarType | undefined {
const self = this.getLatest()
return self.__getVarType
}
getEnvironmentVariables(): Var[] {
const self = this.getLatest()
return self.__environmentVariables || []
}
getConversationVariables(): Var[] {
const self = this.getLatest()
return self.__conversationVariables || []
}
getRagVariables(): Var[] {
const self = this.getLatest()
return self.__ragVariables || []
}
getReadonly(): boolean {
const self = this.getLatest()
return self.__readonly || false
}
static clone(node: HITLInputNode): HITLInputNode {
return new HITLInputNode(
node.__variableName,
node.__nodeId,
node.__formInputs || [],
node.__onFormInputsChange,
node.__onFormInputItemRename,
node.__onFormInputItemRemove,
node.__workflowNodesMap,
node.__getVarType,
node.__environmentVariables,
node.__conversationVariables,
node.__ragVariables,
node.__readonly,
node.__key,
)
}
isInline(): boolean {
return true
}
constructor(
varName: string,
nodeId: string,
formInputs: FormInputItem[],
onFormInputsChange: (inputs: FormInputItem[]) => void,
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void,
onFormInputItemRemove: (varName: string) => void,
workflowNodesMap: WorkflowNodesMap,
getVarType?: GetVarType,
environmentVariables?: Var[],
conversationVariables?: Var[],
ragVariables?: Var[],
readonly?: boolean,
key?: NodeKey,
) {
super(key)
this.__variableName = varName
this.__nodeId = nodeId
this.__formInputs = formInputs
this.__onFormInputsChange = onFormInputsChange
this.__onFormInputItemRename = onFormInputItemRename
this.__onFormInputItemRemove = onFormInputItemRemove
this.__workflowNodesMap = workflowNodesMap
this.__getVarType = getVarType
this.__environmentVariables = environmentVariables
this.__conversationVariables = conversationVariables
this.__ragVariables = ragVariables
this.__readonly = readonly
}
createDOM(): HTMLElement {
const div = document.createElement('div')
div.classList.add('inline-flex', 'w-[calc(100%-1px)]', 'items-center', 'align-middle', 'support-drag')
return div
}
updateDOM(): false {
return false
}
decorate(): React.JSX.Element {
return (
<HILTInputBlockComponent
nodeKey={this.getKey()}
varName={this.getVariableName()}
nodeId={this.getNodeId()}
formInputs={this.getFormInputs()}
onChange={this.getOnFormInputsChange()}
onRename={this.getOnFormInputItemRename()}
onRemove={this.getOnFormInputItemRemove()}
workflowNodesMap={this.getWorkflowNodesMap()}
getVarType={this.getGetVarType()}
environmentVariables={this.getEnvironmentVariables()}
conversationVariables={this.getConversationVariables()}
ragVariables={this.getRagVariables()}
readonly={this.getReadonly()}
/>
)
}
static importJSON(serializedNode: SerializedNode): HITLInputNode {
const node = $createHITLInputNode(
serializedNode.variableName,
serializedNode.nodeId,
serializedNode.formInputs,
serializedNode.onFormInputsChange,
serializedNode.onFormInputItemRename,
serializedNode.onFormInputItemRemove,
serializedNode.workflowNodesMap,
serializedNode.getVarType,
serializedNode.environmentVariables,
serializedNode.conversationVariables,
serializedNode.ragVariables,
serializedNode.readonly,
)
return node
}
exportJSON(): SerializedNode {
return {
type: 'hitl-input-block',
version: 1,
variableName: this.getVariableName(),
nodeId: this.getNodeId(),
formInputs: this.getFormInputs(),
onFormInputsChange: this.getOnFormInputsChange(),
onFormInputItemRename: this.getOnFormInputItemRename(),
onFormInputItemRemove: this.getOnFormInputItemRemove(),
workflowNodesMap: this.getWorkflowNodesMap(),
getVarType: this.getGetVarType(),
environmentVariables: this.getEnvironmentVariables(),
conversationVariables: this.getConversationVariables(),
ragVariables: this.getRagVariables(),
readonly: this.getReadonly(),
}
}
getTextContent(): string {
return `{{#$output.${this.getVariableName()}#}}`
}
}
export function $createHITLInputNode(
variableName: string,
nodeId: string,
formInputs: FormInputItem[],
onFormInputsChange: (inputs: FormInputItem[]) => void,
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void,
onFormInputItemRemove: (varName: string) => void,
workflowNodesMap: WorkflowNodesMap,
getVarType?: GetVarType,
environmentVariables?: Var[],
conversationVariables?: Var[],
ragVariables?: Var[],
readonly?: boolean,
): HITLInputNode {
return new HITLInputNode(
variableName,
nodeId,
formInputs,
onFormInputsChange,
onFormInputItemRename,
onFormInputItemRemove,
workflowNodesMap,
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
readonly,
)
}
export function $isHITLInputNode(
node: HITLInputNode | LexicalNode | null | undefined,
): node is HITLInputNode {
return node instanceof HITLInputNode
}

View File

@ -0,0 +1,148 @@
'use client'
import type { FC } from 'react'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import { VarType } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
import Textarea from '../../../textarea'
import TagLabel from './tag-label'
import TypeSwitch from './type-switch'
type Props = {
isVariable?: boolean
onIsVariableChange?: (isVariable: boolean) => void
nodeId: string
valueSelector?: ValueSelector
onValueSelectorChange?: (valueSelector: ValueSelector | string) => void
value?: string
onValueChange?: (value: string) => void
}
const i18nPrefix = 'nodes.humanInput.insertInputField'
type PlaceholderProps = {
varPickerProps: {
nodeId: string
value: ValueSelector
onChange: (valueSelector: ValueSelector | string) => void
readonly: boolean
zIndex: number
filterVar: (varPayload: Var) => boolean
isJustShowValue?: boolean
}
onTypeClick: (isVariable: boolean) => void
}
const Placeholder = ({
varPickerProps,
onTypeClick,
}: PlaceholderProps) => {
const { t } = useTranslation()
return (
<div className="system-sm-regular mt-1 h-[80px] rounded-lg bg-components-input-bg-normal px-3 pt-2 text-text-tertiary">
<div className="flex flex-wrap items-center leading-5">
<Trans
i18nKey={`${i18nPrefix}.prePopulateFieldPlaceholder`}
ns="workflow"
components={{
staticContent: <TagLabel type="edit" className="mx-1" onClick={() => onTypeClick(false)}>{t(`${i18nPrefix}.staticContent`, { ns: 'workflow' })}</TagLabel>,
variable: (
<VarReferencePicker
{...varPickerProps}
trigger={
<TagLabel type="variable" className="mx-1">{t(`${i18nPrefix}.variable`, { ns: 'workflow' })}</TagLabel>
}
/>
),
}}
/>
</div>
</div>
)
}
const PrePopulate: FC<Props> = ({
isVariable = false,
onIsVariableChange,
nodeId,
valueSelector,
onValueSelectorChange,
value,
onValueChange,
}) => {
const [onPlaceholderClicked, setOnPlaceholderClicked] = useState(false)
const handleTypeChange = useCallback((isVar: boolean) => {
setOnPlaceholderClicked(true)
onIsVariableChange?.(isVar)
}, [onIsVariableChange])
const [isFocus, setIsFocus] = useState(false)
const varPickerProps = {
nodeId,
value: valueSelector || [],
onChange: onValueSelectorChange!,
readonly: false,
zIndex: 1000000, // bigger than shortcut plugin popup
filterVar: (varPayload: Var) => {
return [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
},
}
const isShowPlaceholder = !onPlaceholderClicked && (isVariable ? (!valueSelector || valueSelector.length === 0) : !value)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Tab' && !onPlaceholderClicked) {
e.preventDefault()
setOnPlaceholderClicked(true)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [onPlaceholderClicked, setOnPlaceholderClicked])
if (isShowPlaceholder)
return <Placeholder varPickerProps={varPickerProps} onTypeClick={handleTypeChange} />
if (isVariable) {
return (
<div className="relative h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal px-3 pt-2">
<VarReferencePicker
{...varPickerProps}
isJustShowValue
/>
<TypeSwitch
className="absolute bottom-1 left-1.5"
isVariable={isVariable}
onIsVariableChange={handleTypeChange}
/>
</div>
)
}
return (
<div className={cn('relative min-h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal pb-1', isFocus && 'border-components-input-border-active bg-components-input-bg-active shadow-xs')}>
<Textarea
value={value || ''}
className="h-[43px] min-h-[43px] rounded-none border-none bg-transparent px-3 hover:bg-transparent focus:bg-transparent focus:shadow-none"
onChange={e => onValueChange?.(e.target.value)}
onFocus={() => {
setOnPlaceholderClicked(true)
setIsFocus(true)
}}
onBlur={() => setIsFocus(false)}
autoFocus
/>
<TypeSwitch
className="absolute bottom-1 left-1.5"
isVariable={isVariable}
onIsVariableChange={handleTypeChange}
/>
</div>
)
}
export default React.memo(PrePopulate)

View File

@ -0,0 +1,32 @@
'use client'
import type { FC } from 'react'
import { RiEditLine } from '@remixicon/react'
import * as React from 'react'
import { cn } from '@/utils/classnames'
import { Variable02 } from '../../../icons/src/vender/solid/development'
type Props = {
type: 'edit' | 'variable'
children: string
className?: string
onClick?: () => void
}
const TagLabel: FC<Props> = ({
type,
children,
className,
onClick,
}) => {
const Icon = type === 'edit' ? RiEditLine : Variable02
return (
<div
className={cn('inline-flex h-5 cursor-pointer items-center space-x-1 rounded-md bg-components-button-secondary-bg px-1 text-text-accent', className)}
onClick={onClick}
>
<Icon className="size-3.5" />
<div className="system-xs-medium ">{children}</div>
</div>
)
}
export default React.memo(TagLabel)

View File

@ -0,0 +1,27 @@
'use client'
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import { Variable02 } from '../../../icons/src/vender/solid/development'
type Props = {
className?: string
isVariable?: boolean
onIsVariableChange?: (isVariable: boolean) => void
}
const TypeSwitch: FC<Props> = ({
className,
isVariable,
onIsVariableChange,
}) => {
const { t } = useTranslation()
return (
<div className={cn('inline-flex h-6 cursor-pointer select-none items-center space-x-1 rounded-md pl-1.5 pr-2 text-text-tertiary hover:bg-components-button-ghost-bg-hover', className)} onClick={() => onIsVariableChange?.(!isVariable)}>
<Variable02 className="size-3.5" />
<div className="system-xs-medium">{t(`nodes.humanInput.insertInputField.${isVariable ? 'useConstantInstead' : 'useVarInstead'}`, { ns: 'workflow' })}</div>
</div>
)
}
export default React.memo(TypeSwitch)

View File

@ -0,0 +1,148 @@
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import {
COMMAND_PRIORITY_EDITOR,
} from 'lexical'
import {
memo,
useEffect,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import {
isConversationVar,
isENV,
isGlobalVar,
isRagVariableVar,
isSystemVar,
} from '@/app/components/workflow/nodes/_base/components/variable/utils'
import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel'
import {
VariableLabelInEditor,
} from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import { isExceptionVariable } from '@/app/components/workflow/utils'
import { UPDATE_WORKFLOW_NODES_MAP } from '../workflow-variable-block'
import { HITLInputNode } from './node'
type HITLInputVariableBlockComponentProps = {
variables: string[]
workflowNodesMap: WorkflowNodesMap
environmentVariables?: Var[]
conversationVariables?: Var[]
ragVariables?: Var[]
getVarType?: (payload: {
nodeId: string
valueSelector: ValueSelector
}) => Type
}
const HITLInputVariableBlockComponent = ({
variables,
workflowNodesMap = {},
getVarType,
environmentVariables,
conversationVariables,
ragVariables,
}: HITLInputVariableBlockComponentProps) => {
const { t } = useTranslation()
const [editor] = useLexicalComposerContext()
const variablesLength = variables.length
const isRagVar = isRagVariableVar(variables)
const isShowAPart = variablesLength > 2 && !isRagVar
const varName = (
() => {
const isSystem = isSystemVar(variables)
const varName = variables[variablesLength - 1]
return `${isSystem ? 'sys.' : ''}${varName}`
}
)()
const [localWorkflowNodesMap, setLocalWorkflowNodesMap] = useState<WorkflowNodesMap>(workflowNodesMap)
const node = localWorkflowNodesMap![variables[isRagVar ? 1 : 0]]
const isException = isExceptionVariable(varName, node?.type)
const variableValid = useMemo(() => {
let variableValid = true
const isEnv = isENV(variables)
const isChatVar = isConversationVar(variables)
const isGlobal = isGlobalVar(variables)
if (isGlobal)
return true
if (isEnv) {
if (environmentVariables)
variableValid = environmentVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
}
else if (isChatVar) {
if (conversationVariables)
variableValid = conversationVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
}
else if (isRagVar) {
if (ragVariables)
variableValid = ragVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}.${variables?.[2] ?? ''}`)
}
else {
variableValid = !!node
}
return variableValid
}, [variables, node, environmentVariables, conversationVariables, isRagVar, ragVariables])
useEffect(() => {
if (!editor.hasNodes([HITLInputNode]))
throw new Error('HITLInputNodePlugin: HITLInputNode not registered on editor')
return mergeRegister(
editor.registerCommand(
UPDATE_WORKFLOW_NODES_MAP,
(workflowNodesMap: WorkflowNodesMap) => {
setLocalWorkflowNodesMap(workflowNodesMap)
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor])
const Item = (
<VariableLabelInEditor
nodeType={node?.type}
nodeTitle={node?.title}
variables={variables}
isExceptionVariable={isException}
errorMsg={!variableValid ? t('errorMsg.invalidVariable', { ns: 'workflow' }) : undefined}
notShowFullPath={isShowAPart}
/>
)
if (!node)
return Item
return (
<Tooltip
noDecoration
popupContent={(
<VarFullPathPanel
nodeName={node.title}
path={variables.slice(1)}
varType={getVarType
? getVarType({
nodeId: variables[0],
valueSelector: variables,
})
: Type.string}
nodeType={node?.type}
/>
)}
disabled={!isShowAPart}
>
<div>{Item}</div>
</Tooltip>
)
}
export default memo(HITLInputVariableBlockComponent)

View File

@ -0,0 +1,33 @@
import type { FC } from 'react'
import { RiGlobalLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import { useSelectOrDelete } from '../../hooks'
import { DELETE_REQUEST_URL_BLOCK_COMMAND } from './index'
type RequestURLBlockComponentProps = {
nodeKey: string
}
const RequestURLBlockComponent: FC<RequestURLBlockComponentProps> = ({
nodeKey,
}) => {
const { t } = useTranslation()
const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_REQUEST_URL_BLOCK_COMMAND)
return (
<div
className={cn(
'group/wrap relative mx-0.5 flex h-[18px] select-none items-center rounded-[5px] border border-components-panel-border-subtle bg-components-badge-white-to-dark px-1 hover:border-[#7839ee]',
isSelected && '!border-[#7839ee] hover:!border-[#7839ee]',
)}
ref={ref}
>
<RiGlobalLine className="mr-0.5 h-3.5 w-3.5 text-util-colors-violet-violet-600" />
<div className="system-xs-medium text-util-colors-violet-violet-600">{t('promptEditor.requestURL.item.title', { ns: 'common' })}</div>
</div>
)
}
export default RequestURLBlockComponent

View File

@ -0,0 +1,64 @@
import type { RequestURLBlockType } from '../../types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import {
$insertNodes,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from 'lexical'
import {
memo,
useEffect,
} from 'react'
import {
$createRequestURLBlockNode,
RequestURLBlockNode,
} from './node'
export const INSERT_REQUEST_URL_BLOCK_COMMAND = createCommand('INSERT_REQUEST_URL_BLOCK_COMMAND')
export const DELETE_REQUEST_URL_BLOCK_COMMAND = createCommand('DELETE_REQUEST_URL_BLOCK_COMMAND')
const RequestURLBlock = memo(({
onInsert,
onDelete,
}: RequestURLBlockType) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!editor.hasNodes([RequestURLBlockNode]))
throw new Error('RequestURLBlockPlugin: RequestURLBlock not registered on editor')
return mergeRegister(
editor.registerCommand(
INSERT_REQUEST_URL_BLOCK_COMMAND,
() => {
const contextBlockNode = $createRequestURLBlockNode()
$insertNodes([contextBlockNode])
if (onInsert)
onInsert()
return true
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
DELETE_REQUEST_URL_BLOCK_COMMAND,
() => {
if (onDelete)
onDelete()
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor, onInsert, onDelete])
return null
})
RequestURLBlock.displayName = 'RequestURLBlock'
export { RequestURLBlock }
export { RequestURLBlockNode } from './node'
export { default as RequestURLBlockReplacementBlock } from './request-url-block-replacement-block'

View File

@ -0,0 +1,59 @@
import type { LexicalNode, SerializedLexicalNode } from 'lexical'
import { DecoratorNode } from 'lexical'
import RequestURLBlockComponent from './component'
export type SerializedNode = SerializedLexicalNode
export class RequestURLBlockNode extends DecoratorNode<React.JSX.Element> {
static getType(): string {
return 'request-url-block'
}
static clone(node: RequestURLBlockNode): RequestURLBlockNode {
return new RequestURLBlockNode(node.__key)
}
isInline(): boolean {
return true
}
createDOM(): HTMLElement {
const div = document.createElement('div')
div.classList.add('inline-flex', 'items-center', 'align-middle')
return div
}
updateDOM(): false {
return false
}
decorate(): React.JSX.Element {
return <RequestURLBlockComponent nodeKey={this.getKey()} />
}
static importJSON(): RequestURLBlockNode {
const node = $createRequestURLBlockNode()
return node
}
exportJSON(): SerializedNode {
return {
type: 'request-url-block',
version: 1,
}
}
getTextContent(): string {
return '{{#url#}}'
}
}
export function $createRequestURLBlockNode(): RequestURLBlockNode {
return new RequestURLBlockNode()
}
export function $isRequestURLBlockNode(
node: RequestURLBlockNode | LexicalNode | null | undefined,
): node is RequestURLBlockNode {
return node instanceof RequestURLBlockNode
}

View File

@ -0,0 +1,60 @@
import type { RequestURLBlockType } from '../../types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { $applyNodeReplacement } from 'lexical'
import {
memo,
useCallback,
useEffect,
} from 'react'
import { REQUEST_URL_PLACEHOLDER_TEXT } from '../../constants'
import { decoratorTransform } from '../../utils'
import { CustomTextNode } from '../custom-text/node'
import {
$createRequestURLBlockNode,
RequestURLBlockNode,
} from '../request-url-block/node'
const REGEX = new RegExp(REQUEST_URL_PLACEHOLDER_TEXT)
const RequestURLBlockReplacementBlock = ({
onInsert,
}: RequestURLBlockType) => {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!editor.hasNodes([RequestURLBlockNode]))
throw new Error('RequestURLBlockNodePlugin: RequestURLBlockNode not registered on editor')
}, [editor])
const createRequestURLBlockNode = useCallback((): RequestURLBlockNode => {
if (onInsert)
onInsert()
return $applyNodeReplacement($createRequestURLBlockNode())
}, [onInsert])
const getMatch = useCallback((text: string) => {
const matchArr = REGEX.exec(text)
if (matchArr === null)
return null
const startOffset = matchArr.index
const endOffset = startOffset + REQUEST_URL_PLACEHOLDER_TEXT.length
return {
end: endOffset,
start: startOffset,
}
}, [])
useEffect(() => {
REGEX.lastIndex = 0
return mergeRegister(
editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createRequestURLBlockNode)),
)
}, [])
return null
}
export default memo(RequestURLBlockReplacementBlock)

View File

@ -0,0 +1,134 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useState } from 'react'
import ShortcutsPopupPlugin, { SHORTCUTS_EMPTY_CONTENT } from './index'
import '@testing-library/jest-dom'
// Mock Range.getClientRects and getBoundingClientRect for JSDOM
const mockDOMRect = {
x: 100,
y: 100,
width: 100,
height: 20,
top: 100,
right: 200,
bottom: 120,
left: 100,
toJSON: () => ({}),
}
beforeAll(() => {
// Mock getClientRects on Range prototype
Range.prototype.getClientRects = vi.fn(() => {
const rectList = [mockDOMRect] as unknown as DOMRectList
Object.defineProperty(rectList, 'length', { value: 1 })
Object.defineProperty(rectList, 'item', { value: (index: number) => index === 0 ? mockDOMRect : null })
return rectList
})
// Mock getBoundingClientRect on Range prototype
Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect)
})
const CONTAINER_ID = 'host'
const CONTENT_EDITABLE_ID = 'ce'
const MinimalEditor: React.FC<{
withContainer?: boolean
}> = ({ withContainer = true }) => {
const initialConfig = {
namespace: 'shortcuts-popup-plugin-test',
onError: (e: Error) => {
throw e
},
}
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null)
return (
<LexicalComposer initialConfig={initialConfig}>
<div data-testid={CONTAINER_ID} className="relative" ref={withContainer ? setContainerEl : undefined}>
<RichTextPlugin
contentEditable={<ContentEditable data-testid={CONTENT_EDITABLE_ID} />}
placeholder={null}
ErrorBoundary={LexicalErrorBoundary}
/>
<ShortcutsPopupPlugin
container={withContainer ? containerEl : undefined}
/>
</div>
</LexicalComposer>
)
}
describe('ShortcutsPopupPlugin', () => {
it('opens on hotkey when editor is focused', async () => {
render(<MinimalEditor />)
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
ce.focus()
fireEvent.keyDown(document, { key: '/', ctrlKey: true }) // 模拟 Ctrl+/
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
})
it('does not open when editor is not focused', async () => {
render(<MinimalEditor />)
// 未聚焦
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
await waitFor(() => {
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
})
})
it('closes on Escape', async () => {
render(<MinimalEditor />)
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
ce.focus()
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() => {
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
})
})
it('closes on click outside', async () => {
render(<MinimalEditor />)
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
ce.focus()
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
fireEvent.mouseDown(ce)
await waitFor(() => {
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
})
})
it('portals into provided container when container is set', async () => {
render(<MinimalEditor withContainer />)
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
const host = screen.getByTestId(CONTAINER_ID)
ce.focus()
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
expect(host).toContainElement(portalContent)
})
it('falls back to document.body when container is not provided', async () => {
render(<MinimalEditor withContainer={false} />)
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
ce.focus()
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
expect(document.body).toContainElement(portalContent)
})
})

View File

@ -0,0 +1,305 @@
import type { LexicalCommand } from 'lexical'
import {
autoUpdate,
flip,
offset,
shift,
size,
useFloating,
} from '@floating-ui/react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
$getSelection,
$isRangeSelection,
} from 'lexical'
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/utils/classnames'
export const SHORTCUTS_EMPTY_CONTENT = 'shortcuts_empty_content'
// Hotkey can be:
// - string: 'mod+/'
// - string[]: ['mod', '/']
// - string[][]: [['mod', '/'], ['mod', 'shift', '/']] (any combo matches)
// - function: custom matcher
export type Hotkey = string | string[] | string[][] | ((e: KeyboardEvent) => boolean)
type ShortcutPopupPluginProps = {
hotkey?: Hotkey
children?: React.ReactNode | ((close: () => void, onInsert: (command: LexicalCommand<unknown>, params: any[]) => void) => React.ReactNode)
className?: string
container?: Element | null
onOpen?: () => void
onClose?: () => void
}
const META_ALIASES = new Set(['meta', 'cmd', 'command'])
const CTRL_ALIASES = new Set(['ctrl'])
const ALT_ALIASES = new Set(['alt', 'option'])
const SHIFT_ALIASES = new Set(['shift'])
function matchHotkey(event: KeyboardEvent, hotkey?: Hotkey) {
if (!hotkey)
return false
if (typeof hotkey === 'function')
return hotkey(event)
const matchCombo = (tokens: string[]) => {
const parts = tokens.map(t => t.toLowerCase().trim()).filter(Boolean)
let expectedKey: string | null = null
let needMod = false
let needCtrl = false
let needMeta = false
let needAlt = false
let needShift = false
for (const p of parts) {
if (p === 'mod') {
needMod = true
continue
}
if (CTRL_ALIASES.has(p)) {
needCtrl = true
continue
}
if (META_ALIASES.has(p)) {
needMeta = true
continue
}
if (ALT_ALIASES.has(p)) {
needAlt = true
continue
}
if (SHIFT_ALIASES.has(p)) {
needShift = true
continue
}
expectedKey = p
}
if (needMod && !(event.metaKey || event.ctrlKey))
return false
if (needCtrl && !event.ctrlKey)
return false
if (needMeta && !event.metaKey)
return false
if (needAlt && !event.altKey)
return false
if (needShift && !event.shiftKey)
return false
if (expectedKey) {
const k = event.key.toLowerCase()
const normalized = k === ' ' ? 'space' : k
if (normalized !== expectedKey)
return false
}
return true
}
if (Array.isArray(hotkey)) {
const isNested = hotkey.length > 0 && Array.isArray((hotkey as unknown[])[0])
if (isNested) {
const combos = hotkey as string[][]
return combos.some(tokens => matchCombo(tokens))
}
else {
const tokens = hotkey as string[]
return matchCombo(tokens)
}
}
const tokensFromString = hotkey
.toLowerCase()
.split('+')
.map(t => t.trim())
.filter(Boolean)
return matchCombo(tokensFromString)
}
export default function ShortcutsPopupPlugin({
hotkey = 'mod+/',
children,
className,
container,
onOpen,
onClose,
}: ShortcutPopupPluginProps): React.ReactPortal | null {
const [editor] = useLexicalComposerContext()
const [open, setOpen] = useState(false)
const portalRef = useRef<HTMLDivElement | null>(null)
const lastSelectionRef = useRef<Range | null>(null)
const containerEl = useMemo(() => container ?? (typeof document !== 'undefined' ? document.body : null), [container])
const useContainer = !!containerEl && containerEl !== document.body
const { refs, floatingStyles, isPositioned } = useFloating({
placement: 'bottom-start',
middleware: [
offset(0), // fix hide cursor
shift({
padding: 8,
altBoundary: true,
}),
flip(),
size({
apply({ availableWidth, availableHeight, elements }) {
Object.assign(elements.floating.style, {
maxWidth: `${Math.min(400, availableWidth)}px`,
maxHeight: `${Math.min(300, availableHeight)}px`,
overflow: 'auto',
})
},
padding: 8,
}),
],
whileElementsMounted: autoUpdate,
})
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
const domSelection = window.getSelection()
if (domSelection && domSelection.rangeCount > 0)
lastSelectionRef.current = domSelection.getRangeAt(0).cloneRange()
}
})
})
}, [editor])
const isEditorFocused = useCallback(() => {
const root = editor.getRootElement()
if (!root)
return false
return root.contains(document.activeElement)
}, [editor])
const openPortal = useCallback(() => {
const domSelection = window.getSelection()
let range: Range | null = null
if (domSelection && domSelection.rangeCount > 0)
range = domSelection.getRangeAt(0).cloneRange()
else
range = lastSelectionRef.current
if (range) {
const rects = range.getClientRects()
let rect: DOMRect | null = null
if (rects && rects.length)
rect = rects[rects.length - 1]
else
rect = range.getBoundingClientRect()
if (rect.width === 0 && rect.height === 0) {
const root = editor.getRootElement()
if (root) {
const sc = range.startContainer
const node = sc.nodeType === Node.ELEMENT_NODE
? sc as Element
: (sc.parentElement || root)
rect = node.getBoundingClientRect()
if (rect.width === 0 && rect.height === 0)
rect = root.getBoundingClientRect()
}
}
if (rect && !(rect.top === 0 && rect.left === 0 && rect.width === 0 && rect.height === 0)) {
const virtualEl = {
getBoundingClientRect() {
return rect!
},
}
refs.setReference(virtualEl as Element)
}
}
setOpen(true)
onOpen?.()
}, [onOpen])
const closePortal = useCallback(() => {
setOpen(false)
onClose?.()
}, [onClose])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (open && event.key === 'Escape') {
event.stopPropagation()
event.preventDefault()
closePortal()
return
}
if (!isEditorFocused())
return
if (matchHotkey(event, hotkey)) {
event.preventDefault()
openPortal()
}
}
document.addEventListener('keydown', handleKeyDown, true)
return () => document.removeEventListener('keydown', handleKeyDown, true)
}, [hotkey, open, isEditorFocused, openPortal, closePortal])
useEffect(() => {
if (!open)
return
const onMouseDown = (e: MouseEvent) => {
if (!portalRef.current)
return
if (!portalRef.current.contains(e.target as Node))
closePortal()
}
document.addEventListener('mousedown', onMouseDown, false)
return () => document.removeEventListener('mousedown', onMouseDown, false)
}, [open, closePortal])
const handleInsert = useCallback((command: LexicalCommand<unknown>, params: any) => {
editor.dispatchCommand(command, params)
closePortal()
}, [editor, closePortal])
if (!open || !containerEl)
return null
return createPortal(
<div
ref={(node) => {
portalRef.current = node
refs.setFloating(node)
}}
className={cn(
useContainer ? '' : 'z-[999999]',
'absolute rounded-xl bg-components-panel-bg-blur shadow-lg',
className,
)}
style={{
...floatingStyles,
visibility: isPositioned ? 'visible' : 'hidden',
}}
>
{typeof children === 'function' ? children(closePortal, handleInsert) : (children ?? SHORTCUTS_EMPTY_CONTENT)}
</div>,
containerEl,
)
}

View File

@ -40,7 +40,7 @@ const WorkflowVariableBlockReplacementBlock = ({
const nodePathString = textNode.getTextContent().slice(3, -3)
return $applyNodeReplacement($createWorkflowVariableBlockNode(nodePathString.split('.'), workflowNodesMap, getVarType, variables?.find(o => o.nodeId === 'env')?.vars || [], variables?.find(o => o.nodeId === 'conversation')?.vars || [], ragVariables))
}, [onInsert, workflowNodesMap, getVarType, variables])
}, [onInsert, workflowNodesMap, getVarType, variables, ragVariables])
const getMatch = useCallback((text: string) => {
const matchArr = REGEX.exec(text)

View File

@ -1,4 +1,5 @@
import type { GeneratorType } from '../../app/configuration/config/automatic/types'
import type { FormInputItem } from '../../workflow/nodes/human-input/types'
import type { Type } from '../../workflow/nodes/llm/types'
import type { Dataset } from './plugins/context-block'
import type { RoleName } from './plugins/history-block'
@ -46,6 +47,13 @@ export type HistoryBlockType = {
onEditRole?: () => void
}
export type RequestURLBlockType = {
show?: boolean
selectable?: boolean
onInsert?: () => void
onDelete?: () => void
}
export type VariableBlockType = {
show?: boolean
variables?: Option[]
@ -73,6 +81,21 @@ export type WorkflowVariableBlockType = {
onManageInputField?: () => void
}
export type HITLInputBlockType = {
show?: boolean
nodeId: string
formInputs?: FormInputItem[]
variables?: NodeOutPutVar[]
workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type' | 'height' | 'width' | 'position'>>
getVarType?: GetVarType
onFormInputsChange?: (inputs: FormInputItem[]) => void
onFormInputItemRemove: (varName: string) => void
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void
onInsert?: () => void
onDelete?: () => void
readonly?: boolean
}
export type MenuTextMatch = {
leadOffset: number
matchingString: string