mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 17:08:03 +08:00
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:
@ -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]
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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'
|
||||
@ -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
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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'
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user