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

@ -0,0 +1,27 @@
'use client'
import type { FC } from 'react'
import type { FormInputItem } from '../types'
import * as React from 'react'
import InputField from '@/app/components/base/prompt-editor/plugins/hitl-input-block/input-field'
type Props = {
nodeId: string
onSave: (newPayload: FormInputItem) => void
onCancel: () => void
}
const AddInputField: FC<Props> = ({
nodeId,
onSave,
onCancel,
}) => {
return (
<InputField
nodeId={nodeId}
isEdit={false}
onChange={onSave}
onCancel={onCancel}
/>
)
}
export default React.memo(AddInputField)

View File

@ -0,0 +1,111 @@
import type { FC } from 'react'
import {
RiFontSize,
} from '@remixicon/react'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { cn } from '@/utils/classnames'
import { UserActionButtonType } from '../types'
const i18nPrefix = 'nodes.humanInput'
type Props = {
text: string
data: UserActionButtonType
onChange: (state: UserActionButtonType) => void
readonly?: boolean
}
const ButtonStyleDropdown: FC<Props> = ({
text = 'Button Text',
data,
onChange,
readonly,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const currentStyle = useMemo(() => {
switch (data) {
case UserActionButtonType.Primary:
return 'primary'
case UserActionButtonType.Default:
return 'secondary'
case UserActionButtonType.Accent:
return 'secondary-accent'
default:
return 'ghost'
}
}, [data])
return (
<PortalToFollowElem
open={open && !readonly}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: 44,
}}
>
<PortalToFollowElemTrigger onClick={() => !readonly && setOpen(v => !v)}>
<div className={cn('flex items-center justify-center rounded-lg bg-components-button-tertiary-bg p-1', !readonly && 'cursor-pointer hover:bg-components-button-tertiary-bg-hover', open && 'bg-components-button-tertiary-bg-hover')}>
<Button size="small" className="pointer-events-none px-1" variant={currentStyle}>
<RiFontSize className="h-4 w-4" />
</Button>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
<div className="rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-4 shadow-lg backdrop-blur-sm">
<div className="system-md-medium text-text-primary">{t(`${i18nPrefix}.userActions.chooseStyle`, { ns: 'workflow' })}</div>
<div className="mt-2 flex w-[324px] flex-wrap gap-1">
<div
className={cn(
'box-border flex h-[80px] w-[160px] cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-transparent bg-background-section hover:bg-background-section-burn',
data === UserActionButtonType.Primary && 'border-components-option-card-option-selected-border',
)}
onClick={() => onChange(UserActionButtonType.Primary)}
>
<Button variant="primary" className="pointer-events-none">{text}</Button>
</div>
<div
className={cn(
'box-border flex h-[80px] w-[160px] cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-transparent bg-background-section hover:bg-background-section-burn',
data === UserActionButtonType.Default && 'border-components-option-card-option-selected-border',
)}
onClick={() => onChange(UserActionButtonType.Default)}
>
<Button variant="secondary" className="pointer-events-none">{text}</Button>
</div>
<div
className={cn(
'box-border flex h-[80px] w-[160px] cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-transparent bg-background-section hover:bg-background-section-burn',
data === UserActionButtonType.Accent && 'border-components-option-card-option-selected-border',
)}
onClick={() => onChange(UserActionButtonType.Accent)}
>
<Button variant="secondary-accent" className="pointer-events-none">{text}</Button>
</div>
<div
className={cn(
'box-border flex h-[80px] w-[160px] cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-transparent bg-background-section hover:bg-background-section-burn',
data === UserActionButtonType.Ghost && 'border-components-option-card-option-selected-border',
)}
onClick={() => onChange(UserActionButtonType.Ghost)}
>
<Button variant="ghost" className="pointer-events-none">{text}</Button>
</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default ButtonStyleDropdown

View File

@ -0,0 +1,176 @@
import type { EmailConfig } from '../../types'
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { RiBugLine, RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/compat'
import { memo, useCallback, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import Switch from '@/app/components/base/switch'
import Toast from '@/app/components/base/toast'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import MailBodyInput from './mail-body-input'
import Recipient from './recipient'
const i18nPrefix = 'nodes.humanInput'
type EmailConfigureModalProps = {
isShow: boolean
onClose: () => void
onConfirm: (data: EmailConfig) => void
config?: EmailConfig
nodesOutputVars?: NodeOutPutVar[]
availableNodes?: Node[]
}
const EmailConfigureModal = ({
isShow,
onClose,
onConfirm,
config,
nodesOutputVars = [],
availableNodes = [],
}: EmailConfigureModalProps) => {
const { t } = useTranslation()
const email = useAppContextWithSelector(s => s.userProfile.email)
const [recipients, setRecipients] = useState(config?.recipients || { whole_workspace: false, items: [] })
const [subject, setSubject] = useState(config?.subject || '')
const [body, setBody] = useState(config?.body || '{{#url#}}')
const [debugMode, setDebugMode] = useState(config?.debug_mode || false)
const checkValidConfig = useCallback(() => {
if (!subject.trim()) {
Toast.notify({
type: 'error',
message: 'subject is required',
})
return false
}
if (!body.trim()) {
Toast.notify({
type: 'error',
message: 'body is required',
})
return false
}
if (!/\{\{#url#\}\}/.test(body.trim())) {
Toast.notify({
type: 'error',
message: `body must contain one ${t('promptEditor.requestURL.item.title', { ns: 'common' })}`,
})
return false
}
if (!recipients || (recipients.items.length === 0 && !recipients.whole_workspace)) {
Toast.notify({
type: 'error',
message: 'recipients is required',
})
return false
}
return true
}, [recipients, subject, body, t])
const handleConfirm = useCallback(() => {
if (!checkValidConfig())
return
onConfirm({
recipients,
subject,
body,
debug_mode: debugMode,
})
}, [checkValidConfig, onConfirm, recipients, subject, body, debugMode])
return (
<Modal
isShow={isShow}
onClose={noop}
className="relative !max-w-[720px] !p-0"
>
<div className="absolute right-5 top-5 cursor-pointer p-1.5" onClick={onClose}>
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
</div>
<div className="space-y-1 p-6 pb-3">
<div className="title-2xl-semi-bold text-text-primary">{t(`${i18nPrefix}.deliveryMethod.emailConfigure.title`, { ns: 'workflow' })}</div>
<div className="system-xs-regular text-text-tertiary">{t(`${i18nPrefix}.deliveryMethod.emailConfigure.description`, { ns: 'workflow' })}</div>
</div>
<div className="space-y-5 px-6 py-3">
<div>
<div className="system-sm-medium mb-1 flex h-6 items-center text-text-secondary">
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.subject`, { ns: 'workflow' })}
</div>
<Input
className="w-full"
value={subject}
onChange={e => setSubject(e.target.value)}
placeholder={t(`${i18nPrefix}.deliveryMethod.emailConfigure.subjectPlaceholder`, { ns: 'workflow' })}
/>
</div>
<div>
<div className="system-sm-medium mb-1 flex h-6 items-center text-text-secondary">
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.body`, { ns: 'workflow' })}
</div>
<MailBodyInput
value={body}
onChange={setBody}
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
/>
</div>
<div>
<div className="system-sm-medium mb-1 flex h-6 items-center text-text-secondary">
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.recipient`, { ns: 'workflow' })}
</div>
<Recipient
data={recipients}
onChange={setRecipients}
/>
</div>
<Divider className="!my-0 !mt-5 !h-px" />
<div className="flex items-start justify-between gap-2 rounded-[10px] border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-3 pl-2.5 shadow-xs">
<div className="rounded-[4px] border border-divider-regular bg-components-icon-bg-orange-dark-solid p-0.5">
<RiBugLine className="h-3.5 w-3.5 text-text-primary-on-surface" />
</div>
<div className="grow space-y-1">
<div className="system-sm-medium text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.emailConfigure.debugMode`, { ns: 'workflow' })}</div>
<div className="body-xs-regular text-text-tertiary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailConfigure.debugModeTip1`}
ns="workflow"
components={{ email: <span className="body-md-medium text-text-primary">{email}</span> }}
values={{ email }}
/>
<div>{t(`${i18nPrefix}.deliveryMethod.emailConfigure.debugModeTip2`, { ns: 'workflow' })}</div>
</div>
</div>
<Switch
defaultValue={debugMode}
onChange={checked => setDebugMode(checked)}
/>
</div>
</div>
<div className="flex flex-row-reverse gap-2 p-6 pt-5">
<Button
variant="primary"
className="w-[72px]"
onClick={handleConfirm}
>
{t('operation.save', { ns: 'common' })}
</Button>
<Button
className="w-[72px]"
onClick={onClose}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
</Modal>
)
}
export default memo(EmailConfigureModal)

View File

@ -0,0 +1,119 @@
import type { DeliveryMethod, DeliveryMethodType, FormInputItem } from '../../types'
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { produce } from 'immer'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
import { useNodesSyncDraft } from '@/app/components/workflow/hooks'
import MethodItem from './method-item'
import MethodSelector from './method-selector'
import UpgradeModal from './upgrade-modal'
const i18nPrefix = 'nodes.humanInput'
type Props = {
nodeId: string
value: DeliveryMethod[]
nodesOutputVars?: NodeOutPutVar[]
availableNodes?: Node[]
formContent?: string
formInputs?: FormInputItem[]
onChange: (value: DeliveryMethod[]) => void
readonly?: boolean
}
const DeliveryMethodForm: React.FC<Props> = ({
nodeId,
value,
nodesOutputVars,
availableNodes,
formContent,
formInputs,
onChange,
readonly,
}) => {
const { t } = useTranslation()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const handleMethodChange = (target: DeliveryMethod) => {
const newMethods = produce(value, (draft) => {
const index = draft.findIndex(method => method.type === target.type)
if (index !== -1)
draft[index] = target
})
onChange(newMethods)
handleSyncWorkflowDraft(true, true)
}
const handleMethodAdd = (newMethod: DeliveryMethod) => {
const newMethods = [...value, newMethod]
onChange(newMethods)
}
const handleMethodDelete = (type: DeliveryMethodType) => {
const newMethods = value.filter(method => method.type !== type)
onChange(newMethods)
}
const [showUpgradeModal, setShowUpgradeModal] = React.useState(false)
const handleShowUpgradeModal = () => {
setShowUpgradeModal(true)
}
const handleCloseUpgradeModal = () => {
setShowUpgradeModal(false)
}
return (
<div className="px-4 py-2">
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-0.5">
<div className="system-sm-semibold-uppercase text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.title`, { ns: 'workflow' })}</div>
<Tooltip
popupContent={t(`${i18nPrefix}.deliveryMethod.tooltip`, { ns: 'workflow' })}
/>
</div>
{!readonly && (
<div className="flex items-center px-1">
<MethodSelector
data={value}
onAdd={handleMethodAdd}
onShowUpgradeTip={handleShowUpgradeModal}
/>
</div>
)}
</div>
{!value.length && (
<div className="system-xs-regular flex items-center justify-center rounded-[10px] bg-background-section p-3 text-text-tertiary">{t(`${i18nPrefix}.deliveryMethod.emptyTip`, { ns: 'workflow' })}</div>
)}
{value.length > 0 && (
<div className="space-y-1">
{value.map(method => (
<MethodItem
nodeId={nodeId}
method={method}
key={method.id}
onChange={handleMethodChange}
onDelete={handleMethodDelete}
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
formContent={formContent}
formInputs={formInputs}
readonly={readonly}
/>
))}
</div>
)}
{showUpgradeModal && (
<UpgradeModal
isShow={showUpgradeModal}
onClose={handleCloseUpgradeModal}
/>
)}
</div>
)
}
export default DeliveryMethodForm

View File

@ -0,0 +1,65 @@
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import { useTranslation } from 'react-i18next'
import PromptEditor from '@/app/components/base/prompt-editor'
import Placeholder from '@/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder'
import { BlockEnum } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
type MailBodyInputProps = {
readOnly?: boolean
nodesOutputVars?: NodeOutPutVar[]
availableNodes?: Node[]
value?: string
onChange?: (text: string) => void
}
const MailBodyInput = ({
readOnly = false,
nodesOutputVars,
availableNodes = [],
value = '',
onChange,
}: MailBodyInputProps) => {
const { t } = useTranslation()
return (
<PromptEditor
wrapperClassName={cn(
'w-full rounded-lg border border-transparent bg-components-input-bg-normal px-2 py-1',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs',
)}
className="caret:text-text-accent min-h-[128px]"
editable={!readOnly}
value={value}
requestURLBlock={{
show: true,
selectable: true,
}}
workflowVariableBlock={{
show: true,
variables: nodesOutputVars || [],
workflowNodesMap: availableNodes.reduce((acc, node) => {
acc[node.id] = {
title: node.data.title,
type: node.data.type,
}
if (node.data.type === BlockEnum.Start) {
acc.sys = {
title: t('blocks.start', { ns: 'workflow' }),
type: BlockEnum.Start,
}
}
return acc
}, {} as Record<string, Pick<Node['data'], 'title' | 'type'>>),
}}
placeholder={<Placeholder hideBadge />}
onChange={onChange}
/>
)
}
export default MailBodyInput

View File

@ -0,0 +1,212 @@
import type { FC } from 'react'
import type { DeliveryMethod, EmailConfig, FormInputItem } from '../../types'
import type {
Node,
NodeOutPutVar,
} from '@/app/components/workflow/types'
import {
RiDeleteBinLine,
RiEqualizer2Line,
RiMailSendFill,
RiRobot2Fill,
RiSendPlane2Line,
} from '@remixicon/react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge/index'
import Button from '@/app/components/base/button'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import Indicator from '@/app/components/header/indicator'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { cn } from '@/utils/classnames'
import { DeliveryMethodType } from '../../types'
import EmailConfigureModal from './email-configure-modal'
import TestEmailSender from './test-email-sender'
const i18nPrefix = 'nodes.humanInput'
type DeliveryMethodItemProps = {
nodeId: string
method: DeliveryMethod
nodesOutputVars?: NodeOutPutVar[]
availableNodes?: Node[]
formContent?: string
formInputs?: FormInputItem[]
onChange: (method: DeliveryMethod) => void
onDelete: (type: DeliveryMethodType) => void
readonly?: boolean
}
const DeliveryMethodItem: FC<DeliveryMethodItemProps> = ({
nodeId,
method,
nodesOutputVars,
availableNodes,
formContent,
formInputs,
onChange,
onDelete,
readonly,
}) => {
const { t } = useTranslation()
const email = useAppContextWithSelector(s => s.userProfile.email)
const [isHovering, setIsHovering] = useState(false)
const [showEmailModal, setShowEmailModal] = useState(false)
const [showTestEmailModal, setShowTestEmailModal] = useState(false)
const handleEnableStatusChange = (enabled: boolean) => {
onChange({
...method,
enabled,
})
}
const handleConfigChange = (config: EmailConfig) => {
onChange({
...method,
config,
})
}
const emailSenderTooltipContent = useMemo(() => {
if (method.type !== DeliveryMethodType.Email) {
return ''
}
if (method.config?.debug_mode) {
return t(`${i18nPrefix}.deliveryMethod.emailSender.testSendTipInDebugMode`, { ns: 'workflow', email })
}
return t(`${i18nPrefix}.deliveryMethod.emailSender.testSendTip`, { ns: 'workflow' })
}, [method.type, method.config?.debug_mode, t, email])
const jumpToEmailConfigModal = useCallback(() => {
setShowTestEmailModal(false)
setShowEmailModal(true)
}, [])
return (
<>
<div
className={cn(
'group flex h-8 items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg pl-1.5 pr-2 shadow-xs hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
isHovering && 'border-state-destructive-border bg-state-destructive-hover hover:bg-state-destructive-hover',
)}
>
<div className="flex items-center gap-1.5">
{method.type === DeliveryMethodType.WebApp && (
<div className="rounded-[4px] border border-divider-regular bg-components-icon-bg-indigo-solid p-0.5">
<RiRobot2Fill className="h-3.5 w-3.5 text-text-primary-on-surface" />
</div>
)}
{method.type === DeliveryMethodType.Email && (
<div className="rounded-[4px] border border-divider-regular bg-components-icon-bg-blue-solid p-0.5">
<RiMailSendFill className="h-3.5 w-3.5 text-text-primary-on-surface" />
</div>
)}
<div className="system-xs-medium capitalize text-text-secondary">{method.type}</div>
{method.type === DeliveryMethodType.Email
&& (method.config as EmailConfig)?.debug_mode
&& <Badge size="s" className="!px-1 !py-0.5">DEBUG</Badge>}
</div>
<div className="flex items-center gap-1">
{!readonly && (
<div className="hidden items-end gap-1 group-hover:flex">
{method.type === DeliveryMethodType.Email && method.config && (
<>
<Tooltip
popupContent={emailSenderTooltipContent}
asChild={false}
needsDelay={false}
>
<ActionButton
onClick={() => {
setShowTestEmailModal(true)
}}
>
<RiSendPlane2Line className="h-4 w-4" />
</ActionButton>
</Tooltip>
<Tooltip
popupContent={t('common.configure', { ns: 'workflow' })}
asChild={false}
needsDelay={false}
>
<ActionButton onClick={() => setShowEmailModal(true)}>
<RiEqualizer2Line className="h-4 w-4" />
</ActionButton>
</Tooltip>
</>
)}
<Tooltip
popupContent={t('operation.remove', { ns: 'common' })}
asChild={false}
needsDelay={false}
>
<div
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
>
<ActionButton
state={isHovering ? ActionButtonState.Destructive : ActionButtonState.Default}
onClick={() => onDelete(method.type)}
>
<RiDeleteBinLine className="h-4 w-4" />
</ActionButton>
</div>
</Tooltip>
</div>
)}
{(method.config || method.type === DeliveryMethodType.WebApp) && (
<Switch
defaultValue={method.enabled}
onChange={handleEnableStatusChange}
disabled={readonly}
/>
)}
{method.type === DeliveryMethodType.Email && !method.config && (
<Button
className="-mr-1"
size="small"
onClick={() => setShowEmailModal(true)}
disabled={readonly}
>
{t(`${i18nPrefix}.deliveryMethod.notConfigured`, { ns: 'workflow' })}
<Indicator color="orange" className="ml-1" />
</Button>
)}
</div>
</div>
{showEmailModal && (
<EmailConfigureModal
isShow={showEmailModal}
config={method.config as EmailConfig}
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
onClose={() => setShowEmailModal(false)}
onConfirm={(data) => {
handleConfigChange(data)
setShowEmailModal(false)
}}
/>
)}
{showTestEmailModal && (
<TestEmailSender
nodeId={nodeId}
deliveryId={method.id}
isShow={showTestEmailModal}
config={method.config as EmailConfig}
formContent={formContent}
formInputs={formInputs}
nodesOutputVars={nodesOutputVars}
availableNodes={availableNodes}
onClose={() => setShowTestEmailModal(false)}
jumpToEmailConfigModal={jumpToEmailConfigModal}
/>
)}
</>
)
}
export default DeliveryMethodItem

View File

@ -0,0 +1,222 @@
'use client'
import type { FC } from 'react'
import type { DeliveryMethod } from '../../types'
import {
RiAddLine,
RiDiscordFill,
RiLightbulbFlashFill,
RiMailSendFill,
RiRobot2Fill,
} from '@remixicon/react'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { v4 as uuid4 } from 'uuid'
import ActionButton from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge'
import { Slack, Teams } from '@/app/components/base/icons/src/public/other'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import useWorkflowNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { isTriggerWorkflow } from '@/app/components/workflow/utils/workflow-entry'
import { IS_CE_EDITION } from '@/config'
import { useProviderContextSelector } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
import { DeliveryMethodType } from '../../types'
const i18nPrefix = 'nodes.humanInput'
type MethodSelectorProps = {
data: DeliveryMethod[]
onAdd: (method: DeliveryMethod) => void
onShowUpgradeTip: () => void
}
const MethodSelector: FC<MethodSelectorProps> = ({
data,
onAdd,
onShowUpgradeTip,
}) => {
const { t } = useTranslation()
const [open, doSetOpen] = useState(false)
const humanInputEmailDeliveryEnabled = useProviderContextSelector(s => s.humanInputEmailDeliveryEnabled)
const openRef = useRef(open)
const nodes = useWorkflowNodes()
const setOpen = useCallback((v: boolean) => {
doSetOpen(v)
openRef.current = v
}, [doSetOpen])
const handleTrigger = useCallback(() => {
setOpen(!openRef.current)
}, [setOpen])
const webAppDeliveryInfo = useMemo(() => {
const isTriggerMode = isTriggerWorkflow(nodes)
return {
disabled: isTriggerMode || data.some(method => method.type === DeliveryMethodType.WebApp),
added: data.some(method => method.type === DeliveryMethodType.WebApp),
isTriggerMode,
}
}, [data, nodes])
const emailDeliveryInfo = useMemo(() => {
return {
noPermission: !humanInputEmailDeliveryEnabled,
added: data.some(method => method.type === DeliveryMethodType.Email),
}
}, [data, humanInputEmailDeliveryEnabled])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: 12,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div>
<ActionButton className={cn(open && 'bg-state-base-hover')}>
<RiAddLine className="h-4 w-4" />
</ActionButton>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50">
<div className="w-[360px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
<div className="p-1">
<div
className={cn('relative flex cursor-pointer items-center gap-1 rounded-lg p-1 pl-3 hover:bg-state-base-hover', webAppDeliveryInfo.disabled && 'cursor-not-allowed bg-transparent hover:bg-transparent')}
onClick={() => {
if (webAppDeliveryInfo.disabled)
return
onAdd({
id: uuid4(),
type: DeliveryMethodType.WebApp,
enabled: true,
})
}}
>
<div className={cn('rounded-[4px] border border-divider-regular bg-components-icon-bg-indigo-solid p-1', webAppDeliveryInfo.disabled && 'opacity-50')}>
<RiRobot2Fill className="h-4 w-4 text-text-primary-on-surface" />
</div>
<div className={cn('p-1', webAppDeliveryInfo.disabled && 'opacity-50')}>
<div className="system-sm-medium mb-0.5 truncate text-text-primary">{t(`${i18nPrefix}.deliveryMethod.types.webapp.title`, { ns: 'workflow' })}</div>
<div className="system-xs-regular truncate text-text-tertiary">{t(`${i18nPrefix}.deliveryMethod.types.webapp.description`, { ns: 'workflow' })}</div>
</div>
{webAppDeliveryInfo.added && (
<div className="system-xs-regular absolute right-[12px] top-[13px] text-text-tertiary">{t(`${i18nPrefix}.deliveryMethod.added`, { ns: 'workflow' })}</div>
)}
{webAppDeliveryInfo.isTriggerMode && !webAppDeliveryInfo.added && (
<div className="system-xs-regular absolute right-[12px] top-[13px] text-text-tertiary">{t(`${i18nPrefix}.deliveryMethod.notAvailableInTriggerMode`, { ns: 'workflow' })}</div>
)}
</div>
<div
className={cn(
'relative flex cursor-pointer items-center gap-1 rounded-lg p-1 pl-3 hover:bg-state-base-hover',
emailDeliveryInfo.added && 'cursor-not-allowed bg-transparent hover:bg-transparent',
)}
onClick={() => {
if (emailDeliveryInfo.noPermission) {
onShowUpgradeTip()
return
}
if (emailDeliveryInfo.added)
return
onAdd({
id: uuid4(),
type: DeliveryMethodType.Email,
enabled: false,
})
}}
>
<div
className={cn(
'rounded-[4px] border border-divider-regular bg-components-icon-bg-blue-solid p-1',
emailDeliveryInfo.added && 'opacity-50',
)}
>
<RiMailSendFill className="h-4 w-4 text-text-primary-on-surface" />
</div>
<div className={cn('p-1', emailDeliveryInfo.added && 'opacity-50')}>
<div className="system-sm-medium mb-0.5 truncate text-text-primary">{t(`${i18nPrefix}.deliveryMethod.types.email.title`, { ns: 'workflow' })}</div>
<div className="system-xs-regular truncate text-text-tertiary">{t(`${i18nPrefix}.deliveryMethod.types.email.description`, { ns: 'workflow' })}</div>
</div>
{emailDeliveryInfo.added && (
<div className="system-xs-regular absolute right-[12px] top-[13px] text-text-tertiary">{t(`${i18nPrefix}.deliveryMethod.added`, { ns: 'workflow' })}</div>
)}
</div>
{/* Slack */}
<div
className={cn('relative flex cursor-pointer items-center gap-1 rounded-lg p-1 pl-3 hover:bg-state-base-hover', 'cursor-not-allowed bg-transparent hover:bg-transparent')}
>
<div className={cn('rounded-[4px] border border-divider-regular bg-background-default-dodge p-1', 'opacity-50')}>
<Slack className="h-4 w-4 text-text-primary-on-surface" />
</div>
<div className={cn('p-1', 'opacity-50')}>
<div className="system-sm-medium mb-0.5 truncate text-text-primary">{t(`${i18nPrefix}.deliveryMethod.types.slack.title`, { ns: 'workflow' })}</div>
<div className="system-xs-regular truncate text-text-tertiary">{t(`${i18nPrefix}.deliveryMethod.types.slack.description`, { ns: 'workflow' })}</div>
</div>
<div className="absolute right-[8px] top-[8px]">
<Badge className="h-4">COMING SOON</Badge>
</div>
</div>
{/* Teams */}
<div
className={cn('relative flex cursor-pointer items-center gap-1 rounded-lg p-1 pl-3 hover:bg-state-base-hover', 'cursor-not-allowed bg-transparent hover:bg-transparent')}
>
<div className={cn('rounded-[4px] border border-divider-regular bg-background-default-dodge p-1', 'opacity-50')}>
<Teams className="h-4 w-4 text-text-primary-on-surface" />
</div>
<div className={cn('p-1', 'opacity-50')}>
<div className="system-sm-medium mb-0.5 truncate text-text-primary">{t(`${i18nPrefix}.deliveryMethod.types.teams.title`, { ns: 'workflow' })}</div>
<div className="system-xs-regular truncate text-text-tertiary">{t(`${i18nPrefix}.deliveryMethod.types.teams.description`, { ns: 'workflow' })}</div>
</div>
<div className="absolute right-[8px] top-[8px]">
<Badge className="h-4">COMING SOON</Badge>
</div>
</div>
{/* Discord */}
<div
className={cn('relative flex cursor-pointer items-center gap-1 rounded-lg p-1 pl-3 hover:bg-state-base-hover', 'cursor-not-allowed bg-transparent hover:bg-transparent')}
>
<div className={cn('rounded-[4px] border border-divider-regular bg-components-icon-bg-indigo-solid p-0.5', 'opacity-50')}>
<RiDiscordFill className="h-5 w-5 text-text-primary-on-surface" />
</div>
<div className={cn('p-1', 'opacity-50')}>
<div className="system-sm-medium mb-0.5 truncate text-text-primary">{t(`${i18nPrefix}.deliveryMethod.types.discord.title`, { ns: 'workflow' })}</div>
<div className="system-xs-regular truncate text-text-tertiary">{t(`${i18nPrefix}.deliveryMethod.types.discord.description`, { ns: 'workflow' })}</div>
</div>
<div className="absolute right-[8px] top-[8px]">
<Badge className="h-4">COMING SOON</Badge>
</div>
</div>
</div>
</div>
{!IS_CE_EDITION && (
<div className="mt-1 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
<div className="flex items-center gap-2 px-4 py-3">
<div className={cn('rounded-[4px] border border-divider-regular bg-components-icon-bg-midnight-solid p-1')}>
<RiLightbulbFlashFill className="h-4 w-4 text-text-primary-on-surface" />
</div>
<div className="system-sm-regular text-text-secondary">
<div>{t(`${i18nPrefix}.deliveryMethod.contactTip1`, { ns: 'workflow' })}</div>
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.contactTip2`}
ns="workflow"
components={{ email: <a href="mailto:support@dify.ai" className="text-text-accent-light-mode-only">support@dify.ai</a> }}
/>
</div>
</div>
</div>
)}
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(MethodSelector)

View File

@ -0,0 +1,183 @@
import type { Recipient as RecipientItem } from '../../../types'
import type { Member } from '@/models/common'
import * as React from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { cn } from '@/utils/classnames'
import EmailItem from './email-item'
import MemberList from './member-list'
const i18nPrefix = 'nodes.humanInput'
type Props = {
email: string
value: RecipientItem[]
list: Member[]
onDelete: (recipient: RecipientItem) => void
onSelect: (value: string) => void
onAdd: (email: string) => void
disabled?: boolean
}
const EmailInput = ({
email,
value,
list,
onDelete,
onSelect,
onAdd,
disabled = false,
}: Props) => {
const { t } = useTranslation()
const inputRef = useRef<HTMLInputElement>(null)
const [isFocus, setIsFocus] = useState(false)
const [open, setOpen] = useState(false)
const [searchKey, setSearchKey] = useState('')
const selectedEmails = useMemo(() => {
return value.map((item) => {
const member = list.find(account => account.id === item.user_id)
return member ? { ...item, email: member.email, name: member.name } : item
})
}, [list, value])
const isErrorMember = useCallback((emailItem: RecipientItem) => emailItem.type === 'member' && list.every(item => item.id !== emailItem.user_id), [list])
const placeholder = useMemo(() => {
return (selectedEmails.length === 0 || isFocus)
? t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.placeholder`, { ns: 'workflow' })
: ''
}, [selectedEmails, t, isFocus])
const setInputFocus = () => {
if (disabled)
return
setIsFocus(true)
const input = inputRef.current?.children[0] as HTMLInputElement
input?.focus()
}
const handleValueChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchKey(e.target.value)
if (e.target.value.trim() === '') {
setOpen(false)
return
}
setOpen(true)
}
const handleSelect = (value: string) => {
setSearchKey('')
setOpen(false)
onSelect(value)
setInputFocus()
}
const checkEmailValid = (email: string) => {
const emailRegex = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/
return emailRegex.test(email)
}
const handleEmailAdd = () => {
const emailAddress = searchKey.trim()
if (!checkEmailValid(emailAddress))
return
if (value.some(item => item.email === emailAddress))
return
if (list.some(item => item.email === emailAddress)) {
const item = list.find(item => item.email === emailAddress)!
onSelect(item.id)
}
else {
onAdd(emailAddress)
}
setSearchKey('')
setOpen(false)
}
const handleInputBlur = () => {
setIsFocus(false)
handleEmailAdd()
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === 'Tab' || e.key === ' ' || e.key === ',') {
e.preventDefault()
handleEmailAdd()
}
else if (e.key === 'Backspace') {
if (searchKey === '' && value.length > 0) {
e.preventDefault()
onDelete(value[value.length - 1])
setSearchKey('')
setOpen(false)
}
}
}
return (
<div className="p-1 pt-0">
<div
className={cn(
'flex max-h-24 min-h-16 flex-wrap overflow-y-auto rounded-lg border border-transparent bg-components-input-bg-normal p-2',
isFocus && 'border-components-input-border-active bg-components-input-bg-active shadow-xs',
!disabled && 'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
)}
onClick={setInputFocus}
>
{selectedEmails.map(item => (
<EmailItem
key={item.user_id || item.email}
email={email}
data={item as unknown as Member}
onDelete={onDelete}
disabled={disabled}
isError={isErrorMember(item)}
/>
))}
{!disabled && (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={{
mainAxis: 4,
crossAxis: -40,
}}
>
<PortalToFollowElemTrigger className="block h-6 min-w-[166px]">
<input
ref={inputRef}
className="system-sm-regular h-6 min-w-[166px] appearance-none bg-transparent p-1 text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder"
placeholder={placeholder}
onFocus={() => setIsFocus(true)}
onBlur={handleInputBlur}
value={searchKey}
onChange={handleValueChange}
onKeyDown={handleKeyDown}
/>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1000]">
<MemberList
searchValue={searchKey}
list={list}
value={value}
onSearchChange={setSearchKey}
onSelect={handleSelect}
email={email}
hideSearch
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
)}
</div>
</div>
)
}
export default EmailInput

