mirror of
https://github.com/langgenius/dify.git
synced 2026-04-28 14:38:06 +08:00
feat: Human Input Node (#32060)
The frontend and backend implementation for the human input node. Co-authored-by: twwu <twwu@dify.ai> Co-authored-by: JzoNg <jzongcode@gmail.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> Co-authored-by: zhsama <torvalds@linux.do>
This commit is contained in:
@ -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)
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
75
web/app/components/workflow/nodes/human-input/default.ts
Normal file
75
web/app/components/workflow/nodes/human-input/default.ts
Normal 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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
74
web/app/components/workflow/nodes/human-input/node.tsx
Normal file
74
web/app/components/workflow/nodes/human-input/node.tsx
Normal 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)
|
||||
251
web/app/components/workflow/nodes/human-input/panel.tsx
Normal file
251
web/app/components/workflow/nodes/human-input/panel.tsx
Normal 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)
|
||||
72
web/app/components/workflow/nodes/human-input/types.ts
Normal file
72
web/app/components/workflow/nodes/human-input/types.ts
Normal 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
|
||||
}
|
||||
3
web/app/components/workflow/nodes/human-input/utils.ts
Normal file
3
web/app/components/workflow/nodes/human-input/utils.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const isOutput = (valueSelector: string[]) => {
|
||||
return valueSelector[0] === '$output'
|
||||
}
|
||||
Reference in New Issue
Block a user