View File

@ -0,0 +1,52 @@
import type { Recipient as RecipientItem } from '../../../types'
import type { Member } from '@/models/common'
import { RiCloseCircleFill, RiErrorWarningFill } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Avatar from '@/app/components/base/avatar'
import { cn } from '@/utils/classnames'
type Props = {
email: string
data: Member
disabled?: boolean
onDelete: (recipient: RecipientItem) => void
isError: boolean
}
const EmailItem = ({
email,
data,
onDelete,
disabled = false,
isError,
}: Props) => {
const { t } = useTranslation()
return (
<div
className={cn(
'flex h-6 items-center gap-1 rounded-full border border-components-panel-border-subtle bg-components-badge-white-to-dark p-1 shadow-xs',
isError && 'border-state-destructive-hover-alt bg-state-destructive-hover',
)}
onClick={e => e.stopPropagation()}
>
{isError && (
<RiErrorWarningFill className="h-4 w-4 text-text-destructive" />
)}
{!isError && <Avatar avatar={data.avatar_url} size={16} name={data.name || data.email} />}
<div title={data.email} className="system-xs-regular max-w-[500px] truncate text-text-primary">
{email === data.email ? data.name : data.email}
{email === data.email && <span className="system-xs-regular text-text-tertiary">{t('members.you', { ns: 'common' })}</span>}
</div>
{!disabled && (
<RiCloseCircleFill
className="h-4 w-4 cursor-pointer text-text-quaternary hover:text-text-tertiary"
onClick={() => onDelete(data as unknown as RecipientItem)}
/>
)}
</div>
)
}
export default EmailItem

View File

@ -0,0 +1,102 @@
import type { RecipientData, Recipient as RecipientItem } from '../../../types'
import { RiGroupLine } from '@remixicon/react'
import { produce } from 'immer'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Switch from '@/app/components/base/switch'
import { useAppContext } from '@/context/app-context'
import { useMembers } from '@/service/use-common'
import { cn } from '@/utils/classnames'
import EmailInput from './email-input'
import MemberSelector from './member-selector'
const i18nPrefix = 'nodes.humanInput'
type Props = {
data: RecipientData
onChange: (data: RecipientData) => void
}
const Recipient = ({
data,
onChange,
}: Props) => {
const { t } = useTranslation()
const { userProfile, currentWorkspace } = useAppContext()
const { data: members } = useMembers()
const accounts = members?.accounts || []
const handleMemberSelect = (id: string) => {
onChange(
produce(data, (draft) => {
draft.items.push({
type: 'member',
user_id: id,
})
}),
)
}
const handleEmailAdd = (email: string) => {
onChange(
produce(data, (draft) => {
draft.items.push({
type: 'external',
email,
})
}),
)
}
const handleDelete = (recipient: RecipientItem) => {
onChange(
produce(data, (draft) => {
if (recipient.type === 'member')
draft.items = draft.items.filter(item => item.user_id !== recipient.user_id)
else if (recipient.type === 'external')
draft.items = draft.items.filter(item => item.email !== recipient.email)
}),
)
}
return (
<div className="space-y-1">
<div className="rounded-[10px] border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs">
<div className="flex h-10 items-center justify-between pl-3 pr-1">
<div className="flex grow items-center gap-2">
<RiGroupLine className="h-4 w-4 text-text-secondary" />
<div className="system-sm-medium text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.title`, { ns: 'workflow' })}</div>
</div>
<div className="w-[86px]">
<MemberSelector
value={data.items}
email={userProfile.email}
list={accounts}
onSelect={handleMemberSelect}
/>
</div>
</div>
<EmailInput
email={userProfile.email}
value={data.items}
list={accounts}
onDelete={handleDelete}
onSelect={handleMemberSelect}
onAdd={handleEmailAdd}
/>
</div>
<div className="flex h-10 items-center gap-2 rounded-[10px] border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pl-2.5 pr-3 shadow-xs">
<div className="flex h-5 w-5 items-center justify-center rounded-xl bg-components-icon-bg-blue-solid text-[14px]">
<span className="bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text font-semibold uppercase text-shadow-shadow-1 opacity-90">{currentWorkspace?.name[0]?.toLocaleUpperCase()}</span>
</div>
<div className={cn('system-sm-medium grow text-text-secondary')}>{t(`${i18nPrefix}.deliveryMethod.emailConfigure.allMembers`, { workspaceName: currentWorkspace.name.replace(/'/g, ''), ns: 'workflow' })}</div>
<Switch
defaultValue={data.whole_workspace}
onChange={checked => onChange({ ...data, whole_workspace: checked })}
/>
</div>
</div>
)
}
export default memo(Recipient)

View File

@ -0,0 +1,91 @@
'use client'
import type { FC } from 'react'
import type { Recipient } from '@/app/components/workflow/nodes/human-input/types'
import type { Member } from '@/models/common'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Avatar from '@/app/components/base/avatar'
import Input from '@/app/components/base/input'
import { cn } from '@/utils/classnames'
const i18nPrefix = 'nodes.humanInput'
type Props = {
value: Recipient[]
searchValue: string
onSearchChange: (value: string) => void
list: Member[]
onSelect: (value: string) => void
email: string
hideSearch?: boolean
}
const MemberList: FC<Props> = ({ searchValue, list, value, onSearchChange, onSelect, email, hideSearch }) => {
const { t } = useTranslation()
const filteredList = useMemo(() => {
if (!list.length)
return []
if (!searchValue)
return list
return list.filter((account) => {
const name = account.name || ''
const email = account.email || ''
return name.toLowerCase().includes(searchValue.toLowerCase())
|| email.toLowerCase().includes(searchValue.toLowerCase())
})
}, [list, searchValue])
if (hideSearch && filteredList.length === 0)
return null
return (
<div className="min-w-[320px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
{!hideSearch && (
<div className="p-2 pb-1">
<Input
showLeftIcon
value={searchValue}
onChange={e => onSearchChange(e.target.value)}
/>
</div>
)}
{filteredList.length > 0 && (
<div className="max-h-[248px] overflow-y-auto p-1">
{filteredList.map(account => (
<div
key={account.id}
className={cn(
'group flex cursor-pointer items-center gap-2 rounded-lg py-1 pl-2 pr-3 hover:bg-state-base-hover',
value.some(item => item.user_id === account.id) && 'bg-transparent hover:bg-transparent',
)}
onClick={() => {
if (value.some(item => item.user_id === account.id))
return
onSelect(account.id)
}}
>
<Avatar className={cn(value.some(item => item.user_id === account.id) && 'opacity-50')} avatar={account.avatar_url} size={24} name={account.name} />
<div className={cn('grow', value.some(item => item.user_id === account.id) && 'opacity-50')}>
<div className="system-sm-medium text-text-secondary">
{account.name}
{account.status === 'pending' && <span className="system-xs-medium ml-1 text-text-warning">{t('members.pending', { ns: 'common' })}</span>}
{email === account.email && <span className="system-xs-regular text-text-tertiary">{t('members.you', { ns: 'common' })}</span>}
</div>
<div className="system-xs-regular text-text-tertiary">{account.email}</div>
</div>
{!value.some(item => item.user_id === account.id) && (
<div className="system-xs-medium hidden text-text-accent group-hover:block">{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.add`, { ns: 'workflow' })}</div>
)}
{value.some(item => item.user_id === account.id) && (
<div className="system-xs-regular text-text-tertiary">{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.added`, { ns: 'workflow' })}</div>
)}
</div>
))}
</div>
)}
</div>
)
}
export default MemberList

View File

@ -0,0 +1,69 @@
'use client'
import type { FC } from 'react'
import type { Recipient } from '@/app/components/workflow/nodes/human-input/types'
import type { Member } from '@/models/common'
import {
RiContactsBookLine,
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import { cn } from '@/utils/classnames'
import MemberList from './member-list'
const i18nPrefix = 'nodes.humanInput'
type Props = {
value: Recipient[]
email: string
onSelect: (value: string) => void
list: Member[]
}
const MemberSelector: FC<Props> = ({
value,
email,
onSelect,
list = [],
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState('')
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-end"
offset={{
mainAxis: 4,
crossAxis: 35,
}}
>
<PortalToFollowElemTrigger
className="w-full"
onClick={() => setOpen(v => !v)}
>
<Button
className={cn('w-full justify-between', open && 'bg-state-accent-hover')}
variant="ghost-accent"
>
<RiContactsBookLine className="mr-1 h-4 w-4" />
<div className="">{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.trigger`, { ns: 'workflow' })}</div>
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1000]">
<MemberList
searchValue={searchValue}
list={list}
value={value}
onSearchChange={setSearchValue}
onSelect={onSelect}
email={email}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default MemberSelector

View File

@ -0,0 +1,372 @@
import type { EmailConfig, FormInputItem } from '../../types'
import type {
Node,
NodeOutPutVar,
ValueSelector,
} from '@/app/components/workflow/types'
import { RiArrowRightSFill, RiCloseLine } from '@remixicon/react'
import { noop, unionBy } from 'es-toolkit/compat'
import { memo, useCallback, useMemo, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import Modal from '@/app/components/base/modal'
import { getInputVars as doGetInputVars } from '@/app/components/base/prompt-editor/constants'
import FormItem from '@/app/components/workflow/nodes/_base/components/before-run-form/form-item'
import {
getNodeInfoById,
isConversationVar,
isENV,
isSystemVar,
} from '@/app/components/workflow/nodes/_base/components/variable/utils'
import { InputVarType, VarType } from '@/app/components/workflow/types'
import { useAppContext } from '@/context/app-context'
import { useMembers } from '@/service/use-common'
import { useTestEmailSender } from '@/service/use-workflow'
import { cn } from '@/utils/classnames'
import { isOutput } from '../../utils'
import EmailInput from './recipient/email-input'
const i18nPrefix = 'nodes.humanInput'
type EmailConfigureModalProps = {
nodeId: string
deliveryId: string
isShow: boolean
onClose: () => void
jumpToEmailConfigModal: () => void
config?: EmailConfig
formContent?: string
formInputs?: FormInputItem[]
nodesOutputVars?: NodeOutPutVar[]
availableNodes?: Node[]
}
const getOriginVar = (valueSelector: string[], list: NodeOutPutVar[]) => {
const targetVar = list.find(item => item.nodeId === valueSelector[0])
if (!targetVar)
return undefined
let curr: any = targetVar.vars
for (let i = 1; i < valueSelector.length; i++) {
const key = valueSelector[i]
const isLast = i === valueSelector.length - 1
if (Array.isArray(curr))
curr = curr.find((v: any) => v.variable.replace('conversation.', '') === key)
if (isLast)
return curr
else if (curr?.type === VarType.object || curr?.type === VarType.file)
curr = curr.children
}
return undefined
}
const EmailSenderModal = ({
nodeId,
deliveryId,
isShow,
onClose,
jumpToEmailConfigModal,
config,
formContent,
formInputs,
nodesOutputVars = [],
availableNodes = [],
}: EmailConfigureModalProps) => {
const { t } = useTranslation()
const { userProfile, currentWorkspace } = useAppContext()
const appDetail = useAppStore(state => state.appDetail)
const { mutateAsync: testEmailSender } = useTestEmailSender()
const debugEnabled = !!config?.debug_mode
const onlyWholeTeam = config?.recipients?.whole_workspace && (!config?.recipients?.items || config?.recipients?.items.length === 0)
const onlySpecificUsers = !config?.recipients?.whole_workspace && config?.recipients?.items && config?.recipients?.items.length > 0
const combinedRecipients = config?.recipients?.whole_workspace && config?.recipients?.items && config?.recipients?.items.length > 0
const { data: members } = useMembers()
const accounts = members?.accounts || []
const generatedInputs = useMemo(() => {
const defaultValueSelectors = (formInputs || []).reduce((acc, input) => {
if (input.default.type === 'variable') {
acc.push(input.default.selector)
}
return acc
}, [] as ValueSelector[])
const valueSelectors = doGetInputVars((formContent || '') + (config?.body || ''))
const variables = unionBy([...valueSelectors, ...defaultValueSelectors], item => item.join('.')).map((item) => {
const varInfo = getNodeInfoById(availableNodes, item[0])?.data
return {
label: {
nodeType: varInfo?.type,
nodeName: varInfo?.title || availableNodes[0]?.data.title, // default start node title
variable: isSystemVar(item) ? item.join('.') : item[item.length - 1],
isChatVar: isConversationVar(item),
},
variable: `#${item.join('.')}#`,
value_selector: item,
required: true,
}
})
const varInputs = variables.filter(item => !isENV(item.value_selector) && !isOutput(item.value_selector)).map((item) => {
const originalVar = getOriginVar(item.value_selector, nodesOutputVars)
if (!originalVar) {
return {
label: item.label || item.variable,
variable: item.variable,
type: InputVarType.textInput,
required: true,
value_selector: item.value_selector,
}
}
return {
label: item.label || item.variable,
variable: item.variable,
type: originalVar.type === VarType.number ? InputVarType.number : InputVarType.textInput,
required: true,
}
})
return varInputs
}, [availableNodes, config?.body, formContent, formInputs, nodesOutputVars])
const [inputs, setInputs] = useState<Record<string, unknown>>({})
const [collapsed, setCollapsed] = useState(!(generatedInputs.length > 0))
const [sendingEmail, setSendingEmail] = useState(false)
const [done, setDone] = useState(false)
const handleValueChange = (variable: string, v: string) => {
setInputs({
...inputs,
[variable]: v,
})
}
const confirmChecked = useMemo(() => {
for (const variable of generatedInputs) {
if (variable.required) {
const value = inputs[variable.variable]
if (value === undefined || value === null || value === '') {
return false
}
}
}
return true
}, [generatedInputs, inputs])
const handleConfirm = useCallback(async () => {
if (!confirmChecked)
return
setSendingEmail(true)
try {
await testEmailSender({
appID: appDetail?.id || '',
nodeID: nodeId,
deliveryID: deliveryId,
inputs,
})
setDone(true)
}
finally {
setSendingEmail(false)
}
}, [confirmChecked, testEmailSender, appDetail?.id, nodeId, deliveryId, inputs])
if (done) {
return (
<Modal
isShow={isShow}
onClose={noop}
className="relative !max-w-[480px] !p-0"
>
<div className="space-y-2 p-6 pb-3">
<div className="title-2xl-semi-bold text-text-primary">{t(`${i18nPrefix}.deliveryMethod.emailSender.done`, { ns: 'workflow' })}</div>
{debugEnabled && (
<div className="system-md-regular text-text-secondary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.debugDone`}
ns="workflow"
components={{ email: <span className="system-md-semibold text-text-secondary"></span> }}
values={{ email: userProfile.email }}
/>
</div>
)}
{!debugEnabled && onlyWholeTeam && (
<div className="system-md-regular text-text-secondary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone2`}
ns="workflow"
components={{ team: <span className="system-md-medium text-text-secondary"></span> }}
values={{ team: currentWorkspace.name.replace(/'/g, '') }}
/>
</div>
)}
{!debugEnabled && onlySpecificUsers && (
<div className="system-md-regular text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone3`, { ns: 'workflow' })}</div>
)}
{!debugEnabled && combinedRecipients && (
<div className="system-md-regular text-text-secondary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone1`}
ns="workflow"
components={{ team: <span className="system-md-medium text-text-secondary"></span> }}
values={{ team: currentWorkspace.name.replace(/'/g, '') }}
/>
</div>
)}
</div>
{(onlySpecificUsers || combinedRecipients) && !debugEnabled && (
<div className="px-5">
<EmailInput
disabled
email={userProfile.email}
value={config?.recipients?.items}
list={accounts}
onDelete={noop}
onSelect={noop}
onAdd={noop}
/>
</div>
)}
<div className="flex flex-row-reverse gap-2 p-6 pt-5">
<Button
variant="primary"
className="w-[72px]"
onClick={onClose}
>
{t('operation.ok', { ns: 'common' })}
</Button>
</div>
</Modal>
)
}
return (
<Modal
isShow={isShow}
onClose={noop}
className="relative !max-w-[480px] !p-0"
>
<div className="absolute right-5 top-5 cursor-pointer p-1.5" onClick={onClose}>
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
</div>
<div className="space-y-1 p-6 pb-3">
<div className="title-2xl-semi-bold text-text-primary">{t(`${i18nPrefix}.deliveryMethod.emailSender.title`, { ns: 'workflow' })}</div>
{debugEnabled && (
<>
<div className="system-sm-regular text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip`, { ns: 'workflow' })}</div>
<div className="system-sm-regular text-text-secondary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip2`}
ns="workflow"
components={{ email: <span className="system-sm-semibold text-text-primary"></span> }}
values={{ email: userProfile.email }}
/>
</div>
</>
)}
{!debugEnabled && onlyWholeTeam && (
<div className="system-sm-regular text-text-secondary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip2`}
ns="workflow"
components={{ team: <span className="system-sm-semibold text-text-primary"></span> }}
values={{ team: currentWorkspace.name.replace(/'/g, '') }}
/>
</div>
)}
{!debugEnabled && onlySpecificUsers && (
<div className="system-sm-regular text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip3`, { ns: 'workflow' })}</div>
)}
{!debugEnabled && combinedRecipients && (
<div className="system-sm-regular text-text-secondary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip1`}
ns="workflow"
components={{ team: <span className="system-sm-semibold text-text-primary"></span> }}
values={{ team: currentWorkspace.name.replace(/'/g, '') }}
/>
</div>
)}
</div>
{(onlySpecificUsers || combinedRecipients) && !debugEnabled && (
<>
<div className="px-5">
<EmailInput
disabled
email={userProfile.email}
value={config?.recipients?.items}
list={accounts}
onDelete={noop}
onSelect={noop}
onAdd={noop}
/>
</div>
<div className="system-xs-regular px-6 pt-1 text-text-tertiary">
<Trans
i18nKey={`${i18nPrefix}.deliveryMethod.emailSender.tip`}
ns="workflow"
components={{
strong: <span onClick={jumpToEmailConfigModal} className="system-xs-regular cursor-pointer text-text-accent"></span>,
}}
/>
</div>
</>
)}
{/* vars */}
{generatedInputs.length > 0 && (
<>
<div className="px-6">
<Divider className="!mb-2 !mt-4 !h-px !w-12 bg-divider-regular" />
</div>
<div className="px-6 py-2">
<div className="group flex h-6 cursor-pointer items-center" onClick={() => setCollapsed(!collapsed)}>
<div className="system-sm-semibold-uppercase mr-1 text-text-secondary">{t(`${i18nPrefix}.deliveryMethod.emailSender.vars`, { ns: 'workflow' })}</div>
<RiArrowRightSFill className={cn('h-4 w-4 text-text-quaternary group-hover:text-text-primary', !collapsed && 'rotate-90')} />
</div>
<div className="system-xs-regular text-text-tertiary">{t(`${i18nPrefix}.deliveryMethod.emailSender.varsTip`, { ns: 'workflow' })}</div>
{!collapsed && (
<div className="mt-3 space-y-4">
{generatedInputs.map((variable, index) => (
<div
key={variable.variable}
className="mb-4 last-of-type:mb-0"
>
<FormItem
autoFocus={index === 0}
payload={variable}
value={inputs[variable.variable]}
onChange={v => handleValueChange(variable.variable, v)}
/>
</div>
))}
</div>
)}
</div>
</>
)}
<div className="flex flex-row-reverse gap-2 p-6 pt-5">
<Button
disabled={sendingEmail || !confirmChecked}
loading={sendingEmail}
variant="primary"
onClick={handleConfirm}
>
{t(`${i18nPrefix}.deliveryMethod.emailSender.send`, { ns: 'workflow' })}
</Button>
<Button
className="w-[72px]"
onClick={onClose}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
</Modal>
)
}
export default memo(EmailSenderModal)

View File

@ -0,0 +1,76 @@
import {
RiMailSendFill,
} from '@remixicon/react'
import { noop } from 'es-toolkit/compat'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { SparklesSoft } from '@/app/components/base/icons/src/public/common'
import Modal from '@/app/components/base/modal'
import PremiumBadge from '@/app/components/base/premium-badge'
import { useModalContextSelector } from '@/context/modal-context'
import { cn } from '@/utils/classnames'
type UpgradeModalProps = {
isShow: boolean
onClose: () => void
}
const UpgradeModal: React.FC<UpgradeModalProps> = ({
isShow,
onClose,
}) => {
const { t } = useTranslation()
const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal)
return (
<Modal
isShow={isShow}
onClose={noop}
className="relative !w-[580px] !max-w-[580px] !p-8"
>
<div className="pb-6">
<div
className={cn(
'mb-6 inline-flex rounded-xl border border-divider-regular bg-util-colors-blue-brand-blue-brand-500 p-2',
)}
>
<RiMailSendFill className="h-6 w-6 text-text-primary-on-surface" />
</div>
<p
className="title-3xl-semi-bold bg-[linear-gradient(271deg,_var(--components-input-border-active-prompt-1,_#155AEF)_-12.85%,_var(--components-input-border-active-prompt-2,_#0BA5EC)_95.4%)] bg-clip-text text-transparent"
>
{t('nodes.humanInput.deliveryMethod.upgradeTip', { ns: 'workflow' })}
</p>
<p className="system-md-regular mt-2 text-text-tertiary">
{t('nodes.humanInput.deliveryMethod.upgradeTipContent', { ns: 'workflow' })}
</p>
</div>
<div className="flex justify-end pt-5">
<Button
className="w-[72px]"
onClick={onClose}
>
{t('nodes.humanInput.deliveryMethod.upgradeTipHide', { ns: 'workflow' })}
</Button>
<PremiumBadge
size="custom"
color="blue"
allowHover={true}
className="ml-3 h-8 w-[93px]"
onClick={() => {
setShowPricingModal()
}}
>
<SparklesSoft className="flex h-3.5 w-3.5 items-center py-[1px] pl-[3px] text-components-premium-badge-indigo-text-stop-0" />
<div className="system-sm-medium">
<span className="p-1">
{t('upgradeBtn.encourageShort', { ns: 'billing' })}
</span>
</div>
</PremiumBadge>
</div>
</Modal>
)
}
export default UpgradeModal

View File

@ -0,0 +1,101 @@
'use client'
import type { FC } from 'react'
import type { FormInputItem, UserAction } from '../types'
import type { ButtonProps } from '@/app/components/base/button'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge'
import Button from '@/app/components/base/button'
import { getButtonStyle } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
import { Markdown } from '@/app/components/base/markdown'
import { useStore } from '@/app/components/workflow/store'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { Note, rehypeNotes, rehypeVariable, Variable } from './variable-in-markdown'
const i18nPrefix = 'nodes.humanInput'
type FormContentPreviewProps = {
content: string
formInputs: FormInputItem[]
userActions: UserAction[]
onClose: () => void
}
const FormContentPreview: FC<FormContentPreviewProps> = ({
content,
formInputs,
userActions,
onClose,
}) => {
const { t } = useTranslation()
const panelWidth = useStore(state => state.panelWidth)
const nodes = useNodes()
const nodeName = React.useCallback((nodeId: string) => {
const node = nodes.find(n => n.id === nodeId)
return node?.data.title || nodeId
}, [nodes])
return (
<div
className="fixed top-[112px] z-10 max-h-[calc(100vh-116px)] w-[600px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg py-3 shadow-xl"
style={{
right: panelWidth + 8,
}}
>
<div className="flex h-[26px] items-center justify-between px-4">
<Badge uppercase className="border-text-accent-secondary text-text-accent-secondary">{t(`${i18nPrefix}.formContent.preview`, { ns: 'workflow' })}</Badge>
<ActionButton onClick={onClose}><RiCloseLine className="w-5 text-text-tertiary" /></ActionButton>
</div>
<div className="max-h-[calc(100vh-167px)] overflow-y-auto px-4">
<Markdown
content={content}
rehypePlugins={[rehypeVariable, rehypeNotes]}
customComponents={{
variable: ({ node }: { node: { properties?: { [key: string]: string } } }) => {
const path = node.properties?.['data-path'] as string
let newPath = path
if (path) {
newPath = path.replace(/#([^#.]+)([.#])/g, (match, nodeId, sep) => {
return `#${nodeName(nodeId)}${sep}`
})
}
return <Variable path={newPath} />
},
section: ({ node }: { node: { properties?: { [key: string]: string } } }) => (() => {
const name = node.properties?.['data-name'] as string
const input = formInputs.find(i => i.output_variable_name === name)
if (!input) {
return (
<div>
Can't find note:
{name}
</div>
)
}
const defaultInput = input.default
return (
<Note defaultInput={defaultInput!} nodeName={nodeName} />
)
})(),
}}
/>
<div className="mt-3 flex flex-wrap gap-1 py-1">
{userActions.map((action: UserAction) => (
<Button
key={action.id}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
>
{action.title}
</Button>
))}
</div>
<div className="system-xs-regular mt-1 text-text-tertiary">{t('nodes.humanInput.editor.previewTip', { ns: 'workflow' })}</div>
</div>
</div>
)
}
export default React.memo(FormContentPreview)

View File

@ -0,0 +1,175 @@
'use client'
import type { LexicalCommand } from 'lexical'
import type { FC } from 'react'
import type { FormInputItem } from '../types'
import type { Node, NodeOutPutVar } from '@/app/components/workflow/types'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import PromptEditor from '@/app/components/base/prompt-editor'
import { INSERT_HITL_INPUT_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/hitl-input-block'
import { cn } from '@/utils/classnames'
import { useWorkflowVariableType } from '../../../hooks'
import { BlockEnum } from '../../../types'
import { isMac } from '../../../utils'
import AddInputField from './add-input-field'
type FormContentProps = {
nodeId: string
value: string
onChange: (value: string) => void
formInputs: FormInputItem[]
onFormInputsChange: (payload: FormInputItem[]) => void
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void
onFormInputItemRemove: (varName: string) => void
editorKey: number
isExpand: boolean
availableVars: NodeOutPutVar[]
availableNodes: Node[]
readonly?: boolean
}
const Key: FC<{ children: React.ReactNode, className?: string }> = ({ children, className }) => {
return <span className={cn('system-kbd mx-0.5 inline-flex size-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray text-text-placeholder ', className)}>{children}</span>
}
const CtrlKey: FC = () => {
return <Key className={cn('mr-0', !isMac() && 'w-7')}>{isMac() ? '⌘' : 'Ctrl'}</Key>
}
const FormContent: FC<FormContentProps> = ({
nodeId,
value,
onChange,
formInputs,
onFormInputsChange,
onFormInputItemRename,
onFormInputItemRemove,
editorKey,
isExpand,
availableVars,
availableNodes,
readonly,
}) => {
const { t } = useTranslation()
const getVarType = useWorkflowVariableType()
const [needToAddFormInput, setNeedToAddFormInput] = useState(false)
const [newFormInputs, setNewFormInputs] = useState<FormInputItem[]>([])
const handleInsertHITLNode = (onInsert: (command: LexicalCommand<unknown>, params: any) => void) => {
return (payload: FormInputItem) => {
const newFormInputs = [...(formInputs || []), payload]
onInsert(INSERT_HITL_INPUT_BLOCK_COMMAND, {
variableName: payload.output_variable_name,
nodeId,
formInputs: newFormInputs,
onFormInputsChange,
onFormInputItemRename,
onFormInputItemRemove,
})
setNewFormInputs(newFormInputs)
setNeedToAddFormInput(true)
}
}
// avoid update formInputs would overwrite the value just inserted
useEffect(() => {
if (needToAddFormInput) {
onFormInputsChange(newFormInputs)
setNeedToAddFormInput(false)
}
}, [value])
const [isFocus, {
setTrue: setFocus,
setFalse: setBlur,
}] = useBoolean(false)
const workflowNodesMap = availableNodes.reduce((acc: any, node) => {
acc[node.id] = {
title: node.data.title,
type: node.data.type,
width: node.width,
height: node.height,
position: node.position,
}
if (node.data.type === BlockEnum.Start) {
acc.sys = {
title: t('blocks.start', { ns: 'workflow' }),
type: BlockEnum.Start,
}
}
return acc
}, {})
return (
<div
className={cn(
'flex grow flex-col rounded-[10px] border border-components-input-bg-normal bg-components-input-bg-normal pt-1',
isFocus && 'border-components-input-border-active bg-components-input-bg-active',
!isFocus && 'pb-[32px]',
readonly && 'pointer-events-none',
)}
>
<div className={cn('max-h-[300px] overflow-y-auto px-3', isExpand && 'h-0 max-h-full grow')}>
<PromptEditor
key={editorKey}
value={value}
onChange={onChange}
className={cn('min-h-[80px] ', isExpand && 'h-full')}
onFocus={setFocus}
onBlur={setBlur}
placeholder={t('nodes.humanInput.formContent.placeholder', { ns: 'workflow' })}
hitlInputBlock={{
show: true,
formInputs,
nodeId,
onFormInputsChange,
onFormInputItemRename,
onFormInputItemRemove,
variables: availableVars || [],
workflowNodesMap,
getVarType,
readonly,
}}
workflowVariableBlock={{
show: true,
variables: availableVars || [],
getVarType: getVarType as any,
workflowNodesMap,
}}
editable={!readonly}
shortcutPopups={readonly
? []
: [{
hotkey: ['mod', '/'],
Popup: ({ onClose, onInsert }) => (
<AddInputField
nodeId={nodeId}
onSave={handleInsertHITLNode(onInsert!)}
onCancel={onClose}
/>
),
}]}
/>
</div>
{isFocus && (
<div className="system-xs-regular flex h-8 shrink-0 items-center px-3 text-components-input-text-placeholder">
<Trans
i18nKey="nodes.humanInput.formContent.hotkeyTip"
ns="workflow"
components={
{
Key: <Key>/</Key>,
CtrlKey: <CtrlKey />,
}
}
/>
</div>
)}
</div>
)
}
export default React.memo(FormContent)

View File

@ -0,0 +1,87 @@
'use client'
import type { ButtonProps } from '@/app/components/base/button'
import type { UserAction } from '@/app/components/workflow/nodes/human-input/types'
import type { HumanInputFormData } from '@/types/workflow'
import { RiArrowLeftLine } from '@remixicon/react'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item'
import { getButtonStyle, initializeInputs, splitByOutputVar } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
type Props = {
nodeName: string
data: HumanInputFormData
showBackButton?: boolean
handleBack?: () => void
onSubmit?: ({ inputs, action }: { inputs: Record<string, string>, action: string }) => Promise<void>
}
const FormContent = ({
nodeName,
data,
showBackButton,
handleBack,
onSubmit,
}: Props) => {
const { t } = useTranslation()
const defaultInputs = initializeInputs(data.inputs, data.resolved_default_values || {})
const contentList = splitByOutputVar(data.form_content)
const [inputs, setInputs] = useState(defaultInputs)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleInputsChange = (name: string, value: string) => {
setInputs(prev => ({
...prev,
[name]: value,
}))
}
const submit = async (actionID: string) => {
setIsSubmitting(true)
await onSubmit?.({ inputs, action: actionID })
setIsSubmitting(false)
}
return (
<>
{showBackButton && (
<div className="flex items-center p-4 pb-1">
<div className="system-sm-semibold-uppercase flex cursor-pointer items-center text-text-accent" onClick={handleBack}>
<RiArrowLeftLine className="mr-1 h-4 w-4" />
{t('nodes.humanInput.singleRun.back', { ns: 'workflow' })}
</div>
<div className="system-xs-regular mx-1 text-divider-deep">/</div>
<div className="system-sm-semibold-uppercase text-text-secondary">{nodeName}</div>
</div>
)}
<div className="px-4 py-3">
{contentList.map((content, index) => (
<ContentItem
key={index}
content={content}
formInputFields={data.inputs}
inputs={inputs}
onInputChange={handleInputsChange}
/>
))}
<div className="flex flex-wrap gap-1 py-1">
{data.actions.map((action: UserAction) => (
<Button
key={action.id}
disabled={isSubmitting}
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
onClick={() => submit(action.id)}
>
{action.title}
</Button>
))}
</div>
</div>
</>
)
}
export default React.memo(FormContent)

View File

@ -0,0 +1,69 @@
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { cn } from '@/utils/classnames'
const i18nPrefix = 'nodes.humanInput'
type Props = {
timeout: number
unit: 'day' | 'hour'
onChange: (state: { timeout: number, unit: 'day' | 'hour' }) => void
readonly?: boolean
}
const TimeoutInput: FC<Props> = ({
timeout,
unit,
onChange,
readonly,
}) => {
const { t } = useTranslation()
const handleValueChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
if (/^\d*$/.test(value))
onChange({ timeout: Number(value) || 1, unit })
else
onChange({ timeout: 1, unit })
}
return (
<div className="flex items-center gap-1">
<Input
wrapperClassName="w-16"
type="number"
value={timeout}
min={1}
onChange={handleValueChange}
disabled={readonly}
/>
<div className="flex items-center gap-0.5 rounded-[10px] bg-components-segmented-control-bg-normal p-0.5">
<div
className={cn(
'rounded-lg px-2 py-1 text-text-tertiary',
!readonly && 'cursor-pointer hover:bg-state-base-hover hover:text-text-secondary',
unit === 'day' && 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm',
!readonly && unit === 'day' && 'hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only',
)}
onClick={() => !readonly && onChange({ timeout, unit: 'day' })}
>
<div className="system-sm-medium p-0.5">{t(`${i18nPrefix}.timeout.days`, { ns: 'workflow' })}</div>
</div>
<div
className={cn(
'rounded-lg px-2 py-1 text-text-tertiary',
!readonly && 'cursor-pointer hover:bg-state-base-hover hover:text-text-secondary',
unit === 'hour' && 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-sm',
!readonly && unit === 'hour' && 'hover:bg-components-segmented-control-item-active-bg hover:text-text-accent-light-mode-only',
)}
onClick={() => !readonly && onChange({ timeout, unit: 'hour' })}
>
<div className="system-sm-medium p-0.5">{t(`${i18nPrefix}.timeout.hours`, { ns: 'workflow' })}</div>
</div>
</div>
</div>
)
}
export default TimeoutInput

View File

@ -0,0 +1,111 @@
import type { FC } from 'react'
import type { UserAction } from '../types'
import {
RiDeleteBinLine,
} from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import ButtonStyleDropdown from './button-style-dropdown'
const i18nPrefix = 'nodes.humanInput'
const ACTION_ID_MAX_LENGTH = 20
const BUTTON_TEXT_MAX_LENGTH = 20
type UserActionItemProps = {
data: UserAction
onChange: (state: UserAction) => void
onDelete: (id: string) => void
readonly?: boolean
}
const UserActionItem: FC<UserActionItemProps> = ({
data,
onChange,
onDelete,
readonly,
}) => {
const { t } = useTranslation()
const handleIDChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
if (!value.trim()) {
onChange({ ...data, id: '' })
return
}
// Convert spaces to underscores, then only allow characters matching /^[A-Za-z_][A-Za-z0-9_]*$/
const withUnderscores = value.replace(/ /g, '_')
let sanitized = withUnderscores
.split('')
.filter((char, index) => {
if (index === 0)
return /^[a-z_]$/i.test(char)
return /^\w$/.test(char)
})
.join('')
if (sanitized !== withUnderscores) {
Toast.notify({ type: 'error', message: t(`${i18nPrefix}.userActions.actionIdFormatTip`, { ns: 'workflow' }) })
return
}
// Limit to 20 characters
if (sanitized.length > ACTION_ID_MAX_LENGTH) {
sanitized = sanitized.slice(0, ACTION_ID_MAX_LENGTH)
Toast.notify({ type: 'error', message: t(`${i18nPrefix}.userActions.actionIdTooLong`, { ns: 'workflow', maxLength: ACTION_ID_MAX_LENGTH }) })
}
if (sanitized)
onChange({ ...data, id: sanitized })
}
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value
if (value.length > BUTTON_TEXT_MAX_LENGTH) {
value = value.slice(0, BUTTON_TEXT_MAX_LENGTH)
Toast.notify({ type: 'error', message: t(`${i18nPrefix}.userActions.buttonTextTooLong`, { ns: 'workflow', maxLength: BUTTON_TEXT_MAX_LENGTH }) })
}
onChange({ ...data, title: value })
}
return (
<div className="flex items-center gap-1">
<div className="shrink-0">
<Input
wrapperClassName="w-[120px]"
value={data.id}
placeholder={t(`${i18nPrefix}.userActions.actionNamePlaceholder`, { ns: 'workflow' })}
onChange={handleIDChange}
disabled={readonly}
/>
</div>
<div className="grow">
<Input
value={data.title}
placeholder={t(`${i18nPrefix}.userActions.buttonTextPlaceholder`, { ns: 'workflow' })}
onChange={handleTextChange}
disabled={readonly}
/>
</div>
<ButtonStyleDropdown
text={data.title}
data={data.button_style}
onChange={type => onChange({ ...data, button_style: type })}
readonly={readonly}
/>
{!readonly && (
<Button
className="px-2"
variant="tertiary"
onClick={() => onDelete(data.id)}
>
<RiDeleteBinLine className="h-4 w-4" />
</Button>
)}
</div>
)
}
export default UserActionItem

View File

@ -0,0 +1,140 @@
import type * as React from 'react'
import type { FormInputItemDefault } from '../types'
const variableRegex = /\{\{#(.+?)#\}\}/g
const noteRegex = /\{\{#\$(.+?)#\}\}/g
export function rehypeVariable() {
return (tree: any) => {
const iterate = (node: any, index: number, parent: any) => {
const value = node.value
variableRegex.lastIndex = 0
noteRegex.lastIndex = 0
if (node.type === 'text' && variableRegex.test(value) && !noteRegex.test(value)) {
let m: RegExpExecArray | null
let last = 0
const parts: any[] = []
variableRegex.lastIndex = 0
m = variableRegex.exec(value)
while (m !== null) {
if (m.index > last)
parts.push({ type: 'text', value: value.slice(last, m.index) })
parts.push({
type: 'element',
tagName: 'variable',
properties: { 'data-path': m[0].trim() },
children: [],
})
last = m.index + m[0].length
m = variableRegex.exec(value)
}
if (parts.length) {
if (last < value.length)
parts.push({ type: 'text', value: value.slice(last) })
parent.children.splice(index, 1, ...parts)
}
}
if (node.children) {
let i = 0
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
while (i < node.children.length) {
iterate(node.children[i], i, node)
i++
}
}
}
let i = 0
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
while (i < tree.children.length) {
iterate(tree.children[i], i, tree)
i++
}
}
}
export function rehypeNotes() {
return (tree: any) => {
const iterate = (node: any, index: number, parent: any) => {
const value = node.value
noteRegex.lastIndex = 0
if (node.type === 'text' && noteRegex.test(value)) {
let m: RegExpExecArray | null
let last = 0
const parts: any[] = []
noteRegex.lastIndex = 0
m = noteRegex.exec(value)
while (m !== null) {
if (m.index > last)
parts.push({ type: 'text', value: value.slice(last, m.index) })
const name = m[0].split('.').slice(-1)[0].replace('#}}', '')
parts.push({
type: 'element',
tagName: 'section',
properties: { 'data-name': name },
children: [],
})
last = m.index + m[0].length
m = noteRegex.exec(value)
}
if (parts.length) {
if (last < value.length)
parts.push({ type: 'text', value: value.slice(last) })
parent.children.splice(index, 1, ...parts)
parent.tagName = 'div' // h2 can not in p. In note content include the h2
}
}
if (node.children) {
let i = 0
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
while (i < node.children.length) {
iterate(node.children[i], i, node)
i++
}
}
}
let i = 0
// Caution: can not use forEach. Because the length of tree.children may be changed because of change content: parent.children.splice(index, 1, ...parts)
while (i < tree.children.length) {
iterate(tree.children[i], i, tree)
i++
}
}
}
export const Variable: React.FC<{ path: string }> = ({ path }) => {
return (
<span className="text-text-accent">
{
path.replaceAll('.', '/')
.replace('{{#', '{{')
.replace('#}}', '}}')
}
</span>
)
}
export const Note: React.FC<{ defaultInput: FormInputItemDefault, nodeName: (nodeId: string) => string }> = ({ defaultInput, nodeName }) => {
const isVariable = defaultInput.type === 'variable'
const path = `{{#${defaultInput.selector.join('.')}#}}`
let newPath = path
if (path) {
newPath = path.replace(/#([^#.]+)([.#])/g, (match, nodeId, sep) => {
return `#${nodeName(nodeId)}${sep}`
})
}
return (
<div className="my-3 rounded-[10px] bg-components-input-bg-normal px-2.5 py-2">
{isVariable ? <Variable path={newPath} /> : <span>{defaultInput.value}</span>}
</div>
)
}

View File

@ -0,0 +1,75 @@
import type { NodeDefault, Var } from '../../types'
import type { HumanInputNodeType } from './types'
import { BlockClassificationEnum } from '@/app/components/workflow/block-selector/types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
const i18nPrefix = 'nodes.humanInput.errorMsg'
const metaData = genNodeMetaData({
classification: BlockClassificationEnum.Logic,
sort: 1,
type: BlockEnum.HumanInput,
})
const buildOutputVars = (variables: string[]): Var[] => {
return variables.map((variable) => {
return {
variable,
type: VarType.string,
}
})
}
const nodeDefault: NodeDefault<HumanInputNodeType> = {
metaData,
defaultValue: {
delivery_methods: [],
user_actions: [],
form_content: '',
inputs: [],
timeout: 3,
timeout_unit: 'day',
},
checkValid(payload: HumanInputNodeType, t: (str: string, options: Record<string, unknown>) => string) {
let errorMessages = ''
if (!errorMessages && !payload.delivery_methods.length)
errorMessages = t(`${i18nPrefix}.noDeliveryMethod`, { ns: 'workflow' })
if (!errorMessages && payload.delivery_methods.length > 0 && !payload.delivery_methods.some(method => method.enabled))
errorMessages = t(`${i18nPrefix}.noDeliveryMethodEnabled`, { ns: 'workflow' })
if (!errorMessages && !payload.user_actions.length)
errorMessages = t(`${i18nPrefix}.noUserActions`, { ns: 'workflow' })
if (!errorMessages && payload.user_actions.length > 0) {
const actionIds = payload.user_actions.map(action => action.id)
const hasDuplicateIds = actionIds.length !== new Set(actionIds).size
if (hasDuplicateIds)
errorMessages = t(`${i18nPrefix}.duplicateActionId`, { ns: 'workflow' })
}
if (!errorMessages && payload.user_actions.length > 0) {
const hasEmptyId = payload.user_actions.some(action => !action.id?.trim())
if (hasEmptyId)
errorMessages = t(`${i18nPrefix}.emptyActionId`, { ns: 'workflow' })
}
if (!errorMessages && payload.user_actions.length > 0) {
const hasEmptyTitle = payload.user_actions.some(action => !action.title?.trim())
if (hasEmptyTitle)
errorMessages = t(`${i18nPrefix}.emptyActionTitle`, { ns: 'workflow' })
}
return {
isValid: !errorMessages,
errorMessage: errorMessages,
}
},
getOutputVars(payload, _allPluginInfoList, _ragVars) {
const variables = payload.inputs.map(input => input.output_variable_name)
return buildOutputVars(variables)
},
}
export default nodeDefault

View File

@ -0,0 +1,85 @@
import type { DeliveryMethod, HumanInputNodeType, UserAction } from '../types'
import { produce } from 'immer'
import { useState } from 'react'
import { useUpdateNodeInternals } from 'reactflow'
import {
useNodesReadOnly,
} from '@/app/components/workflow/hooks'
import { useEdgesInteractions } from '@/app/components/workflow/hooks/use-edges-interactions'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import useFormContent from './use-form-content'
const useConfig = (id: string, payload: HumanInputNodeType) => {
const updateNodeInternals = useUpdateNodeInternals()
const { nodesReadOnly: readOnly } = useNodesReadOnly()
const { inputs, setInputs } = useNodeCrud<HumanInputNodeType>(id, payload)
const formContentHook = useFormContent(id, payload)
const { handleEdgeDeleteByDeleteBranch, handleEdgeSourceHandleChange } = useEdgesInteractions()
const [structuredOutputCollapsed, setStructuredOutputCollapsed] = useState(true)
const handleDeliveryMethodChange = (methods: DeliveryMethod[]) => {
setInputs({
...inputs,
delivery_methods: methods,
})
}
const handleUserActionAdd = (newAction: UserAction) => {
setInputs({
...inputs,
user_actions: [...inputs.user_actions, newAction],
})
}
const handleUserActionChange = (index: number, updatedAction: UserAction) => {
const newActions = produce(inputs.user_actions, (draft) => {
if (draft[index])
draft[index] = updatedAction
})
setInputs({
...inputs,
user_actions: newActions,
})
// Update edges to use the new handle
const oldAction = inputs.user_actions[index]
if (oldAction && oldAction.id !== updatedAction.id) {
handleEdgeSourceHandleChange(id, oldAction.id, updatedAction.id)
updateNodeInternals(id) // Update handles
}
}
const handleUserActionDelete = (actionId: string) => {
const newActions = inputs.user_actions.filter(action => action.id !== actionId)
setInputs({
...inputs,
user_actions: newActions,
})
// Delete edges connected to this action
handleEdgeDeleteByDeleteBranch(id, actionId)
}
const handleTimeoutChange = ({ timeout, unit }: { timeout: number, unit: 'hour' | 'day' }) => {
setInputs({
...inputs,
timeout,
timeout_unit: unit,
})
}
return {
readOnly,
inputs,
handleDeliveryMethodChange,
handleUserActionAdd,
handleUserActionChange,
handleUserActionDelete,
handleTimeoutChange,
structuredOutputCollapsed,
setStructuredOutputCollapsed,
...formContentHook,
}
}
export default useConfig

View File

@ -0,0 +1,65 @@
import type { FormInputItem, HumanInputNodeType } from '../types'
import { produce } from 'immer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useWorkflow } from '@/app/components/workflow/hooks'
import useNodeCrud from '../../_base/hooks/use-node-crud'
const useFormContent = (id: string, payload: HumanInputNodeType) => {
const [editorKey, setEditorKey] = useState(0)
const { inputs, setInputs } = useNodeCrud<HumanInputNodeType>(id, payload)
const { handleOutVarRenameChange } = useWorkflow()
const inputsRef = useRef(inputs)
useEffect(() => {
inputsRef.current = inputs
}, [inputs])
const handleFormContentChange = useCallback((value: string) => {
setInputs({
...inputs,
form_content: value,
})
}, [inputs, setInputs])
const handleFormInputsChange = useCallback((formInputs: FormInputItem[]) => {
setInputs({
...inputs,
inputs: formInputs,
})
setEditorKey(editorKey => editorKey + 1)
}, [inputs, setInputs])
const handleFormInputItemRename = useCallback((payload: FormInputItem, oldName: string) => {
const inputs = inputsRef.current
const newInputs = produce(inputs, (draft) => {
draft.form_content = draft.form_content.replaceAll(`{{#$output.${oldName}#}}`, `{{#$output.${payload.output_variable_name}#}}`)
draft.inputs = draft.inputs.map(item => item.output_variable_name === oldName ? payload : item)
if (!draft.inputs.find(item => item.output_variable_name === payload.output_variable_name))
draft.inputs = [...draft.inputs, payload]
})
setInputs(newInputs)
setEditorKey(editorKey => editorKey + 1)
// Update downstream nodes that reference this variable
if (oldName !== payload.output_variable_name)
handleOutVarRenameChange(id, [id, oldName], [id, payload.output_variable_name])
}, [setInputs, handleOutVarRenameChange, id])
const handleFormInputItemRemove = useCallback((varName: string) => {
const inputs = inputsRef.current
const newInputs = produce(inputs, (draft) => {
draft.form_content = draft.form_content.replaceAll(`{{#$output.${varName}#}}`, '')
draft.inputs = draft.inputs.filter(item => item.output_variable_name !== varName)
})
setInputs(newInputs)
setEditorKey(editorKey => editorKey + 1)
}, [setInputs])
return {
editorKey,
handleFormContentChange,
handleFormInputsChange,
handleFormInputItemRename,
handleFormInputItemRemove,
}
}
export default useFormContent

View File

@ -0,0 +1,128 @@
import type { HumanInputNodeType } from '../types'
import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/components/before-run-form/form'
import type { InputVar } from '@/app/components/workflow/types'
import type { HumanInputFormData } from '@/types/workflow'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { fetchHumanInputNodeStepRunForm, submitHumanInputNodeStepRunForm } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import useNodeCrud from '../../_base/hooks/use-node-crud'
import { isOutput } from '../utils'
const i18nPrefix = 'nodes.humanInput'
type Params = {
id: string
payload: HumanInputNodeType
runInputData: Record<string, string>
getInputVars: (textList: string[]) => InputVar[]
setRunInputData: (data: Record<string, string>) => void
}
const useSingleRunFormParams = ({
id,
payload,
runInputData,
getInputVars,
setRunInputData,
}: Params) => {
const { t } = useTranslation()
const { inputs } = useNodeCrud<HumanInputNodeType>(id, payload)
const [showGeneratedForm, setShowGeneratedForm] = useState(false)
const [formData, setFormData] = useState<HumanInputFormData | null>(null)
const [requiredInputs, setRequiredInputs] = useState<Record<string, string>>({})
const generatedInputs = useMemo(() => {
const defaultInputs = inputs.inputs.reduce((acc, input) => {
if (input.default.type === 'variable') {
acc.push(`{{#${input.default.selector.join('.')}#}}`)
}
return acc
}, [] as string[])
const allInputs = getInputVars([...defaultInputs, inputs.form_content || '']).filter(item => !isOutput(item.value_selector || []))
return allInputs
}, [getInputVars, inputs.form_content, inputs.inputs])
const forms = useMemo(() => {
const forms: FormProps[] = [{
label: t(`${i18nPrefix}.singleRun.label`, { ns: 'workflow' })!,
inputs: generatedInputs,
values: runInputData,
onChange: setRunInputData,
}]
return forms
}, [t, generatedInputs, runInputData, setRunInputData])
const getDependentVars = () => {
return generatedInputs.map((item) => {
// Guard against null/undefined variable to prevent app crash
if (!item.variable || typeof item.variable !== 'string')
return []
return item.variable.slice(1, -1).split('.')
}).filter(arr => arr.length > 0)
}
const appDetail = useAppStore(s => s.appDetail)
const appId = appDetail?.id
const isWorkflowMode = appDetail?.mode === AppModeEnum.WORKFLOW
const fetchURL = useMemo(() => {
if (!appId)
return ''
if (!isWorkflowMode) {
return `/apps/${appId}/advanced-chat/workflows/draft/human-input/nodes/${id}/form`
}
else {
return `/apps/${appId}/workflows/draft/human-input/nodes/${id}/form`
}
}, [appId, id, isWorkflowMode])
const handleFetchFormContent = useCallback(async (inputs: Record<string, string>) => {
if (!fetchURL)
return null
let requestParamsObj: Record<string, string> = {}
Object.keys(inputs).forEach((key) => {
if (inputs[key] === undefined) {
delete inputs[key]
}
})
requestParamsObj = { ...inputs }
const data = await fetchHumanInputNodeStepRunForm(fetchURL, { inputs: requestParamsObj! })
setFormData(data)
setRequiredInputs(requestParamsObj)
return data
}, [fetchURL])
const handleSubmitHumanInputForm = useCallback(async (formData: {
inputs: Record<string, string> | undefined
form_inputs: Record<string, string> | undefined
action: string
}) => {
await submitHumanInputNodeStepRunForm(fetchURL, {
inputs: requiredInputs,
form_inputs: formData.inputs,
action: formData.action,
})
}, [fetchURL, requiredInputs])
const handleShowGeneratedForm = async (formValue: Record<string, string>) => {
setShowGeneratedForm(true)
await handleFetchFormContent(formValue)
}
const handleHideGeneratedForm = () => {
setShowGeneratedForm(false)
}
return {
forms,
getDependentVars,
showGeneratedForm,
handleShowGeneratedForm,
handleHideGeneratedForm,
formData,
handleFetchFormContent,
handleSubmitHumanInputForm,
}
}
export default useSingleRunFormParams

View File

@ -0,0 +1,74 @@
import type { FC } from 'react'
import type { HumanInputNodeType } from './types'
import type { NodeProps } from '@/app/components/workflow/types'
import {
RiMailSendFill,
RiRobot2Fill,
} from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { NodeSourceHandle } from '../_base/components/node-handle'
import { DeliveryMethodType } from './types'
const i18nPrefix = 'nodes.humanInput'
const Node: FC<NodeProps<HumanInputNodeType>> = (props) => {
const { t } = useTranslation()
const { data } = props
const deliveryMethods = data.delivery_methods
const userActions = data.user_actions
return (
<>
{deliveryMethods.length > 0 && (
<div className="space-y-0.5 py-1">
<div className="system-2xs-medium-uppercase px-2.5 py-0.5 text-text-tertiary">{t(`${i18nPrefix}.deliveryMethod.title`, { ns: 'workflow' })}</div>
<div className="space-y-0.5 px-2.5">
{deliveryMethods.map(method => (
<div key={method.type} className="flex items-center gap-1 rounded-[6px] bg-workflow-block-parma-bg p-1">
{method.type === DeliveryMethodType.WebApp && (
<div className="rounded-[4px] border border-divider-regular bg-components-icon-bg-indigo-solid p-0.5">
<RiRobot2Fill className="h-3.5 w-3.5 text-text-primary-on-surface" />
</div>
)}
{method.type === DeliveryMethodType.Email && (
<div className="rounded-[4px] border border-divider-regular bg-components-icon-bg-blue-solid p-0.5">
<RiMailSendFill className="h-3.5 w-3.5 text-text-primary-on-surface" />
</div>
)}
<span className="system-xs-regular capitalize text-text-secondary">{method.type}</span>
</div>
))}
</div>
</div>
)}
<div className="space-y-0.5 py-1">
{userActions.length > 0 && (
<>
{userActions.map(userAction => (
<div key={userAction.id} className="relative flex flex-row-reverse items-center px-4 py-1">
<span className="system-xs-semibold-uppercase truncate text-text-secondary">{userAction.id}</span>
<NodeSourceHandle
{...props}
handleId={userAction.id}
handleClassName="!top-1/2 !-right-[9px] !-translate-y-1/2"
/>
</div>
))}
</>
)}
<div className="relative flex flex-row-reverse items-center px-4 py-1">
<div className="system-xs-semibold-uppercase truncate text-text-secondary">Timeout</div>
<NodeSourceHandle
{...props}
handleId="__timeout"
handleClassName="!top-1/2 !-right-[9px] !-translate-y-1/2"
/>
</div>
</div>
</>
)
}
export default React.memo(Node)

View File

@ -0,0 +1,251 @@
import type { FC } from 'react'
import type { HumanInputNodeType } from './types'
import type { NodePanelProps, Var } from '@/app/components/workflow/types'
import {
RiAddLine,
RiClipboardLine,
RiCollapseDiagonalLine,
RiExpandDiagonalLine,
RiEyeLine,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import copy from 'copy-to-clipboard'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import Toast from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/components/output-vars'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
import { useStore } from '@/app/components/workflow/store'
import { VarType } from '@/app/components/workflow/types'
import { cn } from '@/utils/classnames'
import DeliveryMethod from './components/delivery-method'
import FormContent from './components/form-content'
import FormContentPreview from './components/form-content-preview'
import TimeoutInput from './components/timeout'
import UserActionItem from './components/user-action'
import useConfig from './hooks/use-config'
import { UserActionButtonType } from './types'
const i18nPrefix = 'nodes.humanInput'
const Panel: FC<NodePanelProps<HumanInputNodeType>> = ({
id,
data,
}) => {
const { t } = useTranslation()
const {
readOnly,
inputs,
handleDeliveryMethodChange,
handleUserActionAdd,
handleUserActionChange,
handleUserActionDelete,
handleTimeoutChange,
handleFormContentChange,
handleFormInputsChange,
handleFormInputItemRename,
handleFormInputItemRemove,
editorKey,
structuredOutputCollapsed,
setStructuredOutputCollapsed,
} = useConfig(id, data)
const { availableVars, availableNodesWithParent } = useAvailableVarList(id, {
onlyLeafNodeVar: false,
filterVar: (varPayload: Var) => {
return [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
},
})
const [isExpandFormContent, {
toggle: toggleExpandFormContent,
}] = useBoolean(false)
const nodePanelWidth = useStore(state => state.nodePanelWidth)
const [isPreview, {
toggle: togglePreview,
setFalse: hidePreview,
}] = useBoolean(false)
const onAddUseAction = useCallback(() => {
const index = inputs.user_actions.length + 1
handleUserActionAdd({
id: `action_${index}`,
title: `Button Text ${index}`,
button_style: UserActionButtonType.Default,
})
}, [handleUserActionAdd, inputs.user_actions.length])
return (
<div className="py-2">
{/* delivery methods */}
<DeliveryMethod
nodeId={id}
value={inputs.delivery_methods || []}
formContent={inputs.form_content}
formInputs={inputs.inputs}
nodesOutputVars={availableVars}
availableNodes={availableNodesWithParent}
onChange={handleDeliveryMethodChange}
readonly={readOnly}
/>
<div className="px-4 py-2">
<Divider className="!my-0 !h-px !bg-divider-subtle" />
</div>
{/* form content */}
<div
className={cn('px-4 py-2', isExpandFormContent && 'fixed bottom-[8px] right-[4px] top-[244px] z-10 flex flex-col rounded-b-2xl bg-components-panel-bg')}
style={{
width: isExpandFormContent ? nodePanelWidth : '100%',
}}
>
<div className="mb-1 flex shrink-0 items-center justify-between">
<div className="flex h-6 items-center gap-0.5">
<div className="system-sm-semibold-uppercase text-text-secondary">{t(`${i18nPrefix}.formContent.title`, { ns: 'workflow' })}</div>
<Tooltip
popupContent={t(`${i18nPrefix}.formContent.tooltip`, { ns: 'workflow' })}
/>
</div>
{!readOnly && (
<div className="flex items-center ">
<Button
variant="ghost"
size="small"
className={cn(
'flex items-center space-x-1 px-2',
isPreview && 'bg-state-accent-active text-text-accent',
)}
onClick={togglePreview}
>
<RiEyeLine className="size-3.5" />
<div className="system-xs-medium">{t(`${i18nPrefix}.formContent.preview`, { ns: 'workflow' })}</div>
</Button>
<div className="mx-2 h-3 w-px bg-divider-regular"></div>
<div className="flex items-center space-x-1">
<div
className="flex size-6 cursor-pointer items-center justify-center rounded-md hover:bg-components-button-ghost-bg-hover"
onClick={() => {
copy(inputs.form_content)
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
}}
>
<RiClipboardLine className="h-4 w-4 text-text-secondary" />
</div>
<div className={cn('flex size-6 cursor-pointer items-center justify-center rounded-md text-text-secondary hover:bg-components-button-ghost-bg-hover', isExpandFormContent && 'bg-state-accent-active text-text-accent')} onClick={toggleExpandFormContent}>
{isExpandFormContent ? <RiCollapseDiagonalLine className="h-4 w-4" /> : <RiExpandDiagonalLine className="h-4 w-4" />}
</div>
</div>
</div>
)}
</div>
<FormContent
editorKey={editorKey}
nodeId={id}
value={inputs.form_content}
onChange={handleFormContentChange}
formInputs={inputs.inputs}
onFormInputsChange={handleFormInputsChange}
onFormInputItemRename={handleFormInputItemRename}
onFormInputItemRemove={handleFormInputItemRemove}
isExpand={isExpandFormContent}
availableVars={availableVars}
availableNodes={availableNodesWithParent}
readonly={readOnly}
/>
</div>
{/* user actions */}
<div className="px-4 py-2">
<div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-0.5">
<div className="system-sm-semibold-uppercase text-text-secondary">{t(`${i18nPrefix}.userActions.title`, { ns: 'workflow' })}</div>
<Tooltip
popupContent={t(`${i18nPrefix}.userActions.tooltip`, { ns: 'workflow' })}
/>
</div>
{!readOnly && (
<div className="flex items-center px-1">
<ActionButton
onClick={onAddUseAction}
>
<RiAddLine className="h-4 w-4" />
</ActionButton>
</div>
)}
</div>
{!inputs.user_actions.length && (
<div className="system-xs-regular flex items-center justify-center rounded-[10px] bg-background-section p-3 text-text-tertiary">{t(`${i18nPrefix}.userActions.emptyTip`, { ns: 'workflow' })}</div>
)}
{inputs.user_actions.length > 0 && (
<div className="space-y-2">
{inputs.user_actions.map((action, index) => (
<UserActionItem
key={index}
data={action}
onChange={data => handleUserActionChange(index, data)}
onDelete={handleUserActionDelete}
readonly={readOnly}
/>
))}
</div>
)}
</div>
<div className="px-4 py-2">
<Divider className="!my-0 !h-px !bg-divider-subtle" />
</div>
{/* timeout */}
<div className="flex items-center justify-between px-4 py-2">
<div className="system-sm-semibold-uppercase text-text-secondary">{t(`${i18nPrefix}.timeout.title`, { ns: 'workflow' })}</div>
<TimeoutInput
timeout={inputs.timeout}
unit={inputs.timeout_unit}
onChange={handleTimeoutChange}
readonly={readOnly}
/>
</div>
{/* output vars */}
<Split />
<OutputVars
collapsed={structuredOutputCollapsed}
onCollapse={setStructuredOutputCollapsed}
>
{
inputs.inputs.map(input => (
<VarItem
key={input.output_variable_name}
name={input.output_variable_name}
type={VarType.string}
description="Form input value"
/>
))
}
<VarItem
name="__action_id"
type="string"
description="Action ID user triggered"
/>
<VarItem
name="__rendered_content"
type="string"
description="Rendered content"
/>
</OutputVars>
{isPreview && (
<FormContentPreview
content={inputs.form_content}
formInputs={inputs.inputs}
userActions={inputs.user_actions}
onClose={hidePreview}
/>
)}
</div>
)
}
export default React.memo(Panel)

View File

@ -0,0 +1,72 @@
import type {
CommonNodeType,
InputVarType,
ValueSelector,
} from '@/app/components/workflow/types'
export type HumanInputNodeType = CommonNodeType & {
delivery_methods: DeliveryMethod[]
form_content: string
inputs: FormInputItem[]
user_actions: UserAction[]
timeout: number
timeout_unit: 'hour' | 'day'
}
export enum DeliveryMethodType {
WebApp = 'webapp',
Email = 'email',
Slack = 'slack',
Teams = 'teams',
Discord = 'discord',
}
export type Recipient = {
type: 'member' | 'external'
email?: string
user_id?: string
}
export type RecipientData = {
whole_workspace: boolean
items: Recipient[]
}
export type EmailConfig = {
recipients: RecipientData
subject: string
body: string
debug_mode: boolean
}
export type DeliveryMethod = {
id: string
type: DeliveryMethodType
enabled: boolean
config?: EmailConfig
}
export enum UserActionButtonType {
Primary = 'primary',
Default = 'default',
Accent = 'accent',
Ghost = 'ghost',
}
export type UserAction = {
id: string
title: string
button_style: UserActionButtonType
}
export type FormInputItemDefault = {
selector: ValueSelector
type: 'variable' | 'constant'
value: string
}
export type FormInputItem = {
type: InputVarType
output_variable_name: string
default: FormInputItemDefault
}

View File

@ -0,0 +1,3 @@
export const isOutput = (valueSelector: string[]) => {
return valueSelector[0] === '$output'
}