diff --git a/web/app/components/base/action-button/index.css b/web/app/components/base/action-button/index.css
index 3c1a10b86f..4ede34aeb5 100644
--- a/web/app/components/base/action-button/index.css
+++ b/web/app/components/base/action-button/index.css
@@ -26,6 +26,10 @@
@apply p-0.5 w-6 h-6 rounded-lg
}
+ .action-btn-s {
+ @apply w-5 h-5 rounded-[6px]
+ }
+
.action-btn-xs {
@apply p-0 w-4 h-4 rounded
}
diff --git a/web/app/components/base/action-button/index.tsx b/web/app/components/base/action-button/index.tsx
index c91d472087..d182193b00 100644
--- a/web/app/components/base/action-button/index.tsx
+++ b/web/app/components/base/action-button/index.tsx
@@ -18,6 +18,7 @@ const actionButtonVariants = cva(
variants: {
size: {
xs: 'action-btn-xs',
+ s: 'action-btn-s',
m: 'action-btn-m',
l: 'action-btn-l',
xl: 'action-btn-xl',
diff --git a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx
index 38a3f6c6b2..304425b9a7 100644
--- a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx
+++ b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx
@@ -2,6 +2,7 @@ import type { FileEntity } from '../../file-uploader/types'
import type {
ChatConfig,
ChatItem,
+ ChatItemInTree,
OnSend,
} from '../types'
import { useCallback, useEffect, useMemo, useState } from 'react'
@@ -16,7 +17,9 @@ import {
fetchSuggestedQuestions,
getUrl,
stopChatMessageResponding,
+ submitHumanInputForm,
} from '@/service/share'
+import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
import { TransferMethod } from '@/types/app'
import { cn } from '@/utils/classnames'
import { formatBooleanInputs } from '@/utils/model-config'
@@ -73,9 +76,9 @@ const ChatWrapper = () => {
}, [appParams, currentConversationItem?.introduction])
const {
chatList,
- setTargetMessageId,
handleSend,
handleStop,
+ handleSwitchSibling,
isResponding: respondingState,
suggestedQuestions,
} = useChat(
@@ -122,8 +125,11 @@ const ChatWrapper = () => {
if (fileIsUploading)
return true
+
+ if (chatList.some(item => item.isAnswer && item.humanInputFormDataList && item.humanInputFormDataList.length > 0))
+ return true
return false
- }, [inputsFormValue, inputsForms, allInputsHidden])
+ }, [allInputsHidden, inputsForms, chatList, inputsFormValue])
useEffect(() => {
if (currentChatInstanceRef.current)
@@ -134,6 +140,40 @@ const ChatWrapper = () => {
setIsResponding(respondingState)
}, [respondingState, setIsResponding])
+ // Resume paused workflows when chat history is loaded
+ useEffect(() => {
+ if (!appPrevChatTree || appPrevChatTree.length === 0)
+ return
+
+ // Find the last answer item with workflow_run_id that needs resumption (DFS - find deepest first)
+ let lastPausedNode: ChatItemInTree | undefined
+ const findLastPausedWorkflow = (nodes: ChatItemInTree[]) => {
+ nodes.forEach((node) => {
+ // DFS: recurse to children first
+ if (node.children && node.children.length > 0)
+ findLastPausedWorkflow(node.children)
+
+ // Track the last node with humanInputFormDataList
+ if (node.isAnswer && node.workflow_run_id && node.humanInputFormDataList && node.humanInputFormDataList.length > 0)
+ lastPausedNode = node
+ })
+ }
+
+ findLastPausedWorkflow(appPrevChatTree)
+
+ // Only resume the last paused workflow
+ if (lastPausedNode) {
+ handleSwitchSibling(
+ lastPausedNode.id,
+ {
+ onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
+ onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
+ isPublicAPI: appSourceType === AppSourceType.webApp,
+ },
+ )
+ }
+ }, [])
+
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
const data: any = {
query: message,
@@ -149,10 +189,10 @@ const ChatWrapper = () => {
{
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
onConversationComplete: isHistoryConversation ? undefined : handleNewConversationCompleted,
- isPublicAPI: !isInstalledApp,
+ isPublicAPI: appSourceType === AppSourceType.webApp,
},
)
- }, [chatList, handleNewConversationCompleted, handleSend, currentConversationId, currentConversationInputs, newConversationInputs, isInstalledApp, appId])
+ }, [inputsForms, currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, appSourceType, appId, isHistoryConversation, handleNewConversationCompleted])
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
@@ -160,12 +200,27 @@ const ChatWrapper = () => {
doSend(editedQuestion ? editedQuestion.message : question.content, editedQuestion ? editedQuestion.files : question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null)
}, [chatList, doSend])
+ const doSwitchSibling = useCallback((siblingMessageId: string) => {
+ handleSwitchSibling(siblingMessageId, {
+ onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
+ onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
+ isPublicAPI: appSourceType === AppSourceType.webApp,
+ })
+ }, [handleSwitchSibling, currentConversationId, handleNewConversationCompleted, appSourceType, appId])
+
const messageList = useMemo(() => {
if (currentConversationId || chatList.length > 1)
return chatList
// Without messages we are in the welcome screen, so hide the opening statement from chatlist
return chatList.filter(item => !item.isOpeningStatement)
- }, [chatList])
+ }, [chatList, currentConversationId])
+
+ const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: any) => {
+ if (isInstalledApp)
+ await submitHumanInputFormService(formToken, formData)
+ else
+ await submitHumanInputForm(formToken, formData)
+ }, [isInstalledApp])
const [collapsed, setCollapsed] = useState(!!currentConversationId)
@@ -274,6 +329,7 @@ const ChatWrapper = () => {
inputsForm={inputsForms}
onRegenerate={doRegenerate}
onStopResponding={handleStop}
+ onHumanInputFormSubmit={handleSubmitHumanInputForm}
chatNode={(
<>
{chatNode}
@@ -286,7 +342,7 @@ const ChatWrapper = () => {
answerIcon={answerIcon}
hideProcessDetail
themeBuilder={themeBuilder}
- switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
+ switchSibling={doSwitchSibling}
inputDisabled={inputDisabled}
sidebarCollapseState={sidebarCollapseState}
questionIcon={
diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx
index ad1de38d07..da344a9789 100644
--- a/web/app/components/base/chat/chat-with-history/hooks.tsx
+++ b/web/app/components/base/chat/chat-with-history/hooks.tsx
@@ -1,3 +1,4 @@
+import type { ExtraContent } from '../chat/type'
import type {
Callback,
ChatConfig,
@@ -9,6 +10,7 @@ import type {
AppData,
ConversationItem,
} from '@/models/share'
+import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
import { useLocalStorageState } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
@@ -57,6 +59,24 @@ function getFormattedChatList(messages: any[]) {
parentMessageId: item.parent_message_id || undefined,
})
const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || []
+ const humanInputFormDataList: HumanInputFormData[] = []
+ const humanInputFilledFormDataList: HumanInputFilledFormData[] = []
+ let workflowRunId = ''
+ if (item.status === 'paused') {
+ item.extra_contents?.forEach((content: ExtraContent) => {
+ if (content.type === 'human_input' && !content.submitted) {
+ humanInputFormDataList.push(content.form_definition)
+ workflowRunId = content.workflow_run_id
+ }
+ })
+ }
+ else if (item.status === 'normal') {
+ item.extra_contents?.forEach((content: ExtraContent) => {
+ if (content.type === 'human_input' && content.submitted) {
+ humanInputFilledFormDataList.push(content.form_submission_data)
+ }
+ })
+ }
newChatList.push({
id: item.id,
content: item.answer,
@@ -66,6 +86,9 @@ function getFormattedChatList(messages: any[]) {
citation: item.retriever_resources,
message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id, upload_file_id: item.upload_file_id }))),
parentMessageId: `question-${item.id}`,
+ humanInputFormDataList,
+ humanInputFilledFormDataList,
+ workflow_run_id: workflowRunId,
})
})
return newChatList
diff --git a/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx b/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx
new file mode 100644
index 0000000000..3ed777d41e
--- /dev/null
+++ b/web/app/components/base/chat/chat/answer/human-input-content/content-item.tsx
@@ -0,0 +1,54 @@
+import type { ContentItemProps } from './type'
+import * as React from 'react'
+import { useMemo } from 'react'
+import { Markdown } from '@/app/components/base/markdown'
+import Textarea from '@/app/components/base/textarea'
+
+const ContentItem = ({
+ content,
+ formInputFields,
+ inputs,
+ onInputChange,
+}: ContentItemProps) => {
+ const isInputField = (field: string) => {
+ const outputVarRegex = /\{\{#\$output\.[^#]+#\}\}/
+ return outputVarRegex.test(field)
+ }
+
+ const extractFieldName = (str: string): string => {
+ const outputVarRegex = /\{\{#\$output\.([^#]+)#\}\}/
+ const match = str.match(outputVarRegex)
+ return match ? match[1] : ''
+ }
+
+ const fieldName = useMemo(() => {
+ return extractFieldName(content)
+ }, [content])
+
+ const formInputField = useMemo(() => {
+ return formInputFields.find(field => field.output_variable_name === fieldName)
+ }, [formInputFields, fieldName])
+
+ if (!isInputField(content)) {
+ return (
+
+ )
+ }
+
+ if (!formInputField)
+ return null
+
+ return (
+
+ {formInputField.type === 'paragraph' && (
+
+ )
+}
+
+export default React.memo(ContentItem)
diff --git a/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.tsx b/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.tsx
new file mode 100644
index 0000000000..acd154e30a
--- /dev/null
+++ b/web/app/components/base/chat/chat/answer/human-input-content/content-wrapper.tsx
@@ -0,0 +1,64 @@
+import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
+import { useCallback, useState } from 'react'
+import BlockIcon from '@/app/components/workflow/block-icon'
+import { BlockEnum } from '@/app/components/workflow/types'
+import { cn } from '@/utils/classnames'
+
+type ContentWrapperProps = {
+ nodeTitle: string
+ children: React.ReactNode
+ showExpandIcon?: boolean
+ className?: string
+ expanded?: boolean
+}
+
+const ContentWrapper = ({
+ nodeTitle,
+ children,
+ showExpandIcon = false,
+ className,
+ expanded = false,
+}: ContentWrapperProps) => {
+ const [isExpanded, setIsExpanded] = useState(expanded)
+
+ const handleToggleExpand = useCallback(() => {
+ setIsExpanded(!isExpanded)
+ }, [isExpanded])
+
+ return (
+
+
+ {/* node icon */}
+
+ {/* node name */}
+
+ {nodeTitle}
+
+ {showExpandIcon && (
+
+ {
+ isExpanded
+ ? (
+
+ )
+ : (
+
+ )
+ }
+
+ )}
+
+ {(!showExpandIcon || isExpanded) && (
+
+ {/* human input form content */}
+ {children}
+
+ )}
+
+ )
+}
+
+export default ContentWrapper
diff --git a/web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx b/web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx
new file mode 100644
index 0000000000..ccdfcb624b
--- /dev/null
+++ b/web/app/components/base/chat/chat/answer/human-input-content/executed-action.tsx
@@ -0,0 +1,30 @@
+import type { ExecutedAction as ExecutedActionType } from './type'
+import { memo } from 'react'
+import { Trans } from 'react-i18next'
+import Divider from '@/app/components/base/divider'
+import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
+
+type ExecutedActionProps = {
+ executedAction: ExecutedActionType
+}
+
+const ExecutedAction = ({
+ executedAction,
+}: ExecutedActionProps) => {
+ return (
+
+
+
+
+ }}
+ values={{ actionName: executedAction.id }}
+ />
+
+
+ )
+}
+
+export default memo(ExecutedAction)
diff --git a/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.tsx b/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.tsx
new file mode 100644
index 0000000000..786440dc6b
--- /dev/null
+++ b/web/app/components/base/chat/chat/answer/human-input-content/expiration-time.tsx
@@ -0,0 +1,46 @@
+'use client'
+import { RiAlertFill, RiTimeLine } from '@remixicon/react'
+import { useTranslation } from 'react-i18next'
+import { useLocale } from '@/context/i18n'
+import { cn } from '@/utils/classnames'
+import { getRelativeTime, isRelativeTimeSameOrAfter } from './utils'
+
+type ExpirationTimeProps = {
+ expirationTime: number
+}
+
+const ExpirationTime = ({
+ expirationTime,
+}: ExpirationTimeProps) => {
+ const { t } = useTranslation()
+ const locale = useLocale()
+ const relativeTime = getRelativeTime(expirationTime, locale)
+ const isSameOrAfter = isRelativeTimeSameOrAfter(expirationTime)
+
+ return (
+
+ {
+ isSameOrAfter
+ ? (
+ <>
+
+ {t('humanInput.expirationTimeNowOrFuture', { relativeTime, ns: 'share' })}
+ >
+ )
+ : (
+ <>
+
+ {t('humanInput.expiredTip', { ns: 'share' })}
+ >
+ )
+ }
+
+ )
+}
+
+export default ExpirationTime
diff --git a/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx
new file mode 100644
index 0000000000..0b5d54ab7e
--- /dev/null
+++ b/web/app/components/base/chat/chat/answer/human-input-content/human-input-form.tsx
@@ -0,0 +1,61 @@
+'use client'
+import type { HumanInputFormProps } from './type'
+import type { ButtonProps } from '@/app/components/base/button'
+import type { UserAction } from '@/app/components/workflow/nodes/human-input/types'
+import * as React from 'react'
+import { useCallback, useState } from 'react'
+import Button from '@/app/components/base/button'
+import ContentItem from './content-item'
+import { getButtonStyle, initializeInputs, splitByOutputVar } from './utils'
+
+const HumanInputForm = ({
+ formData,
+ onSubmit,
+}: HumanInputFormProps) => {
+ const formToken = formData.form_token
+ const defaultInputs = initializeInputs(formData.inputs, formData.resolved_default_values || {})
+ const contentList = splitByOutputVar(formData.form_content)
+ const [inputs, setInputs] = useState(defaultInputs)
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ const handleInputsChange = useCallback((name: string, value: string) => {
+ setInputs(prev => ({
+ ...prev,
+ [name]: value,
+ }))
+ }, [])
+
+ const submit = async (formToken: string, actionID: string, inputs: Record
) => {
+ setIsSubmitting(true)
+ await onSubmit?.(formToken, { inputs, action: actionID })
+ setIsSubmitting(false)
+ }
+
+ return (
+ <>
+ {contentList.map((content, index) => (
+
+ ))}
+
+ {formData.actions.map((action: UserAction) => (
+ submit(formToken, action.id, inputs)}
+ >
+ {action.title}
+
+ ))}
+
+ >
+ )
+}
+
+export default React.memo(HumanInputForm)
diff --git a/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.tsx b/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.tsx
new file mode 100644
index 0000000000..68d55f7d64
--- /dev/null
+++ b/web/app/components/base/chat/chat/answer/human-input-content/submitted-content.tsx
@@ -0,0 +1,16 @@
+import * as React from 'react'
+import { Markdown } from '@/app/components/base/markdown'
+
+type SubmittedContentProps = {
+ content: string
+}
+
+const SubmittedContent = ({
+ content,
+}: SubmittedContentProps) => {
+ return (
+
+ )
+}
+
+export default React.memo(SubmittedContent)
diff --git a/web/app/components/base/chat/chat/answer/human-input-content/submitted.tsx b/web/app/components/base/chat/chat/answer/human-input-content/submitted.tsx
new file mode 100644
index 0000000000..bf598d4c5d
--- /dev/null
+++ b/web/app/components/base/chat/chat/answer/human-input-content/submitted.tsx
@@ -0,0 +1,25 @@
+import type { SubmittedHumanInputContentProps } from './type'
+import { useMemo } from 'react'
+import ExecutedAction from './executed-action'
+import SubmittedContent from './submitted-content'
+
+export const SubmittedHumanInputContent = ({
+ formData,
+}: SubmittedHumanInputContentProps) => {
+ const { rendered_content, action_id, action_text } = formData
+
+ const executedAction = useMemo(() => {
+ return {
+ id: action_id,
+ title: action_text,
+ }
+ }, [action_id, action_text])
+
+ return (
+ <>
+
+ {/* Executed Action */}
+
+ >
+ )
+}
diff --git a/web/app/components/base/chat/chat/answer/human-input-content/tips.tsx b/web/app/components/base/chat/chat/answer/human-input-content/tips.tsx
new file mode 100644
index 0000000000..54cfc8c5a5
--- /dev/null
+++ b/web/app/components/base/chat/chat/answer/human-input-content/tips.tsx
@@ -0,0 +1,43 @@
+import { memo } from 'react'
+import { Trans, useTranslation } from 'react-i18next'
+import Divider from '@/app/components/base/divider'
+import { useSelector as useAppSelector } from '@/context/app-context'
+
+type TipsProps = {
+ showEmailTip: boolean
+ isEmailDebugMode: boolean
+ showDebugModeTip: boolean
+}
+
+const Tips = ({
+ showEmailTip,
+ isEmailDebugMode,
+ showDebugModeTip,
+}: TipsProps) => {
+ const { t } = useTranslation()
+ const email = useAppSelector(s => s.userProfile.email)
+
+ return (
+ <>
+
+
+ {showEmailTip && !isEmailDebugMode && (
+
{t('common.humanInputEmailTip', { ns: 'workflow' })}
+ )}
+ {showEmailTip && isEmailDebugMode && (
+
+ }}
+ values={{ email }}
+ />
+
+ )}
+ {showDebugModeTip &&
{t('common.humanInputWebappTip', { ns: 'workflow' })}
}
+
+ >
+ )
+}
+
+export default memo(Tips)
diff --git a/web/app/components/base/chat/chat/answer/human-input-content/type.ts b/web/app/components/base/chat/chat/answer/human-input-content/type.ts
new file mode 100644
index 0000000000..7fb4082b4a
--- /dev/null
+++ b/web/app/components/base/chat/chat/answer/human-input-content/type.ts
@@ -0,0 +1,31 @@
+import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
+import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
+
+export type ExecutedAction = {
+ id: string
+ title: string
+}
+
+export type UnsubmittedHumanInputContentProps = {
+ formData: HumanInputFormData
+ showEmailTip?: boolean
+ isEmailDebugMode?: boolean
+ showDebugModeTip?: boolean
+ onSubmit?: (formToken: string, data: { inputs: Record, action: string }) => Promise
+}
+
+export type SubmittedHumanInputContentProps = {
+ formData: HumanInputFilledFormData
+}
+
+export type HumanInputFormProps = {
+ formData: HumanInputFormData
+ onSubmit?: (formToken: string, data: { inputs: Record, action: string }) => Promise
+}
+
+export type ContentItemProps = {
+ content: string
+ formInputFields: FormInputItem[]
+ inputs: Record
+ onInputChange: (name: string, value: string) => void
+}
diff --git a/web/app/components/base/chat/chat/answer/human-input-content/unsubmitted.tsx b/web/app/components/base/chat/chat/answer/human-input-content/unsubmitted.tsx
new file mode 100644
index 0000000000..b8b2145923
--- /dev/null
+++ b/web/app/components/base/chat/chat/answer/human-input-content/unsubmitted.tsx
@@ -0,0 +1,36 @@
+import type { UnsubmittedHumanInputContentProps } from './type'
+import ExpirationTime from './expiration-time'
+import HumanInputForm from './human-input-form'
+import Tips from './tips'
+
+export const UnsubmittedHumanInputContent = ({
+ formData,
+ showEmailTip = false,
+ isEmailDebugMode = false,
+ showDebugModeTip = false,
+ onSubmit,
+}: UnsubmittedHumanInputContentProps) => {
+ const { expiration_time } = formData
+
+ return (
+ <>
+ {/* Form */}
+
+ {/* Tips */}
+ {(showEmailTip || showDebugModeTip) && (
+
+ )}
+ {/* Expiration Time */}
+ {typeof expiration_time === 'number' && (
+
+ )}
+ >
+ )
+}
diff --git a/web/app/components/base/chat/chat/answer/human-input-content/utils.ts b/web/app/components/base/chat/chat/answer/human-input-content/utils.ts
new file mode 100644
index 0000000000..dd35932797
--- /dev/null
+++ b/web/app/components/base/chat/chat/answer/human-input-content/utils.ts
@@ -0,0 +1,64 @@
+import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
+import type { Locale } from '@/i18n-config'
+import dayjs from 'dayjs'
+import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
+import relativeTime from 'dayjs/plugin/relativeTime'
+import utc from 'dayjs/plugin/utc'
+import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
+import 'dayjs/locale/en'
+import 'dayjs/locale/zh-cn'
+import 'dayjs/locale/ja'
+
+dayjs.extend(utc)
+dayjs.extend(relativeTime)
+dayjs.extend(isSameOrAfter)
+
+export const getButtonStyle = (style: UserActionButtonType) => {
+ if (style === UserActionButtonType.Primary)
+ return 'primary'
+ if (style === UserActionButtonType.Default)
+ return 'secondary'
+ if (style === UserActionButtonType.Accent)
+ return 'secondary-accent'
+ if (style === UserActionButtonType.Ghost)
+ return 'ghost'
+}
+
+export const splitByOutputVar = (content: string): string[] => {
+ const outputVarRegex = /(\{\{#\$output\.[^#]+#\}\})/g
+ const parts = content.split(outputVarRegex)
+ return parts.filter(part => part.length > 0)
+}
+
+export const initializeInputs = (formInputs: FormInputItem[], defaultValues: Record = {}) => {
+ const initialInputs: Record = {}
+ formInputs.forEach((item) => {
+ if (item.type === 'text-input' || item.type === 'paragraph')
+ initialInputs[item.output_variable_name] = item.default.type === 'variable' ? defaultValues[item.output_variable_name] || '' : item.default.value
+ else
+ initialInputs[item.output_variable_name] = undefined
+ })
+ return initialInputs
+}
+
+const localeMap: Record = {
+ 'en-US': 'en',
+ 'zh-Hans': 'zh-cn',
+ 'ja-JP': 'ja',
+}
+
+export const getRelativeTime = (
+ utcTimestamp: string | number,
+ locale: Locale = 'en-US',
+) => {
+ const dayjsLocale = localeMap[locale] ?? 'en'
+
+ return dayjs
+ .utc(utcTimestamp)
+ .locale(dayjsLocale)
+ .fromNow()
+}
+
+export const isRelativeTimeSameOrAfter = (utcTimestamp: string | number) => {
+ return dayjs.utc(utcTimestamp).isSameOrAfter(dayjs())
+}
diff --git a/web/app/components/base/chat/chat/answer/human-input-filled-form-list.tsx b/web/app/components/base/chat/chat/answer/human-input-filled-form-list.tsx
new file mode 100644
index 0000000000..4dd82b13a7
--- /dev/null
+++ b/web/app/components/base/chat/chat/answer/human-input-filled-form-list.tsx
@@ -0,0 +1,32 @@
+import type { HumanInputFilledFormData } from '@/types/workflow'
+import ContentWrapper from './human-input-content/content-wrapper'
+import { SubmittedHumanInputContent } from './human-input-content/submitted'
+
+type HumanInputFilledFormListProps = {
+ humanInputFilledFormDataList: HumanInputFilledFormData[]
+}
+
+const HumanInputFilledFormList = ({
+ humanInputFilledFormDataList,
+}: HumanInputFilledFormListProps) => {
+ return (
+
+ {
+ humanInputFilledFormDataList.map(formData => (
+
+
+
+ ))
+ }
+
+ )
+}
+
+export default HumanInputFilledFormList
diff --git a/web/app/components/base/chat/chat/answer/human-input-form-list.tsx b/web/app/components/base/chat/chat/answer/human-input-form-list.tsx
new file mode 100644
index 0000000000..1403bcb600
--- /dev/null
+++ b/web/app/components/base/chat/chat/answer/human-input-form-list.tsx
@@ -0,0 +1,70 @@
+import type { DeliveryMethod, HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
+import type { Node } from '@/app/components/workflow/types'
+import type { HumanInputFormData } from '@/types/workflow'
+import { useMemo } from 'react'
+import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
+import ContentWrapper from './human-input-content/content-wrapper'
+import { UnsubmittedHumanInputContent } from './human-input-content/unsubmitted'
+
+type HumanInputFormListProps = {
+ humanInputFormDataList: HumanInputFormData[]
+ onHumanInputFormSubmit?: (formToken: string, formData: { inputs: Record, action: string }) => Promise
+ getHumanInputNodeData?: (nodeID: string) => Node | undefined
+}
+
+const HumanInputFormList = ({
+ humanInputFormDataList,
+ onHumanInputFormSubmit,
+ getHumanInputNodeData,
+}: HumanInputFormListProps) => {
+ const deliveryMethodsConfig = useMemo((): Record => {
+ if (!humanInputFormDataList.length)
+ return {}
+ return humanInputFormDataList.reduce((acc, formData) => {
+ const deliveryMethodsConfig = getHumanInputNodeData?.(formData.node_id)?.data.delivery_methods || []
+ if (!deliveryMethodsConfig.length) {
+ acc[formData.node_id] = {
+ showEmailTip: false,
+ isEmailDebugMode: false,
+ showDebugModeTip: false,
+ }
+ return acc
+ }
+ const isWebappEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.WebApp && method.enabled)
+ const isEmailEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.enabled)
+ const isEmailDebugMode = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.config?.debug_mode)
+ acc[formData.node_id] = {
+ showEmailTip: isEmailEnabled,
+ isEmailDebugMode,
+ showDebugModeTip: !isWebappEnabled,
+ }
+ return acc
+ }, {} as Record)
+ }, [getHumanInputNodeData, humanInputFormDataList])
+
+ const filteredHumanInputFormDataList = humanInputFormDataList.filter(formData => formData.display_in_ui)
+
+ return (
+
+ {
+ filteredHumanInputFormDataList.map(formData => (
+
+
+
+ ))
+ }
+
+ )
+}
+
+export default HumanInputFormList
diff --git a/web/app/components/base/chat/chat/answer/index.tsx b/web/app/components/base/chat/chat/answer/index.tsx
index da46f47c61..0ea46aa930 100644
--- a/web/app/components/base/chat/chat/answer/index.tsx
+++ b/web/app/components/base/chat/chat/answer/index.tsx
@@ -16,8 +16,11 @@ import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
import { FileList } from '@/app/components/base/file-uploader'
import { cn } from '@/utils/classnames'
import ContentSwitch from '../content-switch'
+import { useChatContext } from '../context'
import AgentContent from './agent-content'
import BasicContent from './basic-content'
+import HumanInputFilledFormList from './human-input-filled-form-list'
+import HumanInputFormList from './human-input-form-list'
import More from './more'
import Operation from './operation'
import SuggestedQuestions from './suggested-questions'
@@ -36,6 +39,8 @@ type AnswerProps = {
appData?: AppData
noChatInput?: boolean
switchSibling?: (siblingMessageId: string) => void
+ hideAvatar?: boolean
+ onHumanInputFormSubmit?: (formToken: string, formData: any) => Promise
}
const Answer: FC = ({
item,
@@ -50,6 +55,8 @@ const Answer: FC = ({
appData,
noChatInput,
switchSibling,
+ hideAvatar,
+ onHumanInputFormSubmit,
}) => {
const { t } = useTranslation()
const {
@@ -61,13 +68,22 @@ const Answer: FC = ({
workflowProcess,
allFiles,
message_files,
+ humanInputFormDataList,
+ humanInputFilledFormDataList,
} = item
const hasAgentThoughts = !!agent_thoughts?.length
+ const hasHumanInputs = !!humanInputFormDataList?.length || !!humanInputFilledFormDataList?.length
const [containerWidth, setContainerWidth] = useState(0)
const [contentWidth, setContentWidth] = useState(0)
+ const [humanInputFormContainerWidth, setHumanInputFormContainerWidth] = useState(0)
const containerRef = useRef(null)
const contentRef = useRef(null)
+ const humanInputFormContainerRef = useRef(null)
+
+ const {
+ getHumanInputNodeData,
+ } = useChatContext()
const getContainerWidth = () => {
if (containerRef.current)
@@ -87,12 +103,23 @@ const Answer: FC = ({
getContentWidth()
}, [responding])
+ const getHumanInputFormContainerWidth = () => {
+ if (humanInputFormContainerRef.current)
+ setHumanInputFormContainerWidth(humanInputFormContainerRef.current?.clientWidth)
+ }
+
+ useEffect(() => {
+ if (hasHumanInputs)
+ getHumanInputFormContainerWidth()
+ }, [hasHumanInputs])
+
// Recalculate contentWidth when content changes (e.g., SVG preview/source toggle)
useEffect(() => {
if (!containerRef.current)
return
const resizeObserver = new ResizeObserver(() => {
getContentWidth()
+ getHumanInputFormContainerWidth()
})
resizeObserver.observe(containerRef.current)
return () => {
@@ -115,115 +142,285 @@ const Answer: FC = ({
return (
-
- {answerIcon ||
}
- {responding && (
-
-
+ {!hideAvatar && (
+
+ {answerIcon ||
}
+ {responding && (
+
+
+
+ )}
+
+ )}
+
+ {/* Block 1: Workflow Process + Human Input Forms */}
+ {hasHumanInputs && (
+
+
+ {
+ !responding && contentIsEmpty && !hasAgentThoughts && (
+
+ )
+ }
+ {/** Render workflow process */}
+ {
+ workflowProcess && (
+
+ )
+ }
+ {
+ humanInputFormDataList && humanInputFormDataList.length > 0 && (
+
+ )
+ }
+ {
+ humanInputFilledFormDataList && humanInputFilledFormDataList.length > 0 && (
+
+ )
+ }
+ {
+ typeof item.siblingCount === 'number'
+ && item.siblingCount > 1
+ && !responding
+ && contentIsEmpty
+ && !hasAgentThoughts
+ && (
+
+ )
+ }
+
)}
-
-
-
-
- {
- !responding && (
-
- )
- }
- {/** Render workflow process */}
- {
- workflowProcess && (
-
- )
- }
- {
- responding && contentIsEmpty && !hasAgentThoughts && (
-
-
-
- )
- }
- {
- !contentIsEmpty && !hasAgentThoughts && (
-
- )
- }
- {
- (hasAgentThoughts) && (
-
- )
- }
- {
- !!allFiles?.length && (
-
- )
- }
- {
- !!message_files?.length && (
-
- )
- }
- {
- annotation?.id && annotation.authorName && (
-
- )
- }
-
- {
- !!citation?.length && !responding && (
-
- )
- }
- {
- !!(item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined) && (
-
- )
- }
+
+ {/* Block 2: Response Content (when human inputs exist) */}
+ {hasHumanInputs && (responding || !contentIsEmpty || hasAgentThoughts) && (
+
+
+
+ {
+ !responding && (
+
+ )
+ }
+ {
+ responding && contentIsEmpty && !hasAgentThoughts && (
+
+
+
+ )
+ }
+ {
+ !contentIsEmpty && !hasAgentThoughts && (
+
+ )
+ }
+ {
+ hasAgentThoughts && (
+
+ )
+ }
+ {
+ !!allFiles?.length && (
+
+ )
+ }
+ {
+ !!message_files?.length && (
+
+ )
+ }
+ {
+ annotation?.id && annotation.authorName && (
+
+ )
+ }
+
+ {
+ !!citation?.length && !responding && (
+
+ )
+ }
+ {
+ typeof item.siblingCount === 'number'
+ && item.siblingCount > 1
+ && (
+
+ )
+ }
+
-
+ )}
+
+ {/* Original single block layout (when no human inputs) */}
+ {!hasHumanInputs && (
+
+
+ {
+ !responding && (
+
+ )
+ }
+ {/** Render workflow process */}
+ {
+ workflowProcess && (
+
+ )
+ }
+ {
+ responding && contentIsEmpty && !hasAgentThoughts && (
+
+
+
+ )
+ }
+ {
+ !contentIsEmpty && !hasAgentThoughts && (
+
+ )
+ }
+ {
+ hasAgentThoughts && (
+
+ )
+ }
+ {
+ !!allFiles?.length && (
+
+ )
+ }
+ {
+ !!message_files?.length && (
+
+ )
+ }
+ {
+ annotation?.id && annotation.authorName && (
+
+ )
+ }
+
+ {
+ !!citation?.length && !responding && (
+
+ )
+ }
+ {
+ typeof item.siblingCount === 'number'
+ && item.siblingCount > 1 && (
+
+ )
+ }
+
+
+ )}
diff --git a/web/app/components/base/chat/chat/answer/operation.tsx b/web/app/components/base/chat/chat/answer/operation.tsx
index e29af5a79c..4acf107232 100644
--- a/web/app/components/base/chat/chat/answer/operation.tsx
+++ b/web/app/components/base/chat/chat/answer/operation.tsx
@@ -69,6 +69,7 @@ const Operation: FC
= ({
feedback,
adminFeedback,
agent_thoughts,
+ humanInputFormDataList,
} = item
const [userLocalFeedback, setUserLocalFeedback] = useState(feedback)
const [adminLocalFeedback, setAdminLocalFeedback] = useState(adminFeedback)
@@ -186,7 +187,7 @@ const Operation: FC = ({
)}
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
>
- {shouldShowUserFeedbackBar && (
+ {shouldShowUserFeedbackBar && !humanInputFormDataList?.length && (
= ({
)}
)}
- {shouldShowAdminFeedbackBar && (
+ {shouldShowAdminFeedbackBar && !humanInputFormDataList?.length && (
= ({
)}
{!isOpeningStatement && (
- {(config?.text_to_speech?.enabled) && (
+ {(config?.text_to_speech?.enabled && !humanInputFormDataList?.length) && (
)}
-
{
- copy(content)
- Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
- }}
- >
-
-
+ {!humanInputFormDataList?.length && (
+
{
+ copy(content)
+ Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
+ }}
+ >
+
+
+ )}
{!noChatInput && (
onRegenerate?.(item)}>
)}
- {(config?.supportAnnotation && config.annotation_reply?.enabled) && (
+ {config?.supportAnnotation && config.annotation_reply?.enabled && !humanInputFormDataList?.length && (
{
setCollapse(!expand)
@@ -50,7 +53,10 @@ const WorkflowProcessItem = ({
running && !collapse && 'bg-background-section-burn',
succeeded && !collapse && 'bg-state-success-hover',
failed && !collapse && 'bg-state-destructive-hover',
- collapse && 'bg-workflow-process-bg',
+ paused && !collapse && 'bg-state-warning-hover',
+ collapse && !failed && !paused && 'bg-workflow-process-bg',
+ collapse && paused && 'bg-workflow-process-paused-bg',
+ collapse && failed && 'bg-workflow-process-failed-bg',
)}
>
)
}
+ {
+ paused && (
+
+ )
+ }
- {t('common.workflowProcess', { ns: 'workflow' })}
+ {!collapse ? t('common.workflowProcess', { ns: 'workflow' }) : latestNode?.title}
diff --git a/web/app/components/base/chat/chat/context.tsx b/web/app/components/base/chat/chat/context.tsx
index 12d3175d39..7843665ad7 100644
--- a/web/app/components/base/chat/chat/context.tsx
+++ b/web/app/components/base/chat/chat/context.tsx
@@ -16,7 +16,8 @@ export type ChatContextValue = Pick
& {
+ | 'onFeedback'
+ | 'getHumanInputNodeData'> & {
readonly?: boolean
}
@@ -45,6 +46,7 @@ export const ChatContextProvider = ({
onAnnotationRemoved,
disableFeedback,
onFeedback,
+ getHumanInputNodeData,
}: ChatContextProviderProps) => {
return (
{children}
diff --git a/web/app/components/base/chat/chat/hooks.ts b/web/app/components/base/chat/chat/hooks.ts
index 182aeebdbb..a154e58bcb 100644
--- a/web/app/components/base/chat/chat/hooks.ts
+++ b/web/app/components/base/chat/chat/hooks.ts
@@ -8,6 +8,10 @@ import type { InputForm } from './type'
import type AudioPlayer from '@/app/components/base/audio-btn/audio'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { Annotation } from '@/models/log'
+import type {
+ IOnDataMoreInfo,
+ IOtherOptions,
+} from '@/service/base'
import { uniqBy } from 'es-toolkit/compat'
import { noop } from 'es-toolkit/function'
import { produce, setAutoFreeze } from 'immer'
@@ -27,9 +31,12 @@ import {
getProcessedFilesFromResponse,
} from '@/app/components/base/file-uploader/utils'
import { useToastContext } from '@/app/components/base/toast'
-import { WorkflowRunningStatus } from '@/app/components/workflow/types'
+import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
import useTimestamp from '@/hooks/use-timestamp'
-import { ssePost } from '@/service/base'
+import {
+ sseGet,
+ ssePost,
+} from '@/service/base'
import { TransferMethod } from '@/types/app'
import { getThreadMessages } from '../utils'
import {
@@ -67,6 +74,7 @@ export const useChat = (
const [suggestedQuestions, setSuggestQuestions] = useState([])
const conversationMessagesAbortControllerRef = useRef(null)
const suggestedQuestionsAbortControllerRef = useRef(null)
+ const workflowEventsAbortControllerRef = useRef(null)
const params = useParams()
const pathname = usePathname()
@@ -102,7 +110,7 @@ export const useChat = (
}
}
return ret
- }, [threadMessages, config?.opening_statement, getIntroduction, config?.suggested_questions])
+ }, [threadMessages, config, getIntroduction])
useEffect(() => {
setAutoFreeze(false)
@@ -164,6 +172,8 @@ export const useChat = (
conversationMessagesAbortControllerRef.current.abort()
if (suggestedQuestionsAbortControllerRef.current)
suggestedQuestionsAbortControllerRef.current.abort()
+ if (workflowEventsAbortControllerRef.current)
+ workflowEventsAbortControllerRef.current.abort()
}, [stopChat, handleResponding])
const handleRestart = useCallback((cb?: any) => {
@@ -175,6 +185,379 @@ export const useChat = (
cb?.()
}, [handleStop])
+ const createAudioPlayerManager = useCallback(() => {
+ let ttsUrl = ''
+ let ttsIsPublic = false
+ if (params.token) {
+ ttsUrl = '/text-to-audio'
+ ttsIsPublic = true
+ }
+ else if (params.appId) {
+ if (pathname.search('explore/installed') > -1)
+ ttsUrl = `/installed-apps/${params.appId}/text-to-audio`
+ else
+ ttsUrl = `/apps/${params.appId}/text-to-audio`
+ }
+
+ let player: AudioPlayer | null = null
+ const getOrCreatePlayer = () => {
+ if (!player)
+ player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', noop)
+
+ return player
+ }
+
+ return getOrCreatePlayer
+ }, [params.token, params.appId, pathname])
+
+ const handleResume = useCallback(async (
+ messageId: string,
+ workflowRunId: string,
+ {
+ onGetSuggestedQuestions,
+ onConversationComplete,
+ isPublicAPI,
+ }: SendCallback,
+ ) => {
+ const getOrCreatePlayer = createAudioPlayerManager()
+ // Re-subscribe to workflow events for the specific message
+ const url = `/workflow/${workflowRunId}/events?include_state_snapshot=true`
+
+ const otherOptions: IOtherOptions = {
+ isPublicAPI,
+ getAbortController: (abortController) => {
+ workflowEventsAbortControllerRef.current = abortController
+ },
+ onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: IOnDataMoreInfo) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ const isAgentMode = responseItem.agent_thoughts && responseItem.agent_thoughts.length > 0
+ if (!isAgentMode) {
+ responseItem.content = responseItem.content + message
+ }
+ else {
+ const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
+ if (lastThought)
+ lastThought.thought = lastThought.thought + message
+ }
+ if (messageId)
+ responseItem.id = messageId
+ })
+
+ if (isFirstMessage && newConversationId)
+ conversationId.current = newConversationId
+
+ if (taskId)
+ taskIdRef.current = taskId
+ },
+ async onCompleted(hasError?: boolean) {
+ handleResponding(false)
+
+ if (hasError)
+ return
+
+ if (onConversationComplete)
+ onConversationComplete(conversationId.current)
+
+ if (config?.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
+ try {
+ const { data }: any = await onGetSuggestedQuestions(
+ messageId,
+ newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
+ )
+ setSuggestQuestions(data)
+ }
+ // eslint-disable-next-line unused-imports/no-unused-vars
+ catch (e) {
+ setSuggestQuestions([])
+ }
+ }
+ },
+ onFile(file) {
+ // Convert simple file type to MIME type for non-agent mode
+ // Backend sends: { id, type: "image", belongs_to, url }
+ // Frontend expects: { id, type: "image/png", transferMethod, url, uploadedId, supportFileType, name, size }
+
+ // Determine file type for MIME conversion
+ const fileType = (file as { type?: string }).type || 'image'
+
+ // If file already has transferMethod, use it as base and ensure all required fields exist
+ // Otherwise, create a new complete file object
+ const baseFile = ('transferMethod' in file) ? (file as Partial) : null
+
+ const convertedFile: FileEntity = {
+ id: baseFile?.id || (file as { id: string }).id,
+ type: baseFile?.type || (fileType === 'image' ? 'image/png' : fileType === 'video' ? 'video/mp4' : fileType === 'audio' ? 'audio/mpeg' : 'application/octet-stream'),
+ transferMethod: (baseFile?.transferMethod as FileEntity['transferMethod']) || (fileType === 'image' ? 'remote_url' : 'local_file'),
+ uploadedId: baseFile?.uploadedId || (file as { id: string }).id,
+ supportFileType: baseFile?.supportFileType || (fileType === 'image' ? 'image' : fileType === 'video' ? 'video' : fileType === 'audio' ? 'audio' : 'document'),
+ progress: baseFile?.progress ?? 100,
+ name: baseFile?.name || `generated_${fileType}.${fileType === 'image' ? 'png' : fileType === 'video' ? 'mp4' : fileType === 'audio' ? 'mp3' : 'bin'}`,
+ url: baseFile?.url || (file as { url?: string }).url,
+ size: baseFile?.size ?? 0, // Generated files don't have a known size
+ }
+ updateChatTreeNode(messageId, (responseItem) => {
+ const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
+ if (lastThought) {
+ responseItem.agent_thoughts![responseItem.agent_thoughts!.length - 1].message_files = [...(lastThought as any).message_files, convertedFile]
+ }
+ else {
+ const currentFiles = (responseItem.message_files as FileEntity[] | undefined) ?? []
+ responseItem.message_files = [...currentFiles, convertedFile]
+ }
+ })
+ },
+ onThought(thought) {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (thought.message_id)
+ responseItem.id = thought.message_id
+ if (thought.conversation_id)
+ responseItem.conversationId = thought.conversation_id
+
+ if (!responseItem.agent_thoughts)
+ responseItem.agent_thoughts = []
+
+ if (responseItem.agent_thoughts.length === 0) {
+ responseItem.agent_thoughts.push(thought)
+ }
+ else {
+ const lastThought = responseItem.agent_thoughts[responseItem.agent_thoughts.length - 1]
+ if (lastThought.id === thought.id) {
+ thought.thought = lastThought.thought
+ thought.message_files = lastThought.message_files
+ responseItem.agent_thoughts[responseItem.agent_thoughts.length - 1] = thought
+ }
+ else {
+ responseItem.agent_thoughts.push(thought)
+ }
+ }
+ })
+ },
+ onMessageEnd: (messageEnd) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (messageEnd.metadata?.annotation_reply) {
+ responseItem.annotation = ({
+ id: messageEnd.metadata.annotation_reply.id,
+ authorName: messageEnd.metadata.annotation_reply.account.name,
+ })
+ return
+ }
+ responseItem.citation = messageEnd.metadata?.retriever_resources || []
+ const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || [])
+ responseItem.allFiles = uniqBy([...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], 'id')
+ })
+ },
+ onMessageReplace: (messageReplace) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ responseItem.content = messageReplace.answer
+ })
+ },
+ onError() {
+ handleResponding(false)
+ },
+ onWorkflowStarted: ({ workflow_run_id, task_id }) => {
+ handleResponding(true)
+ hasStopResponded.current = false
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (responseItem.workflowProcess && responseItem.workflowProcess.tracing.length > 0) {
+ responseItem.workflowProcess.status = WorkflowRunningStatus.Running
+ }
+ else {
+ taskIdRef.current = task_id
+ responseItem.workflow_run_id = workflow_run_id
+ responseItem.workflowProcess = {
+ status: WorkflowRunningStatus.Running,
+ tracing: [],
+ }
+ }
+ })
+ },
+ onWorkflowFinished: ({ data: workflowFinishedData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (responseItem.workflowProcess)
+ responseItem.workflowProcess.status = workflowFinishedData.status as WorkflowRunningStatus
+ })
+ },
+ onIterationStart: ({ data: iterationStartedData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (!responseItem.workflowProcess)
+ return
+ if (!responseItem.workflowProcess.tracing)
+ responseItem.workflowProcess.tracing = []
+ responseItem.workflowProcess.tracing.push({
+ ...iterationStartedData,
+ status: WorkflowRunningStatus.Running,
+ })
+ })
+ },
+ onIterationFinish: ({ data: iterationFinishedData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (!responseItem.workflowProcess?.tracing)
+ return
+ const tracing = responseItem.workflowProcess.tracing
+ const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
+ && (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
+ if (iterationIndex > -1) {
+ tracing[iterationIndex] = {
+ ...tracing[iterationIndex],
+ ...iterationFinishedData,
+ status: WorkflowRunningStatus.Succeeded,
+ }
+ }
+ })
+ },
+ onNodeStarted: ({ data: nodeStartedData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (!responseItem.workflowProcess)
+ return
+ if (!responseItem.workflowProcess.tracing)
+ responseItem.workflowProcess.tracing = []
+
+ const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id)
+ // if the node is already started, update the node
+ if (currentIndex > -1) {
+ responseItem.workflowProcess.tracing[currentIndex] = {
+ ...nodeStartedData,
+ status: NodeRunningStatus.Running,
+ }
+ }
+ else {
+ if (nodeStartedData.iteration_id)
+ return
+
+ responseItem.workflowProcess.tracing.push({
+ ...nodeStartedData,
+ status: WorkflowRunningStatus.Running,
+ })
+ }
+ })
+ },
+ onNodeFinished: ({ data: nodeFinishedData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (!responseItem.workflowProcess?.tracing)
+ return
+
+ if (nodeFinishedData.iteration_id)
+ return
+
+ const currentIndex = responseItem.workflowProcess.tracing.findIndex((item) => {
+ if (!item.execution_metadata?.parallel_id)
+ return item.id === nodeFinishedData.id
+
+ return item.id === nodeFinishedData.id && (item.execution_metadata?.parallel_id === nodeFinishedData.execution_metadata?.parallel_id)
+ })
+ if (currentIndex > -1)
+ responseItem.workflowProcess.tracing[currentIndex] = nodeFinishedData as any
+ })
+ },
+ onTTSChunk: (messageId: string, audio: string) => {
+ if (!audio || audio === '')
+ return
+ const audioPlayer = getOrCreatePlayer()
+ if (audioPlayer) {
+ audioPlayer.playAudioWithAudio(audio, true)
+ AudioPlayerManager.getInstance().resetMsgId(messageId)
+ }
+ },
+ onTTSEnd: (messageId: string, audio: string) => {
+ const audioPlayer = getOrCreatePlayer()
+ if (audioPlayer)
+ audioPlayer.playAudioWithAudio(audio, false)
+ },
+ onLoopStart: ({ data: loopStartedData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (!responseItem.workflowProcess)
+ return
+ if (!responseItem.workflowProcess.tracing)
+ responseItem.workflowProcess.tracing = []
+ responseItem.workflowProcess.tracing.push({
+ ...loopStartedData,
+ status: WorkflowRunningStatus.Running,
+ })
+ })
+ },
+ onLoopFinish: ({ data: loopFinishedData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (!responseItem.workflowProcess?.tracing)
+ return
+ const tracing = responseItem.workflowProcess.tracing
+ const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
+ && (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
+ if (loopIndex > -1) {
+ tracing[loopIndex] = {
+ ...tracing[loopIndex],
+ ...loopFinishedData,
+ status: WorkflowRunningStatus.Succeeded,
+ }
+ }
+ })
+ },
+ onHumanInputRequired: ({ data: humanInputRequiredData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (!responseItem.humanInputFormDataList) {
+ responseItem.humanInputFormDataList = [humanInputRequiredData]
+ }
+ else {
+ const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === humanInputRequiredData.node_id)
+ if (currentFormIndex > -1) {
+ responseItem.humanInputFormDataList[currentFormIndex] = humanInputRequiredData
+ }
+ else {
+ responseItem.humanInputFormDataList.push(humanInputRequiredData)
+ }
+ }
+ if (responseItem.workflowProcess?.tracing) {
+ const currentTracingIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === humanInputRequiredData.node_id)
+ if (currentTracingIndex > -1)
+ responseItem.workflowProcess.tracing[currentTracingIndex].status = NodeRunningStatus.Paused
+ }
+ })
+ },
+ onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (responseItem.humanInputFormDataList?.length) {
+ const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === humanInputFilledFormData.node_id)
+ if (currentFormIndex > -1)
+ responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
+ }
+ if (!responseItem.humanInputFilledFormDataList) {
+ responseItem.humanInputFilledFormDataList = [humanInputFilledFormData]
+ }
+ else {
+ responseItem.humanInputFilledFormDataList.push(humanInputFilledFormData)
+ }
+ })
+ },
+ onHumanInputFormTimeout: ({ data: humanInputFormTimeoutData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (responseItem.humanInputFormDataList?.length) {
+ const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === humanInputFormTimeoutData.node_id)
+ responseItem.humanInputFormDataList[currentFormIndex].expiration_time = humanInputFormTimeoutData.expiration_time
+ }
+ })
+ },
+ onWorkflowPaused: ({ data: workflowPausedData }) => {
+ const resumeUrl = `/workflow/${workflowPausedData.workflow_run_id}/events`
+ sseGet(
+ resumeUrl,
+ {},
+ otherOptions,
+ )
+ updateChatTreeNode(messageId, (responseItem) => {
+ responseItem.workflowProcess!.status = WorkflowRunningStatus.Paused
+ })
+ },
+ }
+
+ if (workflowEventsAbortControllerRef.current)
+ workflowEventsAbortControllerRef.current.abort()
+
+ sseGet(
+ url,
+ {},
+ otherOptions,
+ )
+ }, [updateChatTreeNode, handleResponding, createAudioPlayerManager, config?.suggested_questions_after_answer])
+
const updateCurrentQAOnTree = useCallback(({
parentId,
responseItem,
@@ -297,374 +680,462 @@ export const useChat = (
let isAgentMode = false
let hasSetResponseId = false
- let ttsUrl = ''
- let ttsIsPublic = false
- if (params.token) {
- ttsUrl = '/text-to-audio'
- ttsIsPublic = true
- }
- else if (params.appId) {
- if (pathname.search('explore/installed') > -1)
- ttsUrl = `/installed-apps/${params.appId}/text-to-audio`
- else
- ttsUrl = `/apps/${params.appId}/text-to-audio`
- }
- // Lazy initialization: Only create AudioPlayer when TTS is actually needed
- // This prevents opening audio channel unnecessarily
- let player: AudioPlayer | null = null
- const getOrCreatePlayer = () => {
- if (!player)
- player = AudioPlayerManager.getInstance().getAudioPlayer(ttsUrl, ttsIsPublic, uuidV4(), 'none', 'none', noop)
+ const getOrCreatePlayer = createAudioPlayerManager()
- return player
- }
-
- ssePost(
- url,
- {
- body: bodyParams,
+ const otherOptions: IOtherOptions = {
+ isPublicAPI,
+ getAbortController: (abortController) => {
+ workflowEventsAbortControllerRef.current = abortController
},
- {
- isPublicAPI,
- onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
- if (!isAgentMode) {
- responseItem.content = responseItem.content + message
- }
- else {
- const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
- if (lastThought)
- lastThought.thought = lastThought.thought + message // need immer setAutoFreeze
- }
-
- if (messageId && !hasSetResponseId) {
- questionItem.id = `question-${messageId}`
- responseItem.id = messageId
- responseItem.parentMessageId = questionItem.id
- hasSetResponseId = true
- }
-
- if (isFirstMessage && newConversationId)
- conversationId.current = newConversationId
-
- taskIdRef.current = taskId
- if (messageId)
- responseItem.id = messageId
-
- updateCurrentQAOnTree({
- placeholderQuestionId,
- questionItem,
- responseItem,
- parentId: data.parent_message_id,
- })
- },
- async onCompleted(hasError?: boolean) {
- handleResponding(false)
-
- if (hasError)
- return
-
- if (onConversationComplete)
- onConversationComplete(conversationId.current)
-
- if (conversationId.current && !hasStopResponded.current && onGetConversationMessages) {
- const { data }: any = await onGetConversationMessages(
- conversationId.current,
- newAbortController => conversationMessagesAbortControllerRef.current = newAbortController,
- )
- const newResponseItem = data.find((item: any) => item.id === responseItem.id)
- if (!newResponseItem)
- return
-
- const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0 && newResponseItem.agent_thoughts[newResponseItem.agent_thoughts?.length - 1].thought === newResponseItem.answer
- updateChatTreeNode(responseItem.id, {
- content: isUseAgentThought ? '' : newResponseItem.answer,
- log: [
- ...newResponseItem.message,
- ...(newResponseItem.message[newResponseItem.message.length - 1].role !== 'assistant'
- ? [
- {
- role: 'assistant',
- text: newResponseItem.answer,
- files: newResponseItem.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
- },
- ]
- : []),
- ],
- more: {
- time: formatTime(newResponseItem.created_at, 'hh:mm A'),
- tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens,
- latency: newResponseItem.provider_response_latency.toFixed(2),
- tokens_per_second: newResponseItem.provider_response_latency > 0 ? (newResponseItem.answer_tokens / newResponseItem.provider_response_latency).toFixed(2) : undefined,
- },
- // for agent log
- conversationId: conversationId.current,
- input: {
- inputs: newResponseItem.inputs,
- query: newResponseItem.query,
- },
- })
- }
- if (config?.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
- try {
- const { data }: any = await onGetSuggestedQuestions(
- responseItem.id,
- newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
- )
- setSuggestQuestions(data)
- }
- // eslint-disable-next-line unused-imports/no-unused-vars
- catch (e) {
- setSuggestQuestions([])
- }
- }
- },
- onFile(file) {
- // Convert simple file type to MIME type for non-agent mode
- // Backend sends: { id, type: "image", belongs_to, url }
- // Frontend expects: { id, type: "image/png", transferMethod, url, uploadedId, supportFileType, name, size }
-
- // Determine file type for MIME conversion
- const fileType = (file as { type?: string }).type || 'image'
-
- // If file already has transferMethod, use it as base and ensure all required fields exist
- // Otherwise, create a new complete file object
- const baseFile = ('transferMethod' in file) ? (file as Partial) : null
-
- const convertedFile: FileEntity = {
- id: baseFile?.id || (file as { id: string }).id,
- type: baseFile?.type || (fileType === 'image' ? 'image/png' : fileType === 'video' ? 'video/mp4' : fileType === 'audio' ? 'audio/mpeg' : 'application/octet-stream'),
- transferMethod: (baseFile?.transferMethod as FileEntity['transferMethod']) || (fileType === 'image' ? 'remote_url' : 'local_file'),
- uploadedId: baseFile?.uploadedId || (file as { id: string }).id,
- supportFileType: baseFile?.supportFileType || (fileType === 'image' ? 'image' : fileType === 'video' ? 'video' : fileType === 'audio' ? 'audio' : 'document'),
- progress: baseFile?.progress ?? 100,
- name: baseFile?.name || `generated_${fileType}.${fileType === 'image' ? 'png' : fileType === 'video' ? 'mp4' : fileType === 'audio' ? 'mp3' : 'bin'}`,
- url: baseFile?.url || (file as { url?: string }).url,
- size: baseFile?.size ?? 0, // Generated files don't have a known size
- }
-
- // For agent mode, add files to the last thought
+ onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
+ if (!isAgentMode) {
+ responseItem.content = responseItem.content + message
+ }
+ else {
const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
- if (lastThought) {
- const thought = lastThought as { message_files?: FileEntity[] }
- responseItem.agent_thoughts![responseItem.agent_thoughts!.length - 1].message_files = [...(thought.message_files ?? []), convertedFile]
- }
- // For non-agent mode, add files directly to responseItem.message_files
- else {
- const currentFiles = (responseItem.message_files as FileEntity[] | undefined) ?? []
- responseItem.message_files = [...currentFiles, convertedFile]
- }
+ if (lastThought)
+ lastThought.thought = lastThought.thought + message // need immer setAutoFreeze
+ }
- updateCurrentQAOnTree({
- placeholderQuestionId,
- questionItem,
- responseItem,
- parentId: data.parent_message_id,
- })
- },
- onThought(thought) {
- isAgentMode = true
- const response = responseItem as any
- if (thought.message_id && !hasSetResponseId)
- response.id = thought.message_id
- if (thought.conversation_id)
- response.conversationId = thought.conversation_id
+ if (messageId && !hasSetResponseId) {
+ questionItem.id = `question-${messageId}`
+ responseItem.id = messageId
+ responseItem.parentMessageId = questionItem.id
+ hasSetResponseId = true
+ }
- if (response.agent_thoughts.length === 0) {
- response.agent_thoughts.push(thought)
- }
- else {
- const lastThought = response.agent_thoughts[response.agent_thoughts.length - 1]
- // thought changed but still the same thought, so update.
- if (lastThought.id === thought.id) {
- thought.thought = lastThought.thought
- thought.message_files = lastThought.message_files
- responseItem.agent_thoughts![response.agent_thoughts.length - 1] = thought
- }
- else {
- responseItem.agent_thoughts!.push(thought)
- }
- }
- updateCurrentQAOnTree({
- placeholderQuestionId,
- questionItem,
- responseItem,
- parentId: data.parent_message_id,
- })
- },
- onMessageEnd: (messageEnd) => {
- if (messageEnd.metadata?.annotation_reply) {
- responseItem.id = messageEnd.id
- responseItem.annotation = ({
- id: messageEnd.metadata.annotation_reply.id,
- authorName: messageEnd.metadata.annotation_reply.account.name,
- })
- updateCurrentQAOnTree({
- placeholderQuestionId,
- questionItem,
- responseItem,
- parentId: data.parent_message_id,
- })
+ if (isFirstMessage && newConversationId)
+ conversationId.current = newConversationId
+
+ taskIdRef.current = taskId
+ if (messageId)
+ responseItem.id = messageId
+
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: data.parent_message_id,
+ })
+ },
+ async onCompleted(hasError?: boolean) {
+ handleResponding(false)
+
+ if (hasError)
+ return
+
+ if (onConversationComplete)
+ onConversationComplete(conversationId.current)
+
+ if (conversationId.current && !hasStopResponded.current && onGetConversationMessages) {
+ const { data }: any = await onGetConversationMessages(
+ conversationId.current,
+ newAbortController => conversationMessagesAbortControllerRef.current = newAbortController,
+ )
+ const newResponseItem = data.find((item: any) => item.id === responseItem.id)
+ if (!newResponseItem)
return
- }
- responseItem.citation = messageEnd.metadata?.retriever_resources || []
- const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || [])
- responseItem.allFiles = uniqBy([...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], 'id')
+ const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0 && newResponseItem.agent_thoughts[newResponseItem.agent_thoughts?.length - 1].thought === newResponseItem.answer
+ updateChatTreeNode(responseItem.id, {
+ content: isUseAgentThought ? '' : newResponseItem.answer,
+ log: [
+ ...newResponseItem.message,
+ ...(newResponseItem.message[newResponseItem.message.length - 1].role !== 'assistant'
+ ? [
+ {
+ role: 'assistant',
+ text: newResponseItem.answer,
+ files: newResponseItem.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
+ },
+ ]
+ : []),
+ ],
+ more: {
+ time: formatTime(newResponseItem.created_at, 'hh:mm A'),
+ tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens,
+ latency: newResponseItem.provider_response_latency.toFixed(2),
+ tokens_per_second: newResponseItem.provider_response_latency > 0 ? (newResponseItem.answer_tokens / newResponseItem.provider_response_latency).toFixed(2) : undefined,
+ },
+ // for agent log
+ conversationId: conversationId.current,
+ input: {
+ inputs: newResponseItem.inputs,
+ query: newResponseItem.query,
+ },
+ })
+ }
+ if (config?.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
+ try {
+ const { data }: any = await onGetSuggestedQuestions(
+ responseItem.id,
+ newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
+ )
+ setSuggestQuestions(data)
+ }
+ // eslint-disable-next-line unused-imports/no-unused-vars
+ catch (e) {
+ setSuggestQuestions([])
+ }
+ }
+ },
+ onFile(file) {
+ // Convert simple file type to MIME type for non-agent mode
+ // Backend sends: { id, type: "image", belongs_to, url }
+ // Frontend expects: { id, type: "image/png", transferMethod, url, uploadedId, supportFileType, name, size }
+
+ // Determine file type for MIME conversion
+ const fileType = (file as { type?: string }).type || 'image'
+
+ // If file already has transferMethod, use it as base and ensure all required fields exist
+ // Otherwise, create a new complete file object
+ const baseFile = ('transferMethod' in file) ? (file as Partial) : null
+
+ const convertedFile: FileEntity = {
+ id: baseFile?.id || (file as { id: string }).id,
+ type: baseFile?.type || (fileType === 'image' ? 'image/png' : fileType === 'video' ? 'video/mp4' : fileType === 'audio' ? 'audio/mpeg' : 'application/octet-stream'),
+ transferMethod: (baseFile?.transferMethod as FileEntity['transferMethod']) || (fileType === 'image' ? 'remote_url' : 'local_file'),
+ uploadedId: baseFile?.uploadedId || (file as { id: string }).id,
+ supportFileType: baseFile?.supportFileType || (fileType === 'image' ? 'image' : fileType === 'video' ? 'video' : fileType === 'audio' ? 'audio' : 'document'),
+ progress: baseFile?.progress ?? 100,
+ name: baseFile?.name || `generated_${fileType}.${fileType === 'image' ? 'png' : fileType === 'video' ? 'mp4' : fileType === 'audio' ? 'mp3' : 'bin'}`,
+ url: baseFile?.url || (file as { url?: string }).url,
+ size: baseFile?.size ?? 0, // Generated files don't have a known size
+ }
+
+ // For agent mode, add files to the last thought
+ const lastThought = responseItem.agent_thoughts?.[responseItem.agent_thoughts?.length - 1]
+ if (lastThought) {
+ const thought = lastThought as { message_files?: FileEntity[] }
+ responseItem.agent_thoughts![responseItem.agent_thoughts!.length - 1].message_files = [...(thought.message_files ?? []), convertedFile]
+ }
+ // For non-agent mode, add files directly to responseItem.message_files
+ else {
+ const currentFiles = (responseItem.message_files as FileEntity[] | undefined) ?? []
+ responseItem.message_files = [...currentFiles, convertedFile]
+ }
+
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: data.parent_message_id,
+ })
+ },
+ onThought(thought) {
+ isAgentMode = true
+ const response = responseItem as any
+ if (thought.message_id && !hasSetResponseId)
+ response.id = thought.message_id
+ if (thought.conversation_id)
+ response.conversationId = thought.conversation_id
+
+ if (response.agent_thoughts.length === 0) {
+ response.agent_thoughts.push(thought)
+ }
+ else {
+ const lastThought = response.agent_thoughts[response.agent_thoughts.length - 1]
+ // thought changed but still the same thought, so update.
+ if (lastThought.id === thought.id) {
+ thought.thought = lastThought.thought
+ thought.message_files = lastThought.message_files
+ responseItem.agent_thoughts![response.agent_thoughts.length - 1] = thought
+ }
+ else {
+ responseItem.agent_thoughts!.push(thought)
+ }
+ }
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: data.parent_message_id,
+ })
+ },
+ onMessageEnd: (messageEnd) => {
+ if (messageEnd.metadata?.annotation_reply) {
+ responseItem.id = messageEnd.id
+ responseItem.annotation = ({
+ id: messageEnd.metadata.annotation_reply.id,
+ authorName: messageEnd.metadata.annotation_reply.account.name,
+ })
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: data.parent_message_id,
})
- },
- onMessageReplace: (messageReplace) => {
- responseItem.content = messageReplace.answer
- },
- onError() {
- handleResponding(false)
- updateCurrentQAOnTree({
- placeholderQuestionId,
- questionItem,
- responseItem,
- parentId: data.parent_message_id,
- })
- },
- onWorkflowStarted: ({ workflow_run_id, task_id }) => {
+ return
+ }
+ responseItem.citation = messageEnd.metadata?.retriever_resources || []
+ const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || [])
+ responseItem.allFiles = uniqBy([...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], 'id')
+
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: data.parent_message_id,
+ })
+ },
+ onMessageReplace: (messageReplace) => {
+ responseItem.content = messageReplace.answer
+ },
+ onError() {
+ handleResponding(false)
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: data.parent_message_id,
+ })
+ },
+ onWorkflowStarted: ({ workflow_run_id, task_id, conversation_id, message_id }) => {
+ // If there are no streaming messages, we still need to set the conversation_id to avoid create a new conversation when regeneration in chat-flow.
+ if (conversation_id) {
+ conversationId.current = conversation_id
+ }
+ if (message_id && !hasSetResponseId) {
+ questionItem.id = `question-${message_id}`
+ responseItem.id = message_id
+ responseItem.parentMessageId = questionItem.id
+ hasSetResponseId = true
+ }
+
+ if (responseItem.workflowProcess && responseItem.workflowProcess.tracing.length > 0) {
+ responseItem.workflowProcess.status = WorkflowRunningStatus.Running
+ }
+ else {
taskIdRef.current = task_id
responseItem.workflow_run_id = workflow_run_id
responseItem.workflowProcess = {
status: WorkflowRunningStatus.Running,
tracing: [],
}
- updateCurrentQAOnTree({
- placeholderQuestionId,
- questionItem,
- responseItem,
- parentId: data.parent_message_id,
- })
- },
- onWorkflowFinished: ({ data: workflowFinishedData }) => {
- responseItem.workflowProcess!.status = workflowFinishedData.status as WorkflowRunningStatus
- updateCurrentQAOnTree({
- placeholderQuestionId,
- questionItem,
- responseItem,
- parentId: data.parent_message_id,
- })
- },
- onIterationStart: ({ data: iterationStartedData }) => {
- responseItem.workflowProcess!.tracing!.push({
- ...iterationStartedData,
- status: WorkflowRunningStatus.Running,
- })
- updateCurrentQAOnTree({
- placeholderQuestionId,
- questionItem,
- responseItem,
- parentId: data.parent_message_id,
- })
- },
- onIterationFinish: ({ data: iterationFinishedData }) => {
- const tracing = responseItem.workflowProcess!.tracing!
- const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
- && (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
- tracing[iterationIndex] = {
- ...tracing[iterationIndex],
- ...iterationFinishedData,
- status: WorkflowRunningStatus.Succeeded,
- }
+ }
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: data.parent_message_id,
+ })
+ },
+ onWorkflowFinished: ({ data: workflowFinishedData }) => {
+ responseItem.workflowProcess!.status = workflowFinishedData.status as WorkflowRunningStatus
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: data.parent_message_id,
+ })
+ },
+ onIterationStart: ({ data: iterationStartedData }) => {
+ responseItem.workflowProcess!.tracing!.push({
+ ...iterationStartedData,
+ status: WorkflowRunningStatus.Running,
+ })
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: data.parent_message_id,
+ })
+ },
+ onIterationFinish: ({ data: iterationFinishedData }) => {
+ const tracing = responseItem.workflowProcess!.tracing!
+ const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
+ && (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
+ tracing[iterationIndex] = {
+ ...tracing[iterationIndex],
+ ...iterationFinishedData,
+ status: WorkflowRunningStatus.Succeeded,
+ }
- updateCurrentQAOnTree({
- placeholderQuestionId,
- questionItem,
- responseItem,
- parentId: data.parent_message_id,
- })
- },
- onNodeStarted: ({ data: nodeStartedData }) => {
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: data.parent_message_id,
+ })
+ },
+ onNodeStarted: ({ data: nodeStartedData }) => {
+ if (!responseItem.workflowProcess)
+ return
+ if (!responseItem.workflowProcess.tracing)
+ responseItem.workflowProcess.tracing = []
+
+ const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id)
+ if (currentIndex > -1) {
+ responseItem.workflowProcess.tracing[currentIndex] = {
+ ...nodeStartedData,
+ status: NodeRunningStatus.Running,
+ }
+ }
+ else {
if (nodeStartedData.iteration_id)
return
if (data.loop_id)
return
- responseItem.workflowProcess!.tracing!.push({
+ responseItem.workflowProcess.tracing.push({
...nodeStartedData,
status: WorkflowRunningStatus.Running,
})
- updateCurrentQAOnTree({
- placeholderQuestionId,
- questionItem,
- responseItem,
- parentId: data.parent_message_id,
- })
- },
- onNodeFinished: ({ data: nodeFinishedData }) => {
- if (nodeFinishedData.iteration_id)
- return
-
- if (data.loop_id)
- return
-
- const currentIndex = responseItem.workflowProcess!.tracing!.findIndex((item) => {
- if (!item.execution_metadata?.parallel_id)
- return item.node_id === nodeFinishedData.node_id
-
- return item.node_id === nodeFinishedData.node_id && (item.execution_metadata?.parallel_id === nodeFinishedData.execution_metadata?.parallel_id)
- })
- responseItem.workflowProcess!.tracing[currentIndex] = nodeFinishedData as any
-
- updateCurrentQAOnTree({
- placeholderQuestionId,
- questionItem,
- responseItem,
- parentId: data.parent_message_id,
- })
- },
- onTTSChunk: (messageId: string, audio: string) => {
- if (!audio || audio === '')
- return
- const audioPlayer = getOrCreatePlayer()
- if (audioPlayer) {
- audioPlayer.playAudioWithAudio(audio, true)
- AudioPlayerManager.getInstance().resetMsgId(messageId)
- }
- },
- onTTSEnd: (messageId: string, audio: string) => {
- const audioPlayer = getOrCreatePlayer()
- if (audioPlayer)
- audioPlayer.playAudioWithAudio(audio, false)
- },
- onLoopStart: ({ data: loopStartedData }) => {
- responseItem.workflowProcess!.tracing!.push({
- ...loopStartedData,
- status: WorkflowRunningStatus.Running,
- })
- updateCurrentQAOnTree({
- placeholderQuestionId,
- questionItem,
- responseItem,
- parentId: data.parent_message_id,
- })
- },
- onLoopFinish: ({ data: loopFinishedData }) => {
- const tracing = responseItem.workflowProcess!.tracing!
- const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
- && (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
- tracing[loopIndex] = {
- ...tracing[loopIndex],
- ...loopFinishedData,
- status: WorkflowRunningStatus.Succeeded,
- }
-
- updateCurrentQAOnTree({
- placeholderQuestionId,
- questionItem,
- responseItem,
- parentId: data.parent_message_id,
- })
- },
+ }
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: data.parent_message_id,
+ })
},
+ onNodeFinished: ({ data: nodeFinishedData }) => {
+ if (nodeFinishedData.iteration_id)
+ return
+
+ if (data.loop_id)
+ return
+
+ const currentIndex = responseItem.workflowProcess!.tracing!.findIndex((item) => {
+ if (!item.execution_metadata?.parallel_id)
+ return item.id === nodeFinishedData.id
+
+ return item.id === nodeFinishedData.id && (item.execution_metadata?.parallel_id === nodeFinishedData.execution_metadata?.parallel_id)
+ })
+ responseItem.workflowProcess!.tracing[currentIndex] = nodeFinishedData as any
+
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: data.parent_message_id,
+ })
+ },
+ onTTSChunk: (messageId: string, audio: string) => {
+ if (!audio || audio === '')
+ return
+ const audioPlayer = getOrCreatePlayer()
+ if (audioPlayer) {
+ audioPlayer.playAudioWithAudio(audio, true)
+ AudioPlayerManager.getInstance().resetMsgId(messageId)
+ }
+ },
+ onTTSEnd: (messageId: string, audio: string) => {
+ const audioPlayer = getOrCreatePlayer()
+ if (audioPlayer)
+ audioPlayer.playAudioWithAudio(audio, false)
+ },
+ onLoopStart: ({ data: loopStartedData }) => {
+ responseItem.workflowProcess!.tracing!.push({
+ ...loopStartedData,
+ status: WorkflowRunningStatus.Running,
+ })
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: data.parent_message_id,
+ })
+ },
+ onLoopFinish: ({ data: loopFinishedData }) => {
+ const tracing = responseItem.workflowProcess!.tracing!
+ const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
+ && (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
+ tracing[loopIndex] = {
+ ...tracing[loopIndex],
+ ...loopFinishedData,
+ status: WorkflowRunningStatus.Succeeded,
+ }
+
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: data.parent_message_id,
+ })
+ },
+ onHumanInputRequired: ({ data: humanInputRequiredData }) => {
+ if (!responseItem.humanInputFormDataList) {
+ responseItem.humanInputFormDataList = [humanInputRequiredData]
+ }
+ else {
+ const currentFormIndex = responseItem.humanInputFormDataList!.findIndex(item => item.node_id === humanInputRequiredData.node_id)
+ if (currentFormIndex > -1) {
+ responseItem.humanInputFormDataList[currentFormIndex] = humanInputRequiredData
+ }
+ else {
+ responseItem.humanInputFormDataList.push(humanInputRequiredData)
+ }
+ }
+ const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === humanInputRequiredData.node_id)
+ if (currentTracingIndex > -1) {
+ responseItem.workflowProcess!.tracing[currentTracingIndex].status = NodeRunningStatus.Paused
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: data.parent_message_id,
+ })
+ }
+ },
+ onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => {
+ if (responseItem.humanInputFormDataList?.length) {
+ const currentFormIndex = responseItem.humanInputFormDataList!.findIndex(item => item.node_id === humanInputFilledFormData.node_id)
+ responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
+ }
+ if (!responseItem.humanInputFilledFormDataList) {
+ responseItem.humanInputFilledFormDataList = [humanInputFilledFormData]
+ }
+ else {
+ responseItem.humanInputFilledFormDataList.push(humanInputFilledFormData)
+ }
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: data.parent_message_id,
+ })
+ },
+ onHumanInputFormTimeout: ({ data: humanInputFormTimeoutData }) => {
+ if (responseItem.humanInputFormDataList?.length) {
+ const currentFormIndex = responseItem.humanInputFormDataList!.findIndex(item => item.node_id === humanInputFormTimeoutData.node_id)
+ responseItem.humanInputFormDataList[currentFormIndex].expiration_time = humanInputFormTimeoutData.expiration_time
+ }
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: data.parent_message_id,
+ })
+ },
+ onWorkflowPaused: ({ data: workflowPausedData }) => {
+ const url = `/workflow/${workflowPausedData.workflow_run_id}/events`
+ sseGet(
+ url,
+ {},
+ otherOptions,
+ )
+ responseItem.workflowProcess!.status = WorkflowRunningStatus.Paused
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: data.parent_message_id,
+ })
+ },
+ }
+
+ // Abort the previous workflow events SSE request
+ if (workflowEventsAbortControllerRef.current)
+ workflowEventsAbortControllerRef.current.abort()
+
+ ssePost(
+ url,
+ {
+ body: bodyParams,
+ },
+ otherOptions,
)
return true
}, [
@@ -677,9 +1148,7 @@ export const useChat = (
notify,
handleResponding,
formatTime,
- params.token,
- params.appId,
- pathname,
+ createAudioPlayerManager,
formSettings,
])
@@ -736,6 +1205,36 @@ export const useChat = (
})
}, [chatList, updateChatTreeNode])
+ const handleSwitchSibling = useCallback((
+ siblingMessageId: string,
+ callbacks: SendCallback,
+ ) => {
+ setTargetMessageId(siblingMessageId)
+
+ // Helper to find message in tree
+ const findMessageInTree = (nodes: ChatItemInTree[], targetId: string): ChatItemInTree | undefined => {
+ for (const node of nodes) {
+ if (node.id === targetId)
+ return node
+ if (node.children) {
+ const found = findMessageInTree(node.children, targetId)
+ if (found)
+ return found
+ }
+ }
+ return undefined
+ }
+
+ const targetMessage = findMessageInTree(chatTreeRef.current, siblingMessageId)
+ if (targetMessage?.workflow_run_id && targetMessage.humanInputFormDataList && targetMessage.humanInputFormDataList.length > 0) {
+ handleResume(
+ targetMessage.id,
+ targetMessage.workflow_run_id,
+ callbacks,
+ )
+ }
+ }, [setTargetMessageId, handleResume])
+
useEffect(() => {
if (clearChatList)
handleRestart(() => clearChatListCallback?.(false))
@@ -744,10 +1243,11 @@ export const useChat = (
return {
chatList,
setTargetMessageId,
- conversationId: conversationId.current,
isResponding,
setIsResponding,
handleSend,
+ handleResume,
+ handleSwitchSibling,
suggestedQuestions,
handleRestart,
handleStop,
diff --git a/web/app/components/base/chat/chat/index.tsx b/web/app/components/base/chat/chat/index.tsx
index 5422ee8e4f..e9669ba3f8 100644
--- a/web/app/components/base/chat/chat/index.tsx
+++ b/web/app/components/base/chat/chat/index.tsx
@@ -75,6 +75,9 @@ export type ChatProps = {
noSpacing?: boolean
inputDisabled?: boolean
sidebarCollapseState?: boolean
+ hideAvatar?: boolean
+ onHumanInputFormSubmit?: (formToken: string, formData: any) => Promise
+ getHumanInputNodeData?: (nodeID: string) => any
}
const Chat: FC = ({
@@ -116,6 +119,9 @@ const Chat: FC = ({
noSpacing,
inputDisabled,
sidebarCollapseState,
+ hideAvatar,
+ onHumanInputFormSubmit,
+ getHumanInputNodeData,
}) => {
const { t } = useTranslation()
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
@@ -265,6 +271,7 @@ const Chat: FC = ({
onAnnotationRemoved={onAnnotationRemoved}
disableFeedback={disableFeedback}
onFeedback={onFeedback}
+ getHumanInputNodeData={getHumanInputNodeData}
>
= ({
hideProcessDetail={hideProcessDetail}
noChatInput={noChatInput}
switchSibling={switchSibling}
+ hideAvatar={hideAvatar}
+ onHumanInputFormSubmit={onHumanInputFormSubmit}
/>
)
}
@@ -306,6 +315,7 @@ const Chat: FC = ({
theme={themeBuilder?.theme}
enableEdit={config?.questionEditEnable}
switchSibling={switchSibling}
+ hideAvatar={hideAvatar}
/>
)
})
diff --git a/web/app/components/base/chat/chat/question.tsx b/web/app/components/base/chat/chat/question.tsx
index 73f1b5cd93..ff7571ef6e 100644
--- a/web/app/components/base/chat/chat/question.tsx
+++ b/web/app/components/base/chat/chat/question.tsx
@@ -32,6 +32,7 @@ type QuestionProps = {
theme: Theme | null | undefined
enableEdit?: boolean
switchSibling?: (siblingMessageId: string) => void
+ hideAvatar?: boolean
}
const Question: FC = ({
@@ -40,6 +41,7 @@ const Question: FC = ({
theme,
enableEdit = true,
switchSibling,
+ hideAvatar,
}) => {
const { t } = useTranslation()
@@ -174,15 +176,17 @@ const Question: FC = ({
-
- {
- questionIcon || (
-
-
-
- )
- }
-
+ {!hideAvatar && (
+
+ {
+ questionIcon || (
+
+
+
+ )
+ }
+
+ )}
)
}
diff --git a/web/app/components/base/chat/chat/type.ts b/web/app/components/base/chat/chat/type.ts
index 291b0ae064..7bd4de5b05 100644
--- a/web/app/components/base/chat/chat/type.ts
+++ b/web/app/components/base/chat/chat/type.ts
@@ -2,7 +2,11 @@ import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { InputVarType } from '@/app/components/workflow/types'
import type { Annotation, MessageRating } from '@/models/log'
-import type { FileResponse } from '@/types/workflow'
+import type {
+ FileResponse,
+ HumanInputFilledFormData,
+ HumanInputFormData,
+} from '@/types/workflow'
export type MessageMore = {
time: string
@@ -64,6 +68,19 @@ export type CitationItem = {
word_count: number
}
+export type ExtraContent
+ = {
+ type: 'human_input'
+ submitted: false
+ form_definition: HumanInputFormData
+ workflow_run_id: string
+ }
+ | {
+ type: 'human_input'
+ submitted: true
+ form_submission_data: HumanInputFilledFormData
+ }
+
export type IChatItem = {
id: string
content: string
@@ -104,6 +121,10 @@ export type IChatItem = {
siblingIndex?: number
prevSibling?: string
nextSibling?: string
+ // for human input
+ humanInputFormDataList?: HumanInputFormData[]
+ humanInputFilledFormDataList?: HumanInputFilledFormData[]
+ extra_contents?: ExtraContent[]
}
export type Metadata = {
diff --git a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx
index 5d40d8da56..b781eae918 100644
--- a/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx
+++ b/web/app/components/base/chat/embedded-chatbot/chat-wrapper.tsx
@@ -2,6 +2,7 @@ import type { FileEntity } from '../../file-uploader/types'
import type {
ChatConfig,
ChatItem,
+ ChatItemInTree,
OnSend,
} from '../types'
import { useCallback, useEffect, useMemo, useState } from 'react'
@@ -17,7 +18,9 @@ import {
fetchSuggestedQuestions,
getUrl,
stopChatMessageResponding,
+ submitHumanInputForm,
} from '@/service/share'
+import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
import { TransferMethod } from '@/types/app'
import { cn } from '@/utils/classnames'
import Avatar from '../../avatar'
@@ -70,9 +73,9 @@ const ChatWrapper = () => {
}, [appParams, currentConversationItem?.introduction])
const {
chatList,
- setTargetMessageId,
handleSend,
handleStop,
+ handleSwitchSibling,
isResponding: respondingState,
suggestedQuestions,
} = useChat(
@@ -130,6 +133,40 @@ const ChatWrapper = () => {
setIsResponding(respondingState)
}, [respondingState, setIsResponding])
+ // Resume paused workflows when chat history is loaded
+ useEffect(() => {
+ if (!appPrevChatList || appPrevChatList.length === 0)
+ return
+
+ // Find the last answer item with workflow_run_id that needs resumption (DFS - find deepest first)
+ let lastPausedNode: ChatItemInTree | undefined
+ const findLastPausedWorkflow = (nodes: ChatItemInTree[]) => {
+ nodes.forEach((node) => {
+ // DFS: recurse to children first
+ if (node.children && node.children.length > 0)
+ findLastPausedWorkflow(node.children)
+
+ // Track the last node with humanInputFormDataList
+ if (node.isAnswer && node.workflow_run_id && node.humanInputFormDataList && node.humanInputFormDataList.length > 0)
+ lastPausedNode = node
+ })
+ }
+
+ findLastPausedWorkflow(appPrevChatList)
+
+ // Only resume the last paused workflow
+ if (lastPausedNode) {
+ handleSwitchSibling(
+ lastPausedNode.id,
+ {
+ onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
+ onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
+ isPublicAPI: appSourceType === AppSourceType.webApp,
+ },
+ )
+ }
+ }, [])
+
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
const data: any = {
query: message,
@@ -147,7 +184,7 @@ const ChatWrapper = () => {
isPublicAPI: appSourceType === AppSourceType.webApp,
},
)
- }, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, isInstalledApp, appId, handleNewConversationCompleted])
+ }, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, appSourceType, appId, handleNewConversationCompleted])
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
@@ -155,6 +192,14 @@ const ChatWrapper = () => {
doSend(editedQuestion ? editedQuestion.message : question.content, editedQuestion ? editedQuestion.files : question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null)
}, [chatList, doSend])
+ const doSwitchSibling = useCallback((siblingMessageId: string) => {
+ handleSwitchSibling(siblingMessageId, {
+ onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
+ onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
+ isPublicAPI: appSourceType === AppSourceType.webApp,
+ })
+ }, [handleSwitchSibling, appSourceType, appId, currentConversationId, handleNewConversationCompleted])
+
const messageList = useMemo(() => {
if (currentConversationId || chatList.length > 1)
return chatList
@@ -178,6 +223,13 @@ const ChatWrapper = () => {
}
}, [inputsForms.length, isMobile, currentConversationId, collapsed, allInputsHidden])
+ const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: any) => {
+ if (isInstalledApp)
+ await submitHumanInputFormService(formToken, formData)
+ else
+ await submitHumanInputForm(formToken, formData)
+ }, [isInstalledApp])
+
const welcome = useMemo(() => {
const welcomeMessage = chatList.find(item => item.isOpeningStatement)
if (respondingState)
@@ -223,7 +275,7 @@ const ChatWrapper = () => {
)
- }, [appData?.site, chatList, collapsed, currentConversationId, inputsForms.length, respondingState, allInputsHidden])
+ }, [chatList, respondingState, currentConversationId, collapsed, inputsForms.length, allInputsHidden, appData?.site, isMobile])
const answerIcon = isDify()
?
@@ -253,6 +305,7 @@ const ChatWrapper = () => {
inputsForm={inputsForms}
onRegenerate={doRegenerate}
onStopResponding={handleStop}
+ onHumanInputFormSubmit={handleSubmitHumanInputForm}
chatNode={(
<>
{chatNode}
@@ -266,7 +319,7 @@ const ChatWrapper = () => {
answerIcon={answerIcon}
hideProcessDetail
themeBuilder={themeBuilder}
- switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
+ switchSibling={doSwitchSibling}
inputDisabled={inputDisabled}
questionIcon={
initUserVariables?.avatar_url
diff --git a/web/app/components/base/chat/types.ts b/web/app/components/base/chat/types.ts
index e12c6fca54..1502a32e92 100644
--- a/web/app/components/base/chat/types.ts
+++ b/web/app/components/base/chat/types.ts
@@ -5,7 +5,7 @@ import type {
ModelConfig,
VisionSettings,
} from '@/types/app'
-import type { NodeTracing } from '@/types/workflow'
+import type { HumanInputFilledFormData, HumanInputFormData, NodeTracing } from '@/types/workflow'
export type {
Inputs,
@@ -67,6 +67,8 @@ export type WorkflowProcess = {
expand?: boolean // for UI
resultText?: string
files?: FileEntity[]
+ humanInputFormDataList?: HumanInputFormData[]
+ humanInputFilledFormDataList?: HumanInputFilledFormData[]
}
export type ChatItem = IChatItem & {
diff --git a/web/app/components/base/icons/assets/public/other/slack.svg b/web/app/components/base/icons/assets/public/other/slack.svg
new file mode 100644
index 0000000000..656c4c2326
--- /dev/null
+++ b/web/app/components/base/icons/assets/public/other/slack.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/web/app/components/base/icons/assets/public/other/teams.svg b/web/app/components/base/icons/assets/public/other/teams.svg
new file mode 100644
index 0000000000..cdcc6a6242
--- /dev/null
+++ b/web/app/components/base/icons/assets/public/other/teams.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/app/components/base/icons/assets/vender/workflow/human-in-loop.svg b/web/app/components/base/icons/assets/vender/workflow/human-in-loop.svg
new file mode 100644
index 0000000000..07efc8ecc6
--- /dev/null
+++ b/web/app/components/base/icons/assets/vender/workflow/human-in-loop.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/web/app/components/base/icons/src/public/other/Slack.json b/web/app/components/base/icons/src/public/other/Slack.json
new file mode 100644
index 0000000000..e83673ed31
--- /dev/null
+++ b/web/app/components/base/icons/src/public/other/Slack.json
@@ -0,0 +1,61 @@
+{
+ "icon": {
+ "type": "element",
+ "isRootNode": true,
+ "name": "svg",
+ "attributes": {
+ "width": "27",
+ "height": "27",
+ "viewBox": "0 0 27 27",
+ "fill": "none",
+ "xmlns": "http://www.w3.org/2000/svg"
+ },
+ "children": [
+ {
+ "type": "element",
+ "name": "path",
+ "attributes": {
+ "fill-rule": "evenodd",
+ "clip-rule": "evenodd",
+ "d": "M9.625 0C9.27992 0.000320434 8.93828 0.0686147 8.6196 0.200983C8.30091 0.333351 8.01142 0.527199 7.76766 0.771458C7.5239 1.01572 7.33064 1.3056 7.19893 1.62456C7.06721 1.94351 6.99962 2.28529 7 2.63037C6.99968 2.97541 7.06732 3.31714 7.19907 3.63603C7.33081 3.95493 7.52408 4.24476 7.76783 4.48897C8.01159 4.73317 8.30105 4.92698 8.61971 5.05932C8.93836 5.19165 9.27996 5.25993 9.625 5.26025H12.25V2.63037C12.2504 2.28529 12.1828 1.94351 12.0511 1.62456C11.9194 1.3056 11.7261 1.01572 11.4823 0.771458C11.2386 0.527199 10.9491 0.333351 10.6304 0.200983C10.3117 0.0686147 9.97008 0.000320434 9.625 0ZM9.625 7.01416H2.625C2.27996 7.01448 1.93836 7.08276 1.61971 7.21509C1.30106 7.34743 1.01159 7.54124 0.767835 7.78544C0.524081 8.02965 0.330814 8.31948 0.199069 8.63838C0.0673241 8.95728 -0.000319124 9.299 1.63476e-06 9.64404C-0.000383292 9.98912 0.0672126 10.3309 0.198929 10.6499C0.330645 10.9688 0.523902 11.2587 0.767662 11.503C1.01142 11.7472 1.30091 11.9411 1.6196 12.0734C1.93828 12.2058 2.27992 12.2741 2.625 12.2744H9.625C9.97008 12.2741 10.3117 12.2058 10.6304 12.0734C10.9491 11.9411 11.2386 11.7472 11.4823 11.503C11.7261 11.2587 11.9194 10.9688 12.0511 10.6499C12.1828 10.3309 12.2504 9.98912 12.25 9.64404C12.2503 9.299 12.1827 8.95728 12.0509 8.63838C11.9192 8.31948 11.7259 8.02965 11.4822 7.78544C11.2384 7.54124 10.9489 7.34743 10.6303 7.21509C10.3116 7.08276 9.97004 7.01448 9.625 7.01416Z",
+ "fill": "#44BEDF"
+ },
+ "children": []
+ },
+ {
+ "type": "element",
+ "name": "path",
+ "attributes": {
+ "fill-rule": "evenodd",
+ "clip-rule": "evenodd",
+ "d": "M26.25 9.64404C26.2503 9.299 26.1827 8.95728 26.0509 8.63838C25.9192 8.31948 25.7259 8.02965 25.4822 7.78544C25.2384 7.54124 24.9489 7.34743 24.6303 7.21509C24.3116 7.08276 23.97 7.01448 23.625 7.01416C23.28 7.01448 22.9384 7.08276 22.6197 7.21509C22.3011 7.34743 22.0116 7.54124 21.7678 7.78544C21.5241 8.02965 21.3308 8.31948 21.1991 8.63838C21.0673 8.95728 20.9997 9.299 21 9.64404V12.2744H23.625C23.9701 12.2741 24.3117 12.2058 24.6304 12.0734C24.9491 11.9411 25.2386 11.7472 25.4823 11.503C25.7261 11.2587 25.9194 10.9688 26.0511 10.6499C26.1828 10.3309 26.2504 9.98912 26.25 9.64404ZM19.25 9.64404V2.63037C19.2504 2.28529 19.1828 1.94351 19.0511 1.62456C18.9194 1.3056 18.7261 1.01572 18.4823 0.771458C18.2386 0.527199 17.9491 0.333351 17.6304 0.200983C17.3117 0.0686147 16.9701 0.000320434 16.625 0C16.2799 0.000320434 15.9383 0.0686147 15.6196 0.200983C15.3009 0.333351 15.0114 0.527199 14.7677 0.771458C14.5239 1.01572 14.3306 1.3056 14.1989 1.62456C14.0672 1.94351 13.9996 2.28529 14 2.63037V9.64404C13.9996 9.98912 14.0672 10.3309 14.1989 10.6499C14.3306 10.9688 14.5239 11.2587 14.7677 11.503C15.0114 11.7472 15.3009 11.9411 15.6196 12.0734C15.9383 12.2058 16.2799 12.2741 16.625 12.2744C16.9701 12.2741 17.3117 12.2058 17.6304 12.0734C17.9491 11.9411 18.2386 11.7472 18.4823 11.503C18.7261 11.2587 18.9194 10.9688 19.0511 10.6499C19.1828 10.3309 19.2504 9.98912 19.25 9.64404Z",
+ "fill": "#2EB67D"
+ },
+ "children": []
+ },
+ {
+ "type": "element",
+ "name": "path",
+ "attributes": {
+ "fill-rule": "evenodd",
+ "clip-rule": "evenodd",
+ "d": "M16.625 26.302C16.9701 26.3017 17.3117 26.2334 17.6304 26.101C17.9491 25.9686 18.2386 25.7748 18.4823 25.5305C18.7261 25.2863 18.9194 24.9964 19.0511 24.6774C19.1828 24.3585 19.2504 24.0167 19.25 23.6716C19.2503 23.3266 19.1827 22.9849 19.0509 22.666C18.9192 22.3471 18.7259 22.0572 18.4822 21.813C18.2384 21.5688 17.9489 21.375 17.6303 21.2427C17.3116 21.1103 16.97 21.0421 16.625 21.0417H14V23.6716C13.9996 24.0167 14.0672 24.3585 14.1989 24.6774C14.3306 24.9964 14.5239 25.2863 14.7677 25.5305C15.0114 25.7748 15.3009 25.9686 15.6196 26.101C15.9383 26.2334 16.2799 26.3017 16.625 26.302ZM16.625 19.2878H23.625C23.97 19.2875 24.3116 19.2192 24.6303 19.0869C24.9489 18.9546 25.2384 18.7608 25.4822 18.5166C25.7259 18.2723 25.9192 17.9825 26.0509 17.6636C26.1827 17.3447 26.2503 17.003 26.25 16.658C26.2504 16.3129 26.1828 15.9711 26.0511 15.6521C25.9194 15.3332 25.7261 15.0433 25.4823 14.799C25.2386 14.5548 24.9491 14.3609 24.6304 14.2286C24.3117 14.0962 23.9701 14.0279 23.625 14.0276H16.625C16.2799 14.0279 15.9383 14.0962 15.6196 14.2286C15.3009 14.3609 15.0114 14.5548 14.7677 14.799C14.5239 15.0433 14.3306 15.3332 14.1989 15.6521C14.0672 15.9711 13.9996 16.3129 14 16.658C13.9997 17.003 14.0673 17.3447 14.1991 17.6636C14.3308 17.9825 14.5241 18.2723 14.7678 18.5166C15.0116 18.7608 15.3011 18.9546 15.6197 19.0869C15.9384 19.2192 16.28 19.2875 16.625 19.2878Z",
+ "fill": "#ECB22E"
+ },
+ "children": []
+ },
+ {
+ "type": "element",
+ "name": "path",
+ "attributes": {
+ "fill-rule": "evenodd",
+ "clip-rule": "evenodd",
+ "d": "M2.22485e-06 16.658C-0.000318534 17.003 0.0673247 17.3447 0.19907 17.6636C0.330815 17.9825 0.524081 18.2723 0.767835 18.5166C1.01159 18.7608 1.30106 18.9546 1.61971 19.0869C1.93836 19.2192 2.27996 19.2875 2.625 19.2878C2.97004 19.2875 3.31164 19.2192 3.63029 19.0869C3.94895 18.9546 4.23841 18.7608 4.48217 18.5166C4.72592 18.2723 4.91919 17.9825 5.05093 17.6636C5.18268 17.3447 5.25032 17.003 5.25 16.658V14.0276H2.625C2.27988 14.0279 1.9382 14.0962 1.61948 14.2286C1.30077 14.361 1.01126 14.5549 0.76749 14.7992C0.523723 15.0435 0.330477 15.3335 0.198789 15.6525C0.0671016 15.9715 -0.000446885 16.3128 2.22485e-06 16.658ZM7 16.658V23.6716C6.99962 24.0167 7.06721 24.3585 7.19893 24.6774C7.33064 24.9964 7.5239 25.2863 7.76766 25.5305C8.01142 25.7748 8.30091 25.9686 8.6196 26.101C8.93828 26.2334 9.27992 26.3017 9.625 26.302C9.97008 26.3017 10.3117 26.2334 10.6304 26.101C10.9491 25.9686 11.2386 25.7748 11.4823 25.5305C11.7261 25.2863 11.9194 24.9964 12.0511 24.6774C12.1828 24.3585 12.2504 24.0167 12.25 23.6716V16.6584C12.2504 16.3134 12.1828 15.9716 12.0511 15.6526C11.9194 15.3337 11.7261 15.0438 11.4823 14.7995C11.2386 14.5553 10.9491 14.3614 10.6304 14.2291C10.3117 14.0967 9.97008 14.0284 9.625 14.0281C9.27992 14.0284 8.93828 14.0967 8.6196 14.2291C8.30091 14.3614 8.01142 14.5553 7.76766 14.7995C7.5239 15.0438 7.33064 15.3337 7.19893 15.6526C7.06721 15.9716 6.99962 16.3129 7 16.658Z",
+ "fill": "#E01E5A"
+ },
+ "children": []
+ }
+ ]
+ },
+ "name": "Slack"
+}
diff --git a/web/app/components/base/icons/src/public/other/Slack.tsx b/web/app/components/base/icons/src/public/other/Slack.tsx
new file mode 100644
index 0000000000..3150598cdb
--- /dev/null
+++ b/web/app/components/base/icons/src/public/other/Slack.tsx
@@ -0,0 +1,20 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import type { IconData } from '@/app/components/base/icons/IconBase'
+import * as React from 'react'
+import IconBase from '@/app/components/base/icons/IconBase'
+import data from './Slack.json'
+
+const Icon = (
+ {
+ ref,
+ ...props
+ }: React.SVGProps
& {
+ ref?: React.RefObject>
+ },
+) =>
+
+Icon.displayName = 'Slack'
+
+export default Icon
diff --git a/web/app/components/base/icons/src/public/other/Teams.json b/web/app/components/base/icons/src/public/other/Teams.json
new file mode 100644
index 0000000000..aa9afa2240
--- /dev/null
+++ b/web/app/components/base/icons/src/public/other/Teams.json
@@ -0,0 +1,146 @@
+{
+ "icon": {
+ "type": "element",
+ "isRootNode": true,
+ "name": "svg",
+ "attributes": {
+ "width": "28",
+ "height": "28",
+ "viewBox": "0 0 28 28",
+ "fill": "none",
+ "xmlns": "http://www.w3.org/2000/svg"
+ },
+ "children": [
+ {
+ "type": "element",
+ "name": "g",
+ "attributes": {
+ "clip-path": "url(#clip0_20307_29670)"
+ },
+ "children": [
+ {
+ "type": "element",
+ "name": "path",
+ "attributes": {
+ "opacity": "0.1",
+ "d": "M9.33332 11.8067V20.4167C9.33204 21.432 9.57619 22.4327 10.045 23.3333H15.8783C16.2912 23.305 16.6807 23.1312 16.9776 22.8428C17.2745 22.5545 17.4596 22.1702 17.5 21.7583V10.5H17.1733H10.64C10.2934 10.5 9.96108 10.6377 9.71603 10.8827C9.47098 11.1278 9.33332 11.4601 9.33332 11.8067Z",
+ "fill": "black"
+ },
+ "children": []
+ },
+ {
+ "type": "element",
+ "name": "path",
+ "attributes": {
+ "opacity": "0.1",
+ "d": "M16.135 7H12.635C12.8805 7.78672 13.3726 8.47355 14.0386 8.9589C14.7047 9.44425 15.5093 9.70234 16.3333 9.695C16.7301 9.68885 17.1235 9.62197 17.5 9.49667V8.33C17.488 7.97504 17.3392 7.63847 17.0849 7.39061C16.8305 7.14276 16.4902 7.00281 16.135 7Z",
+ "fill": "black"
+ },
+ "children": []
+ },
+ {
+ "type": "element",
+ "name": "path",
+ "attributes": {
+ "d": "M26.6817 10.5H20.8484L19.2267 11.8183V18.34C19.2846 19.4628 19.7715 20.5204 20.5867 21.2946C21.4019 22.0689 22.4833 22.5005 23.6075 22.5005C24.7318 22.5005 25.8131 22.0689 26.6283 21.2946C27.4436 20.5204 27.9304 19.4628 27.9884 18.34V11.8183C27.9899 11.6458 27.9572 11.4746 27.8923 11.3147C27.8273 11.1548 27.7313 11.0094 27.6098 10.8868C27.4883 10.7643 27.3437 10.667 27.1844 10.6006C27.0251 10.5342 26.8543 10.5 26.6817 10.5Z",
+ "fill": "#5059C9"
+ },
+ "children": []
+ },
+ {
+ "type": "element",
+ "name": "path",
+ "attributes": {
+ "d": "M23.9167 9.33333C25.5275 9.33333 26.8333 8.0275 26.8333 6.41667C26.8333 4.80584 25.5275 3.5 23.9167 3.5C22.3058 3.5 21 4.80584 21 6.41667C21 8.0275 22.3058 9.33333 23.9167 9.33333Z",
+ "fill": "#5059C9"
+ },
+ "children": []
+ },
+ {
+ "type": "element",
+ "name": "path",
+ "attributes": {
+ "d": "M10.64 10.5H20.86C21.2065 10.5 21.5389 10.6377 21.7839 10.8827C22.029 11.1278 22.1666 11.4601 22.1666 11.8067V20.4167C22.1666 22.1185 21.4906 23.7506 20.2872 24.9539C19.0839 26.1573 17.4518 26.8333 15.75 26.8333C14.0482 26.8333 12.4161 26.1573 11.2127 24.9539C10.0094 23.7506 9.33331 22.1185 9.33331 20.4167V11.8067C9.33331 11.4601 9.47098 11.1278 9.71603 10.8827C9.96107 10.6377 10.2934 10.5 10.64 10.5Z",
+ "fill": "#7B83EB"
+ },
+ "children": []
+ },
+ {
+ "type": "element",
+ "name": "path",
+ "attributes": {
+ "d": "M16.3333 9.69477C18.4661 9.69477 20.195 7.96584 20.195 5.8331C20.195 3.70036 18.4661 1.97144 16.3333 1.97144C14.2006 1.97144 12.4717 3.70036 12.4717 5.8331C12.4717 7.96584 14.2006 9.69477 16.3333 9.69477Z",
+ "fill": "#7B83EB"
+ },
+ "children": []
+ },
+ {
+ "type": "element",
+ "name": "path",
+ "attributes": {
+ "opacity": "0.5",
+ "d": "M9.33332 11.8067V20.4167C9.33204 21.432 9.57619 22.4327 10.045 23.3333H15.8783C16.2912 23.305 16.6807 23.1312 16.9776 22.8428C17.2745 22.5545 17.4596 22.1702 17.5 21.7583V10.5H17.1733H10.64C10.2934 10.5 9.96108 10.6377 9.71603 10.8827C9.47098 11.1278 9.33332 11.4601 9.33332 11.8067Z",
+ "fill": "black"
+ },
+ "children": []
+ },
+ {
+ "type": "element",
+ "name": "path",
+ "attributes": {
+ "opacity": "0.5",
+ "d": "M16.135 7H12.635C12.8805 7.78672 13.3726 8.47355 14.0386 8.9589C14.7047 9.44425 15.5093 9.70234 16.3333 9.695C16.7301 9.68885 17.1235 9.62197 17.5 9.49667V8.33C17.488 7.97504 17.3392 7.63847 17.0849 7.39061C16.8305 7.14276 16.4902 7.00281 16.135 7Z",
+ "fill": "black"
+ },
+ "children": []
+ },
+ {
+ "type": "element",
+ "name": "path",
+ "attributes": {
+ "d": "M14.9683 5.83325H1.365C0.611131 5.83325 0 6.44438 0 7.19825V20.8016C0 21.5555 0.611131 22.1666 1.365 22.1666H14.9683C15.7222 22.1666 16.3333 21.5555 16.3333 20.8016V7.19825C16.3333 6.44438 15.7222 5.83325 14.9683 5.83325Z",
+ "fill": "#4B53BC"
+ },
+ "children": []
+ },
+ {
+ "type": "element",
+ "name": "path",
+ "attributes": {
+ "d": "M11.8767 11.1766H9.08833V18.6666H7.25666V11.1766H4.45667V9.33325H11.8767V11.1766Z",
+ "fill": "white"
+ },
+ "children": []
+ }
+ ]
+ },
+ {
+ "type": "element",
+ "name": "defs",
+ "attributes": {},
+ "children": [
+ {
+ "type": "element",
+ "name": "clipPath",
+ "attributes": {
+ "id": "clip0_20307_29670"
+ },
+ "children": [
+ {
+ "type": "element",
+ "name": "rect",
+ "attributes": {
+ "width": "28",
+ "height": "28",
+ "fill": "white"
+ },
+ "children": []
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "name": "Teams"
+}
diff --git a/web/app/components/base/icons/src/public/other/Teams.tsx b/web/app/components/base/icons/src/public/other/Teams.tsx
new file mode 100644
index 0000000000..4559b50793
--- /dev/null
+++ b/web/app/components/base/icons/src/public/other/Teams.tsx
@@ -0,0 +1,20 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import type { IconData } from '@/app/components/base/icons/IconBase'
+import * as React from 'react'
+import IconBase from '@/app/components/base/icons/IconBase'
+import data from './Teams.json'
+
+const Icon = (
+ {
+ ref,
+ ...props
+ }: React.SVGProps & {
+ ref?: React.RefObject>
+ },
+) =>
+
+Icon.displayName = 'Teams'
+
+export default Icon
diff --git a/web/app/components/base/icons/src/public/other/index.ts b/web/app/components/base/icons/src/public/other/index.ts
index 71887f11cc..10987368fb 100644
--- a/web/app/components/base/icons/src/public/other/index.ts
+++ b/web/app/components/base/icons/src/public/other/index.ts
@@ -2,3 +2,5 @@ export { default as DefaultToolIcon } from './DefaultToolIcon'
export { default as Icon3Dots } from './Icon3Dots'
export { default as Message3Fill } from './Message3Fill'
export { default as RowStruct } from './RowStruct'
+export { default as Slack } from './Slack'
+export { default as Teams } from './Teams'
diff --git a/web/app/components/base/icons/src/vender/workflow/HumanInLoop.json b/web/app/components/base/icons/src/vender/workflow/HumanInLoop.json
new file mode 100644
index 0000000000..f6612414a5
--- /dev/null
+++ b/web/app/components/base/icons/src/vender/workflow/HumanInLoop.json
@@ -0,0 +1,48 @@
+{
+ "icon": {
+ "type": "element",
+ "isRootNode": true,
+ "name": "svg",
+ "attributes": {
+ "width": "16",
+ "height": "16",
+ "viewBox": "0 0 16 16",
+ "fill": "none",
+ "xmlns": "http://www.w3.org/2000/svg"
+ },
+ "children": [
+ {
+ "type": "element",
+ "name": "path",
+ "attributes": {
+ "d": "M3.66634 2.66675C3.66634 2.29856 3.36787 2.00008 2.99967 2.00008C2.63149 2.00008 2.33301 2.29855 2.33301 2.66675L3.66634 2.66675ZM2.99967 5.33341H2.33301C2.33301 5.51023 2.40325 5.6798 2.52827 5.80482C2.65329 5.92984 2.82286 6.00008 2.99967 6.00008V5.33341ZM5.66641 6.00008C6.03459 6.00008 6.33307 5.7016 6.33307 5.33341C6.33307 4.96523 6.03459 4.66675 5.66641 4.66675L5.66641 6.00008ZM2.41183 5.01659C2.23816 5.34125 2.36056 5.74523 2.68522 5.91889C3.00988 6.09256 3.41385 5.97016 3.58752 5.6455L2.41183 5.01659ZM3.03395 8.58915C2.99109 8.22348 2.6599 7.96175 2.29421 8.00461C1.92853 8.04748 1.66682 8.37868 1.70967 8.74435L3.03395 8.58915ZM12.0439 5.05931C12.2607 5.3569 12.6777 5.42238 12.9753 5.20557C13.2729 4.98876 13.3383 4.57176 13.1215 4.27417L12.0439 5.05931ZM5.02145 13.5907C5.34627 13.7641 5.75013 13.6413 5.92349 13.3165C6.09685 12.9917 5.97407 12.5878 5.64925 12.4145L5.02145 13.5907ZM2.33301 2.66675L2.33301 5.33341H3.66634L3.66634 2.66675L2.33301 2.66675ZM2.99967 6.00008L5.66641 6.00008L5.66641 4.66675H2.99967L2.99967 6.00008ZM3.58752 5.6455C4.43045 4.06972 6.09066 3.00008 7.99968 3.00008V1.66675C5.57951 1.66675 3.47747 3.02445 2.41183 5.01659L3.58752 5.6455ZM7.99968 3.00008C9.66128 3.00008 11.1336 3.80991 12.0439 5.05931L13.1215 4.27417C11.9711 2.69513 10.1055 1.66675 7.99968 1.66675V3.00008ZM5.64925 12.4145C4.23557 11.6599 3.22828 10.2474 3.03395 8.58915L1.70967 8.74435C1.95639 10.8495 3.23403 12.6367 5.02145 13.5907L5.64925 12.4145Z",
+ "fill": "currentColor"
+ },
+ "children": []
+ },
+ {
+ "type": "element",
+ "name": "path",
+ "attributes": {
+ "fill-rule": "evenodd",
+ "clip-rule": "evenodd",
+ "d": "M13.1946 8.13826C12.9027 7.84637 12.4294 7.84638 12.1375 8.13829L9.11842 11.1574C9.01642 11.2594 8.95025 11.3917 8.92985 11.5345C8.92985 11.5345 8.92985 11.5345 8.92985 11.5345L8.78508 12.5479L9.79846 12.4031C9.94127 12.3827 10.0736 12.3165 10.1756 12.2145L13.1947 9.19548C13.4866 8.90359 13.4866 8.43027 13.1946 8.13826C13.1947 8.13827 13.1946 8.13825 13.1946 8.13826ZM11.1947 7.19548C12.0073 6.38286 13.3249 6.38286 14.1375 7.19548C14.95 8.00814 14.9501 9.32565 14.1375 10.1383L11.1184 13.1574C10.8124 13.4633 10.4154 13.6618 9.98703 13.723L8.09369 13.9935C7.88596 14.0232 7.67639 13.9533 7.52801 13.805C7.37963 13.6566 7.30977 13.447 7.33945 13.2393L7.60991 11.3459C7.67111 10.9175 7.86961 10.5205 8.17561 10.2145L11.1947 7.19548Z",
+ "fill": "currentColor"
+ },
+ "children": []
+ },
+ {
+ "type": "element",
+ "name": "path",
+ "attributes": {
+ "fill-rule": "evenodd",
+ "clip-rule": "evenodd",
+ "d": "M12.528 10.8048L10.528 8.80482L11.4708 7.86201L13.4708 9.86201L12.528 10.8048Z",
+ "fill": "currentColor"
+ },
+ "children": []
+ }
+ ]
+ },
+ "name": "HumanInLoop"
+}
diff --git a/web/app/components/base/icons/src/vender/workflow/HumanInLoop.tsx b/web/app/components/base/icons/src/vender/workflow/HumanInLoop.tsx
new file mode 100644
index 0000000000..a94daf432a
--- /dev/null
+++ b/web/app/components/base/icons/src/vender/workflow/HumanInLoop.tsx
@@ -0,0 +1,20 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import type { IconData } from '@/app/components/base/icons/IconBase'
+import * as React from 'react'
+import IconBase from '@/app/components/base/icons/IconBase'
+import data from './HumanInLoop.json'
+
+const Icon = (
+ {
+ ref,
+ ...props
+ }: React.SVGProps & {
+ ref?: React.RefObject>
+ },
+) =>
+
+Icon.displayName = 'HumanInLoop'
+
+export default Icon
diff --git a/web/app/components/base/icons/src/vender/workflow/index.ts b/web/app/components/base/icons/src/vender/workflow/index.ts
index ec8dce100d..c21e865a09 100644
--- a/web/app/components/base/icons/src/vender/workflow/index.ts
+++ b/web/app/components/base/icons/src/vender/workflow/index.ts
@@ -10,6 +10,7 @@ export { default as DocsExtractor } from './DocsExtractor'
export { default as End } from './End'
export { default as Home } from './Home'
export { default as Http } from './Http'
+export { default as HumanInLoop } from './HumanInLoop'
export { default as IfElse } from './IfElse'
export { default as Iteration } from './Iteration'
export { default as IterationStart } from './IterationStart'
diff --git a/web/app/components/base/markdown/index.tsx b/web/app/components/base/markdown/index.tsx
index c487b20550..8b6728f246 100644
--- a/web/app/components/base/markdown/index.tsx
+++ b/web/app/components/base/markdown/index.tsx
@@ -18,7 +18,7 @@ export type MarkdownProps = {
content: string
className?: string
pluginInfo?: SimplePluginInfo
-} & Pick
+} & Pick
export const Markdown = (props: MarkdownProps) => {
const { customComponents = {}, pluginInfo } = props
@@ -29,7 +29,13 @@ export const Markdown = (props: MarkdownProps) => {
return (
-
+
)
}
diff --git a/web/app/components/base/markdown/react-markdown-wrapper.tsx b/web/app/components/base/markdown/react-markdown-wrapper.tsx
index ed9e93e8b3..a3693a561a 100644
--- a/web/app/components/base/markdown/react-markdown-wrapper.tsx
+++ b/web/app/components/base/markdown/react-markdown-wrapper.tsx
@@ -22,6 +22,7 @@ export type ReactMarkdownWrapperProps = {
customDisallowedElements?: string[]
customComponents?: Record>
pluginInfo?: SimplePluginInfo
+ rehypePlugins?: any// js: PluggableList[]
}
export const ReactMarkdownWrapper: FC = (props) => {
@@ -55,6 +56,7 @@ export const ReactMarkdownWrapper: FC = (props) => {
tree.children.forEach(iterate)
}
},
+ ...(props.rehypePlugins || []),
]}
urlTransform={customUrlTransform}
disallowedElements={['iframe', 'head', 'html', 'meta', 'link', 'style', 'body', ...(props.customDisallowedElements || [])]}
diff --git a/web/app/components/base/prompt-editor/constants.tsx b/web/app/components/base/prompt-editor/constants.tsx
index d6b8e9fcb4..2dcc62706a 100644
--- a/web/app/components/base/prompt-editor/constants.tsx
+++ b/web/app/components/base/prompt-editor/constants.tsx
@@ -4,6 +4,7 @@ import { SupportUploadFileTypes } from '../../workflow/types'
export const CONTEXT_PLACEHOLDER_TEXT = '{{#context#}}'
export const HISTORY_PLACEHOLDER_TEXT = '{{#histories#}}'
export const QUERY_PLACEHOLDER_TEXT = '{{#query#}}'
+export const REQUEST_URL_PLACEHOLDER_TEXT = '{{#url#}}'
export const CURRENT_PLACEHOLDER_TEXT = '{{#current#}}'
export const ERROR_MESSAGE_PLACEHOLDER_TEXT = '{{#error_message#}}'
export const LAST_RUN_PLACEHOLDER_TEXT = '{{#last_run#}}'
@@ -30,6 +31,12 @@ export const checkHasQueryBlock = (text: string) => {
return text.includes(QUERY_PLACEHOLDER_TEXT)
}
+export const checkHasRequestURLBlock = (text: string) => {
+ if (!text)
+ return false
+ return text.includes(REQUEST_URL_PLACEHOLDER_TEXT)
+}
+
/*
* {{#1711617514996.name#}} => [1711617514996, name]
* {{#1711617514996.sys.query#}} => [sys, query]
diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx
index 99e3a3325c..6f1d40f6eb 100644
--- a/web/app/components/base/prompt-editor/index.tsx
+++ b/web/app/components/base/prompt-editor/index.tsx
@@ -2,16 +2,20 @@
import type {
EditorState,
+ LexicalCommand,
} from 'lexical'
import type { FC } from 'react'
+import type { Hotkey } from './plugins/shortcuts-popup-plugin'
import type {
ContextBlockType,
CurrentBlockType,
ErrorMessageBlockType,
ExternalToolBlockType,
HistoryBlockType,
+ HITLInputBlockType,
LastRunBlockType,
QueryBlockType,
+ RequestURLBlockType,
VariableBlockType,
WorkflowVariableBlockType,
} from './types'
@@ -27,7 +31,7 @@ import {
TextNode,
} from 'lexical'
import * as React from 'react'
-import { useEffect } from 'react'
+import { useEffect, useState } from 'react'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { cn } from '@/utils/classnames'
import {
@@ -46,17 +50,23 @@ import {
CurrentBlockReplacementBlock,
} from './plugins/current-block'
import { CustomTextNode } from './plugins/custom-text/node'
+import DraggableBlockPlugin from './plugins/draggable-plugin'
import {
ErrorMessageBlock,
ErrorMessageBlockNode,
ErrorMessageBlockReplacementBlock,
} from './plugins/error-message-block'
-
import {
HistoryBlock,
HistoryBlockNode,
HistoryBlockReplacementBlock,
} from './plugins/history-block'
+
+import {
+ HITLInputBlock,
+ HITLInputBlockReplacementBlock,
+ HITLInputNode,
+} from './plugins/hitl-input-block'
import {
LastRunBlock,
LastRunBlockNode,
@@ -70,6 +80,12 @@ import {
QueryBlockNode,
QueryBlockReplacementBlock,
} from './plugins/query-block'
+import {
+ RequestURLBlock,
+ RequestURLBlockNode,
+ RequestURLBlockReplacementBlock,
+} from './plugins/request-url-block'
+import ShortcutsPopupPlugin from './plugins/shortcuts-popup-plugin'
import UpdateBlock from './plugins/update-block'
import VariableBlock from './plugins/variable-block'
import VariableValueBlock from './plugins/variable-value-block'
@@ -96,14 +112,17 @@ export type PromptEditorProps = {
onFocus?: () => void
contextBlock?: ContextBlockType
queryBlock?: QueryBlockType
+ requestURLBlock?: RequestURLBlockType
historyBlock?: HistoryBlockType
variableBlock?: VariableBlockType
externalToolBlock?: ExternalToolBlockType
workflowVariableBlock?: WorkflowVariableBlockType
+ hitlInputBlock?: HITLInputBlockType
currentBlock?: CurrentBlockType
errorMessageBlock?: ErrorMessageBlockType
lastRunBlock?: LastRunBlockType
isSupportFileVar?: boolean
+ shortcutPopups?: Array<{ hotkey: Hotkey, Popup: React.ComponentType<{ onClose: () => void, onInsert: (command: LexicalCommand, params: any[]) => void }> }>
}
const PromptEditor: FC = ({
@@ -121,14 +140,17 @@ const PromptEditor: FC = ({
onFocus,
contextBlock,
queryBlock,
+ requestURLBlock,
historyBlock,
variableBlock,
externalToolBlock,
workflowVariableBlock,
+ hitlInputBlock,
currentBlock,
errorMessageBlock,
lastRunBlock,
isSupportFileVar,
+ shortcutPopups = [],
}) => {
const { eventEmitter } = useEventEmitterContextContext()
const initialConfig = {
@@ -143,8 +165,10 @@ const PromptEditor: FC = ({
ContextBlockNode,
HistoryBlockNode,
QueryBlockNode,
+ RequestURLBlockNode,
WorkflowVariableBlockNode,
VariableValueBlockNode,
+ HITLInputNode,
CurrentBlockNode,
ErrorMessageBlockNode,
LastRunBlockNode, // LastRunBlockNode is used for error message block replacement
@@ -176,9 +200,16 @@ const PromptEditor: FC = ({
} as any)
}, [eventEmitter, historyBlock?.history])
+ const [floatingAnchorElem, setFloatingAnchorElem] = useState(null)
+
+ const onRef = (_floatingAnchorElem: any) => {
+ if (_floatingAnchorElem !== null)
+ setFloatingAnchorElem(_floatingAnchorElem)
+ }
+
return (
-
+
= ({
)}
ErrorBoundary={LexicalErrorBoundary}
/>
+ {shortcutPopups?.map(({ hotkey, Popup }, idx) => (
+
+ {(closePortal, onInsert) => }
+
+ ))}
= ({
contextBlock={contextBlock}
historyBlock={historyBlock}
queryBlock={queryBlock}
+ requestURLBlock={requestURLBlock}
variableBlock={variableBlock}
externalToolBlock={externalToolBlock}
workflowVariableBlock={workflowVariableBlock}
@@ -265,6 +303,14 @@ const PromptEditor: FC = ({
>
)
}
+ {
+ hitlInputBlock?.show && (
+ <>
+
+
+ >
+ )
+ }
{
currentBlock?.show && (
<>
@@ -273,6 +319,14 @@ const PromptEditor: FC = ({
>
)
}
+ {
+ requestURLBlock?.show && (
+ <>
+
+
+ >
+ )
+ }
{
errorMessageBlock?.show && (
<>
@@ -298,6 +352,9 @@ const PromptEditor: FC = ({
+ {floatingAnchorElem && (
+
+ )}
{/* */}
diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/hooks.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/hooks.tsx
index 8855d948df..247a4a782c 100644
--- a/web/app/components/base/prompt-editor/plugins/component-picker-block/hooks.tsx
+++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/hooks.tsx
@@ -6,10 +6,12 @@ import type {
HistoryBlockType,
LastRunBlockType,
QueryBlockType,
+ RequestURLBlockType,
VariableBlockType,
WorkflowVariableBlockType,
} from '../../types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { RiGlobalLine } from '@remixicon/react'
import { $insertNodes } from 'lexical'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@@ -27,6 +29,7 @@ import { INSERT_CONTEXT_BLOCK_COMMAND } from '../context-block'
import { $createCustomTextNode } from '../custom-text/node'
import { INSERT_HISTORY_BLOCK_COMMAND } from '../history-block'
import { INSERT_QUERY_BLOCK_COMMAND } from '../query-block'
+import { INSERT_REQUEST_URL_BLOCK_COMMAND } from '../request-url-block'
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '../variable-block'
import { PickerBlockMenuOption } from './menu'
import { PromptMenuItem } from './prompt-option'
@@ -36,6 +39,7 @@ export const usePromptOptions = (
contextBlock?: ContextBlockType,
queryBlock?: QueryBlockType,
historyBlock?: HistoryBlockType,
+ requestURLBlock?: RequestURLBlockType,
) => {
const { t } = useTranslation()
const [editor] = useLexicalComposerContext()
@@ -91,6 +95,30 @@ export const usePromptOptions = (
)
}
+ if (requestURLBlock?.show) {
+ promptOptions.push(new PickerBlockMenuOption({
+ key: t('promptEditor.requestURL.item.title', { ns: 'common' }),
+ group: 'request URL',
+ render: ({ isSelected, onSelect, onSetHighlight }) => {
+ return (
+
}
+ disabled={!requestURLBlock.selectable}
+ isSelected={isSelected}
+ onClick={onSelect}
+ onMouseEnter={onSetHighlight}
+ />
+ )
+ },
+ onSelect: () => {
+ if (!requestURLBlock?.selectable)
+ return
+ editor.dispatchCommand(INSERT_REQUEST_URL_BLOCK_COMMAND, undefined)
+ },
+ }))
+ }
+
if (historyBlock?.show) {
promptOptions.push(
new PickerBlockMenuOption({
@@ -272,12 +300,13 @@ export const useOptions = (
variableBlock?: VariableBlockType,
externalToolBlockType?: ExternalToolBlockType,
workflowVariableBlockType?: WorkflowVariableBlockType,
+ requestURLBlock?: RequestURLBlockType,
currentBlockType?: CurrentBlockType,
errorMessageBlockType?: ErrorMessageBlockType,
lastRunBlockType?: LastRunBlockType,
queryString?: string,
) => {
- const promptOptions = usePromptOptions(contextBlock, queryBlock, historyBlock)
+ const promptOptions = usePromptOptions(contextBlock, queryBlock, historyBlock, requestURLBlock)
const variableOptions = useVariableOptions(variableBlock, queryString)
const externalToolOptions = useExternalToolOptions(externalToolBlockType, queryString)
diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx
index 728ae21a70..8001a2755b 100644
--- a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx
+++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx
@@ -8,6 +8,7 @@ import type {
HistoryBlockType,
LastRunBlockType,
QueryBlockType,
+ RequestURLBlockType,
VariableBlockType,
WorkflowVariableBlockType,
} from '../../types'
@@ -44,6 +45,7 @@ type ComponentPickerProps = {
triggerString: string
contextBlock?: ContextBlockType
queryBlock?: QueryBlockType
+ requestURLBlock?: RequestURLBlockType
historyBlock?: HistoryBlockType
variableBlock?: VariableBlockType
externalToolBlock?: ExternalToolBlockType
@@ -57,6 +59,7 @@ const ComponentPicker = ({
triggerString,
contextBlock,
queryBlock,
+ requestURLBlock,
historyBlock,
variableBlock,
externalToolBlock,
@@ -100,6 +103,7 @@ const ComponentPicker = ({
variableBlock,
externalToolBlock,
workflowVariableBlock,
+ requestURLBlock,
currentBlock,
errorMessageBlock,
lastRunBlock,
diff --git a/web/app/components/base/prompt-editor/plugins/draggable-plugin/index.tsx b/web/app/components/base/prompt-editor/plugins/draggable-plugin/index.tsx
new file mode 100644
index 0000000000..68daed4761
--- /dev/null
+++ b/web/app/components/base/prompt-editor/plugins/draggable-plugin/index.tsx
@@ -0,0 +1,86 @@
+import type { JSX } from 'react'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { DraggableBlockPlugin_EXPERIMENTAL } from '@lexical/react/LexicalDraggableBlockPlugin'
+import { RiDraggable } from '@remixicon/react'
+import { useEffect, useRef, useState } from 'react'
+import { cn } from '@/utils/classnames'
+
+const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu'
+
+function isOnMenu(element: HTMLElement): boolean {
+ return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`)
+}
+
+const SUPPORT_DRAG_CLASS = 'support-drag'
+function checkSupportDrag(element: Element | null): boolean {
+ if (!element)
+ return false
+
+ if (element.classList.contains(SUPPORT_DRAG_CLASS))
+ return true
+
+ if (element.querySelector(`.${SUPPORT_DRAG_CLASS}`))
+ return true
+
+ return !!(element.closest(`.${SUPPORT_DRAG_CLASS}`))
+}
+
+export default function DraggableBlockPlugin({
+ anchorElem = document.body,
+}: {
+ anchorElem?: HTMLElement
+}): JSX.Element {
+ const menuRef = useRef
(null)
+ const targetLineRef = useRef(null)
+ const [, setDraggableElement] = useState(
+ null,
+ )
+ const [editor] = useLexicalComposerContext()
+
+ const [isSupportDrag, setIsSupportDrag] = useState(false)
+
+ useEffect(() => {
+ const root = editor.getRootElement()
+ if (!root)
+ return
+
+ const onMove = (e: MouseEvent) => {
+ const isSupportDrag = checkSupportDrag(e.target as Element)
+ setIsSupportDrag(isSupportDrag)
+ }
+
+ root.addEventListener('mousemove', onMove)
+ return () => root.removeEventListener('mousemove', onMove)
+ }, [editor])
+
+ return (
+
+
+
+ )
+ : null
+ }
+ targetLineComponent={(
+
+ )}
+ isOnMenu={isOnMenu}
+ onElementChanged={setDraggableElement}
+ />
+ )
+}
diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx
new file mode 100644
index 0000000000..736f6bafe9
--- /dev/null
+++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component-ui.tsx
@@ -0,0 +1,170 @@
+'use client'
+import type { FC } from 'react'
+import type { WorkflowNodesMap } from '../workflow-variable-block/node'
+import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
+import type { Type } from '@/app/components/workflow/nodes/llm/types'
+import type { ValueSelector, Var } from '@/app/components/workflow/types'
+import { RiDeleteBinLine, RiEditLine } from '@remixicon/react'
+import { useBoolean } from 'ahooks'
+import * as React from 'react'
+import { useCallback, useEffect, useMemo, useRef } from 'react'
+import { InputVarType } from '@/app/components/workflow/types'
+import ActionButton from '../../../action-button'
+import { VariableX } from '../../../icons/src/vender/workflow'
+import Modal from '../../../modal'
+import InputField from './input-field'
+import VariableBlock from './variable-block'
+
+type HITLInputComponentUIProps = {
+ nodeId: string
+ varName: string
+ formInput?: FormInputItem
+ onChange: (input: FormInputItem) => void
+ onRename: (payload: FormInputItem, oldName: string) => void
+ onRemove: (varName: string) => void
+ workflowNodesMap: WorkflowNodesMap
+ environmentVariables?: Var[]
+ conversationVariables?: Var[]
+ ragVariables?: Var[]
+ getVarType?: (payload: {
+ nodeId: string
+ valueSelector: ValueSelector
+ }) => Type
+ readonly?: boolean
+}
+
+const HITLInputComponentUI: FC = ({
+ nodeId,
+ varName,
+ formInput = {
+ type: InputVarType.paragraph,
+ output_variable_name: varName,
+ default: {
+ type: 'constant',
+ selector: [],
+ value: '',
+ },
+ },
+ onChange,
+ onRename,
+ onRemove,
+ workflowNodesMap = {},
+ getVarType,
+ environmentVariables,
+ conversationVariables,
+ ragVariables,
+ readonly,
+}) => {
+ const [isShowEditModal, {
+ setTrue: showEditModal,
+ setFalse: hideEditModal,
+ }] = useBoolean(false)
+
+ // Lexical delegate the click make it unable to add click by the method of react
+ const editBtnRef = useRef(null)
+ useEffect(() => {
+ const editBtn = editBtnRef.current
+ if (editBtn)
+ editBtn.addEventListener('click', showEditModal)
+
+ return () => {
+ if (editBtn)
+ editBtn.removeEventListener('click', showEditModal)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ const removeBtnRef = useRef(null)
+ useEffect(() => {
+ const removeBtn = removeBtnRef.current
+ const removeHandler = () => onRemove(varName)
+ if (removeBtn)
+ removeBtn.addEventListener('click', removeHandler)
+
+ return () => {
+ if (removeBtn)
+ removeBtn.removeEventListener('click', removeHandler)
+ }
+ }, [onRemove, varName])
+
+ const handleChange = useCallback((newPayload: FormInputItem) => {
+ if (varName === newPayload.output_variable_name)
+ onChange(newPayload)
+ else
+ onRename(newPayload, varName)
+ hideEditModal()
+ }, [hideEditModal, onChange, onRename, varName])
+
+ const isDefaultValueVariable = useMemo(() => {
+ return formInput.default?.type === 'variable'
+ }, [formInput.default?.type])
+
+ return (
+
+
+
+
+
+ {/* Default Value Info */}
+ {isDefaultValueVariable && (
+
+ )}
+ {!isDefaultValueVariable && (
+
{formInput.default?.value}
+ )}
+
+
+ {/* Actions */}
+ {!readonly && (
+
+ )}
+
+
+ {isShowEditModal && (
+
+
+
+ )}
+
+ )
+}
+
+export default React.memo(HITLInputComponentUI)
diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/component.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component.tsx
new file mode 100644
index 0000000000..df4147fc33
--- /dev/null
+++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/component.tsx
@@ -0,0 +1,86 @@
+import type { FC } from 'react'
+import type { WorkflowNodesMap } from '../workflow-variable-block/node'
+import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
+import type { Type } from '@/app/components/workflow/nodes/llm/types'
+import type { ValueSelector, Var } from '@/app/components/workflow/types'
+import { produce } from 'immer'
+import { useCallback } from 'react'
+import { useSelectOrDelete } from '../../hooks'
+import { DELETE_HITL_INPUT_BLOCK_COMMAND } from './'
+import ComponentUi from './component-ui'
+
+type HITLInputComponentProps = {
+ nodeKey: string
+ nodeId: string
+ varName: string
+ formInputs?: FormInputItem[]
+ onChange: (inputs: FormInputItem[]) => void
+ onRename: (payload: FormInputItem, oldName: string) => void
+ onRemove: (varName: string) => void
+ workflowNodesMap: WorkflowNodesMap
+ environmentVariables?: Var[]
+ conversationVariables?: Var[]
+ ragVariables?: Var[]
+ getVarType?: (payload: {
+ nodeId: string
+ valueSelector: ValueSelector
+ }) => Type
+ readonly?: boolean
+}
+
+const HITLInputComponent: FC = ({
+ nodeKey,
+ nodeId,
+ varName,
+ formInputs = [],
+ onChange,
+ onRename,
+ onRemove,
+ workflowNodesMap = {},
+ getVarType,
+ environmentVariables,
+ conversationVariables,
+ ragVariables,
+ readonly,
+}) => {
+ const [ref] = useSelectOrDelete(nodeKey, DELETE_HITL_INPUT_BLOCK_COMMAND)
+ const payload = formInputs.find(item => item.output_variable_name === varName)
+
+ const handleChange = useCallback((newPayload: FormInputItem) => {
+ if (!payload) {
+ onChange([...formInputs, newPayload])
+ return
+ }
+ if (payload?.output_variable_name !== newPayload.output_variable_name) {
+ onChange(produce(formInputs, (draft) => {
+ draft.splice(draft.findIndex(item => item.output_variable_name === payload?.output_variable_name), 1, newPayload)
+ }))
+ return
+ }
+ onChange(formInputs.map(item => item.output_variable_name === varName ? newPayload : item))
+ }, [formInputs, onChange, payload, varName])
+
+ return (
+
+
+
+ )
+}
+
+export default HITLInputComponent
diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx
new file mode 100644
index 0000000000..cd1515c57d
--- /dev/null
+++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx
@@ -0,0 +1,89 @@
+import type { TextNode } from 'lexical'
+import type { HITLInputBlockType } from '../../types'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { mergeRegister } from '@lexical/utils'
+import { $applyNodeReplacement } from 'lexical'
+import {
+ memo,
+ useCallback,
+ useEffect,
+ useMemo,
+} from 'react'
+import { HITL_INPUT_REG } from '@/config'
+import { decoratorTransform } from '../../utils'
+import { CustomTextNode } from '../custom-text/node'
+import { $createHITLInputNode, HITLInputNode } from './node'
+
+const REGEX = new RegExp(HITL_INPUT_REG)
+
+const HITLInputReplacementBlock = ({
+ nodeId,
+ formInputs,
+ onFormInputsChange,
+ onFormInputItemRename,
+ onFormInputItemRemove,
+ workflowNodesMap,
+ getVarType,
+ variables,
+ readonly,
+}: HITLInputBlockType) => {
+ const [editor] = useLexicalComposerContext()
+
+ const environmentVariables = useMemo(() => variables?.find(o => o.nodeId === 'env')?.vars || [], [variables])
+ const conversationVariables = useMemo(() => variables?.find(o => o.nodeId === 'conversation')?.vars || [], [variables])
+ const ragVariables = useMemo(() => variables?.reduce((acc, curr) => {
+ if (curr.nodeId === 'rag')
+ acc.push(...curr.vars)
+ else
+ acc.push(...curr.vars.filter(v => v.isRagVariable))
+ return acc
+ }, []), [variables])
+
+ useEffect(() => {
+ if (!editor.hasNodes([HITLInputNode]))
+ throw new Error('HITLInputNodePlugin: HITLInputNode not registered on editor')
+ }, [editor])
+
+ const createHITLInputBlockNode = useCallback((textNode: TextNode): HITLInputNode => {
+ const varName = textNode.getTextContent().split('.')[1].replace(/#\}\}$/, '')
+ return $applyNodeReplacement($createHITLInputNode(
+ varName,
+ nodeId,
+ formInputs || [],
+ onFormInputsChange!,
+ onFormInputItemRename,
+ onFormInputItemRemove!,
+ workflowNodesMap,
+ getVarType,
+ environmentVariables,
+ conversationVariables,
+ ragVariables,
+ readonly,
+ ))
+ }, [nodeId, formInputs, onFormInputsChange, onFormInputItemRename, onFormInputItemRemove, workflowNodesMap, getVarType, environmentVariables, conversationVariables, ragVariables, readonly])
+
+ const getMatch = useCallback((text: string) => {
+ const matchArr = REGEX.exec(text)
+
+ if (matchArr === null)
+ return null
+
+ const startOffset = matchArr.index
+ const endOffset = startOffset + matchArr[0].length
+ return {
+ end: endOffset,
+ start: startOffset,
+ }
+ }, [])
+
+ useEffect(() => {
+ REGEX.lastIndex = 0
+ return mergeRegister(
+ editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createHITLInputBlockNode)),
+ )
+ }, [])
+
+ return null
+}
+
+export default memo(HITLInputReplacementBlock)
diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx
new file mode 100644
index 0000000000..49b0e3d150
--- /dev/null
+++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/index.tsx
@@ -0,0 +1,106 @@
+import type { HITLInputBlockType } from '../../types'
+import type {
+ HITLNodeProps,
+} from './node'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { mergeRegister } from '@lexical/utils'
+import {
+ $insertNodes,
+ COMMAND_PRIORITY_EDITOR,
+ createCommand,
+} from 'lexical'
+import {
+ memo,
+ useEffect,
+} from 'react'
+import { CustomTextNode } from '../custom-text/node'
+import {
+ $createHITLInputNode,
+ HITLInputNode,
+} from './node'
+
+export const INSERT_HITL_INPUT_BLOCK_COMMAND = createCommand('INSERT_HITL_INPUT_BLOCK_COMMAND')
+export const DELETE_HITL_INPUT_BLOCK_COMMAND = createCommand('DELETE_HITL_INPUT_BLOCK_COMMAND')
+export const UPDATE_WORKFLOW_NODES_MAP = createCommand('UPDATE_WORKFLOW_NODES_MAP')
+
+export type HITLInputProps = {
+ onInsert?: () => void
+ onDelete?: () => void
+}
+const HITLInputBlock = memo(({
+ onInsert,
+ onDelete,
+ workflowNodesMap,
+ getVarType,
+ readonly,
+}: HITLInputBlockType) => {
+ const [editor] = useLexicalComposerContext()
+
+ useEffect(() => {
+ editor.update(() => {
+ editor.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, workflowNodesMap)
+ })
+ }, [editor, workflowNodesMap])
+
+ useEffect(() => {
+ if (!editor.hasNodes([HITLInputNode]))
+ throw new Error('HITLInputBlockPlugin: HITLInputBlock not registered on editor')
+ return mergeRegister(
+ editor.registerCommand(
+ INSERT_HITL_INPUT_BLOCK_COMMAND,
+ (nodeProps: HITLNodeProps) => {
+ const {
+ variableName,
+ nodeId,
+ formInputs,
+ onFormInputsChange,
+ onFormInputItemRename,
+ onFormInputItemRemove,
+ } = nodeProps
+ const currentHITLNode = $createHITLInputNode(
+ variableName,
+ nodeId,
+ formInputs,
+ onFormInputsChange,
+ onFormInputItemRename,
+ onFormInputItemRemove,
+ workflowNodesMap,
+ getVarType,
+ undefined,
+ undefined,
+ undefined,
+ readonly,
+ )
+ const prev = new CustomTextNode('\n')
+ $insertNodes([prev])
+ $insertNodes([currentHITLNode])
+ const next = new CustomTextNode('\n')
+ $insertNodes([next])
+ if (onInsert)
+ onInsert()
+
+ return true
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand(
+ DELETE_HITL_INPUT_BLOCK_COMMAND,
+ () => {
+ if (onDelete)
+ onDelete()
+
+ return true
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ )
+ }, [editor, onInsert, onDelete])
+
+ return null
+})
+
+HITLInputBlock.displayName = 'HITLInputBlock'
+
+export { HITLInputBlock }
+export { default as HITLInputBlockReplacementBlock } from './hitl-input-block-replacement-block'
+export { HITLInputNode } from './node'
diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx
new file mode 100644
index 0000000000..06f7e1db7a
--- /dev/null
+++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx
@@ -0,0 +1,153 @@
+import type { FormInputItem, FormInputItemDefault } from '@/app/components/workflow/nodes/human-input/types'
+import type { ValueSelector } from '@/app/components/workflow/types'
+import { produce } from 'immer'
+import * as React from 'react'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Input from '@/app/components/base/input'
+import { InputVarType } from '@/app/components/workflow/types'
+import { getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
+import Button from '../../../button'
+import PrePopulate from './pre-populate'
+
+const i18nPrefix = 'nodes.humanInput.insertInputField'
+
+type InputFieldProps = {
+ nodeId: string
+ isEdit: boolean
+ payload?: FormInputItem
+ onChange: (newPayload: FormInputItem) => void
+ onCancel: () => void
+}
+const defaultPayload: FormInputItem = {
+ type: InputVarType.paragraph,
+ output_variable_name: '',
+ default: { type: 'constant', selector: [], value: '' },
+}
+const InputField: React.FC = ({
+ nodeId,
+ isEdit,
+ payload,
+ onChange,
+ onCancel,
+}) => {
+ const { t } = useTranslation()
+ const [tempPayload, setTempPayload] = useState(payload || defaultPayload)
+ const nameValid = useMemo(() => {
+ const name = tempPayload.output_variable_name.trim()
+ if (!name)
+ return false
+ if (name.includes(' '))
+ return false
+ return /^[a-z_]\w{0,29}$/.test(name)
+ }, [tempPayload.output_variable_name])
+ const handleSave = useCallback(() => {
+ if (!nameValid)
+ return
+ onChange(tempPayload)
+ }, [nameValid, onChange, tempPayload])
+ const defaultValueConfig = tempPayload.default
+ const handleDefaultValueChange = useCallback((key: keyof FormInputItemDefault) => {
+ return (value: ValueSelector | string) => {
+ const nextValue = produce(tempPayload, (draft) => {
+ if (!draft.default)
+ draft.default = { type: 'constant', selector: [], value: '' }
+ if (key === 'selector') {
+ draft.default.type = 'variable'
+ draft.default.selector = value as ValueSelector
+ }
+ else if (key === 'value') {
+ draft.default.type = 'constant'
+ draft.default.value = value as string
+ }
+ else if (key === 'type') {
+ draft.default.type = value as 'constant' | 'variable'
+ }
+ })
+ setTempPayload(nextValue)
+ }
+ }, [tempPayload])
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
+ e.preventDefault()
+ handleSave()
+ }
+ }
+ window.addEventListener('keydown', handleKeyDown)
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown)
+ }
+ }, [handleSave])
+
+ return (
+
+
{t(`${i18nPrefix}.title`, { ns: 'workflow' })}
+
+
+ {t(`${i18nPrefix}.saveResponseAs`, { ns: 'workflow' })}
+ *
+
+
{
+ setTempPayload(prev => ({ ...prev, output_variable_name: e.target.value }))
+ }}
+ autoFocus
+ />
+ {tempPayload.output_variable_name && !nameValid && (
+
+ {t(`${i18nPrefix}.variableNameInvalid`, { ns: 'workflow' })}
+
+ )}
+
+
+
+ {t(`${i18nPrefix}.prePopulateField`, { ns: 'workflow' })}
+
+
{
+ handleDefaultValueChange('type')(isVariable ? 'variable' : 'constant')
+ }}
+ nodeId={nodeId}
+ valueSelector={defaultValueConfig?.selector}
+ onValueSelectorChange={handleDefaultValueChange('selector')}
+ value={defaultValueConfig?.value}
+ onValueChange={handleDefaultValueChange('value')}
+ />
+
+
+ {t('operation.cancel', { ns: 'common' })}
+ {isEdit
+ ? (
+
+ {t('operation.save', { ns: 'common' })}
+
+ )
+ : (
+
+ {t(`${i18nPrefix}.insert`, { ns: 'workflow' })}
+ {getKeyboardKeyNameBySystem('ctrl')}
+ ↩︎
+
+ )}
+
+
+
+ )
+}
+
+export default InputField
diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/node.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/node.tsx
new file mode 100644
index 0000000000..bf3c44acf3
--- /dev/null
+++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/node.tsx
@@ -0,0 +1,272 @@
+import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
+import type { GetVarType } from '../../types'
+import type { WorkflowNodesMap } from '../workflow-variable-block/node'
+import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
+import type { Var } from '@/app/components/workflow/types'
+import { DecoratorNode } from 'lexical'
+import HILTInputBlockComponent from './component'
+
+export type HITLNodeProps = {
+ variableName: string
+ nodeId: string
+ formInputs: FormInputItem[]
+ onFormInputsChange: (inputs: FormInputItem[]) => void
+ onFormInputItemRename: (payload: FormInputItem, oldName: string) => void
+ onFormInputItemRemove: (varName: string) => void
+ workflowNodesMap: WorkflowNodesMap
+ getVarType?: GetVarType
+ environmentVariables?: Var[]
+ conversationVariables?: Var[]
+ ragVariables?: Var[]
+ readonly?: boolean
+}
+
+export type SerializedNode = SerializedLexicalNode & HITLNodeProps
+
+export class HITLInputNode extends DecoratorNode {
+ __variableName: string
+ __nodeId: string
+ __formInputs?: FormInputItem[]
+ __onFormInputsChange: (inputs: FormInputItem[]) => void
+ __onFormInputItemRename: (payload: FormInputItem, oldName: string) => void
+ __onFormInputItemRemove: (varName: string) => void
+ __workflowNodesMap: WorkflowNodesMap
+ __getVarType?: GetVarType
+ __environmentVariables?: Var[]
+ __conversationVariables?: Var[]
+ __ragVariables?: Var[]
+ __readonly?: boolean
+
+ isIsolated(): boolean {
+ return true // This is necessary for drag-and-drop to work correctly
+ }
+
+ isTopLevel(): boolean {
+ return true // This is necessary for drag-and-drop to work correctly
+ }
+
+ static getType(): string {
+ return 'hitl-input-block'
+ }
+
+ getVariableName(): string {
+ const self = this.getLatest()
+ return self.__variableName
+ }
+
+ getNodeId(): string {
+ const self = this.getLatest()
+ return self.__nodeId
+ }
+
+ getFormInputs(): FormInputItem[] {
+ const self = this.getLatest()
+ return self.__formInputs || []
+ }
+
+ getOnFormInputsChange(): (inputs: FormInputItem[]) => void {
+ const self = this.getLatest()
+ return self.__onFormInputsChange
+ }
+
+ getOnFormInputItemRename(): (payload: FormInputItem, oldName: string) => void {
+ const self = this.getLatest()
+ return self.__onFormInputItemRename
+ }
+
+ getOnFormInputItemRemove(): (varName: string) => void {
+ const self = this.getLatest()
+ return self.__onFormInputItemRemove
+ }
+
+ getWorkflowNodesMap(): WorkflowNodesMap {
+ const self = this.getLatest()
+ return self.__workflowNodesMap
+ }
+
+ getGetVarType(): GetVarType | undefined {
+ const self = this.getLatest()
+ return self.__getVarType
+ }
+
+ getEnvironmentVariables(): Var[] {
+ const self = this.getLatest()
+ return self.__environmentVariables || []
+ }
+
+ getConversationVariables(): Var[] {
+ const self = this.getLatest()
+ return self.__conversationVariables || []
+ }
+
+ getRagVariables(): Var[] {
+ const self = this.getLatest()
+ return self.__ragVariables || []
+ }
+
+ getReadonly(): boolean {
+ const self = this.getLatest()
+ return self.__readonly || false
+ }
+
+ static clone(node: HITLInputNode): HITLInputNode {
+ return new HITLInputNode(
+ node.__variableName,
+ node.__nodeId,
+ node.__formInputs || [],
+ node.__onFormInputsChange,
+ node.__onFormInputItemRename,
+ node.__onFormInputItemRemove,
+ node.__workflowNodesMap,
+ node.__getVarType,
+ node.__environmentVariables,
+ node.__conversationVariables,
+ node.__ragVariables,
+ node.__readonly,
+ node.__key,
+ )
+ }
+
+ isInline(): boolean {
+ return true
+ }
+
+ constructor(
+ varName: string,
+ nodeId: string,
+ formInputs: FormInputItem[],
+ onFormInputsChange: (inputs: FormInputItem[]) => void,
+ onFormInputItemRename: (payload: FormInputItem, oldName: string) => void,
+ onFormInputItemRemove: (varName: string) => void,
+ workflowNodesMap: WorkflowNodesMap,
+ getVarType?: GetVarType,
+ environmentVariables?: Var[],
+ conversationVariables?: Var[],
+ ragVariables?: Var[],
+ readonly?: boolean,
+ key?: NodeKey,
+ ) {
+ super(key)
+
+ this.__variableName = varName
+ this.__nodeId = nodeId
+ this.__formInputs = formInputs
+ this.__onFormInputsChange = onFormInputsChange
+ this.__onFormInputItemRename = onFormInputItemRename
+ this.__onFormInputItemRemove = onFormInputItemRemove
+ this.__workflowNodesMap = workflowNodesMap
+ this.__getVarType = getVarType
+ this.__environmentVariables = environmentVariables
+ this.__conversationVariables = conversationVariables
+ this.__ragVariables = ragVariables
+ this.__readonly = readonly
+ }
+
+ createDOM(): HTMLElement {
+ const div = document.createElement('div')
+ div.classList.add('inline-flex', 'w-[calc(100%-1px)]', 'items-center', 'align-middle', 'support-drag')
+ return div
+ }
+
+ updateDOM(): false {
+ return false
+ }
+
+ decorate(): React.JSX.Element {
+ return (
+
+ )
+ }
+
+ static importJSON(serializedNode: SerializedNode): HITLInputNode {
+ const node = $createHITLInputNode(
+ serializedNode.variableName,
+ serializedNode.nodeId,
+ serializedNode.formInputs,
+ serializedNode.onFormInputsChange,
+ serializedNode.onFormInputItemRename,
+ serializedNode.onFormInputItemRemove,
+ serializedNode.workflowNodesMap,
+ serializedNode.getVarType,
+ serializedNode.environmentVariables,
+ serializedNode.conversationVariables,
+ serializedNode.ragVariables,
+ serializedNode.readonly,
+ )
+
+ return node
+ }
+
+ exportJSON(): SerializedNode {
+ return {
+ type: 'hitl-input-block',
+ version: 1,
+ variableName: this.getVariableName(),
+ nodeId: this.getNodeId(),
+ formInputs: this.getFormInputs(),
+ onFormInputsChange: this.getOnFormInputsChange(),
+ onFormInputItemRename: this.getOnFormInputItemRename(),
+ onFormInputItemRemove: this.getOnFormInputItemRemove(),
+ workflowNodesMap: this.getWorkflowNodesMap(),
+ getVarType: this.getGetVarType(),
+ environmentVariables: this.getEnvironmentVariables(),
+ conversationVariables: this.getConversationVariables(),
+ ragVariables: this.getRagVariables(),
+ readonly: this.getReadonly(),
+ }
+ }
+
+ getTextContent(): string {
+ return `{{#$output.${this.getVariableName()}#}}`
+ }
+}
+
+export function $createHITLInputNode(
+ variableName: string,
+ nodeId: string,
+ formInputs: FormInputItem[],
+ onFormInputsChange: (inputs: FormInputItem[]) => void,
+ onFormInputItemRename: (payload: FormInputItem, oldName: string) => void,
+ onFormInputItemRemove: (varName: string) => void,
+ workflowNodesMap: WorkflowNodesMap,
+ getVarType?: GetVarType,
+ environmentVariables?: Var[],
+ conversationVariables?: Var[],
+ ragVariables?: Var[],
+ readonly?: boolean,
+): HITLInputNode {
+ return new HITLInputNode(
+ variableName,
+ nodeId,
+ formInputs,
+ onFormInputsChange,
+ onFormInputItemRename,
+ onFormInputItemRemove,
+ workflowNodesMap,
+ getVarType,
+ environmentVariables,
+ conversationVariables,
+ ragVariables,
+ readonly,
+ )
+}
+
+export function $isHITLInputNode(
+ node: HITLInputNode | LexicalNode | null | undefined,
+): node is HITLInputNode {
+ return node instanceof HITLInputNode
+}
diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/pre-populate.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/pre-populate.tsx
new file mode 100644
index 0000000000..bbcf5485b9
--- /dev/null
+++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/pre-populate.tsx
@@ -0,0 +1,148 @@
+'use client'
+import type { FC } from 'react'
+import type { ValueSelector, Var } from '@/app/components/workflow/types'
+import * as React from 'react'
+import { useCallback, useEffect, useState } from 'react'
+import { Trans, useTranslation } from 'react-i18next'
+import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
+import { VarType } from '@/app/components/workflow/types'
+import { cn } from '@/utils/classnames'
+import Textarea from '../../../textarea'
+import TagLabel from './tag-label'
+import TypeSwitch from './type-switch'
+
+type Props = {
+ isVariable?: boolean
+ onIsVariableChange?: (isVariable: boolean) => void
+ nodeId: string
+ valueSelector?: ValueSelector
+ onValueSelectorChange?: (valueSelector: ValueSelector | string) => void
+ value?: string
+ onValueChange?: (value: string) => void
+}
+
+const i18nPrefix = 'nodes.humanInput.insertInputField'
+
+type PlaceholderProps = {
+ varPickerProps: {
+ nodeId: string
+ value: ValueSelector
+ onChange: (valueSelector: ValueSelector | string) => void
+ readonly: boolean
+ zIndex: number
+ filterVar: (varPayload: Var) => boolean
+ isJustShowValue?: boolean
+ }
+ onTypeClick: (isVariable: boolean) => void
+}
+const Placeholder = ({
+ varPickerProps,
+ onTypeClick,
+}: PlaceholderProps) => {
+ const { t } = useTranslation()
+ return (
+
+
+ onTypeClick(false)}>{t(`${i18nPrefix}.staticContent`, { ns: 'workflow' })},
+ variable: (
+ {t(`${i18nPrefix}.variable`, { ns: 'workflow' })}
+ }
+ />
+ ),
+ }}
+ />
+
+
+ )
+}
+
+const PrePopulate: FC = ({
+ isVariable = false,
+ onIsVariableChange,
+ nodeId,
+ valueSelector,
+ onValueSelectorChange,
+ value,
+ onValueChange,
+}) => {
+ const [onPlaceholderClicked, setOnPlaceholderClicked] = useState(false)
+ const handleTypeChange = useCallback((isVar: boolean) => {
+ setOnPlaceholderClicked(true)
+ onIsVariableChange?.(isVar)
+ }, [onIsVariableChange])
+
+ const [isFocus, setIsFocus] = useState(false)
+
+ const varPickerProps = {
+ nodeId,
+ value: valueSelector || [],
+ onChange: onValueSelectorChange!,
+ readonly: false,
+ zIndex: 1000000, // bigger than shortcut plugin popup
+ filterVar: (varPayload: Var) => {
+ return [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
+ },
+ }
+
+ const isShowPlaceholder = !onPlaceholderClicked && (isVariable ? (!valueSelector || valueSelector.length === 0) : !value)
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Tab' && !onPlaceholderClicked) {
+ e.preventDefault()
+ setOnPlaceholderClicked(true)
+ }
+ }
+ window.addEventListener('keydown', handleKeyDown)
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown)
+ }
+ }, [onPlaceholderClicked, setOnPlaceholderClicked])
+
+ if (isShowPlaceholder)
+ return
+
+ if (isVariable) {
+ return (
+
+
+
+
+ )
+ }
+ return (
+
+
+ )
+}
+export default React.memo(PrePopulate)
diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/tag-label.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/tag-label.tsx
new file mode 100644
index 0000000000..c414db866d
--- /dev/null
+++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/tag-label.tsx
@@ -0,0 +1,32 @@
+'use client'
+import type { FC } from 'react'
+import { RiEditLine } from '@remixicon/react'
+import * as React from 'react'
+import { cn } from '@/utils/classnames'
+import { Variable02 } from '../../../icons/src/vender/solid/development'
+
+type Props = {
+ type: 'edit' | 'variable'
+ children: string
+ className?: string
+ onClick?: () => void
+}
+
+const TagLabel: FC = ({
+ type,
+ children,
+ className,
+ onClick,
+}) => {
+ const Icon = type === 'edit' ? RiEditLine : Variable02
+ return (
+
+ )
+}
+export default React.memo(TagLabel)
diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/type-switch.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/type-switch.tsx
new file mode 100644
index 0000000000..667f0605e8
--- /dev/null
+++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/type-switch.tsx
@@ -0,0 +1,27 @@
+'use client'
+import type { FC } from 'react'
+import * as React from 'react'
+import { useTranslation } from 'react-i18next'
+import { cn } from '@/utils/classnames'
+import { Variable02 } from '../../../icons/src/vender/solid/development'
+
+type Props = {
+ className?: string
+ isVariable?: boolean
+ onIsVariableChange?: (isVariable: boolean) => void
+}
+
+const TypeSwitch: FC = ({
+ className,
+ isVariable,
+ onIsVariableChange,
+}) => {
+ const { t } = useTranslation()
+ return (
+ onIsVariableChange?.(!isVariable)}>
+
+
{t(`nodes.humanInput.insertInputField.${isVariable ? 'useConstantInstead' : 'useVarInstead'}`, { ns: 'workflow' })}
+
+ )
+}
+export default React.memo(TypeSwitch)
diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx
new file mode 100644
index 0000000000..b1374b994f
--- /dev/null
+++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/variable-block.tsx
@@ -0,0 +1,148 @@
+import type { WorkflowNodesMap } from '../workflow-variable-block/node'
+import type { ValueSelector, Var } from '@/app/components/workflow/types'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { mergeRegister } from '@lexical/utils'
+import {
+ COMMAND_PRIORITY_EDITOR,
+} from 'lexical'
+import {
+ memo,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react'
+import { useTranslation } from 'react-i18next'
+import Tooltip from '@/app/components/base/tooltip'
+import {
+ isConversationVar,
+ isENV,
+ isGlobalVar,
+ isRagVariableVar,
+ isSystemVar,
+} from '@/app/components/workflow/nodes/_base/components/variable/utils'
+import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel'
+import {
+ VariableLabelInEditor,
+} from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
+import { Type } from '@/app/components/workflow/nodes/llm/types'
+import { isExceptionVariable } from '@/app/components/workflow/utils'
+import { UPDATE_WORKFLOW_NODES_MAP } from '../workflow-variable-block'
+import { HITLInputNode } from './node'
+
+type HITLInputVariableBlockComponentProps = {
+ variables: string[]
+ workflowNodesMap: WorkflowNodesMap
+ environmentVariables?: Var[]
+ conversationVariables?: Var[]
+ ragVariables?: Var[]
+ getVarType?: (payload: {
+ nodeId: string
+ valueSelector: ValueSelector
+ }) => Type
+}
+
+const HITLInputVariableBlockComponent = ({
+ variables,
+ workflowNodesMap = {},
+ getVarType,
+ environmentVariables,
+ conversationVariables,
+ ragVariables,
+}: HITLInputVariableBlockComponentProps) => {
+ const { t } = useTranslation()
+ const [editor] = useLexicalComposerContext()
+ const variablesLength = variables.length
+ const isRagVar = isRagVariableVar(variables)
+ const isShowAPart = variablesLength > 2 && !isRagVar
+ const varName = (
+ () => {
+ const isSystem = isSystemVar(variables)
+ const varName = variables[variablesLength - 1]
+ return `${isSystem ? 'sys.' : ''}${varName}`
+ }
+ )()
+ const [localWorkflowNodesMap, setLocalWorkflowNodesMap] = useState(workflowNodesMap)
+ const node = localWorkflowNodesMap![variables[isRagVar ? 1 : 0]]
+
+ const isException = isExceptionVariable(varName, node?.type)
+ const variableValid = useMemo(() => {
+ let variableValid = true
+ const isEnv = isENV(variables)
+ const isChatVar = isConversationVar(variables)
+ const isGlobal = isGlobalVar(variables)
+ if (isGlobal)
+ return true
+
+ if (isEnv) {
+ if (environmentVariables)
+ variableValid = environmentVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
+ }
+ else if (isChatVar) {
+ if (conversationVariables)
+ variableValid = conversationVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
+ }
+ else if (isRagVar) {
+ if (ragVariables)
+ variableValid = ragVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}.${variables?.[2] ?? ''}`)
+ }
+ else {
+ variableValid = !!node
+ }
+ return variableValid
+ }, [variables, node, environmentVariables, conversationVariables, isRagVar, ragVariables])
+
+ useEffect(() => {
+ if (!editor.hasNodes([HITLInputNode]))
+ throw new Error('HITLInputNodePlugin: HITLInputNode not registered on editor')
+
+ return mergeRegister(
+ editor.registerCommand(
+ UPDATE_WORKFLOW_NODES_MAP,
+ (workflowNodesMap: WorkflowNodesMap) => {
+ setLocalWorkflowNodesMap(workflowNodesMap)
+
+ return true
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ )
+ }, [editor])
+
+ const Item = (
+
+ )
+
+ if (!node)
+ return Item
+
+ return (
+
+ )}
+ disabled={!isShowAPart}
+ >
+ {Item}
+
+ )
+}
+
+export default memo(HITLInputVariableBlockComponent)
diff --git a/web/app/components/base/prompt-editor/plugins/request-url-block/component.tsx b/web/app/components/base/prompt-editor/plugins/request-url-block/component.tsx
new file mode 100644
index 0000000000..80b5ef94da
--- /dev/null
+++ b/web/app/components/base/prompt-editor/plugins/request-url-block/component.tsx
@@ -0,0 +1,33 @@
+import type { FC } from 'react'
+import { RiGlobalLine } from '@remixicon/react'
+import { useTranslation } from 'react-i18next'
+
+import { cn } from '@/utils/classnames'
+import { useSelectOrDelete } from '../../hooks'
+import { DELETE_REQUEST_URL_BLOCK_COMMAND } from './index'
+
+type RequestURLBlockComponentProps = {
+ nodeKey: string
+}
+
+const RequestURLBlockComponent: FC = ({
+ nodeKey,
+}) => {
+ const { t } = useTranslation()
+ const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_REQUEST_URL_BLOCK_COMMAND)
+
+ return (
+
+
+
{t('promptEditor.requestURL.item.title', { ns: 'common' })}
+
+ )
+}
+
+export default RequestURLBlockComponent
diff --git a/web/app/components/base/prompt-editor/plugins/request-url-block/index.tsx b/web/app/components/base/prompt-editor/plugins/request-url-block/index.tsx
new file mode 100644
index 0000000000..8f41f449b6
--- /dev/null
+++ b/web/app/components/base/prompt-editor/plugins/request-url-block/index.tsx
@@ -0,0 +1,64 @@
+import type { RequestURLBlockType } from '../../types'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { mergeRegister } from '@lexical/utils'
+import {
+ $insertNodes,
+ COMMAND_PRIORITY_EDITOR,
+ createCommand,
+} from 'lexical'
+import {
+ memo,
+ useEffect,
+} from 'react'
+import {
+ $createRequestURLBlockNode,
+ RequestURLBlockNode,
+} from './node'
+
+export const INSERT_REQUEST_URL_BLOCK_COMMAND = createCommand('INSERT_REQUEST_URL_BLOCK_COMMAND')
+export const DELETE_REQUEST_URL_BLOCK_COMMAND = createCommand('DELETE_REQUEST_URL_BLOCK_COMMAND')
+
+const RequestURLBlock = memo(({
+ onInsert,
+ onDelete,
+}: RequestURLBlockType) => {
+ const [editor] = useLexicalComposerContext()
+
+ useEffect(() => {
+ if (!editor.hasNodes([RequestURLBlockNode]))
+ throw new Error('RequestURLBlockPlugin: RequestURLBlock not registered on editor')
+
+ return mergeRegister(
+ editor.registerCommand(
+ INSERT_REQUEST_URL_BLOCK_COMMAND,
+ () => {
+ const contextBlockNode = $createRequestURLBlockNode()
+
+ $insertNodes([contextBlockNode])
+ if (onInsert)
+ onInsert()
+
+ return true
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand(
+ DELETE_REQUEST_URL_BLOCK_COMMAND,
+ () => {
+ if (onDelete)
+ onDelete()
+
+ return true
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ )
+ }, [editor, onInsert, onDelete])
+
+ return null
+})
+RequestURLBlock.displayName = 'RequestURLBlock'
+
+export { RequestURLBlock }
+export { RequestURLBlockNode } from './node'
+export { default as RequestURLBlockReplacementBlock } from './request-url-block-replacement-block'
diff --git a/web/app/components/base/prompt-editor/plugins/request-url-block/node.tsx b/web/app/components/base/prompt-editor/plugins/request-url-block/node.tsx
new file mode 100644
index 0000000000..b1e74aa3a6
--- /dev/null
+++ b/web/app/components/base/prompt-editor/plugins/request-url-block/node.tsx
@@ -0,0 +1,59 @@
+import type { LexicalNode, SerializedLexicalNode } from 'lexical'
+import { DecoratorNode } from 'lexical'
+import RequestURLBlockComponent from './component'
+
+export type SerializedNode = SerializedLexicalNode
+
+export class RequestURLBlockNode extends DecoratorNode {
+ static getType(): string {
+ return 'request-url-block'
+ }
+
+ static clone(node: RequestURLBlockNode): RequestURLBlockNode {
+ return new RequestURLBlockNode(node.__key)
+ }
+
+ isInline(): boolean {
+ return true
+ }
+
+ createDOM(): HTMLElement {
+ const div = document.createElement('div')
+ div.classList.add('inline-flex', 'items-center', 'align-middle')
+ return div
+ }
+
+ updateDOM(): false {
+ return false
+ }
+
+ decorate(): React.JSX.Element {
+ return
+ }
+
+ static importJSON(): RequestURLBlockNode {
+ const node = $createRequestURLBlockNode()
+
+ return node
+ }
+
+ exportJSON(): SerializedNode {
+ return {
+ type: 'request-url-block',
+ version: 1,
+ }
+ }
+
+ getTextContent(): string {
+ return '{{#url#}}'
+ }
+}
+export function $createRequestURLBlockNode(): RequestURLBlockNode {
+ return new RequestURLBlockNode()
+}
+
+export function $isRequestURLBlockNode(
+ node: RequestURLBlockNode | LexicalNode | null | undefined,
+): node is RequestURLBlockNode {
+ return node instanceof RequestURLBlockNode
+}
diff --git a/web/app/components/base/prompt-editor/plugins/request-url-block/request-url-block-replacement-block.tsx b/web/app/components/base/prompt-editor/plugins/request-url-block/request-url-block-replacement-block.tsx
new file mode 100644
index 0000000000..7cac5d0e19
--- /dev/null
+++ b/web/app/components/base/prompt-editor/plugins/request-url-block/request-url-block-replacement-block.tsx
@@ -0,0 +1,60 @@
+import type { RequestURLBlockType } from '../../types'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { mergeRegister } from '@lexical/utils'
+import { $applyNodeReplacement } from 'lexical'
+import {
+ memo,
+ useCallback,
+ useEffect,
+} from 'react'
+import { REQUEST_URL_PLACEHOLDER_TEXT } from '../../constants'
+import { decoratorTransform } from '../../utils'
+import { CustomTextNode } from '../custom-text/node'
+import {
+ $createRequestURLBlockNode,
+ RequestURLBlockNode,
+} from '../request-url-block/node'
+
+const REGEX = new RegExp(REQUEST_URL_PLACEHOLDER_TEXT)
+
+const RequestURLBlockReplacementBlock = ({
+ onInsert,
+}: RequestURLBlockType) => {
+ const [editor] = useLexicalComposerContext()
+
+ useEffect(() => {
+ if (!editor.hasNodes([RequestURLBlockNode]))
+ throw new Error('RequestURLBlockNodePlugin: RequestURLBlockNode not registered on editor')
+ }, [editor])
+
+ const createRequestURLBlockNode = useCallback((): RequestURLBlockNode => {
+ if (onInsert)
+ onInsert()
+ return $applyNodeReplacement($createRequestURLBlockNode())
+ }, [onInsert])
+
+ const getMatch = useCallback((text: string) => {
+ const matchArr = REGEX.exec(text)
+
+ if (matchArr === null)
+ return null
+
+ const startOffset = matchArr.index
+ const endOffset = startOffset + REQUEST_URL_PLACEHOLDER_TEXT.length
+ return {
+ end: endOffset,
+ start: startOffset,
+ }
+ }, [])
+
+ useEffect(() => {
+ REGEX.lastIndex = 0
+ return mergeRegister(
+ editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createRequestURLBlockNode)),
+ )
+ }, [])
+
+ return null
+}
+
+export default memo(RequestURLBlockReplacementBlock)
diff --git a/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.spec.tsx
new file mode 100644
index 0000000000..ad44970187
--- /dev/null
+++ b/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.spec.tsx
@@ -0,0 +1,134 @@
+import { LexicalComposer } from '@lexical/react/LexicalComposer'
+import { ContentEditable } from '@lexical/react/LexicalContentEditable'
+import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
+import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import * as React from 'react'
+import { useState } from 'react'
+import ShortcutsPopupPlugin, { SHORTCUTS_EMPTY_CONTENT } from './index'
+import '@testing-library/jest-dom'
+
+// Mock Range.getClientRects and getBoundingClientRect for JSDOM
+const mockDOMRect = {
+ x: 100,
+ y: 100,
+ width: 100,
+ height: 20,
+ top: 100,
+ right: 200,
+ bottom: 120,
+ left: 100,
+ toJSON: () => ({}),
+}
+
+beforeAll(() => {
+ // Mock getClientRects on Range prototype
+ Range.prototype.getClientRects = vi.fn(() => {
+ const rectList = [mockDOMRect] as unknown as DOMRectList
+ Object.defineProperty(rectList, 'length', { value: 1 })
+ Object.defineProperty(rectList, 'item', { value: (index: number) => index === 0 ? mockDOMRect : null })
+ return rectList
+ })
+
+ // Mock getBoundingClientRect on Range prototype
+ Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect)
+})
+
+const CONTAINER_ID = 'host'
+const CONTENT_EDITABLE_ID = 'ce'
+
+const MinimalEditor: React.FC<{
+ withContainer?: boolean
+}> = ({ withContainer = true }) => {
+ const initialConfig = {
+ namespace: 'shortcuts-popup-plugin-test',
+ onError: (e: Error) => {
+ throw e
+ },
+ }
+ const [containerEl, setContainerEl] = useState(null)
+
+ return (
+
+
+ }
+ placeholder={null}
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+
+
+
+ )
+}
+
+describe('ShortcutsPopupPlugin', () => {
+ it('opens on hotkey when editor is focused', async () => {
+ render( )
+ const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
+ ce.focus()
+
+ fireEvent.keyDown(document, { key: '/', ctrlKey: true }) // 模拟 Ctrl+/
+ expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
+ })
+
+ it('does not open when editor is not focused', async () => {
+ render( )
+ // 未聚焦
+ fireEvent.keyDown(document, { key: '/', ctrlKey: true })
+ await waitFor(() => {
+ expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
+ })
+ })
+
+ it('closes on Escape', async () => {
+ render( )
+ const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
+ ce.focus()
+
+ fireEvent.keyDown(document, { key: '/', ctrlKey: true })
+ expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
+
+ fireEvent.keyDown(document, { key: 'Escape' })
+ await waitFor(() => {
+ expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
+ })
+ })
+
+ it('closes on click outside', async () => {
+ render( )
+ const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
+ ce.focus()
+
+ fireEvent.keyDown(document, { key: '/', ctrlKey: true })
+ expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
+
+ fireEvent.mouseDown(ce)
+ await waitFor(() => {
+ expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
+ })
+ })
+
+ it('portals into provided container when container is set', async () => {
+ render( )
+ const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
+ const host = screen.getByTestId(CONTAINER_ID)
+ ce.focus()
+
+ fireEvent.keyDown(document, { key: '/', ctrlKey: true })
+ const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
+ expect(host).toContainElement(portalContent)
+ })
+
+ it('falls back to document.body when container is not provided', async () => {
+ render( )
+ const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
+ ce.focus()
+
+ fireEvent.keyDown(document, { key: '/', ctrlKey: true })
+ const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
+ expect(document.body).toContainElement(portalContent)
+ })
+})
diff --git a/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx b/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx
new file mode 100644
index 0000000000..bf559193ec
--- /dev/null
+++ b/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx
@@ -0,0 +1,305 @@
+import type { LexicalCommand } from 'lexical'
+import {
+ autoUpdate,
+ flip,
+ offset,
+ shift,
+ size,
+ useFloating,
+} from '@floating-ui/react'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import {
+ $getSelection,
+ $isRangeSelection,
+} from 'lexical'
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import { createPortal } from 'react-dom'
+import { cn } from '@/utils/classnames'
+
+export const SHORTCUTS_EMPTY_CONTENT = 'shortcuts_empty_content'
+
+// Hotkey can be:
+// - string: 'mod+/'
+// - string[]: ['mod', '/']
+// - string[][]: [['mod', '/'], ['mod', 'shift', '/']] (any combo matches)
+// - function: custom matcher
+export type Hotkey = string | string[] | string[][] | ((e: KeyboardEvent) => boolean)
+
+type ShortcutPopupPluginProps = {
+ hotkey?: Hotkey
+ children?: React.ReactNode | ((close: () => void, onInsert: (command: LexicalCommand, params: any[]) => void) => React.ReactNode)
+ className?: string
+ container?: Element | null
+ onOpen?: () => void
+ onClose?: () => void
+}
+
+const META_ALIASES = new Set(['meta', 'cmd', 'command'])
+const CTRL_ALIASES = new Set(['ctrl'])
+const ALT_ALIASES = new Set(['alt', 'option'])
+const SHIFT_ALIASES = new Set(['shift'])
+
+function matchHotkey(event: KeyboardEvent, hotkey?: Hotkey) {
+ if (!hotkey)
+ return false
+
+ if (typeof hotkey === 'function')
+ return hotkey(event)
+
+ const matchCombo = (tokens: string[]) => {
+ const parts = tokens.map(t => t.toLowerCase().trim()).filter(Boolean)
+ let expectedKey: string | null = null
+
+ let needMod = false
+ let needCtrl = false
+ let needMeta = false
+ let needAlt = false
+ let needShift = false
+
+ for (const p of parts) {
+ if (p === 'mod') {
+ needMod = true
+ continue
+ }
+ if (CTRL_ALIASES.has(p)) {
+ needCtrl = true
+ continue
+ }
+ if (META_ALIASES.has(p)) {
+ needMeta = true
+ continue
+ }
+ if (ALT_ALIASES.has(p)) {
+ needAlt = true
+ continue
+ }
+ if (SHIFT_ALIASES.has(p)) {
+ needShift = true
+ continue
+ }
+ expectedKey = p
+ }
+
+ if (needMod && !(event.metaKey || event.ctrlKey))
+ return false
+ if (needCtrl && !event.ctrlKey)
+ return false
+ if (needMeta && !event.metaKey)
+ return false
+ if (needAlt && !event.altKey)
+ return false
+ if (needShift && !event.shiftKey)
+ return false
+
+ if (expectedKey) {
+ const k = event.key.toLowerCase()
+ const normalized = k === ' ' ? 'space' : k
+ if (normalized !== expectedKey)
+ return false
+ }
+
+ return true
+ }
+
+ if (Array.isArray(hotkey)) {
+ const isNested = hotkey.length > 0 && Array.isArray((hotkey as unknown[])[0])
+ if (isNested) {
+ const combos = hotkey as string[][]
+ return combos.some(tokens => matchCombo(tokens))
+ }
+ else {
+ const tokens = hotkey as string[]
+ return matchCombo(tokens)
+ }
+ }
+
+ const tokensFromString = hotkey
+ .toLowerCase()
+ .split('+')
+ .map(t => t.trim())
+ .filter(Boolean)
+ return matchCombo(tokensFromString)
+}
+
+export default function ShortcutsPopupPlugin({
+ hotkey = 'mod+/',
+ children,
+ className,
+ container,
+ onOpen,
+ onClose,
+}: ShortcutPopupPluginProps): React.ReactPortal | null {
+ const [editor] = useLexicalComposerContext()
+ const [open, setOpen] = useState(false)
+ const portalRef = useRef(null)
+ const lastSelectionRef = useRef(null)
+
+ const containerEl = useMemo(() => container ?? (typeof document !== 'undefined' ? document.body : null), [container])
+ const useContainer = !!containerEl && containerEl !== document.body
+
+ const { refs, floatingStyles, isPositioned } = useFloating({
+ placement: 'bottom-start',
+ middleware: [
+ offset(0), // fix hide cursor
+ shift({
+ padding: 8,
+ altBoundary: true,
+ }),
+ flip(),
+ size({
+ apply({ availableWidth, availableHeight, elements }) {
+ Object.assign(elements.floating.style, {
+ maxWidth: `${Math.min(400, availableWidth)}px`,
+ maxHeight: `${Math.min(300, availableHeight)}px`,
+ overflow: 'auto',
+ })
+ },
+ padding: 8,
+ }),
+ ],
+ whileElementsMounted: autoUpdate,
+ })
+
+ useEffect(() => {
+ return editor.registerUpdateListener(({ editorState }) => {
+ editorState.read(() => {
+ const selection = $getSelection()
+ if ($isRangeSelection(selection)) {
+ const domSelection = window.getSelection()
+ if (domSelection && domSelection.rangeCount > 0)
+ lastSelectionRef.current = domSelection.getRangeAt(0).cloneRange()
+ }
+ })
+ })
+ }, [editor])
+
+ const isEditorFocused = useCallback(() => {
+ const root = editor.getRootElement()
+ if (!root)
+ return false
+ return root.contains(document.activeElement)
+ }, [editor])
+
+ const openPortal = useCallback(() => {
+ const domSelection = window.getSelection()
+ let range: Range | null = null
+ if (domSelection && domSelection.rangeCount > 0)
+ range = domSelection.getRangeAt(0).cloneRange()
+ else
+ range = lastSelectionRef.current
+
+ if (range) {
+ const rects = range.getClientRects()
+ let rect: DOMRect | null = null
+
+ if (rects && rects.length)
+ rect = rects[rects.length - 1]
+
+ else
+ rect = range.getBoundingClientRect()
+
+ if (rect.width === 0 && rect.height === 0) {
+ const root = editor.getRootElement()
+ if (root) {
+ const sc = range.startContainer
+ const node = sc.nodeType === Node.ELEMENT_NODE
+ ? sc as Element
+ : (sc.parentElement || root)
+
+ rect = node.getBoundingClientRect()
+
+ if (rect.width === 0 && rect.height === 0)
+ rect = root.getBoundingClientRect()
+ }
+ }
+
+ if (rect && !(rect.top === 0 && rect.left === 0 && rect.width === 0 && rect.height === 0)) {
+ const virtualEl = {
+ getBoundingClientRect() {
+ return rect!
+ },
+ }
+ refs.setReference(virtualEl as Element)
+ }
+ }
+
+ setOpen(true)
+ onOpen?.()
+ }, [onOpen])
+
+ const closePortal = useCallback(() => {
+ setOpen(false)
+ onClose?.()
+ }, [onClose])
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (open && event.key === 'Escape') {
+ event.stopPropagation()
+ event.preventDefault()
+ closePortal()
+ return
+ }
+
+ if (!isEditorFocused())
+ return
+
+ if (matchHotkey(event, hotkey)) {
+ event.preventDefault()
+ openPortal()
+ }
+ }
+
+ document.addEventListener('keydown', handleKeyDown, true)
+ return () => document.removeEventListener('keydown', handleKeyDown, true)
+ }, [hotkey, open, isEditorFocused, openPortal, closePortal])
+
+ useEffect(() => {
+ if (!open)
+ return
+
+ const onMouseDown = (e: MouseEvent) => {
+ if (!portalRef.current)
+ return
+ if (!portalRef.current.contains(e.target as Node))
+ closePortal()
+ }
+ document.addEventListener('mousedown', onMouseDown, false)
+ return () => document.removeEventListener('mousedown', onMouseDown, false)
+ }, [open, closePortal])
+
+ const handleInsert = useCallback((command: LexicalCommand, params: any) => {
+ editor.dispatchCommand(command, params)
+ closePortal()
+ }, [editor, closePortal])
+
+ if (!open || !containerEl)
+ return null
+
+ return createPortal(
+ {
+ portalRef.current = node
+ refs.setFloating(node)
+ }}
+ className={cn(
+ useContainer ? '' : 'z-[999999]',
+ 'absolute rounded-xl bg-components-panel-bg-blur shadow-lg',
+ className,
+ )}
+ style={{
+ ...floatingStyles,
+ visibility: isPositioned ? 'visible' : 'hidden',
+ }}
+ >
+ {typeof children === 'function' ? children(closePortal, handleInsert) : (children ?? SHORTCUTS_EMPTY_CONTENT)}
+
,
+ containerEl,
+ )
+}
diff --git a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/workflow-variable-block-replacement-block.tsx b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/workflow-variable-block-replacement-block.tsx
index 615fa837e1..573c97f465 100644
--- a/web/app/components/base/prompt-editor/plugins/workflow-variable-block/workflow-variable-block-replacement-block.tsx
+++ b/web/app/components/base/prompt-editor/plugins/workflow-variable-block/workflow-variable-block-replacement-block.tsx
@@ -40,7 +40,7 @@ const WorkflowVariableBlockReplacementBlock = ({
const nodePathString = textNode.getTextContent().slice(3, -3)
return $applyNodeReplacement($createWorkflowVariableBlockNode(nodePathString.split('.'), workflowNodesMap, getVarType, variables?.find(o => o.nodeId === 'env')?.vars || [], variables?.find(o => o.nodeId === 'conversation')?.vars || [], ragVariables))
- }, [onInsert, workflowNodesMap, getVarType, variables])
+ }, [onInsert, workflowNodesMap, getVarType, variables, ragVariables])
const getMatch = useCallback((text: string) => {
const matchArr = REGEX.exec(text)
diff --git a/web/app/components/base/prompt-editor/types.ts b/web/app/components/base/prompt-editor/types.ts
index 875f9eab07..99221ffebd 100644
--- a/web/app/components/base/prompt-editor/types.ts
+++ b/web/app/components/base/prompt-editor/types.ts
@@ -1,4 +1,5 @@
import type { GeneratorType } from '../../app/configuration/config/automatic/types'
+import type { FormInputItem } from '../../workflow/nodes/human-input/types'
import type { Type } from '../../workflow/nodes/llm/types'
import type { Dataset } from './plugins/context-block'
import type { RoleName } from './plugins/history-block'
@@ -46,6 +47,13 @@ export type HistoryBlockType = {
onEditRole?: () => void
}
+export type RequestURLBlockType = {
+ show?: boolean
+ selectable?: boolean
+ onInsert?: () => void
+ onDelete?: () => void
+}
+
export type VariableBlockType = {
show?: boolean
variables?: Option[]
@@ -73,6 +81,21 @@ export type WorkflowVariableBlockType = {
onManageInputField?: () => void
}
+export type HITLInputBlockType = {
+ show?: boolean
+ nodeId: string
+ formInputs?: FormInputItem[]
+ variables?: NodeOutPutVar[]
+ workflowNodesMap?: Record>
+ getVarType?: GetVarType
+ onFormInputsChange?: (inputs: FormInputItem[]) => void
+ onFormInputItemRemove: (varName: string) => void
+ onFormInputItemRename: (payload: FormInputItem, oldName: string) => void
+ onInsert?: () => void
+ onDelete?: () => void
+ readonly?: boolean
+}
+
export type MenuTextMatch = {
leadOffset: number
matchingString: string
diff --git a/web/app/components/billing/plan/index.spec.tsx b/web/app/components/billing/plan/index.spec.tsx
index fb1800653e..db22b47db4 100644
--- a/web/app/components/billing/plan/index.spec.tsx
+++ b/web/app/components/billing/plan/index.spec.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
import { Plan } from '../type'
import PlanComp from './index'
@@ -188,7 +188,9 @@ describe('PlanComp', () => {
expect(lastCall.onCancel).toBeDefined()
// Call onConfirm to close modal
- lastCall.onConfirm()
- lastCall.onCancel()
+ act(() => {
+ lastCall.onConfirm()
+ lastCall.onCancel()
+ })
})
})
diff --git a/web/app/components/billing/type.ts b/web/app/components/billing/type.ts
index 1522de63b2..e3eb8b6799 100644
--- a/web/app/components/billing/type.ts
+++ b/web/app/components/billing/type.ts
@@ -118,6 +118,7 @@ export type CurrentPlanInfoBackend = {
knowledge_pipeline: {
publish_enabled: boolean
}
+ human_input_email_delivery_enabled: boolean
}
export type SubscriptionItem = {
diff --git a/web/app/components/billing/utils/index.spec.ts b/web/app/components/billing/utils/index.spec.ts
index 03a159c18a..d85155d6ff 100644
--- a/web/app/components/billing/utils/index.spec.ts
+++ b/web/app/components/billing/utils/index.spec.ts
@@ -94,6 +94,7 @@ describe('billing utils', () => {
knowledge_pipeline: {
publish_enabled: false,
},
+ human_input_email_delivery_enabled: false,
...overrides,
})
diff --git a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx
index 543d3deebc..35539a2144 100644
--- a/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx
+++ b/web/app/components/plugins/plugin-detail-panel/subscription-list/create/common-modal.spec.tsx
@@ -1247,6 +1247,10 @@ describe('CommonCreateModal', () => {
const input = screen.getByTestId('form-field-webhook_url')
fireEvent.change(input, { target: { value: 'https://example.com/webhook' } })
+ await waitFor(() => {
+ expect(mockUpdateBuilder).toHaveBeenCalled()
+ })
+
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
diff --git a/web/app/components/rag-pipeline/components/index.spec.tsx b/web/app/components/rag-pipeline/components/index.spec.tsx
index 827fe124a7..e17f07303d 100644
--- a/web/app/components/rag-pipeline/components/index.spec.tsx
+++ b/web/app/components/rag-pipeline/components/index.spec.tsx
@@ -295,6 +295,8 @@ vi.mock('@/utils/var', () => ({
// Mock provider context
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => createMockProviderContextValue(),
+ useProviderContextSelector: (selector: (state: ReturnType) => T): T =>
+ selector(createMockProviderContextValue()),
}))
// Mock WorkflowWithInnerContext
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/index.spec.tsx
index 1a7a7422c1..4f3465a920 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/index.spec.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/index.spec.tsx
@@ -141,6 +141,8 @@ vi.mock('@/context/modal-context', () => ({
let mockProviderContextValue = createMockProviderContextValue()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockProviderContextValue,
+ useProviderContextSelector: (selector: (s: ReturnType) => T): T =>
+ selector(mockProviderContextValue),
}))
// Mock event emitter context
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.spec.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.spec.tsx
index 86cd15db97..2a01218ee6 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.spec.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/index.spec.tsx
@@ -131,6 +131,8 @@ vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
isAllowPublishAsCustomKnowledgePipelineTemplate: mockIsAllowPublishAsCustomKnowledgePipelineTemplate(),
}),
+ useProviderContextSelector: (selector: (s: { isAllowPublishAsCustomKnowledgePipelineTemplate: boolean }) => T): T =>
+ selector({ isAllowPublishAsCustomKnowledgePipelineTemplate: mockIsAllowPublishAsCustomKnowledgePipelineTemplate() }),
}))
// Mock toast context
diff --git a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx
index c66b293d8a..2dd56b4277 100644
--- a/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx
+++ b/web/app/components/rag-pipeline/components/rag-pipeline-header/publisher/popup.tsx
@@ -37,7 +37,7 @@ import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDocLink } from '@/context/i18n'
import { useModalContextSelector } from '@/context/modal-context'
-import { useProviderContext } from '@/context/provider-context'
+import { useProviderContextSelector } from '@/context/provider-context'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
@@ -68,7 +68,7 @@ const Popup = () => {
const { mutateAsync: publishWorkflow } = usePublishWorkflow()
const { notify } = useToastContext()
const workflowStore = useWorkflowStore()
- const { isAllowPublishAsCustomKnowledgePipelineTemplate } = useProviderContext()
+ const isAllowPublishAsCustomKnowledgePipelineTemplate = useProviderContextSelector(s => s.isAllowPublishAsCustomKnowledgePipelineTemplate)
const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal)
const apiReferenceUrl = useDatasetApiAccessUrl()
@@ -152,7 +152,7 @@ const Popup = () => {
if (confirmVisible)
hideConfirm()
}
- }, [handleCheckBeforePublish, publishWorkflow, pipelineId, notify, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, showConfirm, publishedAt, confirmVisible, hidePublishing, showPublishing, hideConfirm, publishing])
+ }, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, notify, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, hideConfirm])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
e.preventDefault()
@@ -207,15 +207,7 @@ const Popup = () => {
hidePublishingAsCustomizedPipeline()
hidePublishAsKnowledgePipelineModal()
}
- }, [
- pipelineId,
- publishAsCustomizedPipeline,
- showPublishingAsCustomizedPipeline,
- hidePublishingAsCustomizedPipeline,
- hidePublishAsKnowledgePipelineModal,
- notify,
- t,
- ])
+ }, [showPublishingAsCustomizedPipeline, publishAsCustomizedPipeline, pipelineId, notify, t, invalidCustomizedTemplateList, hidePublishingAsCustomizedPipeline, hidePublishAsKnowledgePipelineModal])
const handleClickPublishAsKnowledgePipeline = useCallback(() => {
if (!isAllowPublishAsCustomKnowledgePipelineTemplate)
diff --git a/web/app/components/rag-pipeline/hooks/use-DSL.spec.ts b/web/app/components/rag-pipeline/hooks/use-DSL.spec.ts
index 0f235516e0..1b258058db 100644
--- a/web/app/components/rag-pipeline/hooks/use-DSL.spec.ts
+++ b/web/app/components/rag-pipeline/hooks/use-DSL.spec.ts
@@ -67,6 +67,12 @@ vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: (url: string) => mockFetchWorkflowDraft(url),
}))
+// Mock download utility
+const mockDownloadBlob = vi.fn()
+vi.mock('@/utils/download', () => ({
+ downloadBlob: (options: { data: Blob, fileName: string }) => mockDownloadBlob(options),
+}))
+
// Mock workflow constants
vi.mock('@/app/components/workflow/constants', () => ({
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
@@ -77,33 +83,9 @@ vi.mock('@/app/components/workflow/constants', () => ({
// ============================================================================
describe('useDSL', () => {
- let mockLink: { href: string, download: string, click: ReturnType }
- let originalCreateElement: typeof document.createElement
- let mockCreateObjectURL: ReturnType
- let mockRevokeObjectURL: ReturnType
-
beforeEach(() => {
vi.clearAllMocks()
- // Create a proper mock link element
- mockLink = {
- href: '',
- download: '',
- click: vi.fn(),
- }
-
- // Save original and mock selectively - only intercept 'a' elements
- originalCreateElement = document.createElement.bind(document)
- document.createElement = vi.fn((tagName: string) => {
- if (tagName === 'a') {
- return mockLink as unknown as HTMLElement
- }
- return originalCreateElement(tagName)
- }) as typeof document.createElement
-
- mockCreateObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-url')
- mockRevokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
-
// Default store state
mockWorkflowStoreGetState.mockReturnValue({
pipelineId: 'test-pipeline-id',
@@ -118,9 +100,6 @@ describe('useDSL', () => {
})
afterEach(() => {
- document.createElement = originalCreateElement
- mockCreateObjectURL.mockRestore()
- mockRevokeObjectURL.mockRestore()
vi.clearAllMocks()
})
@@ -187,9 +166,10 @@ describe('useDSL', () => {
await result.current.handleExportDSL()
})
- expect(document.createElement).toHaveBeenCalledWith('a')
- expect(mockCreateObjectURL).toHaveBeenCalled()
- expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url')
+ expect(mockDownloadBlob).toHaveBeenCalled()
+ const callArg = mockDownloadBlob.mock.calls[0][0]
+ expect(callArg.data).toBeInstanceOf(Blob)
+ expect(callArg.fileName).toBe('Test Knowledge Base.pipeline')
})
it('should use correct file extension for download', async () => {
@@ -199,17 +179,23 @@ describe('useDSL', () => {
await result.current.handleExportDSL()
})
- expect(mockLink.download).toBe('Test Knowledge Base.pipeline')
+ expect(mockDownloadBlob).toHaveBeenCalledWith(
+ expect.objectContaining({
+ fileName: 'Test Knowledge Base.pipeline',
+ }),
+ )
})
- it('should trigger download click', async () => {
+ it('should trigger download with yaml blob', async () => {
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
- expect(mockLink.click).toHaveBeenCalled()
+ expect(mockDownloadBlob).toHaveBeenCalled()
+ const callArg = mockDownloadBlob.mock.calls[0][0]
+ expect(callArg.data.type).toBe('application/yaml')
})
it('should show error notification on export failure', async () => {
diff --git a/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts b/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts
index 3720ef388b..eaadf47106 100644
--- a/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts
+++ b/web/app/components/rag-pipeline/hooks/use-available-nodes-meta-data.ts
@@ -13,7 +13,8 @@ export const useAvailableNodesMetaData = () => {
const docLink = useDocLink()
const mergedNodesMetaData = useMemo(() => [
- ...WORKFLOW_COMMON_NODES,
+ // RAG pipeline doesn't support human-input node temporarily
+ ...WORKFLOW_COMMON_NODES.filter(node => node.metaData.type !== BlockEnum.HumanInput),
{
...dataSourceDefault,
defaultValue: {
@@ -47,7 +48,7 @@ export const useAvailableNodesMetaData = () => {
title,
},
}
- }), [mergedNodesMetaData, t])
+ }), [helpLinkUri, mergedNodesMetaData, t])
const availableNodesMetaDataMap = useMemo(() => availableNodesMetaData.reduce((acc, node) => {
acc![node.metaData.type] = node
diff --git a/web/app/components/rag-pipeline/hooks/use-configs-map.ts b/web/app/components/rag-pipeline/hooks/use-configs-map.ts
index aa76dbf6ca..99c4292349 100644
--- a/web/app/components/rag-pipeline/hooks/use-configs-map.ts
+++ b/web/app/components/rag-pipeline/hooks/use-configs-map.ts
@@ -20,5 +20,5 @@ export const useConfigsMap = () => {
fileUploadConfig,
},
}
- }, [pipelineId])
+ }, [fileUploadConfig, pipelineId])
}
diff --git a/web/app/components/rag-pipeline/hooks/use-pipeline-run.ts b/web/app/components/rag-pipeline/hooks/use-pipeline-run.ts
index 1f53262482..dc2a234d1e 100644
--- a/web/app/components/rag-pipeline/hooks/use-pipeline-run.ts
+++ b/web/app/components/rag-pipeline/hooks/use-pipeline-run.ts
@@ -1,7 +1,7 @@
import type { IOtherOptions } from '@/service/base'
import type { VersionHistory } from '@/types/workflow'
import { produce } from 'immer'
-import { useCallback } from 'react'
+import { useCallback, useRef } from 'react'
import {
useReactFlow,
useStoreApi,
@@ -42,6 +42,8 @@ export const usePipelineRun = () => {
handleWorkflowTextReplace,
} = useWorkflowRunEvent()
+ const abortControllerRef = useRef(null)
+
const handleBackupDraft = useCallback(() => {
const {
getNodes,
@@ -154,12 +156,18 @@ export const usePipelineRun = () => {
resultText: '',
})
+ abortControllerRef.current?.abort()
+ abortControllerRef.current = null
+
ssePost(
url,
{
body: params,
},
{
+ getAbortController: (controller: AbortController) => {
+ abortControllerRef.current = controller
+ },
onWorkflowStarted: (params) => {
handleWorkflowStarted(params)
@@ -267,31 +275,17 @@ export const usePipelineRun = () => {
...restCallback,
},
)
- }, [
- store,
- workflowStore,
- doSyncWorkflowDraft,
- handleWorkflowStarted,
- handleWorkflowFinished,
- handleWorkflowFailed,
- handleWorkflowNodeStarted,
- handleWorkflowNodeFinished,
- handleWorkflowNodeIterationStarted,
- handleWorkflowNodeIterationNext,
- handleWorkflowNodeIterationFinished,
- handleWorkflowNodeLoopStarted,
- handleWorkflowNodeLoopNext,
- handleWorkflowNodeLoopFinished,
- handleWorkflowNodeRetry,
- handleWorkflowTextChunk,
- handleWorkflowTextReplace,
- handleWorkflowAgentLog,
- ])
+ }, [store, doSyncWorkflowDraft, workflowStore, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, handleWorkflowFailed, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace])
const handleStopRun = useCallback((taskId: string) => {
const { pipelineId } = workflowStore.getState()
stopWorkflowRun(`/rag/pipelines/${pipelineId}/workflow-runs/tasks/${taskId}/stop`)
+
+ if (abortControllerRef.current)
+ abortControllerRef.current.abort()
+
+ abortControllerRef.current = null
}, [workflowStore])
const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => {
diff --git a/web/app/components/share/text-generation/result/content.spec.tsx b/web/app/components/share/text-generation/result/content.spec.tsx
deleted file mode 100644
index 242ae7aa5f..0000000000
--- a/web/app/components/share/text-generation/result/content.spec.tsx
+++ /dev/null
@@ -1,133 +0,0 @@
-import type { FeedbackType } from '@/app/components/base/chat/chat/type'
-import { cleanup, render, screen } from '@testing-library/react'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import Result from './content'
-
-// Only mock react-i18next for translations
-vi.mock('react-i18next', () => ({
- useTranslation: () => ({
- t: (key: string) => key,
- }),
-}))
-
-// Mock copy-to-clipboard for the Header component
-vi.mock('copy-to-clipboard', () => ({
- default: vi.fn(() => true),
-}))
-
-// Mock the format function from service/base
-vi.mock('@/service/base', () => ({
- format: (content: string) => content.replace(/\n/g, ' '),
-}))
-
-afterEach(() => {
- cleanup()
-})
-
-describe('Result (content)', () => {
- const mockOnFeedback = vi.fn()
-
- const defaultProps = {
- content: 'Test content here',
- showFeedback: true,
- feedback: { rating: null } as FeedbackType,
- onFeedback: mockOnFeedback,
- }
-
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- describe('rendering', () => {
- it('should render the Header component', () => {
- render( )
-
- // Header renders the result title
- expect(screen.getByText('generation.resultTitle')).toBeInTheDocument()
- })
-
- it('should render content', () => {
- render( )
-
- expect(screen.getByText('Test content here')).toBeInTheDocument()
- })
-
- it('should render formatted content with line breaks', () => {
- render(
- ,
- )
-
- // The format function converts \n to
- const contentDiv = document.querySelector('[class*="overflow-scroll"]')
- expect(contentDiv?.innerHTML).toContain('Line 1 Line 2')
- })
-
- it('should have max height style', () => {
- render( )
-
- const contentDiv = document.querySelector('[class*="overflow-scroll"]')
- expect(contentDiv).toHaveStyle({ maxHeight: '70vh' })
- })
-
- it('should render with empty content', () => {
- render(
- ,
- )
-
- expect(screen.getByText('generation.resultTitle')).toBeInTheDocument()
- })
-
- it('should render with HTML content safely', () => {
- render(
- ,
- )
-
- // Content is rendered via dangerouslySetInnerHTML
- const contentDiv = document.querySelector('[class*="overflow-scroll"]')
- expect(contentDiv).toBeInTheDocument()
- })
- })
-
- describe('feedback props', () => {
- it('should pass showFeedback to Header', () => {
- render(
- ,
- )
-
- // Feedback buttons should not be visible
- const feedbackArea = document.querySelector('[class*="space-x-1 rounded-lg border"]')
- expect(feedbackArea).not.toBeInTheDocument()
- })
-
- it('should pass feedback to Header', () => {
- render(
- ,
- )
-
- // Like button should be highlighted
- const likeButton = document.querySelector('[class*="primary"]')
- expect(likeButton).toBeInTheDocument()
- })
- })
-
- describe('memoization', () => {
- it('should be wrapped with React.memo', () => {
- expect((Result as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
- })
- })
-})
diff --git a/web/app/components/share/text-generation/result/content.tsx b/web/app/components/share/text-generation/result/content.tsx
deleted file mode 100644
index 01161d6dcd..0000000000
--- a/web/app/components/share/text-generation/result/content.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import type { FC } from 'react'
-import type { FeedbackType } from '@/app/components/base/chat/chat/type'
-import * as React from 'react'
-import { format } from '@/service/base'
-import Header from './header'
-
-export type IResultProps = {
- content: string
- showFeedback: boolean
- feedback: FeedbackType
- onFeedback: (feedback: FeedbackType) => void
-}
-const Result: FC = ({
- content,
- showFeedback,
- feedback,
- onFeedback,
-}) => {
- return (
-
- )
-}
-export default React.memo(Result)
diff --git a/web/app/components/share/text-generation/result/header.spec.tsx b/web/app/components/share/text-generation/result/header.spec.tsx
deleted file mode 100644
index b2ef0fadc4..0000000000
--- a/web/app/components/share/text-generation/result/header.spec.tsx
+++ /dev/null
@@ -1,176 +0,0 @@
-import type { FeedbackType } from '@/app/components/base/chat/chat/type'
-import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import Header from './header'
-
-// Only mock react-i18next for translations
-vi.mock('react-i18next', () => ({
- useTranslation: () => ({
- t: (key: string) => key,
- }),
-}))
-
-// Mock copy-to-clipboard
-const mockCopy = vi.fn((_text: string) => true)
-vi.mock('copy-to-clipboard', () => ({
- default: (text: string) => mockCopy(text),
-}))
-
-afterEach(() => {
- cleanup()
-})
-
-describe('Header', () => {
- const mockOnFeedback = vi.fn()
-
- const defaultProps = {
- result: 'Test result content',
- showFeedback: true,
- feedback: { rating: null } as FeedbackType,
- onFeedback: mockOnFeedback,
- }
-
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- describe('rendering', () => {
- it('should render the result title', () => {
- render()
-
- expect(screen.getByText('generation.resultTitle')).toBeInTheDocument()
- })
-
- it('should render the copy button', () => {
- render()
-
- expect(screen.getByText('generation.copy')).toBeInTheDocument()
- })
- })
-
- describe('copy functionality', () => {
- it('should copy result when copy button is clicked', () => {
- render()
-
- const copyButton = screen.getByText('generation.copy').closest('button')
- fireEvent.click(copyButton!)
-
- expect(mockCopy).toHaveBeenCalledWith('Test result content')
- })
- })
-
- describe('feedback buttons when showFeedback is true', () => {
- it('should show feedback buttons when no rating is given', () => {
- render()
-
- // Should show both thumbs up and down buttons
- const buttons = document.querySelectorAll('[class*="cursor-pointer"]')
- expect(buttons.length).toBeGreaterThan(0)
- })
-
- it('should show like button highlighted when rating is like', () => {
- render(
- ,
- )
-
- // Should show the undo button for like
- const likeButton = document.querySelector('[class*="primary"]')
- expect(likeButton).toBeInTheDocument()
- })
-
- it('should show dislike button highlighted when rating is dislike', () => {
- render(
- ,
- )
-
- // Should show the undo button for dislike
- const dislikeButton = document.querySelector('[class*="red"]')
- expect(dislikeButton).toBeInTheDocument()
- })
-
- it('should call onFeedback with like when thumbs up is clicked', () => {
- render()
-
- // Find the thumbs up button (first one in the feedback area)
- const thumbButtons = document.querySelectorAll('[class*="cursor-pointer"]')
- const thumbsUp = Array.from(thumbButtons).find(btn =>
- btn.className.includes('rounded-md') && !btn.className.includes('primary'),
- )
-
- if (thumbsUp) {
- fireEvent.click(thumbsUp)
- expect(mockOnFeedback).toHaveBeenCalledWith({ rating: 'like' })
- }
- })
-
- it('should call onFeedback with dislike when thumbs down is clicked', () => {
- render()
-
- // Find the thumbs down button
- const thumbButtons = document.querySelectorAll('[class*="cursor-pointer"]')
- const thumbsDown = Array.from(thumbButtons).pop()
-
- if (thumbsDown) {
- fireEvent.click(thumbsDown)
- expect(mockOnFeedback).toHaveBeenCalledWith({ rating: 'dislike' })
- }
- })
-
- it('should call onFeedback with null when undo like is clicked', () => {
- render(
- ,
- )
-
- // When liked, clicking the like button again should undo it (has bg-primary-100 class)
- const likeButton = document.querySelector('[class*="bg-primary-100"]')
- expect(likeButton).toBeInTheDocument()
- fireEvent.click(likeButton!)
- expect(mockOnFeedback).toHaveBeenCalledWith({ rating: null })
- })
-
- it('should call onFeedback with null when undo dislike is clicked', () => {
- render(
- ,
- )
-
- // When disliked, clicking the dislike button again should undo it (has bg-red-100 class)
- const dislikeButton = document.querySelector('[class*="bg-red-100"]')
- expect(dislikeButton).toBeInTheDocument()
- fireEvent.click(dislikeButton!)
- expect(mockOnFeedback).toHaveBeenCalledWith({ rating: null })
- })
- })
-
- describe('feedback buttons when showFeedback is false', () => {
- it('should not show feedback buttons', () => {
- render(
- ,
- )
-
- // Should not show feedback area buttons (only copy button)
- const feedbackArea = document.querySelector('[class*="space-x-1 rounded-lg border"]')
- expect(feedbackArea).not.toBeInTheDocument()
- })
- })
-
- describe('memoization', () => {
- it('should be wrapped with React.memo', () => {
- expect((Header as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
- })
- })
-})
diff --git a/web/app/components/share/text-generation/result/header.tsx b/web/app/components/share/text-generation/result/header.tsx
deleted file mode 100644
index 250a46f088..0000000000
--- a/web/app/components/share/text-generation/result/header.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-'use client'
-import type { FC } from 'react'
-import type { FeedbackType } from '@/app/components/base/chat/chat/type'
-import { ClipboardDocumentIcon, HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline'
-import copy from 'copy-to-clipboard'
-import * as React from 'react'
-import { useTranslation } from 'react-i18next'
-import Button from '@/app/components/base/button'
-import Toast from '@/app/components/base/toast'
-import Tooltip from '@/app/components/base/tooltip'
-
-type IResultHeaderProps = {
- result: string
- showFeedback: boolean
- feedback: FeedbackType
- onFeedback: (feedback: FeedbackType) => void
-}
-
-const Header: FC = ({
- feedback,
- showFeedback,
- onFeedback,
- result,
-}) => {
- const { t } = useTranslation()
- return (
-
-
{t('generation.resultTitle', { ns: 'share' })}
-
-
{
- copy(result)
- Toast.notify({ type: 'success', message: 'copied' })
- }}
- >
- <>
-
- {t('generation.copy', { ns: 'share' })}
- >
-
-
- {showFeedback && feedback.rating && feedback.rating === 'like' && (
-
- {
- onFeedback({
- rating: null,
- })
- }}
- className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-primary-200 bg-primary-100 !text-primary-600 hover:border-primary-300 hover:bg-primary-200"
- >
-
-
-
- )}
-
- {showFeedback && feedback.rating && feedback.rating === 'dislike' && (
-
- {
- onFeedback({
- rating: null,
- })
- }}
- className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-red-200 bg-red-100 !text-red-600 hover:border-red-300 hover:bg-red-200"
- >
-
-
-
- )}
-
- {showFeedback && !feedback.rating && (
-
-
- {
- onFeedback({
- rating: 'like',
- })
- }}
- className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-gray-100"
- >
-
-
-
-
- {
- onFeedback({
- rating: 'dislike',
- })
- }}
- className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-gray-100"
- >
-
-
-
-
- )}
-
-
-
- )
-}
-
-export default React.memo(Header)
diff --git a/web/app/components/share/text-generation/result/index.tsx b/web/app/components/share/text-generation/result/index.tsx
index fe518c6d25..05e6e30dcf 100644
--- a/web/app/components/share/text-generation/result/index.tsx
+++ b/web/app/components/share/text-generation/result/index.tsx
@@ -5,7 +5,9 @@ import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { PromptConfig } from '@/models/debug'
import type { SiteInfo } from '@/models/share'
-import type { AppSourceType } from '@/service/share'
+import type {
+ IOtherOptions,
+} from '@/service/base'
import type { VisionFile, VisionSettings } from '@/types/app'
import { RiLoader2Line } from '@remixicon/react'
import { useBoolean } from 'ahooks'
@@ -25,7 +27,17 @@ import Toast from '@/app/components/base/toast'
import NoData from '@/app/components/share/text-generation/no-data'
import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
import { TEXT_GENERATION_TIMEOUT_MS } from '@/config'
-import { sendCompletionMessage, sendWorkflowMessage, stopChatMessageResponding, stopWorkflowMessage, updateFeedback } from '@/service/share'
+import {
+ sseGet,
+} from '@/service/base'
+import {
+ AppSourceType,
+ sendCompletionMessage,
+ sendWorkflowMessage,
+ stopChatMessageResponding,
+ stopWorkflowMessage,
+ updateFeedback,
+} from '@/service/share'
import { TransferMethod } from '@/types/app'
import { sleep } from '@/utils'
import { formatBooleanInputs } from '@/utils/model-config'
@@ -93,10 +105,10 @@ const Result: FC = ({
const getCompletionRes = () => completionResRef.current
const [workflowProcessData, doSetWorkflowProcessData] = useState()
const workflowProcessDataRef = useRef(undefined)
- const setWorkflowProcessData = (data: WorkflowProcess) => {
+ const setWorkflowProcessData = useCallback((data: WorkflowProcess | undefined) => {
workflowProcessDataRef.current = data
doSetWorkflowProcessData(data)
- }
+ }, [])
const getWorkflowProcessData = () => workflowProcessDataRef.current
const [currentTaskId, setCurrentTaskId] = useState(null)
const [isStopping, setIsStopping] = useState(false)
@@ -157,7 +169,7 @@ const Result: FC = ({
finally {
setIsStopping(false)
}
- }, [appId, currentTaskId, appSourceType, appId, isStopping, isWorkflow, notify])
+ }, [appId, currentTaskId, appSourceType, isStopping, isWorkflow, notify])
useEffect(() => {
if (!onRunControlChange)
@@ -257,6 +269,7 @@ const Result: FC = ({
rating: null,
})
setCompletionRes('')
+ setWorkflowProcessData(undefined)
resetRunState()
let res: string[] = []
@@ -281,10 +294,17 @@ const Result: FC = ({
})()
if (isWorkflow) {
- sendWorkflowMessage(
- data,
- {
- onWorkflowStarted: ({ workflow_run_id, task_id }) => {
+ const otherOptions: IOtherOptions = {
+ isPublicAPI: appSourceType === AppSourceType.webApp,
+ onWorkflowStarted: ({ workflow_run_id, task_id }) => {
+ const workflowProcessData = getWorkflowProcessData()
+ if (workflowProcessData && workflowProcessData.tracing.length > 0) {
+ setWorkflowProcessData(produce(workflowProcessData, (draft) => {
+ draft.expand = true
+ draft.status = WorkflowRunningStatus.Running
+ }))
+ }
+ else {
tempMessageId = workflow_run_id
setCurrentTaskId(task_id || null)
setIsStopping(false)
@@ -294,178 +314,258 @@ const Result: FC = ({
expand: false,
resultText: '',
})
- },
- onIterationStart: ({ data }) => {
- setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
- draft.expand = true
- draft.tracing!.push({
- ...data,
- status: NodeRunningStatus.Running,
- expand: true,
- })
- }))
- },
- onIterationNext: () => {
- setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
- draft.expand = true
- const iterations = draft.tracing.find(item => item.node_id === data.node_id
- && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
- iterations?.details!.push([])
- }))
- },
- onIterationFinish: ({ data }) => {
- setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
- draft.expand = true
- const iterationsIndex = draft.tracing.findIndex(item => item.node_id === data.node_id
- && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
- draft.tracing[iterationsIndex] = {
- ...data,
- expand: !!data.error,
- }
- }))
- },
- onLoopStart: ({ data }) => {
- setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
- draft.expand = true
- draft.tracing!.push({
- ...data,
- status: NodeRunningStatus.Running,
- expand: true,
- })
- }))
- },
- onLoopNext: () => {
- setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
- draft.expand = true
- const loops = draft.tracing.find(item => item.node_id === data.node_id
- && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
- loops?.details!.push([])
- }))
- },
- onLoopFinish: ({ data }) => {
- setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
- draft.expand = true
- const loopsIndex = draft.tracing.findIndex(item => item.node_id === data.node_id
- && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
- draft.tracing[loopsIndex] = {
- ...data,
- expand: !!data.error,
- }
- }))
- },
- onNodeStarted: ({ data }) => {
- if (data.iteration_id)
- return
+ }
+ },
+ onIterationStart: ({ data }) => {
+ setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
+ draft.expand = true
+ draft.tracing!.push({
+ ...data,
+ status: NodeRunningStatus.Running,
+ expand: true,
+ })
+ }))
+ },
+ onIterationNext: () => {
+ setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
+ draft.expand = true
+ const iterations = draft.tracing.find(item => item.node_id === data.node_id
+ && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
+ iterations?.details!.push([])
+ }))
+ },
+ onIterationFinish: ({ data }) => {
+ setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
+ draft.expand = true
+ const iterationsIndex = draft.tracing.findIndex(item => item.node_id === data.node_id
+ && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
+ draft.tracing[iterationsIndex] = {
+ ...data,
+ expand: !!data.error,
+ }
+ }))
+ },
+ onLoopStart: ({ data }) => {
+ setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
+ draft.expand = true
+ draft.tracing!.push({
+ ...data,
+ status: NodeRunningStatus.Running,
+ expand: true,
+ })
+ }))
+ },
+ onLoopNext: () => {
+ setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
+ draft.expand = true
+ const loops = draft.tracing.find(item => item.node_id === data.node_id
+ && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
+ loops?.details!.push([])
+ }))
+ },
+ onLoopFinish: ({ data }) => {
+ setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
+ draft.expand = true
+ const loopsIndex = draft.tracing.findIndex(item => item.node_id === data.node_id
+ && (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
+ draft.tracing[loopsIndex] = {
+ ...data,
+ expand: !!data.error,
+ }
+ }))
+ },
+ onNodeStarted: ({ data }) => {
+ if (data.iteration_id)
+ return
- if (data.loop_id)
- return
-
- setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
- draft.expand = true
- draft.tracing!.push({
- ...data,
- status: NodeRunningStatus.Running,
- expand: true,
- })
- }))
- },
- onNodeFinished: ({ data }) => {
- if (data.iteration_id)
- return
-
- if (data.loop_id)
- return
-
- setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
- const currentIndex = draft.tracing!.findIndex(trace => trace.node_id === data.node_id
- && (trace.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || trace.parallel_id === data.execution_metadata?.parallel_id))
- if (currentIndex > -1 && draft.tracing) {
- draft.tracing[currentIndex] = {
- ...(draft.tracing[currentIndex].extras
- ? { extras: draft.tracing[currentIndex].extras }
- : {}),
+ if (data.loop_id)
+ return
+ const workflowProcessData = getWorkflowProcessData()
+ setWorkflowProcessData(produce(workflowProcessData!, (draft) => {
+ if (draft.tracing.length > 0) {
+ const currentIndex = draft.tracing.findIndex(item => item.node_id === data.node_id)
+ if (currentIndex > -1) {
+ draft.expand = true
+ draft.tracing![currentIndex] = {
...data,
- expand: !!data.error,
+ status: NodeRunningStatus.Running,
+ expand: true,
}
}
- }))
- },
- onWorkflowFinished: ({ data }) => {
- if (isTimeout) {
- notify({ type: 'warning', message: t('warningMessage.timeoutExceeded', { ns: 'appDebug' }) })
- return
- }
- const workflowStatus = data.status as WorkflowRunningStatus | undefined
- const markNodesStopped = (traces?: WorkflowProcess['tracing']) => {
- if (!traces)
- return
- const markTrace = (trace: WorkflowProcess['tracing'][number]) => {
- if ([NodeRunningStatus.Running, NodeRunningStatus.Waiting].includes(trace.status as NodeRunningStatus))
- trace.status = NodeRunningStatus.Stopped
- trace.details?.forEach(detailGroup => detailGroup.forEach(markTrace))
- trace.retryDetail?.forEach(markTrace)
- trace.parallelDetail?.children?.forEach(markTrace)
+ else {
+ draft.expand = true
+ draft.tracing.push({
+ ...data,
+ status: NodeRunningStatus.Running,
+ expand: true,
+ })
}
- traces.forEach(markTrace)
- }
- if (workflowStatus === WorkflowRunningStatus.Stopped) {
- setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
- draft.status = WorkflowRunningStatus.Stopped
- markNodesStopped(draft.tracing)
- }))
- setRespondingFalse()
- resetRunState()
- onCompleted(getCompletionRes(), taskId, false)
- isEnd = true
- return
- }
- if (data.error) {
- notify({ type: 'error', message: data.error })
- setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
- draft.status = WorkflowRunningStatus.Failed
- markNodesStopped(draft.tracing)
- }))
- setRespondingFalse()
- resetRunState()
- onCompleted(getCompletionRes(), taskId, false)
- isEnd = true
- return
- }
- setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
- draft.status = WorkflowRunningStatus.Succeeded
- draft.files = getFilesInLogs(data.outputs || []) as any[]
- }))
- if (!data.outputs) {
- setCompletionRes('')
}
else {
- setCompletionRes(data.outputs)
- const isStringOutput = Object.keys(data.outputs).length === 1 && typeof data.outputs[Object.keys(data.outputs)[0]] === 'string'
- if (isStringOutput) {
- setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
- draft.resultText = data.outputs[Object.keys(data.outputs)[0]]
- }))
+ draft.expand = true
+ draft.tracing!.push({
+ ...data,
+ status: NodeRunningStatus.Running,
+ expand: true,
+ })
+ }
+ }))
+ },
+ onNodeFinished: ({ data }) => {
+ if (data.iteration_id)
+ return
+
+ if (data.loop_id)
+ return
+
+ setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
+ const currentIndex = draft.tracing!.findIndex(trace => trace.node_id === data.node_id
+ && (trace.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || trace.parallel_id === data.execution_metadata?.parallel_id))
+ if (currentIndex > -1 && draft.tracing) {
+ draft.tracing[currentIndex] = {
+ ...(draft.tracing[currentIndex].extras
+ ? { extras: draft.tracing[currentIndex].extras }
+ : {}),
+ ...data,
+ expand: !!data.error,
}
}
+ }))
+ },
+ onWorkflowFinished: ({ data }) => {
+ if (isTimeout) {
+ notify({ type: 'warning', message: t('warningMessage.timeoutExceeded', { ns: 'appDebug' }) })
+ return
+ }
+ const workflowStatus = data.status as WorkflowRunningStatus | undefined
+ const markNodesStopped = (traces?: WorkflowProcess['tracing']) => {
+ if (!traces)
+ return
+ const markTrace = (trace: WorkflowProcess['tracing'][number]) => {
+ if ([NodeRunningStatus.Running, NodeRunningStatus.Waiting].includes(trace.status as NodeRunningStatus))
+ trace.status = NodeRunningStatus.Stopped
+ trace.details?.forEach(detailGroup => detailGroup.forEach(markTrace))
+ trace.retryDetail?.forEach(markTrace)
+ trace.parallelDetail?.children?.forEach(markTrace)
+ }
+ traces.forEach(markTrace)
+ }
+ if (workflowStatus === WorkflowRunningStatus.Stopped) {
+ setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
+ draft.status = WorkflowRunningStatus.Stopped
+ markNodesStopped(draft.tracing)
+ }))
setRespondingFalse()
resetRunState()
- setMessageId(tempMessageId)
- onCompleted(getCompletionRes(), taskId, true)
+ onCompleted(getCompletionRes(), taskId, false)
isEnd = true
- },
- onTextChunk: (params) => {
- const { data: { text } } = params
+ return
+ }
+ if (data.error) {
+ notify({ type: 'error', message: data.error })
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
- draft.resultText += text
+ draft.status = WorkflowRunningStatus.Failed
+ markNodesStopped(draft.tracing)
}))
- },
- onTextReplace: (params) => {
- const { data: { text } } = params
- setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
- draft.resultText = text
- }))
- },
+ setRespondingFalse()
+ resetRunState()
+ onCompleted(getCompletionRes(), taskId, false)
+ isEnd = true
+ return
+ }
+ setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
+ draft.status = WorkflowRunningStatus.Succeeded
+ draft.files = getFilesInLogs(data.outputs || []) as any[]
+ }))
+ if (!data.outputs) {
+ setCompletionRes('')
+ }
+ else {
+ setCompletionRes(data.outputs)
+ const isStringOutput = Object.keys(data.outputs).length === 1 && typeof data.outputs[Object.keys(data.outputs)[0]] === 'string'
+ if (isStringOutput) {
+ setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
+ draft.resultText = data.outputs[Object.keys(data.outputs)[0]]
+ }))
+ }
+ }
+ setRespondingFalse()
+ resetRunState()
+ setMessageId(tempMessageId)
+ onCompleted(getCompletionRes(), taskId, true)
+ isEnd = true
},
+ onTextChunk: (params) => {
+ const { data: { text } } = params
+ setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
+ draft.resultText += text
+ }))
+ },
+ onTextReplace: (params) => {
+ const { data: { text } } = params
+ setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
+ draft.resultText = text
+ }))
+ },
+ onHumanInputRequired: ({ data: humanInputRequiredData }) => {
+ const workflowProcessData = getWorkflowProcessData()
+ setWorkflowProcessData(produce(workflowProcessData!, (draft) => {
+ if (!draft.humanInputFormDataList) {
+ draft.humanInputFormDataList = [humanInputRequiredData]
+ }
+ else {
+ const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === humanInputRequiredData.node_id)
+ if (currentFormIndex > -1) {
+ draft.humanInputFormDataList[currentFormIndex] = humanInputRequiredData
+ }
+ else {
+ draft.humanInputFormDataList.push(humanInputRequiredData)
+ }
+ }
+ const currentIndex = draft.tracing!.findIndex(item => item.node_id === humanInputRequiredData.node_id)
+ if (currentIndex > -1) {
+ draft.tracing![currentIndex].status = NodeRunningStatus.Paused
+ }
+ }))
+ },
+ onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => {
+ setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
+ if (draft.humanInputFormDataList?.length) {
+ const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === humanInputFilledFormData.node_id)
+ draft.humanInputFormDataList.splice(currentFormIndex, 1)
+ }
+ if (!draft.humanInputFilledFormDataList) {
+ draft.humanInputFilledFormDataList = [humanInputFilledFormData]
+ }
+ else {
+ draft.humanInputFilledFormDataList.push(humanInputFilledFormData)
+ }
+ }))
+ },
+ onHumanInputFormTimeout: ({ data: humanInputFormTimeoutData }) => {
+ setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
+ if (draft.humanInputFormDataList?.length) {
+ const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === humanInputFormTimeoutData.node_id)
+ draft.humanInputFormDataList[currentFormIndex].expiration_time = humanInputFormTimeoutData.expiration_time
+ }
+ }))
+ },
+ onWorkflowPaused: ({ data: workflowPausedData }) => {
+ const url = `/workflow/${workflowPausedData.workflow_run_id}/events`
+ sseGet(
+ url,
+ {},
+ otherOptions,
+ )
+ setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
+ draft.expand = false
+ draft.status = WorkflowRunningStatus.Paused
+ }))
+ },
+ }
+ sendWorkflowMessage(
+ data,
+ otherOptions,
appSourceType,
appId,
).catch((error) => {
@@ -562,7 +662,8 @@ const Result: FC = ({
isMobile={isMobile}
appSourceType={appSourceType}
installedAppId={appId}
- isLoading={isCallBatchAPI ? (!completionRes && isResponding) : false}
+ // isLoading={isCallBatchAPI ? (!completionRes && isResponding) : false}
+ isLoading={false}
taskId={isCallBatchAPI ? ((taskId as number) < 10 ? `0${taskId}` : `${taskId}`) : undefined}
controlClearMoreLikeThis={controlClearMoreLikeThis}
isShowTextToSpeech={isShowTextToSpeech}
diff --git a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx
index 466220b611..d58eb6c669 100644
--- a/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx
+++ b/web/app/components/workflow-app/components/workflow-header/features-trigger.tsx
@@ -111,6 +111,10 @@ const FeaturesTrigger = () => {
return isFetchedPlan && plan.type === Plan.sandbox && entryCount > 2
}, [nodes, plan.type, isFetchedPlan])
+ const hasHumanInputNode = useMemo(() => {
+ return nodes.some(node => node.data.type === BlockEnum.HumanInput)
+ }, [nodes])
+
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
const invalidateAppTriggers = useInvalidateAppTriggers()
@@ -171,7 +175,7 @@ const FeaturesTrigger = () => {
else {
throw new Error('Checklist failed')
}
- }, [needWarningNodes, handleCheckBeforePublish, publishWorkflow, notify, appID, t, updatePublishedWorkflow, updateAppDetail, workflowStore, resetWorkflowVersionHistory, invalidateAppTriggers])
+ }, [needWarningNodes, handleCheckBeforePublish, publishWorkflow, notify, appID, t, updatePublishedWorkflow, updateAppDetail, workflowStore, resetWorkflowVersionHistory, invalidateAppTriggers, hasUserInputNode])
const onPublisherToggle = useCallback((state: boolean) => {
if (state)
@@ -214,6 +218,7 @@ const FeaturesTrigger = () => {
hasTriggerNode,
startNodeLimitExceeded,
publishDisabled: !hasWorkflowNodes || startNodeLimitExceeded,
+ hasHumanInputNode,
}}
/>
>
diff --git a/web/app/components/workflow-app/hooks/use-workflow-run.ts b/web/app/components/workflow-app/hooks/use-workflow-run.ts
index 49c5d20dde..ef6d7731a4 100644
--- a/web/app/components/workflow-app/hooks/use-workflow-run.ts
+++ b/web/app/components/workflow-app/hooks/use-workflow-run.ts
@@ -21,7 +21,7 @@ import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-
import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
-import { handleStream, post, ssePost } from '@/service/base'
+import { handleStream, post, sseGet, ssePost } from '@/service/base'
import { ContentType } from '@/service/fetch'
import { useInvalidAllLastRun } from '@/service/use-workflow'
import { stopWorkflowRun } from '@/service/workflow'
@@ -79,6 +79,9 @@ export const useWorkflowRun = () => {
handleWorkflowFailed,
handleWorkflowNodeStarted,
handleWorkflowNodeFinished,
+ handleWorkflowNodeHumanInputRequired,
+ handleWorkflowNodeHumanInputFormFilled,
+ handleWorkflowNodeHumanInputFormTimeout,
handleWorkflowNodeIterationStarted,
handleWorkflowNodeIterationNext,
handleWorkflowNodeIterationFinished,
@@ -89,6 +92,7 @@ export const useWorkflowRun = () => {
handleWorkflowAgentLog,
handleWorkflowTextChunk,
handleWorkflowTextReplace,
+ handleWorkflowPaused,
} = useWorkflowRunEvent()
const handleBackupDraft = useCallback(() => {
@@ -176,6 +180,10 @@ export const useWorkflowRun = () => {
onNodeRetry,
onAgentLog,
onError,
+ onWorkflowPaused,
+ onHumanInputRequired,
+ onHumanInputFormFilled,
+ onHumanInputFormTimeout,
onCompleted,
...restCallback
} = callback || {}
@@ -372,12 +380,6 @@ export const useWorkflowRun = () => {
const baseSseOptions: IOtherOptions = {
...restCallback,
onWorkflowStarted: (params) => {
- const state = workflowStore.getState()
- if (state.workflowRunningData) {
- state.setWorkflowRunningData(produce(state.workflowRunningData, (draft) => {
- draft.resultText = ''
- }))
- }
handleWorkflowStarted(params)
if (onWorkflowStarted)
@@ -492,6 +494,32 @@ export const useWorkflowRun = () => {
if (audioPlayer)
audioPlayer.playAudioWithAudio(audio, false)
},
+ onWorkflowPaused: (params) => {
+ handleWorkflowPaused()
+ if (onWorkflowPaused)
+ onWorkflowPaused(params)
+ const url = `/workflow/${params.workflow_run_id}/events`
+ sseGet(
+ url,
+ {},
+ baseSseOptions,
+ )
+ },
+ onHumanInputRequired: (params) => {
+ handleWorkflowNodeHumanInputRequired(params)
+ if (onHumanInputRequired)
+ onHumanInputRequired(params)
+ },
+ onHumanInputFormFilled: (params) => {
+ handleWorkflowNodeHumanInputFormFilled(params)
+ if (onHumanInputFormFilled)
+ onHumanInputFormFilled(params)
+ },
+ onHumanInputFormTimeout: (params) => {
+ handleWorkflowNodeHumanInputFormTimeout(params)
+ if (onHumanInputFormTimeout)
+ onHumanInputFormTimeout(params)
+ },
onError: wrappedOnError,
onCompleted: wrappedOnCompleted,
}
@@ -604,6 +632,10 @@ export const useWorkflowRun = () => {
baseSseOptions.onTTSEnd,
baseSseOptions.onTextReplace,
baseSseOptions.onAgentLog,
+ baseSseOptions.onHumanInputRequired,
+ baseSseOptions.onHumanInputFormFilled,
+ baseSseOptions.onHumanInputFormTimeout,
+ baseSseOptions.onWorkflowPaused,
baseSseOptions.onDataSourceNodeProcessing,
baseSseOptions.onDataSourceNodeCompleted,
baseSseOptions.onDataSourceNodeError,
@@ -655,19 +687,157 @@ export const useWorkflowRun = () => {
return
}
+ const finalCallbacks: IOtherOptions = {
+ ...baseSseOptions,
+ getAbortController: (controller: AbortController) => {
+ abortControllerRef.current = controller
+ },
+ onWorkflowFinished: (params) => {
+ handleWorkflowFinished(params)
+
+ if (onWorkflowFinished)
+ onWorkflowFinished(params)
+ if (isInWorkflowDebug) {
+ fetchInspectVars({})
+ invalidAllLastRun()
+ }
+ },
+ onError: (params) => {
+ handleWorkflowFailed()
+
+ if (onError)
+ onError(params)
+ },
+ onNodeStarted: (params) => {
+ handleWorkflowNodeStarted(
+ params,
+ {
+ clientWidth,
+ clientHeight,
+ },
+ )
+
+ if (onNodeStarted)
+ onNodeStarted(params)
+ },
+ onNodeFinished: (params) => {
+ handleWorkflowNodeFinished(params)
+
+ if (onNodeFinished)
+ onNodeFinished(params)
+ },
+ onIterationStart: (params) => {
+ handleWorkflowNodeIterationStarted(
+ params,
+ {
+ clientWidth,
+ clientHeight,
+ },
+ )
+
+ if (onIterationStart)
+ onIterationStart(params)
+ },
+ onIterationNext: (params) => {
+ handleWorkflowNodeIterationNext(params)
+
+ if (onIterationNext)
+ onIterationNext(params)
+ },
+ onIterationFinish: (params) => {
+ handleWorkflowNodeIterationFinished(params)
+
+ if (onIterationFinish)
+ onIterationFinish(params)
+ },
+ onLoopStart: (params) => {
+ handleWorkflowNodeLoopStarted(
+ params,
+ {
+ clientWidth,
+ clientHeight,
+ },
+ )
+
+ if (onLoopStart)
+ onLoopStart(params)
+ },
+ onLoopNext: (params) => {
+ handleWorkflowNodeLoopNext(params)
+
+ if (onLoopNext)
+ onLoopNext(params)
+ },
+ onLoopFinish: (params) => {
+ handleWorkflowNodeLoopFinished(params)
+
+ if (onLoopFinish)
+ onLoopFinish(params)
+ },
+ onNodeRetry: (params) => {
+ handleWorkflowNodeRetry(params)
+
+ if (onNodeRetry)
+ onNodeRetry(params)
+ },
+ onAgentLog: (params) => {
+ handleWorkflowAgentLog(params)
+
+ if (onAgentLog)
+ onAgentLog(params)
+ },
+ onTextChunk: (params) => {
+ handleWorkflowTextChunk(params)
+ },
+ onTextReplace: (params) => {
+ handleWorkflowTextReplace(params)
+ },
+ onTTSChunk: (messageId: string, audio: string) => {
+ if (!audio || audio === '')
+ return
+ player?.playAudioWithAudio(audio, true)
+ AudioPlayerManager.getInstance().resetMsgId(messageId)
+ },
+ onTTSEnd: (messageId: string, audio: string) => {
+ player?.playAudioWithAudio(audio, false)
+ },
+ onWorkflowPaused: (params) => {
+ handleWorkflowPaused()
+ if (onWorkflowPaused)
+ onWorkflowPaused(params)
+ const url = `/workflow/${params.workflow_run_id}/events`
+ sseGet(
+ url,
+ {},
+ finalCallbacks,
+ )
+ },
+ onHumanInputRequired: (params) => {
+ handleWorkflowNodeHumanInputRequired(params)
+ if (onHumanInputRequired)
+ onHumanInputRequired(params)
+ },
+ onHumanInputFormFilled: (params) => {
+ handleWorkflowNodeHumanInputFormFilled(params)
+ if (onHumanInputFormFilled)
+ onHumanInputFormFilled(params)
+ },
+ onHumanInputFormTimeout: (params) => {
+ handleWorkflowNodeHumanInputFormTimeout(params)
+ if (onHumanInputFormTimeout)
+ onHumanInputFormTimeout(params)
+ },
+ ...restCallback,
+ }
+
ssePost(
url,
{
body: requestBody,
},
- {
- ...baseSseOptions,
- getAbortController: (controller: AbortController) => {
- abortControllerRef.current = controller
- },
- },
+ finalCallbacks,
)
- }, [store, doSyncWorkflowDraft, workflowStore, pathname, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, handleWorkflowFailed, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace])
+ }, [store, doSyncWorkflowDraft, workflowStore, pathname, handleWorkflowFailed, flowId, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace, handleWorkflowPaused, handleWorkflowNodeHumanInputRequired, handleWorkflowNodeHumanInputFormFilled, handleWorkflowNodeHumanInputFormTimeout])
const handleStopRun = useCallback((taskId: string) => {
const setStoppedState = () => {
diff --git a/web/app/components/workflow/block-icon.tsx b/web/app/components/workflow/block-icon.tsx
index 32b06d475f..f12812af78 100644
--- a/web/app/components/workflow/block-icon.tsx
+++ b/web/app/components/workflow/block-icon.tsx
@@ -11,6 +11,7 @@ import {
End,
Home,
Http,
+ HumanInLoop,
IfElse,
Iteration,
KnowledgeBase,
@@ -71,6 +72,7 @@ const DEFAULT_ICON_MAP: Record {
@@ -102,6 +104,7 @@ const ICON_CONTAINER_BG_COLOR_MAP: Record = {
[BlockEnum.DocExtractor]: 'bg-util-colors-green-green-500',
[BlockEnum.ListFilter]: 'bg-util-colors-cyan-cyan-500',
[BlockEnum.Agent]: 'bg-util-colors-indigo-indigo-500',
+ [BlockEnum.HumanInput]: 'bg-util-colors-cyan-cyan-500',
[BlockEnum.KnowledgeBase]: 'bg-util-colors-warning-warning-500',
[BlockEnum.DataSource]: 'bg-components-icon-bg-midnight-solid',
[BlockEnum.TriggerSchedule]: 'bg-util-colors-violet-violet-500',
diff --git a/web/app/components/workflow/constants.ts b/web/app/components/workflow/constants.ts
index 4d95db7fcf..ed9a072824 100644
--- a/web/app/components/workflow/constants.ts
+++ b/web/app/components/workflow/constants.ts
@@ -128,6 +128,7 @@ export const SUPPORT_OUTPUT_VARS_NODE = [
BlockEnum.ListFilter,
BlockEnum.Agent,
BlockEnum.DataSource,
+ BlockEnum.HumanInput,
]
export const AGENT_OUTPUT_STRUCT: Var[] = [
@@ -211,6 +212,17 @@ export const TOOL_OUTPUT_STRUCT: Var[] = [
},
]
+export const HUMAN_INPUT_OUTPUT_STRUCT: Var[] = [
+ {
+ variable: '__action_id',
+ type: VarType.string,
+ },
+ {
+ variable: '__rendered_content',
+ type: VarType.string,
+ },
+]
+
export const PARAMETER_EXTRACTOR_COMMON_STRUCT: Var[] = [
{
variable: '__is_success',
diff --git a/web/app/components/workflow/constants/node.ts b/web/app/components/workflow/constants/node.ts
index 5de9512752..4d67f278c2 100644
--- a/web/app/components/workflow/constants/node.ts
+++ b/web/app/components/workflow/constants/node.ts
@@ -5,12 +5,13 @@ import codeDefault from '@/app/components/workflow/nodes/code/default'
import documentExtractorDefault from '@/app/components/workflow/nodes/document-extractor/default'
import httpRequestDefault from '@/app/components/workflow/nodes/http/default'
+import humanInputDefault from '@/app/components/workflow/nodes/human-input/default'
import ifElseDefault from '@/app/components/workflow/nodes/if-else/default'
import iterationStartDefault from '@/app/components/workflow/nodes/iteration-start/default'
import iterationDefault from '@/app/components/workflow/nodes/iteration/default'
import knowledgeRetrievalDefault from '@/app/components/workflow/nodes/knowledge-retrieval/default'
-import listOperatorDefault from '@/app/components/workflow/nodes/list-operator/default'
+import listOperatorDefault from '@/app/components/workflow/nodes/list-operator/default'
import llmDefault from '@/app/components/workflow/nodes/llm/default'
import loopEndDefault from '@/app/components/workflow/nodes/loop-end/default'
import loopStartDefault from '@/app/components/workflow/nodes/loop-start/default'
@@ -41,4 +42,5 @@ export const WORKFLOW_COMMON_NODES = [
httpRequestDefault,
listOperatorDefault,
toolDefault,
+ humanInputDefault,
]
diff --git a/web/app/components/workflow/header/run-mode.tsx b/web/app/components/workflow/header/run-mode.tsx
index 74bc5bc80a..63c48ff0dc 100644
--- a/web/app/components/workflow/header/run-mode.tsx
+++ b/web/app/components/workflow/header/run-mode.tsx
@@ -32,7 +32,7 @@ const RunMode = ({
handleWorkflowRunAllTriggersInWorkflow,
} = useWorkflowStartRun()
const { handleStopRun } = useWorkflowRun()
- const { validateBeforeRun, warningNodes } = useWorkflowRunValidation()
+ const { warningNodes } = useWorkflowRunValidation()
const workflowRunningData = useStore(s => s.workflowRunningData)
const isListening = useStore(s => s.isListening)
@@ -98,14 +98,7 @@ const RunMode = ({
// Placeholder for trigger-specific execution logic for schedule, webhook, plugin types
console.log('TODO: Handle trigger execution for type:', option.type, 'nodeId:', option.nodeId)
}
- }, [
- validateBeforeRun,
- handleWorkflowStartRunInWorkflow,
- handleWorkflowTriggerScheduleRunInWorkflow,
- handleWorkflowTriggerWebhookRunInWorkflow,
- handleWorkflowTriggerPluginRunInWorkflow,
- handleWorkflowRunAllTriggersInWorkflow,
- ])
+ }, [warningNodes, notify, t, handleWorkflowStartRunInWorkflow, handleWorkflowTriggerScheduleRunInWorkflow, handleWorkflowTriggerWebhookRunInWorkflow, handleWorkflowTriggerPluginRunInWorkflow, handleWorkflowRunAllTriggersInWorkflow])
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription((v: any) => {
diff --git a/web/app/components/workflow/hooks/use-available-blocks.ts b/web/app/components/workflow/hooks/use-available-blocks.ts
index 4969cb9d89..3d92142b10 100644
--- a/web/app/components/workflow/hooks/use-available-blocks.ts
+++ b/web/app/components/workflow/hooks/use-available-blocks.ts
@@ -6,7 +6,7 @@ import { BlockEnum } from '../types'
import { useNodesMetaData } from './use-nodes-meta-data'
const availableBlocksFilter = (nodeType: BlockEnum, inContainer?: boolean) => {
- if (inContainer && (nodeType === BlockEnum.Iteration || nodeType === BlockEnum.Loop || nodeType === BlockEnum.End || nodeType === BlockEnum.DataSource || nodeType === BlockEnum.KnowledgeBase))
+ if (inContainer && (nodeType === BlockEnum.Iteration || nodeType === BlockEnum.Loop || nodeType === BlockEnum.End || nodeType === BlockEnum.DataSource || nodeType === BlockEnum.KnowledgeBase || nodeType === BlockEnum.HumanInput))
return false
if (!inContainer && nodeType === BlockEnum.LoopEnd)
diff --git a/web/app/components/workflow/hooks/use-checklist.ts b/web/app/components/workflow/hooks/use-checklist.ts
index 5a9e4dacb7..642179aed7 100644
--- a/web/app/components/workflow/hooks/use-checklist.ts
+++ b/web/app/components/workflow/hooks/use-checklist.ts
@@ -249,7 +249,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
})
return list
- }, [nodes, nodesExtraData, edges, buildInTools, customTools, workflowTools, language, dataSourceList, getToolIcon, strategyProviders, getCheckData, t, map, shouldCheckStartNode])
+ }, [nodes, edges, shouldCheckStartNode, nodesExtraData, buildInTools, customTools, workflowTools, language, dataSourceList, triggerPlugins, getToolIcon, strategyProviders, getCheckData, t, map])
return needWarningNodes
}
@@ -419,7 +419,7 @@ export const useChecklistBeforePublish = () => {
}
return true
- }, [store, notify, t, language, nodesExtraData, strategyProviders, updateDatasetsDetail, getCheckData, workflowStore, buildInTools, customTools, workflowTools, shouldCheckStartNode])
+ }, [store, workflowStore, getNodesAvailableVarList, shouldCheckStartNode, nodesExtraData, notify, t, updateDatasetsDetail, buildInTools, customTools, workflowTools, language, getCheckData, strategyProviders])
return {
handleCheckBeforePublish,
diff --git a/web/app/components/workflow/hooks/use-edges-interactions.ts b/web/app/components/workflow/hooks/use-edges-interactions.ts
index 5104b47ef4..0e911c3de8 100644
--- a/web/app/components/workflow/hooks/use-edges-interactions.ts
+++ b/web/app/components/workflow/hooks/use-edges-interactions.ts
@@ -151,11 +151,65 @@ export const useEdgesInteractions = () => {
setEdges(newEdges)
}, [store, getNodesReadOnly])
+ const handleEdgeSourceHandleChange = useCallback((nodeId: string, oldHandleId: string, newHandleId: string) => {
+ if (getNodesReadOnly())
+ return
+
+ const { getNodes, setNodes, edges, setEdges } = store.getState()
+ const nodes = getNodes()
+
+ // Find edges connected to the old handle
+ const affectedEdges = edges.filter(
+ edge => edge.source === nodeId && edge.sourceHandle === oldHandleId,
+ )
+
+ if (affectedEdges.length === 0)
+ return
+
+ // Update node metadata: remove old handle, add new handle
+ const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
+ [
+ ...affectedEdges.map(edge => ({ type: 'remove', edge })),
+ ...affectedEdges.map(edge => ({
+ type: 'add',
+ edge: { ...edge, sourceHandle: newHandleId },
+ })),
+ ],
+ nodes,
+ )
+
+ const newNodes = produce(nodes, (draft: Node[]) => {
+ draft.forEach((node) => {
+ if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
+ node.data = {
+ ...node.data,
+ ...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
+ }
+ }
+ })
+ })
+ setNodes(newNodes)
+
+ // Update edges to use new sourceHandle and regenerate edge IDs
+ const newEdges = produce(edges, (draft) => {
+ draft.forEach((edge) => {
+ if (edge.source === nodeId && edge.sourceHandle === oldHandleId) {
+ edge.sourceHandle = newHandleId
+ edge.id = `${edge.source}-${newHandleId}-${edge.target}-${edge.targetHandle}`
+ }
+ })
+ })
+ setEdges(newEdges)
+ handleSyncWorkflowDraft()
+ saveStateToHistory(WorkflowHistoryEvent.EdgeSourceHandleChange)
+ }, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
+
return {
handleEdgeEnter,
handleEdgeLeave,
handleEdgeDeleteByDeleteBranch,
handleEdgeDelete,
handleEdgesChange,
+ handleEdgeSourceHandleChange,
}
}
diff --git a/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts b/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts
index 54c2c77d0d..f013402cd3 100644
--- a/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts
+++ b/web/app/components/workflow/hooks/use-fetch-workflow-inspect-vars.ts
@@ -2,7 +2,7 @@ import type { Node, ToolWithProvider } from '@/app/components/workflow/types'
import type { SchemaTypeDefinition } from '@/service/use-common'
import type { FlowType } from '@/types/common'
import type { NodeWithVar, VarInInspect } from '@/types/workflow'
-import { useCallback } from 'react'
+import { useCallback, useMemo } from 'react'
import { useStoreApi } from 'reactflow'
import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
@@ -14,7 +14,7 @@ import {
} from '@/service/use-tools'
import { useInvalidateConversationVarValues, useInvalidateSysVarValues } from '@/service/use-workflow'
import { fetchAllInspectVars } from '@/service/workflow'
-import useMatchSchemaType, { getMatchedSchemaType } from '../nodes/_base/components/variable/use-match-schema-type'
+import useMatchSchemaType from '../nodes/_base/components/variable/use-match-schema-type'
import { toNodeOutputVars } from '../nodes/_base/components/variable/utils'
type Params = {
@@ -37,15 +37,18 @@ export const useSetWorkflowVarsWithValue = ({
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const dataSourceList = useStore(s => s.dataSourceList)
- const allPluginInfoList = {
- buildInTools: buildInTools || [],
- customTools: customTools || [],
- workflowTools: workflowTools || [],
- mcpTools: mcpTools || [],
- dataSourceList: dataSourceList || [],
- }
- const setInspectVarsToStore = (inspectVars: VarInInspect[], passedInAllPluginInfoList?: Record, passedInSchemaTypeDefinitions?: SchemaTypeDefinition[]) => {
+ const allPluginInfoList = useMemo(() => {
+ return {
+ buildInTools: buildInTools || [],
+ customTools: customTools || [],
+ workflowTools: workflowTools || [],
+ mcpTools: mcpTools || [],
+ dataSourceList: dataSourceList || [],
+ }
+ }, [buildInTools, customTools, workflowTools, mcpTools, dataSourceList])
+
+ const setInspectVarsToStore = useCallback((inspectVars: VarInInspect[], passedInAllPluginInfoList?: Record, passedInSchemaTypeDefinitions?: SchemaTypeDefinition[]) => {
const { setNodesWithInspectVars } = workflowStore.getState()
const { getNodes } = store.getState()
@@ -95,7 +98,7 @@ export const useSetWorkflowVarsWithValue = ({
return nodeWithVar
})
setNodesWithInspectVars(res)
- }
+ }, [workflowStore, store, allPluginInfoList, schemaTypeDefinitions])
const fetchInspectVars = useCallback(async (params: {
passInVars?: boolean
@@ -109,7 +112,8 @@ export const useSetWorkflowVarsWithValue = ({
const data = passInVars ? vars! : await fetchAllInspectVars(flowType, flowId)
setInspectVarsToStore(data, passedInAllPluginInfoList, passedInSchemaTypeDefinitions)
handleCancelAllNodeSuccessStatus() // to make sure clear node output show the unset status
- }, [invalidateConversationVarValues, invalidateSysVarValues, flowType, flowId, setInspectVarsToStore, handleCancelAllNodeSuccessStatus, schemaTypeDefinitions, getMatchedSchemaType])
+ }, [invalidateConversationVarValues, invalidateSysVarValues, flowType, flowId, setInspectVarsToStore, handleCancelAllNodeSuccessStatus])
+
return {
fetchInspectVars,
}
diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts
index 8277e7dac8..4635baa787 100644
--- a/web/app/components/workflow/hooks/use-nodes-interactions.ts
+++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts
@@ -299,6 +299,7 @@ export const useNodesInteractions = () => {
|| connectingNode.data.type === BlockEnum.VariableAggregator)
&& node.data.type !== BlockEnum.IfElse
&& node.data.type !== BlockEnum.QuestionClassifier
+ && node.data.type !== BlockEnum.HumanInput
) {
n.data._isEntering = true
}
@@ -1017,6 +1018,7 @@ export const useNodesInteractions = () => {
if (
nodeType !== BlockEnum.IfElse
&& nodeType !== BlockEnum.QuestionClassifier
+ && nodeType !== BlockEnum.HumanInput
) {
newNode.data._connectedSourceHandleIds = [sourceHandle]
}
@@ -1053,6 +1055,7 @@ export const useNodesInteractions = () => {
if (
nodeType !== BlockEnum.IfElse
&& nodeType !== BlockEnum.QuestionClassifier
+ && nodeType !== BlockEnum.HumanInput
&& nodeType !== BlockEnum.LoopEnd
) {
newEdge = {
@@ -1244,6 +1247,7 @@ export const useNodesInteractions = () => {
if (
nodeType !== BlockEnum.IfElse
&& nodeType !== BlockEnum.QuestionClassifier
+ && nodeType !== BlockEnum.HumanInput
&& nodeType !== BlockEnum.LoopEnd
) {
newNextEdge = {
diff --git a/web/app/components/workflow/hooks/use-workflow-history.ts b/web/app/components/workflow/hooks/use-workflow-history.ts
index 17270bea63..7feaec9709 100644
--- a/web/app/components/workflow/hooks/use-workflow-history.ts
+++ b/web/app/components/workflow/hooks/use-workflow-history.ts
@@ -27,6 +27,7 @@ export const WorkflowHistoryEvent = {
NodeDelete: 'NodeDelete',
EdgeDelete: 'EdgeDelete',
EdgeDeleteByDeleteBranch: 'EdgeDeleteByDeleteBranch',
+ EdgeSourceHandleChange: 'EdgeSourceHandleChange',
NodeAdd: 'NodeAdd',
NodeResize: 'NodeResize',
NoteAdd: 'NoteAdd',
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/index.ts b/web/app/components/workflow/hooks/use-workflow-run-event/index.ts
index 3f31b423ad..4348687333 100644
--- a/web/app/components/workflow/hooks/use-workflow-run-event/index.ts
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/index.ts
@@ -2,6 +2,9 @@ export * from './use-workflow-agent-log'
export * from './use-workflow-failed'
export * from './use-workflow-finished'
export * from './use-workflow-node-finished'
+export * from './use-workflow-node-human-input-form-filled'
+export * from './use-workflow-node-human-input-form-timeout'
+export * from './use-workflow-node-human-input-required'
export * from './use-workflow-node-iteration-finished'
export * from './use-workflow-node-iteration-next'
export * from './use-workflow-node-iteration-started'
@@ -10,6 +13,7 @@ export * from './use-workflow-node-loop-next'
export * from './use-workflow-node-loop-started'
export * from './use-workflow-node-retry'
export * from './use-workflow-node-started'
+export * from './use-workflow-paused'
export * from './use-workflow-started'
export * from './use-workflow-text-chunk'
export * from './use-workflow-text-replace'
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-finished.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-finished.ts
index cf0d9bcef1..6768273f20 100644
--- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-finished.ts
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-finished.ts
@@ -49,6 +49,8 @@ export const useWorkflowNodeFinished = () => {
if (data.node_type === BlockEnum.QuestionClassifier)
currentNode.data._runningBranchId = data?.outputs?.class_id
+ if (data.node_type === BlockEnum.HumanInput)
+ currentNode.data._runningBranchId = data?.outputs?.__action_id
}
})
setNodes(newNodes)
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-form-filled.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-form-filled.ts
new file mode 100644
index 0000000000..f3750b6996
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-form-filled.ts
@@ -0,0 +1,34 @@
+import type { HumanInputFormFilledResponse } from '@/types/workflow'
+import { produce } from 'immer'
+import { useCallback } from 'react'
+import { useWorkflowStore } from '@/app/components/workflow/store'
+
+export const useWorkflowNodeHumanInputFormFilled = () => {
+ const workflowStore = useWorkflowStore()
+
+ const handleWorkflowNodeHumanInputFormFilled = useCallback((params: HumanInputFormFilledResponse) => {
+ const { data } = params
+ const {
+ workflowRunningData,
+ setWorkflowRunningData,
+ } = workflowStore.getState()
+
+ const newWorkflowRunningData = produce(workflowRunningData!, (draft) => {
+ if (draft.humanInputFormDataList?.length) {
+ const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === data.node_id)
+ draft.humanInputFormDataList.splice(currentFormIndex, 1)
+ }
+ if (!draft.humanInputFilledFormDataList) {
+ draft.humanInputFilledFormDataList = [data]
+ }
+ else {
+ draft.humanInputFilledFormDataList.push(data)
+ }
+ })
+ setWorkflowRunningData(newWorkflowRunningData)
+ }, [workflowStore])
+
+ return {
+ handleWorkflowNodeHumanInputFormFilled,
+ }
+}
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-form-timeout.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-form-timeout.ts
new file mode 100644
index 0000000000..d3016eeb1d
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-form-timeout.ts
@@ -0,0 +1,28 @@
+import type { HumanInputFormTimeoutResponse } from '@/types/workflow'
+import { produce } from 'immer'
+import { useCallback } from 'react'
+import { useWorkflowStore } from '@/app/components/workflow/store'
+
+export const useWorkflowNodeHumanInputFormTimeout = () => {
+ const workflowStore = useWorkflowStore()
+
+ const handleWorkflowNodeHumanInputFormTimeout = useCallback((params: HumanInputFormTimeoutResponse) => {
+ const { data } = params
+ const {
+ workflowRunningData,
+ setWorkflowRunningData,
+ } = workflowStore.getState()
+
+ const newWorkflowRunningData = produce(workflowRunningData!, (draft) => {
+ if (draft.humanInputFormDataList?.length) {
+ const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === data.node_id)
+ draft.humanInputFormDataList[currentFormIndex].expiration_time = data.expiration_time
+ }
+ })
+ setWorkflowRunningData(newWorkflowRunningData)
+ }, [workflowStore])
+
+ return {
+ handleWorkflowNodeHumanInputFormTimeout,
+ }
+}
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-required.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-required.ts
new file mode 100644
index 0000000000..87f82c69f2
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-human-input-required.ts
@@ -0,0 +1,60 @@
+import type { HumanInputRequiredResponse } from '@/types/workflow'
+import { produce } from 'immer'
+import { useCallback } from 'react'
+import {
+ useStoreApi,
+} from 'reactflow'
+import { useWorkflowStore } from '@/app/components/workflow/store'
+import { NodeRunningStatus } from '@/app/components/workflow/types'
+
+export const useWorkflowNodeHumanInputRequired = () => {
+ const store = useStoreApi()
+ const workflowStore = useWorkflowStore()
+
+ // Notice: Human input required !== Workflow Paused
+ const handleWorkflowNodeHumanInputRequired = useCallback((params: HumanInputRequiredResponse) => {
+ const { data } = params
+ const {
+ workflowRunningData,
+ setWorkflowRunningData,
+ } = workflowStore.getState()
+
+ const newWorkflowRunningData = produce(workflowRunningData!, (draft) => {
+ if (!draft.humanInputFormDataList) {
+ draft.humanInputFormDataList = [data]
+ }
+ else {
+ const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === data.node_id)
+ if (currentFormIndex > -1) {
+ draft.humanInputFormDataList[currentFormIndex] = data
+ }
+ else {
+ draft.humanInputFormDataList.push(data)
+ }
+ }
+ const currentIndex = draft.tracing!.findIndex(item => item.node_id === data.node_id)
+ if (currentIndex > -1) {
+ draft.tracing![currentIndex] = {
+ ...draft.tracing![currentIndex],
+ status: NodeRunningStatus.Paused,
+ }
+ }
+ })
+ setWorkflowRunningData(newWorkflowRunningData)
+
+ const {
+ getNodes,
+ setNodes,
+ } = store.getState()
+ const nodes = getNodes()
+ const currentNodeIndex = nodes.findIndex(node => node.id === data.node_id)
+ const newNodes = produce(nodes, (draft) => {
+ draft[currentNodeIndex].data._runningStatus = NodeRunningStatus.Paused
+ })
+ setNodes(newNodes)
+ }, [store, workflowStore])
+
+ return {
+ handleWorkflowNodeHumanInputRequired,
+ }
+}
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-started.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-started.ts
index 03c7387d38..01f60e12e9 100644
--- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-started.ts
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-node-started.ts
@@ -33,12 +33,23 @@ export const useWorkflowNodeStarted = () => {
transform,
} = store.getState()
const nodes = getNodes()
- setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
- draft.tracing!.push({
- ...data,
- status: NodeRunningStatus.Running,
- })
- }))
+ const currentIndex = workflowRunningData?.tracing?.findIndex(item => item.node_id === data.node_id)
+ if (currentIndex && currentIndex > -1) {
+ setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
+ draft.tracing![currentIndex] = {
+ ...data,
+ status: NodeRunningStatus.Running,
+ }
+ }))
+ }
+ else {
+ setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
+ draft.tracing!.push({
+ ...data,
+ status: NodeRunningStatus.Running,
+ })
+ }))
+ }
const {
setViewport,
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-paused.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-paused.ts
new file mode 100644
index 0000000000..fc85d3d459
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-paused.ts
@@ -0,0 +1,26 @@
+import { produce } from 'immer'
+import { useCallback } from 'react'
+import { useWorkflowStore } from '@/app/components/workflow/store'
+import { WorkflowRunningStatus } from '@/app/components/workflow/types'
+
+export const useWorkflowPaused = () => {
+ const workflowStore = useWorkflowStore()
+
+ const handleWorkflowPaused = useCallback(() => {
+ const {
+ workflowRunningData,
+ setWorkflowRunningData,
+ } = workflowStore.getState()
+
+ setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
+ draft.result = {
+ ...draft.result,
+ status: WorkflowRunningStatus.Paused,
+ }
+ }))
+ }, [workflowStore])
+
+ return {
+ handleWorkflowPaused,
+ }
+}
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts
index 64883076cd..bf8fd319a2 100644
--- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event.ts
@@ -3,6 +3,9 @@ import {
useWorkflowFailed,
useWorkflowFinished,
useWorkflowNodeFinished,
+ useWorkflowNodeHumanInputFormFilled,
+ useWorkflowNodeHumanInputFormTimeout,
+ useWorkflowNodeHumanInputRequired,
useWorkflowNodeIterationFinished,
useWorkflowNodeIterationNext,
useWorkflowNodeIterationStarted,
@@ -11,6 +14,7 @@ import {
useWorkflowNodeLoopStarted,
useWorkflowNodeRetry,
useWorkflowNodeStarted,
+ useWorkflowPaused,
useWorkflowStarted,
useWorkflowTextChunk,
useWorkflowTextReplace,
@@ -32,6 +36,10 @@ export const useWorkflowRunEvent = () => {
const { handleWorkflowTextChunk } = useWorkflowTextChunk()
const { handleWorkflowTextReplace } = useWorkflowTextReplace()
const { handleWorkflowAgentLog } = useWorkflowAgentLog()
+ const { handleWorkflowPaused } = useWorkflowPaused()
+ const { handleWorkflowNodeHumanInputRequired } = useWorkflowNodeHumanInputRequired()
+ const { handleWorkflowNodeHumanInputFormFilled } = useWorkflowNodeHumanInputFormFilled()
+ const { handleWorkflowNodeHumanInputFormTimeout } = useWorkflowNodeHumanInputFormTimeout()
return {
handleWorkflowStarted,
@@ -49,5 +57,9 @@ export const useWorkflowRunEvent = () => {
handleWorkflowTextChunk,
handleWorkflowTextReplace,
handleWorkflowAgentLog,
+ handleWorkflowPaused,
+ handleWorkflowNodeHumanInputFormFilled,
+ handleWorkflowNodeHumanInputRequired,
+ handleWorkflowNodeHumanInputFormTimeout,
}
}
diff --git a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-started.ts b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-started.ts
index 16ad976607..fa9109c460 100644
--- a/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-started.ts
+++ b/web/app/components/workflow/hooks/use-workflow-run-event/use-workflow-started.ts
@@ -22,6 +22,15 @@ export const useWorkflowStarted = () => {
edges,
setEdges,
} = store.getState()
+ if (workflowRunningData?.result?.status === WorkflowRunningStatus.Paused) {
+ setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
+ draft.result = {
+ ...draft.result,
+ status: WorkflowRunningStatus.Running,
+ }
+ }))
+ return
+ }
setIterParallelLogMap(new Map())
setWorkflowRunningData(produce(workflowRunningData!, (draft) => {
draft.task_id = task_id
@@ -30,6 +39,7 @@ export const useWorkflowStarted = () => {
...data,
status: WorkflowRunningStatus.Running,
}
+ draft.resultText = ''
}))
const nodes = getNodes()
const newNodes = produce(nodes, (draft) => {
diff --git a/web/app/components/workflow/hooks/use-workflow-variables.ts b/web/app/components/workflow/hooks/use-workflow-variables.ts
index d6a5b8bdd1..8a975d80ec 100644
--- a/web/app/components/workflow/hooks/use-workflow-variables.ts
+++ b/web/app/components/workflow/hooks/use-workflow-variables.ts
@@ -116,7 +116,7 @@ export const useWorkflowVariables = () => {
schemaTypeDefinitions,
preferSchemaType,
})
- }, [workflowStore, getVarType, schemaTypeDefinitions, buildInTools, customTools, workflowTools, mcpTools])
+ }, [workflowStore, schemaTypeDefinitions, buildInTools, customTools, workflowTools, mcpTools])
return {
getNodeAvailableVars,
diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts
index 990c8c950d..0c6aa7466e 100644
--- a/web/app/components/workflow/hooks/use-workflow.ts
+++ b/web/app/components/workflow/hooks/use-workflow.ts
@@ -479,11 +479,21 @@ export const useNodesReadOnly = () => {
isRestoring,
} = workflowStore.getState()
- return !!(workflowRunningData?.result.status === WorkflowRunningStatus.Running || historyWorkflowData || isRestoring)
+ return !!(
+ workflowRunningData?.result.status === WorkflowRunningStatus.Running
+ || workflowRunningData?.result.status === WorkflowRunningStatus.Paused
+ || historyWorkflowData
+ || isRestoring
+ )
}, [workflowStore])
return {
- nodesReadOnly: !!(workflowRunningData?.result.status === WorkflowRunningStatus.Running || historyWorkflowData || isRestoring),
+ nodesReadOnly: !!(
+ workflowRunningData?.result.status === WorkflowRunningStatus.Running
+ || workflowRunningData?.result.status === WorkflowRunningStatus.Paused
+ || historyWorkflowData
+ || isRestoring
+ ),
getNodesReadOnly,
}
}
diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx
index 3eef34bd7b..d66d47cc1f 100644
--- a/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx
+++ b/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx
@@ -82,7 +82,7 @@ const FormItem: FC = ({
-
+
{nodeName}
diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx
index 8967b76f6c..4b7f65bcc1 100644
--- a/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx
+++ b/web/app/components/workflow/nodes/_base/components/before-run-form/index.tsx
@@ -3,7 +3,8 @@ import type { FC } from 'react'
import type { Props as FormProps } from './form'
import type { Emoji } from '@/app/components/tools/types'
import type { SpecialResultPanelProps } from '@/app/components/workflow/run/special-result-panel'
-import type { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types'
+import type { NodeRunningStatus } from '@/app/components/workflow/types'
+import type { HumanInputFormData } from '@/types/workflow'
import * as React from 'react'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
@@ -11,7 +12,8 @@ import Button from '@/app/components/base/button'
import { getProcessedFiles } from '@/app/components/base/file-uploader/utils'
import Toast from '@/app/components/base/toast'
import Split from '@/app/components/workflow/nodes/_base/components/split'
-import { InputVarType } from '@/app/components/workflow/types'
+import SingleRunForm from '@/app/components/workflow/nodes/human-input/components/single-run-form'
+import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { TransferMethod } from '@/types/app'
import { cn } from '@/utils/classnames'
import Form from './form'
@@ -31,6 +33,12 @@ export type BeforeRunFormProps = {
showSpecialResultPanel?: boolean
existVarValuesInForms: Record
[]
filteredExistVarForms: FormProps[]
+ showGeneratedForm?: boolean
+ handleShowGeneratedForm?: (data: Record) => void
+ handleHideGeneratedForm?: () => void
+ formData?: HumanInputFormData
+ handleSubmitHumanInputForm?: (data: any) => Promise
+ handleAfterHumanInputStepRun?: () => void
} & Partial
function formatValue(value: string | any, type: InputVarType) {
@@ -62,14 +70,24 @@ function formatValue(value: string | any, type: InputVarType) {
}
const BeforeRunForm: FC = ({
nodeName,
+ nodeType,
onHide,
onRun,
forms,
filteredExistVarForms,
existVarValuesInForms,
+ showGeneratedForm = false,
+ handleShowGeneratedForm,
+ handleHideGeneratedForm,
+ formData,
+ handleSubmitHumanInputForm,
+ handleAfterHumanInputStepRun,
}) => {
const { t } = useTranslation()
+ const isHumanInput = nodeType === BlockEnum.HumanInput
+ const showBackButton = filteredExistVarForms.length > 0
+
const isFileLoaded = (() => {
if (!forms || forms.length === 0)
return true
@@ -84,7 +102,8 @@ const BeforeRunForm: FC = ({
return true
})()
- const handleRun = () => {
+
+ const handleRunOrGenerateForm = () => {
let errMsg = ''
forms.forEach((form, i) => {
const existVarValuesInForm = existVarValuesInForms[i]
@@ -135,19 +154,30 @@ const BeforeRunForm: FC = ({
return
}
- onRun(submitData)
+ if (isHumanInput)
+ handleShowGeneratedForm?.(submitData)
+ else
+ onRun(submitData)
}
+
+ const handleHumanInputFormSubmit = async (data: any) => {
+ await handleSubmitHumanInputForm?.(data)
+ handleAfterHumanInputStepRun?.()
+ }
+
const hasRun = useRef(false)
useEffect(() => {
// React 18 run twice in dev mode
if (hasRun.current)
return
hasRun.current = true
- if (filteredExistVarForms.length === 0)
+ if (filteredExistVarForms.length === 0 && !isHumanInput)
onRun({})
- }, [filteredExistVarForms, onRun])
+ if (filteredExistVarForms.length === 0 && isHumanInput)
+ handleShowGeneratedForm?.({})
+ }, [filteredExistVarForms, handleShowGeneratedForm, isHumanInput, onRun])
- if (filteredExistVarForms.length === 0)
+ if (filteredExistVarForms.length === 0 && !isHumanInput)
return null
return (
@@ -156,23 +186,43 @@ const BeforeRunForm: FC = ({
onHide={onHide}
>
-
- {filteredExistVarForms.map((form, index) => (
-
-
- {index < forms.length - 1 && }
-
- ))}
-
-
-
- {t(`${i18nPrefix}.startRun`, { ns: 'workflow' })}
-
-
+ {!showGeneratedForm && (
+
+ {filteredExistVarForms.map((form, index) => (
+
+
+ {index < forms.length - 1 && }
+
+ ))}
+
+ )}
+ {showGeneratedForm && formData && (
+
+ )}
+ {!showGeneratedForm && (
+
+ {!isHumanInput && (
+
+ {t(`${i18nPrefix}.startRun`, { ns: 'workflow' })}
+
+ )}
+ {isHumanInput && (
+
+ {t('nodes.humanInput.singleRun.button', { ns: 'workflow' })}
+
+ )}
+
+ )}
)
diff --git a/web/app/components/workflow/nodes/_base/components/variable/utils.ts b/web/app/components/workflow/nodes/_base/components/variable/utils.ts
index 2f83945dc2..867221ea31 100644
--- a/web/app/components/workflow/nodes/_base/components/variable/utils.ts
+++ b/web/app/components/workflow/nodes/_base/components/variable/utils.ts
@@ -15,6 +15,7 @@ import type { QuestionClassifierNodeType } from '../../../question-classifier/ty
import type { TemplateTransformNodeType } from '../../../template-transform/types'
import type { ToolNodeType } from '../../../tool/types'
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
+import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
import type { CaseItem, Condition } from '@/app/components/workflow/nodes/if-else/types'
import type { Field as StructField } from '@/app/components/workflow/nodes/llm/types'
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
@@ -41,6 +42,7 @@ import {
FILE_STRUCT,
getGlobalVars,
HTTP_REQUEST_OUTPUT_STRUCT,
+ HUMAN_INPUT_OUTPUT_STRUCT,
KNOWLEDGE_RETRIEVAL_OUTPUT_STRUCT,
LLM_OUTPUT_STRUCT,
PARAMETER_EXTRACTOR_COMMON_STRUCT,
@@ -50,6 +52,7 @@ import {
TOOL_OUTPUT_STRUCT,
} from '@/app/components/workflow/constants'
import DataSourceNodeDefault from '@/app/components/workflow/nodes/data-source/default'
+import HumanInputNodeDefault from '@/app/components/workflow/nodes/human-input/default'
import ToolNodeDefault from '@/app/components/workflow/nodes/tool/default'
import PluginTriggerNodeDefault from '@/app/components/workflow/nodes/trigger-plugin/default'
import {
@@ -630,6 +633,17 @@ const formatItem = (
break
}
+ case BlockEnum.HumanInput: {
+ const outputSchema = HumanInputNodeDefault.getOutputVars?.(
+ data as HumanInputNodeType,
+ allPluginInfoList,
+ [],
+ { schemaTypeDefinitions },
+ ) || []
+ res.vars = [...outputSchema, ...HUMAN_INPUT_OUTPUT_STRUCT]
+ break
+ }
+
case 'env': {
res.vars = data.envList.map((env: EnvironmentVariable) => {
return {
@@ -1487,6 +1501,13 @@ export const getNodeUsedVars = (node: Node): ValueSelector[] => {
res = valueSelectors
break
}
+
+ case BlockEnum.HumanInput: {
+ const payload = data as HumanInputNodeType
+ const formContent = payload.form_content
+ res = matchNotSystemVars([formContent])
+ break
+ }
}
return res || []
}
@@ -1585,6 +1606,11 @@ export const getNodeUsedVarPassToServerKey = (
res = 'query'
break
}
+
+ case BlockEnum.HumanInput: {
+ res = `#${valueSelector.join('.')}#`
+ break
+ }
}
return res
}
@@ -1921,6 +1947,15 @@ export const updateNodeVars = (
payload.variable = newVarSelector
break
}
+ case BlockEnum.HumanInput: {
+ const payload = data as HumanInputNodeType
+ payload.form_content = replaceOldVarInText(
+ payload.form_content,
+ oldVarSelector,
+ newVarSelector,
+ )
+ break
+ }
}
})
return newNode
diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx
index 6dfcbaf4d8..8ff42bc5c4 100644
--- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx
+++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx
@@ -71,6 +71,8 @@ type Props = {
availableNodes?: Node[]
availableVars?: NodeOutPutVar[]
isAddBtnTrigger?: boolean
+ trigger?: React.ReactNode
+ isJustShowValue?: boolean
schema?: Partial
valueTypePlaceHolder?: string
isInTable?: boolean
@@ -103,6 +105,8 @@ const VarReferencePicker: FC = ({
isFilterFileVar,
availableNodes: passedInAvailableNodes,
availableVars: passedInAvailableVars,
+ trigger,
+ isJustShowValue,
isAddBtnTrigger,
schema,
valueTypePlaceHolder,
@@ -423,204 +427,207 @@ const VarReferencePicker: FC = ({
onOpenChange={setOpen}
placement={isAddBtnTrigger ? 'bottom-end' : 'bottom-start'}
>
- {
- if (readonly)
- return
- if (!isConstant)
- setOpen(!open)
- else
- setControlFocus(Date.now())
- }}
- className="group/picker-trigger-wrap relative !flex"
- >
- <>
- {isAddBtnTrigger
- ? (
-
- )
- : (
-
- {isSupportConstantValue
- ? (
-
{
- e.stopPropagation()
- setOpen(false)
- setControlFocus(Date.now())
- }}
- className="mr-1 flex h-full items-center space-x-1"
- >
-
- {varKindTypes.find(item => item.value === varKindType)?.label}
-
-
- )}
- popupClassName="top-8"
- readonly={readonly}
- value={varKindType}
- options={varKindTypes}
- onChange={handleVarKindTypeChange}
- showChecked
- />
-
- )
- : (!hasValue && (
-
-
-
- ))}
- {isConstant
- ? (
- void)}
- schema={schemaWithDynamicSelect as CredentialFormSchema}
- readonly={readonly}
- isLoading={isLoading}
- />
- )
- : (
- {
- if (readonly)
- return
- if (!isConstant)
- setOpen(!open)
- else
+ {!!trigger && setOpen(!open)}>{trigger} }
+ {!trigger && (
+ {
+ if (readonly)
+ return
+ if (!isConstant)
+ setOpen(!open)
+ else
+ setControlFocus(Date.now())
+ }}
+ className="group/picker-trigger-wrap relative !flex"
+ >
+ <>
+ {isAddBtnTrigger
+ ? (
+
+ )
+ : (
+
+ {isSupportConstantValue
+ ? (
+
{
+ e.stopPropagation()
+ setOpen(false)
setControlFocus(Date.now())
- }}
- className="h-full grow"
- >
-
-
-
- {hasValue
- ? (
- <>
- {isShowNodeName && !isEnv && !isChatVar && !isGlobal && !isRagVar && (
-
{
- if (e.metaKey || e.ctrlKey) {
- e.stopPropagation()
- handleVariableJump(outputVarNode?.id)
- }
- }}
- >
-
- {outputVarNode?.type && (
-
- )}
-
+ }}
+ className="mr-1 flex h-full items-center space-x-1"
+ >
+
+ {varKindTypes.find(item => item.value === varKindType)?.label}
+
+
+ )}
+ popupClassName="top-8"
+ readonly={readonly}
+ value={varKindType}
+ options={varKindTypes}
+ onChange={handleVarKindTypeChange}
+ showChecked
+ />
+
+ )
+ : (!hasValue && (
+
+
+
+ ))}
+ {isConstant
+ ? (
+ void)}
+ schema={schemaWithDynamicSelect as CredentialFormSchema}
+ readonly={readonly}
+ isLoading={isLoading}
+ />
+ )
+ : (
+ {
+ if (readonly)
+ return
+ if (!isConstant)
+ setOpen(!open)
+ else
+ setControlFocus(Date.now())
+ }}
+ className="h-full grow"
+ >
+
+
+
+ {hasValue
+ ? (
+ <>
+ {isShowNodeName && !isEnv && !isChatVar && !isGlobal && !isRagVar && (
{
+ if (e.metaKey || e.ctrlKey) {
+ e.stopPropagation()
+ handleVariableJump(outputVarNode?.id)
+ }
}}
>
- {outputVarNode?.title}
+
+ {outputVarNode?.type && (
+
+ )}
+
+
+ {outputVarNode?.title}
+
+
+
+ )}
+ {isShowAPart && (
+
+
+
+
+ )}
+
+ {isLoading &&
}
+
+
+ {varName}
-
- )}
- {isShowAPart && (
-
-
-
-
- )}
-
- {isLoading &&
}
-
- {varName}
+ {type}
+ {!isValidVar &&
}
+ >
+ )
+ : (
+
+ {isLoading
+ ? (
+
+
+ {placeholder ?? t('common.setVarValuePlaceholder', { ns: 'workflow' })}
+
+ )
+ : (
+ placeholder ?? t('common.setVarValuePlaceholder', { ns: 'workflow' })
+ )}
-
- {type}
-
- {!isValidVar &&
}
- >
- )
- : (
-
- {isLoading
- ? (
-
-
- {placeholder ?? t('common.setVarValuePlaceholder', { ns: 'workflow' })}
-
- )
- : (
- placeholder ?? t('common.setVarValuePlaceholder', { ns: 'workflow' })
- )}
-
- )}
-
-
-
+ )}
+
+
+
-
- )}
- {(hasValue && !readonly && !isInTable) && (
-
-
-
- )}
- {!hasValue && valueTypePlaceHolder && (
-
- )}
-
- )}
- {!readonly && isInTable && (
-
onRemove?.()}
- />
- )}
+
+ )}
+ {(hasValue && !readonly && !isInTable && !isJustShowValue) && (
+
+
+
+ )}
+ {!hasValue && valueTypePlaceHolder && (
+
+ )}
+
+ )}
+ {!readonly && isInTable && (
+ onRemove?.()}
+ />
+ )}
- {!hasValue && typePlaceHolder && (
-
- )}
- >
-
+ {!hasValue && typePlaceHolder && (
+
+ )}
+ >
+
+ )}
= ({
}, [isHovering])
const handleChosen = (e: React.MouseEvent) => {
e.stopPropagation()
+ e.nativeEvent.stopImmediatePropagation()
if (!isSupportFileVar && isFile)
return
@@ -189,7 +190,11 @@ const Item: FC = ({
className,
)}
onClick={handleChosen}
- onMouseDown={e => e.preventDefault()}
+ onMouseDown={(e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ e.nativeEvent.stopImmediatePropagation()
+ }}
>
{!isFlat && (
diff --git a/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx b/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx
index 032746ad5b..18d563ca01 100644
--- a/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx
+++ b/web/app/components/workflow/nodes/_base/components/variable/variable-label/base/variable-label.tsx
@@ -27,7 +27,7 @@ const VariableLabel = ({
rightSlot,
}: VariablePayload) => {
const varColorClassName = useVarColor(variables, isExceptionVariable)
- const isHideNodeLabel = !(isENV(variables) || isConversationVar(variables) || isGlobalVar(variables) || isRagVariableVar(variables))
+ const isShowNodeLabel = !(isENV(variables) || isConversationVar(variables) || isGlobalVar(variables) || isRagVariableVar(variables))
return (
- { isHideNodeLabel && (
+ {isShowNodeLabel && (
= ({
const currentDataSource = useMemo(() => {
if (data.type === BlockEnum.DataSource && data.provider_type !== DataSourceClassification.localFile)
return dataSourceList?.find(item => item.plugin_id === data.plugin_id)
- }, [dataSourceList, data.provider_id, data.type, data.provider_type])
+ }, [data.type, data.provider_type, data.plugin_id, dataSourceList])
const handleAuthorizationItemClick = useCallback((credential_id: string) => {
handleNodeDataUpdateWithSyncDraft({
@@ -445,6 +445,7 @@ const BasePanel: FC = ({
{...passedLogParams}
existVarValuesInForms={getExistVarValuesInForms(singleRunParams?.forms as any)}
filteredExistVarForms={getFilteredExistVarForms(singleRunParams?.forms as any)}
+ handleAfterHumanInputStepRun={handleAfterCustomSingleRun}
/>
)}
diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts
index 0de98db032..dcbf392a8f 100644
--- a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts
+++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts
@@ -15,6 +15,7 @@ import useVariableAssignerSingleRunFormParams from '@/app/components/workflow/no
import useCodeSingleRunFormParams from '@/app/components/workflow/nodes/code/use-single-run-form-params'
import useDocExtractorSingleRunFormParams from '@/app/components/workflow/nodes/document-extractor/use-single-run-form-params'
import useHttpRequestSingleRunFormParams from '@/app/components/workflow/nodes/http/use-single-run-form-params'
+import useHumanInputSingleRunFormParams from '@/app/components/workflow/nodes/human-input/hooks/use-single-run-form-params'
import useIfElseSingleRunFormParams from '@/app/components/workflow/nodes/if-else/use-single-run-form-params'
import useIterationSingleRunFormParams from '@/app/components/workflow/nodes/iteration/use-single-run-form-params'
import useKnowledgeBaseSingleRunFormParams from '@/app/components/workflow/nodes/knowledge-base/use-single-run-form-params'
@@ -22,15 +23,16 @@ import useKnowledgeRetrievalSingleRunFormParams from '@/app/components/workflow/
import useLLMSingleRunFormParams from '@/app/components/workflow/nodes/llm/use-single-run-form-params'
import useLoopSingleRunFormParams from '@/app/components/workflow/nodes/loop/use-single-run-form-params'
import useParameterExtractorSingleRunFormParams from '@/app/components/workflow/nodes/parameter-extractor/use-single-run-form-params'
-import useQuestionClassifierSingleRunFormParams from '@/app/components/workflow/nodes/question-classifier/use-single-run-form-params'
+import useQuestionClassifierSingleRunFormParams from '@/app/components/workflow/nodes/question-classifier/use-single-run-form-params'
import useStartSingleRunFormParams from '@/app/components/workflow/nodes/start/use-single-run-form-params'
import useTemplateTransformSingleRunFormParams from '@/app/components/workflow/nodes/template-transform/use-single-run-form-params'
-import useToolGetDataForCheckMore from '@/app/components/workflow/nodes/tool/use-get-data-for-check-more'
+import useToolGetDataForCheckMore from '@/app/components/workflow/nodes/tool/use-get-data-for-check-more'
import useToolSingleRunFormParams from '@/app/components/workflow/nodes/tool/use-single-run-form-params'
import useTriggerPluginGetDataForCheckMore from '@/app/components/workflow/nodes/trigger-plugin/use-check-params'
import useVariableAggregatorSingleRunFormParams from '@/app/components/workflow/nodes/variable-assigner/use-single-run-form-params'
+
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
import { isSupportCustomRunForm } from '@/app/components/workflow/utils'
@@ -63,6 +65,7 @@ const singleRunFormParamsHooks: Record = {
[BlockEnum.IterationStart]: undefined,
[BlockEnum.LoopStart]: undefined,
[BlockEnum.LoopEnd]: undefined,
+ [BlockEnum.HumanInput]: useHumanInputSingleRunFormParams,
[BlockEnum.DataSource]: undefined,
[BlockEnum.DataSourceEmpty]: undefined,
[BlockEnum.TriggerWebhook]: undefined,
@@ -100,6 +103,7 @@ const getDataForCheckMoreHooks: Record = {
[BlockEnum.Assigner]: undefined,
[BlockEnum.LoopStart]: undefined,
[BlockEnum.LoopEnd]: undefined,
+ [BlockEnum.HumanInput]: undefined,
[BlockEnum.DataSource]: undefined,
[BlockEnum.DataSourceEmpty]: undefined,
[BlockEnum.KnowledgeBase]: undefined,
@@ -129,6 +133,7 @@ const useLastRun = ({
const isLoopNode = blockType === BlockEnum.Loop
const isAggregatorNode = blockType === BlockEnum.VariableAggregator
const isCustomRunNode = isSupportCustomRunForm(blockType)
+ const isHumanInputNode = blockType === BlockEnum.HumanInput
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const {
getData: getDataForCheckMore,
@@ -338,7 +343,7 @@ const useLastRun = ({
return
if (blockType === BlockEnum.TriggerWebhook || blockType === BlockEnum.TriggerPlugin || blockType === BlockEnum.TriggerSchedule)
setShowVariableInspectPanel(true)
- if (isCustomRunNode) {
+ if (isCustomRunNode || isHumanInputNode) {
showSingleRun()
return
}
diff --git a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts
index d2d7b6b6d9..06843eacef 100644
--- a/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts
+++ b/web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts
@@ -24,6 +24,7 @@ import Assigner from '@/app/components/workflow/nodes/assigner/default'
import CodeDefault from '@/app/components/workflow/nodes/code/default'
import DocumentExtractorDefault from '@/app/components/workflow/nodes/document-extractor/default'
import HTTPDefault from '@/app/components/workflow/nodes/http/default'
+import HumanInputDefault from '@/app/components/workflow/nodes/human-input/default'
import IfElseDefault from '@/app/components/workflow/nodes/if-else/default'
import IterationDefault from '@/app/components/workflow/nodes/iteration/default'
import KnowledgeRetrievalDefault from '@/app/components/workflow/nodes/knowledge-retrieval/default'
@@ -69,6 +70,7 @@ const { checkValid: checkParameterExtractorValid } = ParameterExtractorDefault
const { checkValid: checkIterationValid } = IterationDefault
const { checkValid: checkDocumentExtractorValid } = DocumentExtractorDefault
const { checkValid: checkLoopValid } = LoopDefault
+const { checkValid: checkHumanInputValid } = HumanInputDefault
// eslint-disable-next-line ts/no-unsafe-function-type
const checkValidFns: Partial> = {
@@ -86,6 +88,7 @@ const checkValidFns: Partial> = {
[BlockEnum.Iteration]: checkIterationValid,
[BlockEnum.DocExtractor]: checkDocumentExtractorValid,
[BlockEnum.Loop]: checkLoopValid,
+ [BlockEnum.HumanInput]: checkHumanInputValid,
}
type RequestError = {
@@ -313,20 +316,7 @@ const useOneStepRun = ({
invalidateSysVarValues()
invalidateConversationVarValues() // loop, iteration, variable assigner node can update the conversation variables, but to simple the logic(some nodes may also can update in the future), all nodes refresh.
}
- }, [
- isRunAfterSingleRun,
- runningStatus,
- flowId,
- id,
- store,
- appendNodeInspectVars,
- updateNodeInspectRunningState,
- invalidLastRun,
- isStartNode,
- isTriggerNode,
- invalidateSysVarValues,
- invalidateConversationVarValues,
- ])
+ }, [isRunAfterSingleRun, runningStatus, flowType, flowId, id, store, appendNodeInspectVars, updateNodeInspectRunningState, invalidLastRun, isStartNode, isTriggerNode, invalidateSysVarValues, invalidateConversationVarValues])
const { handleNodeDataUpdate }: { handleNodeDataUpdate: (data: any) => void } = useNodeDataUpdate()
const setNodeRunning = () => {
diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx
index 47f5028054..dbecd2d817 100644
--- a/web/app/components/workflow/nodes/_base/node.tsx
+++ b/web/app/components/workflow/nodes/_base/node.tsx
@@ -9,6 +9,7 @@ import {
RiCheckboxCircleFill,
RiErrorWarningFill,
RiLoader2Line,
+ RiPauseCircleFill,
} from '@remixicon/react'
import {
cloneElement,
@@ -107,7 +108,7 @@ const BaseNode: FC = ({
showExceptionBorder,
} = useMemo(() => {
return {
- showRunningBorder: data._runningStatus === NodeRunningStatus.Running && !showSelectedBorder,
+ showRunningBorder: (data._runningStatus === NodeRunningStatus.Running || data._runningStatus === NodeRunningStatus.Paused) && !showSelectedBorder,
showSuccessBorder: (data._runningStatus === NodeRunningStatus.Succeeded || hasVarValue) && !showSelectedBorder,
showFailedBorder: data._runningStatus === NodeRunningStatus.Failed && !showSelectedBorder,
showExceptionBorder: data._runningStatus === NodeRunningStatus.Exception && !showSelectedBorder,
@@ -221,7 +222,7 @@ const BaseNode: FC = ({
)
}
{
- data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && !data._isCandidate && (
+ data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && data.type !== BlockEnum.HumanInput && !data._isCandidate && (
= ({
!!(data.type === BlockEnum.Loop && data._loopIndex) && LoopIndex
}
{
- isLoading
- ?
- : data._runningStatus === NodeRunningStatus.Failed
- ?
- : data._runningStatus === NodeRunningStatus.Exception
- ?
- : (data._runningStatus === NodeRunningStatus.Succeeded || hasVarValue)
- ?
- : null
+ isLoading &&
+ }
+ {
+ !isLoading && data._runningStatus === NodeRunningStatus.Failed && (
+
+ )
+ }
+ {
+ !isLoading && data._runningStatus === NodeRunningStatus.Exception && (
+
+ )
+ }
+ {
+ !isLoading && (data._runningStatus === NodeRunningStatus.Succeeded || hasVarValue) && (
+
+ )
+ }
+ {
+ !isLoading && data._runningStatus === NodeRunningStatus.Paused && (
+
+ )
}
{
diff --git a/web/app/components/workflow/nodes/components.ts b/web/app/components/workflow/nodes/components.ts
index 87c0066d15..ec1e4422bf 100644
--- a/web/app/components/workflow/nodes/components.ts
+++ b/web/app/components/workflow/nodes/components.ts
@@ -16,6 +16,8 @@ import EndNode from './end/node'
import EndPanel from './end/panel'
import HttpNode from './http/node'
import HttpPanel from './http/panel'
+import HumanInputNode from './human-input/node'
+import HumanInputPanel from './human-input/panel'
import IfElseNode from './if-else/node'
import IfElsePanel from './if-else/panel'
import IterationNode from './iteration/node'
@@ -72,6 +74,7 @@ export const NodeComponentMap: Record
> = {
[BlockEnum.Agent]: AgentNode,
[BlockEnum.DataSource]: DataSourceNode,
[BlockEnum.KnowledgeBase]: KnowledgeBaseNode,
+ [BlockEnum.HumanInput]: HumanInputNode,
[BlockEnum.TriggerSchedule]: TriggerScheduleNode,
[BlockEnum.TriggerWebhook]: TriggerWebhookNode,
[BlockEnum.TriggerPlugin]: TriggerPluginNode,
@@ -100,6 +103,7 @@ export const PanelComponentMap: Record> = {
[BlockEnum.Agent]: AgentPanel,
[BlockEnum.DataSource]: DataSourcePanel,
[BlockEnum.KnowledgeBase]: KnowledgeBasePanel,
+ [BlockEnum.HumanInput]: HumanInputPanel,
[BlockEnum.TriggerSchedule]: TriggerSchedulePanel,
[BlockEnum.TriggerWebhook]: TriggerWebhookPanel,
[BlockEnum.TriggerPlugin]: TriggerPluginPanel,
diff --git a/web/app/components/workflow/nodes/human-input/components/add-input-field.tsx b/web/app/components/workflow/nodes/human-input/components/add-input-field.tsx
new file mode 100644
index 0000000000..35049f683b
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/add-input-field.tsx
@@ -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 = ({
+ nodeId,
+ onSave,
+ onCancel,
+}) => {
+ return (
+
+ )
+}
+export default React.memo(AddInputField)
diff --git a/web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx b/web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx
new file mode 100644
index 0000000000..d3896049b5
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/button-style-dropdown.tsx
@@ -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 = ({
+ 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 (
+
+ !readonly && setOpen(v => !v)}>
+
+
+
+
+
+
+
+
+
{t(`${i18nPrefix}.userActions.chooseStyle`, { ns: 'workflow' })}
+
+
onChange(UserActionButtonType.Primary)}
+ >
+ {text}
+
+
onChange(UserActionButtonType.Default)}
+ >
+ {text}
+
+
onChange(UserActionButtonType.Accent)}
+ >
+ {text}
+
+
onChange(UserActionButtonType.Ghost)}
+ >
+ {text}
+
+
+
+
+
+ )
+}
+
+export default ButtonStyleDropdown
diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx
new file mode 100644
index 0000000000..4ca1c28290
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/email-configure-modal.tsx
@@ -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 (
+
+
+
+
+
+
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.title`, { ns: 'workflow' })}
+
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.description`, { ns: 'workflow' })}
+
+
+
+
+ {t(`${i18nPrefix}.deliveryMethod.emailConfigure.subject`, { ns: 'workflow' })}
+
+
setSubject(e.target.value)}
+ placeholder={t(`${i18nPrefix}.deliveryMethod.emailConfigure.subjectPlaceholder`, { ns: 'workflow' })}
+ />
+
+
+
+ {t(`${i18nPrefix}.deliveryMethod.emailConfigure.body`, { ns: 'workflow' })}
+
+
+
+
+
+ {t(`${i18nPrefix}.deliveryMethod.emailConfigure.recipient`, { ns: 'workflow' })}
+
+
+
+
+
+
+
+
+
+
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.debugMode`, { ns: 'workflow' })}
+
+
{email} }}
+ values={{ email }}
+ />
+ {t(`${i18nPrefix}.deliveryMethod.emailConfigure.debugModeTip2`, { ns: 'workflow' })}
+
+
+
setDebugMode(checked)}
+ />
+
+
+
+
+ {t('operation.save', { ns: 'common' })}
+
+
+ {t('operation.cancel', { ns: 'common' })}
+
+
+
+ )
+}
+
+export default memo(EmailConfigureModal)
diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx
new file mode 100644
index 0000000000..62a0ba0d8b
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/index.tsx
@@ -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 = ({
+ 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 (
+
+
+
+
{t(`${i18nPrefix}.deliveryMethod.title`, { ns: 'workflow' })}
+
+
+ {!readonly && (
+
+
+
+ )}
+
+ {!value.length && (
+
{t(`${i18nPrefix}.deliveryMethod.emptyTip`, { ns: 'workflow' })}
+ )}
+ {value.length > 0 && (
+
+ {value.map(method => (
+
+ ))}
+
+ )}
+ {showUpgradeModal && (
+
+ )}
+
+ )
+}
+
+export default DeliveryMethodForm
diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/mail-body-input.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/mail-body-input.tsx
new file mode 100644
index 0000000000..8e127d932e
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/mail-body-input.tsx
@@ -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 (
+ {
+ 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>),
+ }}
+ placeholder={ }
+ onChange={onChange}
+ />
+ )
+}
+
+export default MailBodyInput
diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx
new file mode 100644
index 0000000000..bea2f8cb35
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/method-item.tsx
@@ -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 = ({
+ 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 (
+ <>
+
+
+ {method.type === DeliveryMethodType.WebApp && (
+
+
+
+ )}
+ {method.type === DeliveryMethodType.Email && (
+
+
+
+ )}
+
{method.type}
+ {method.type === DeliveryMethodType.Email
+ && (method.config as EmailConfig)?.debug_mode
+ &&
DEBUG }
+
+
+ {!readonly && (
+
+ {method.type === DeliveryMethodType.Email && method.config && (
+ <>
+
+ {
+ setShowTestEmailModal(true)
+ }}
+ >
+
+
+
+
+ setShowEmailModal(true)}>
+
+
+
+
+ >
+ )}
+
+ setIsHovering(true)}
+ onMouseLeave={() => setIsHovering(false)}
+ >
+
onDelete(method.type)}
+ >
+
+
+
+
+
+ )}
+ {(method.config || method.type === DeliveryMethodType.WebApp) && (
+
+ )}
+ {method.type === DeliveryMethodType.Email && !method.config && (
+
setShowEmailModal(true)}
+ disabled={readonly}
+ >
+ {t(`${i18nPrefix}.deliveryMethod.notConfigured`, { ns: 'workflow' })}
+
+
+ )}
+
+
+ {showEmailModal && (
+ setShowEmailModal(false)}
+ onConfirm={(data) => {
+ handleConfigChange(data)
+ setShowEmailModal(false)
+ }}
+ />
+ )}
+ {showTestEmailModal && (
+ setShowTestEmailModal(false)}
+ jumpToEmailConfigModal={jumpToEmailConfigModal}
+ />
+ )}
+ >
+ )
+}
+
+export default DeliveryMethodItem
diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx
new file mode 100644
index 0000000000..3c3c0ddba2
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/method-selector.tsx
@@ -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 = ({
+ 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 (
+
+
+
+
+
+
+
+
{
+ if (webAppDeliveryInfo.disabled)
+ return
+ onAdd({
+ id: uuid4(),
+ type: DeliveryMethodType.WebApp,
+ enabled: true,
+ })
+ }}
+ >
+
+
+
+
+
{t(`${i18nPrefix}.deliveryMethod.types.webapp.title`, { ns: 'workflow' })}
+
{t(`${i18nPrefix}.deliveryMethod.types.webapp.description`, { ns: 'workflow' })}
+
+ {webAppDeliveryInfo.added && (
+
{t(`${i18nPrefix}.deliveryMethod.added`, { ns: 'workflow' })}
+ )}
+ {webAppDeliveryInfo.isTriggerMode && !webAppDeliveryInfo.added && (
+
{t(`${i18nPrefix}.deliveryMethod.notAvailableInTriggerMode`, { ns: 'workflow' })}
+ )}
+
+
{
+ if (emailDeliveryInfo.noPermission) {
+ onShowUpgradeTip()
+ return
+ }
+ if (emailDeliveryInfo.added)
+ return
+ onAdd({
+ id: uuid4(),
+ type: DeliveryMethodType.Email,
+ enabled: false,
+ })
+ }}
+ >
+
+
+
+
+
{t(`${i18nPrefix}.deliveryMethod.types.email.title`, { ns: 'workflow' })}
+
{t(`${i18nPrefix}.deliveryMethod.types.email.description`, { ns: 'workflow' })}
+
+ {emailDeliveryInfo.added && (
+
{t(`${i18nPrefix}.deliveryMethod.added`, { ns: 'workflow' })}
+ )}
+
+ {/* Slack */}
+
+
+
+
+
+
{t(`${i18nPrefix}.deliveryMethod.types.slack.title`, { ns: 'workflow' })}
+
{t(`${i18nPrefix}.deliveryMethod.types.slack.description`, { ns: 'workflow' })}
+
+
+ COMING SOON
+
+
+ {/* Teams */}
+
+
+
+
+
+
{t(`${i18nPrefix}.deliveryMethod.types.teams.title`, { ns: 'workflow' })}
+
{t(`${i18nPrefix}.deliveryMethod.types.teams.description`, { ns: 'workflow' })}
+
+
+ COMING SOON
+
+
+ {/* Discord */}
+
+
+
+
+
+
{t(`${i18nPrefix}.deliveryMethod.types.discord.title`, { ns: 'workflow' })}
+
{t(`${i18nPrefix}.deliveryMethod.types.discord.description`, { ns: 'workflow' })}
+
+
+ COMING SOON
+
+
+
+
+ {!IS_CE_EDITION && (
+
+
+
+
+
+
+
{t(`${i18nPrefix}.deliveryMethod.contactTip1`, { ns: 'workflow' })}
+
support@dify.ai }}
+ />
+
+
+
+ )}
+
+
+ )
+}
+export default memo(MethodSelector)
diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx
new file mode 100644
index 0000000000..b7a94412e7
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-input.tsx
@@ -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(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) => {
+ 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) => {
+ 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 (
+
+
+ {selectedEmails.map(item => (
+
+ ))}
+ {!disabled && (
+
+
+ setIsFocus(true)}
+ onBlur={handleInputBlur}
+ value={searchKey}
+ onChange={handleValueChange}
+ onKeyDown={handleKeyDown}
+ />
+
+
+
+
+
+ )}
+
+
+ )
+}
+
+export default EmailInput
diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx
new file mode 100644
index 0000000000..be26c9bece
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/email-item.tsx
@@ -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 (
+ e.stopPropagation()}
+ >
+ {isError && (
+
+ )}
+ {!isError &&
}
+
+ {email === data.email ? data.name : data.email}
+ {email === data.email && {t('members.you', { ns: 'common' })} }
+
+ {!disabled && (
+
onDelete(data as unknown as RecipientItem)}
+ />
+ )}
+
+ )
+}
+
+export default EmailItem
diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx
new file mode 100644
index 0000000000..9b5f4ef68c
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/index.tsx
@@ -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 (
+
+
+
+
+
+
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.title`, { ns: 'workflow' })}
+
+
+
+
+
+
+
+
+
+ {currentWorkspace?.name[0]?.toLocaleUpperCase()}
+
+
{t(`${i18nPrefix}.deliveryMethod.emailConfigure.allMembers`, { workspaceName: currentWorkspace.name.replace(/'/g, '’'), ns: 'workflow' })}
+
onChange({ ...data, whole_workspace: checked })}
+ />
+
+
+ )
+}
+
+export default memo(Recipient)
diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx
new file mode 100644
index 0000000000..eca07fd6ce
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-list.tsx
@@ -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 = ({ 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 (
+
+ {!hideSearch && (
+
+ onSearchChange(e.target.value)}
+ />
+
+ )}
+ {filteredList.length > 0 && (
+
+ {filteredList.map(account => (
+
item.user_id === account.id) && 'bg-transparent hover:bg-transparent',
+ )}
+ onClick={() => {
+ if (value.some(item => item.user_id === account.id))
+ return
+ onSelect(account.id)
+ }}
+ >
+
item.user_id === account.id) && 'opacity-50')} avatar={account.avatar_url} size={24} name={account.name} />
+ item.user_id === account.id) && 'opacity-50')}>
+
+ {account.name}
+ {account.status === 'pending' && {t('members.pending', { ns: 'common' })} }
+ {email === account.email && {t('members.you', { ns: 'common' })} }
+
+
{account.email}
+
+ {!value.some(item => item.user_id === account.id) && (
+ {t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.add`, { ns: 'workflow' })}
+ )}
+ {value.some(item => item.user_id === account.id) && (
+ {t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.added`, { ns: 'workflow' })}
+ )}
+
+ ))}
+
+ )}
+
+ )
+}
+
+export default MemberList
diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx
new file mode 100644
index 0000000000..4ce8232a2e
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/recipient/member-selector.tsx
@@ -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 = ({
+ value,
+ email,
+ onSelect,
+ list = [],
+}) => {
+ const { t } = useTranslation()
+ const [open, setOpen] = useState(false)
+ const [searchValue, setSearchValue] = useState('')
+
+ return (
+
+ setOpen(v => !v)}
+ >
+
+
+ {t(`${i18nPrefix}.deliveryMethod.emailConfigure.memberSelector.trigger`, { ns: 'workflow' })}
+
+
+
+
+
+
+ )
+}
+export default MemberSelector
diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx
new file mode 100644
index 0000000000..20fa9dfb44
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx
@@ -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>({})
+ 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 (
+
+
+
{t(`${i18nPrefix}.deliveryMethod.emailSender.done`, { ns: 'workflow' })}
+ {debugEnabled && (
+
+ }}
+ values={{ email: userProfile.email }}
+ />
+
+ )}
+ {!debugEnabled && onlyWholeTeam && (
+
+ }}
+ values={{ team: currentWorkspace.name.replace(/'/g, '’') }}
+ />
+
+ )}
+ {!debugEnabled && onlySpecificUsers && (
+
{t(`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamDone3`, { ns: 'workflow' })}
+ )}
+ {!debugEnabled && combinedRecipients && (
+
+ }}
+ values={{ team: currentWorkspace.name.replace(/'/g, '’') }}
+ />
+
+ )}
+
+ {(onlySpecificUsers || combinedRecipients) && !debugEnabled && (
+
+
+
+ )}
+
+
+ {t('operation.ok', { ns: 'common' })}
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
{t(`${i18nPrefix}.deliveryMethod.emailSender.title`, { ns: 'workflow' })}
+ {debugEnabled && (
+ <>
+
{t(`${i18nPrefix}.deliveryMethod.emailSender.debugModeTip`, { ns: 'workflow' })}
+
+ }}
+ values={{ email: userProfile.email }}
+ />
+
+ >
+ )}
+ {!debugEnabled && onlyWholeTeam && (
+
+ }}
+ values={{ team: currentWorkspace.name.replace(/'/g, '’') }}
+ />
+
+ )}
+ {!debugEnabled && onlySpecificUsers && (
+
{t(`${i18nPrefix}.deliveryMethod.emailSender.wholeTeamTip3`, { ns: 'workflow' })}
+ )}
+ {!debugEnabled && combinedRecipients && (
+
+ }}
+ values={{ team: currentWorkspace.name.replace(/'/g, '’') }}
+ />
+
+ )}
+
+ {(onlySpecificUsers || combinedRecipients) && !debugEnabled && (
+ <>
+
+
+
+
+ ,
+ }}
+ />
+
+ >
+ )}
+ {/* vars */}
+ {generatedInputs.length > 0 && (
+ <>
+
+
+
setCollapsed(!collapsed)}>
+
{t(`${i18nPrefix}.deliveryMethod.emailSender.vars`, { ns: 'workflow' })}
+
+
+
{t(`${i18nPrefix}.deliveryMethod.emailSender.varsTip`, { ns: 'workflow' })}
+ {!collapsed && (
+
+ {generatedInputs.map((variable, index) => (
+
+ handleValueChange(variable.variable, v)}
+ />
+
+ ))}
+
+ )}
+
+ >
+ )}
+
+
+ {t(`${i18nPrefix}.deliveryMethod.emailSender.send`, { ns: 'workflow' })}
+
+
+ {t('operation.cancel', { ns: 'common' })}
+
+
+
+ )
+}
+
+export default memo(EmailSenderModal)
diff --git a/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx b/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx
new file mode 100644
index 0000000000..8d41746b54
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/delivery-method/upgrade-modal.tsx
@@ -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 = ({
+ isShow,
+ onClose,
+}) => {
+ const { t } = useTranslation()
+ const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal)
+
+ return (
+
+
+
+
+
+
+ {t('nodes.humanInput.deliveryMethod.upgradeTip', { ns: 'workflow' })}
+
+
+ {t('nodes.humanInput.deliveryMethod.upgradeTipContent', { ns: 'workflow' })}
+
+
+
+
+ {t('nodes.humanInput.deliveryMethod.upgradeTipHide', { ns: 'workflow' })}
+
+
{
+ setShowPricingModal()
+ }}
+ >
+
+
+
+ {t('upgradeBtn.encourageShort', { ns: 'billing' })}
+
+
+
+
+
+ )
+}
+
+export default UpgradeModal
diff --git a/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx b/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx
new file mode 100644
index 0000000000..d0001810a5
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/form-content-preview.tsx
@@ -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 = ({
+ 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 (
+
+
+
{t(`${i18nPrefix}.formContent.preview`, { ns: 'workflow' })}
+
+
+
+
{
+ 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
+ },
+ 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 (
+
+ Can't find note:
+ {name}
+
+ )
+ }
+ const defaultInput = input.default
+ return (
+
+ )
+ })(),
+ }}
+ />
+
+ {userActions.map((action: UserAction) => (
+
+ {action.title}
+
+ ))}
+
+ {t('nodes.humanInput.editor.previewTip', { ns: 'workflow' })}
+
+
+ )
+}
+
+export default React.memo(FormContentPreview)
diff --git a/web/app/components/workflow/nodes/human-input/components/form-content.tsx b/web/app/components/workflow/nodes/human-input/components/form-content.tsx
new file mode 100644
index 0000000000..fa9d1a539a
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/form-content.tsx
@@ -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 {children}
+}
+
+const CtrlKey: FC = () => {
+ return {isMac() ? '⌘' : 'Ctrl'}
+}
+
+const FormContent: FC = ({
+ 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([])
+ const handleInsertHITLNode = (onInsert: (command: LexicalCommand, 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 (
+
+
+ {isFocus && (
+
+ /,
+ CtrlKey: ,
+ }
+ }
+ />
+
+ )}
+
+ )
+}
+export default React.memo(FormContent)
diff --git a/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx b/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx
new file mode 100644
index 0000000000..7fd0c74000
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/single-run-form.tsx
@@ -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, action: string }) => Promise
+}
+
+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 && (
+
+
+
+ {t('nodes.humanInput.singleRun.back', { ns: 'workflow' })}
+
+
/
+
{nodeName}
+
+ )}
+
+ {contentList.map((content, index) => (
+
+ ))}
+
+ {data.actions.map((action: UserAction) => (
+ submit(action.id)}
+ >
+ {action.title}
+
+ ))}
+
+
+ >
+ )
+}
+
+export default React.memo(FormContent)
diff --git a/web/app/components/workflow/nodes/human-input/components/timeout.tsx b/web/app/components/workflow/nodes/human-input/components/timeout.tsx
new file mode 100644
index 0000000000..a5ccf9556f
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/timeout.tsx
@@ -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 = ({
+ timeout,
+ unit,
+ onChange,
+ readonly,
+}) => {
+ const { t } = useTranslation()
+
+ const handleValueChange = (e: React.ChangeEvent) => {
+ const value = e.target.value
+ if (/^\d*$/.test(value))
+ onChange({ timeout: Number(value) || 1, unit })
+ else
+ onChange({ timeout: 1, unit })
+ }
+ return (
+
+
+
+
!readonly && onChange({ timeout, unit: 'day' })}
+ >
+
{t(`${i18nPrefix}.timeout.days`, { ns: 'workflow' })}
+
+
!readonly && onChange({ timeout, unit: 'hour' })}
+ >
+
{t(`${i18nPrefix}.timeout.hours`, { ns: 'workflow' })}
+
+
+
+ )
+}
+
+export default TimeoutInput
diff --git a/web/app/components/workflow/nodes/human-input/components/user-action.tsx b/web/app/components/workflow/nodes/human-input/components/user-action.tsx
new file mode 100644
index 0000000000..d124a80051
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/user-action.tsx
@@ -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 = ({
+ data,
+ onChange,
+ onDelete,
+ readonly,
+}) => {
+ const { t } = useTranslation()
+
+ const handleIDChange = (e: React.ChangeEvent) => {
+ 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) => {
+ 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 (
+
+ )
+}
+
+export default UserActionItem
diff --git a/web/app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx b/web/app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx
new file mode 100644
index 0000000000..2b9387d7bf
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx
@@ -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 (
+
+ {
+ path.replaceAll('.', '/')
+ .replace('{{#', '{{')
+ .replace('#}}', '}}')
+ }
+
+ )
+}
+
+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 (
+
+ {isVariable ? : {defaultInput.value} }
+
+ )
+}
diff --git a/web/app/components/workflow/nodes/human-input/default.ts b/web/app/components/workflow/nodes/human-input/default.ts
new file mode 100644
index 0000000000..7e3d8d581e
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/default.ts
@@ -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 = {
+ metaData,
+ defaultValue: {
+ delivery_methods: [],
+ user_actions: [],
+ form_content: '',
+ inputs: [],
+ timeout: 3,
+ timeout_unit: 'day',
+ },
+ checkValid(payload: HumanInputNodeType, t: (str: string, options: Record) => 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
diff --git a/web/app/components/workflow/nodes/human-input/hooks/use-config.ts b/web/app/components/workflow/nodes/human-input/hooks/use-config.ts
new file mode 100644
index 0000000000..dc87cadf1e
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/hooks/use-config.ts
@@ -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(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
diff --git a/web/app/components/workflow/nodes/human-input/hooks/use-form-content.ts b/web/app/components/workflow/nodes/human-input/hooks/use-form-content.ts
new file mode 100644
index 0000000000..c1a7591437
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/hooks/use-form-content.ts
@@ -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(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
diff --git a/web/app/components/workflow/nodes/human-input/hooks/use-single-run-form-params.ts b/web/app/components/workflow/nodes/human-input/hooks/use-single-run-form-params.ts
new file mode 100644
index 0000000000..de7084390b
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/hooks/use-single-run-form-params.ts
@@ -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
+ getInputVars: (textList: string[]) => InputVar[]
+ setRunInputData: (data: Record) => void
+}
+const useSingleRunFormParams = ({
+ id,
+ payload,
+ runInputData,
+ getInputVars,
+ setRunInputData,
+}: Params) => {
+ const { t } = useTranslation()
+ const { inputs } = useNodeCrud(id, payload)
+ const [showGeneratedForm, setShowGeneratedForm] = useState(false)
+ const [formData, setFormData] = useState(null)
+ const [requiredInputs, setRequiredInputs] = useState>({})
+ 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) => {
+ if (!fetchURL)
+ return null
+ let requestParamsObj: Record = {}
+ 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 | undefined
+ form_inputs: Record | undefined
+ action: string
+ }) => {
+ await submitHumanInputNodeStepRunForm(fetchURL, {
+ inputs: requiredInputs,
+ form_inputs: formData.inputs,
+ action: formData.action,
+ })
+ }, [fetchURL, requiredInputs])
+
+ const handleShowGeneratedForm = async (formValue: Record) => {
+ setShowGeneratedForm(true)
+ await handleFetchFormContent(formValue)
+ }
+
+ const handleHideGeneratedForm = () => {
+ setShowGeneratedForm(false)
+ }
+
+ return {
+ forms,
+ getDependentVars,
+ showGeneratedForm,
+ handleShowGeneratedForm,
+ handleHideGeneratedForm,
+ formData,
+ handleFetchFormContent,
+ handleSubmitHumanInputForm,
+ }
+}
+
+export default useSingleRunFormParams
diff --git a/web/app/components/workflow/nodes/human-input/node.tsx b/web/app/components/workflow/nodes/human-input/node.tsx
new file mode 100644
index 0000000000..289c2efdc7
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/node.tsx
@@ -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> = (props) => {
+ const { t } = useTranslation()
+
+ const { data } = props
+ const deliveryMethods = data.delivery_methods
+ const userActions = data.user_actions
+
+ return (
+ <>
+ {deliveryMethods.length > 0 && (
+
+
{t(`${i18nPrefix}.deliveryMethod.title`, { ns: 'workflow' })}
+
+ {deliveryMethods.map(method => (
+
+ {method.type === DeliveryMethodType.WebApp && (
+
+
+
+ )}
+ {method.type === DeliveryMethodType.Email && (
+
+
+
+ )}
+
{method.type}
+
+ ))}
+
+
+ )}
+
+ {userActions.length > 0 && (
+ <>
+ {userActions.map(userAction => (
+
+ {userAction.id}
+
+
+ ))}
+ >
+ )}
+
+
+ >
+ )
+}
+
+export default React.memo(Node)
diff --git a/web/app/components/workflow/nodes/human-input/panel.tsx b/web/app/components/workflow/nodes/human-input/panel.tsx
new file mode 100644
index 0000000000..525821d042
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/panel.tsx
@@ -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> = ({
+ 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 (
+
+ {/* delivery methods */}
+
+
+ {/* form content */}
+
+
+
+
{t(`${i18nPrefix}.formContent.title`, { ns: 'workflow' })}
+
+
+ {!readOnly && (
+
+
+
+ {t(`${i18nPrefix}.formContent.preview`, { ns: 'workflow' })}
+
+
+
+
{
+ copy(inputs.form_content)
+ Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
+ }}
+ >
+
+
+
+ {isExpandFormContent ? : }
+
+
+
+ )}
+
+
+
+ {/* user actions */}
+
+
+
+
{t(`${i18nPrefix}.userActions.title`, { ns: 'workflow' })}
+
+
+ {!readOnly && (
+
+ )}
+
+ {!inputs.user_actions.length && (
+
{t(`${i18nPrefix}.userActions.emptyTip`, { ns: 'workflow' })}
+ )}
+ {inputs.user_actions.length > 0 && (
+
+ {inputs.user_actions.map((action, index) => (
+ handleUserActionChange(index, data)}
+ onDelete={handleUserActionDelete}
+ readonly={readOnly}
+ />
+ ))}
+
+ )}
+
+
+ {/* timeout */}
+
+
{t(`${i18nPrefix}.timeout.title`, { ns: 'workflow' })}
+
+
+ {/* output vars */}
+
+
+ {
+ inputs.inputs.map(input => (
+
+ ))
+ }
+
+
+
+
+ {isPreview && (
+
+ )}
+
+ )
+}
+
+export default React.memo(Panel)
diff --git a/web/app/components/workflow/nodes/human-input/types.ts b/web/app/components/workflow/nodes/human-input/types.ts
new file mode 100644
index 0000000000..128cfd8b98
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/types.ts
@@ -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
+}
diff --git a/web/app/components/workflow/nodes/human-input/utils.ts b/web/app/components/workflow/nodes/human-input/utils.ts
new file mode 100644
index 0000000000..59358fab1e
--- /dev/null
+++ b/web/app/components/workflow/nodes/human-input/utils.ts
@@ -0,0 +1,3 @@
+export const isOutput = (valueSelector: string[]) => {
+ return valueSelector[0] === '$output'
+}
diff --git a/web/app/components/workflow/nodes/llm/panel.tsx b/web/app/components/workflow/nodes/llm/panel.tsx
index 670d3149be..7146d9e64a 100644
--- a/web/app/components/workflow/nodes/llm/panel.tsx
+++ b/web/app/components/workflow/nodes/llm/panel.tsx
@@ -94,7 +94,8 @@ const Panel: FC> = ({
handleModelChanged(model)
}
})()
- }, [inputs.model.completion_params])
+ }, [handleCompletionParamsChange, handleModelChanged, inputs.model.completion_params, t])
+
return (
diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx
index 6e999975f1..a1f7152796 100644
--- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx
+++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/placeholder.tsx
@@ -4,12 +4,14 @@ import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node'
+import { cn } from '@/utils/classnames'
type PlaceholderProps = {
disableVariableInsertion?: boolean
+ hideBadge?: boolean
}
-const Placeholder = ({ disableVariableInsertion = false }: PlaceholderProps) => {
+const Placeholder = ({ disableVariableInsertion = false, hideBadge = false }: PlaceholderProps) => {
const { t } = useTranslation()
const [editor] = useLexicalComposerContext()
@@ -23,7 +25,10 @@ const Placeholder = ({ disableVariableInsertion = false }: PlaceholderProps) =>
return (
{
e.stopPropagation()
handleInsert('')
@@ -47,11 +52,13 @@ const Placeholder = ({ disableVariableInsertion = false }: PlaceholderProps) =>
>
)}
-
+ {!hideBadge && (
+
+ )}
)
}
diff --git a/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx b/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx
index 8ff356cad7..d7023c079c 100644
--- a/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx
+++ b/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx
@@ -18,7 +18,7 @@ import {
useStore,
useWorkflowStore,
} from '../../store'
-import { BlockEnum } from '../../types'
+import { BlockEnum, WorkflowRunningStatus } from '../../types'
import ConversationVariableModal from './conversation-variable-modal'
import Empty from './empty'
import { useChat } from './hooks'
@@ -84,7 +84,9 @@ const ChatWrapper = (
suggestedQuestions,
handleSend,
handleRestart,
- setTargetMessageId,
+ handleSwitchSibling,
+ handleSubmitHumanInputForm,
+ getHumanInputNodeData,
} = useChat(
config,
{
@@ -121,6 +123,22 @@ const ChatWrapper = (
doSend(editedQuestion ? editedQuestion.message : question.content, editedQuestion ? editedQuestion.files : question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null)
}, [chatList, doSend])
+ const doSwitchSibling = useCallback((siblingMessageId: string) => {
+ handleSwitchSibling(siblingMessageId, {
+ onGetSuggestedQuestions: (messageId, getAbortController) => fetchSuggestedQuestions(appDetail!.id, messageId, getAbortController),
+ })
+ }, [handleSwitchSibling, appDetail])
+
+ const doHumanInputFormSubmit = useCallback(async (formToken: string, formData: any) => {
+ // Handle human input form submission
+ await handleSubmitHumanInputForm(formToken, formData)
+ }, [handleSubmitHumanInputForm])
+
+ const inputDisabled = useMemo(() => {
+ const latestMessage = chatList[chatList.length - 1]
+ return latestMessage?.isAnswer && (latestMessage.workflowProcess?.status === WorkflowRunningStatus.Paused)
+ }, [chatList])
+
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription((v: any) => {
if (v.type === EVENT_WORKFLOW_STOP)
@@ -168,6 +186,8 @@ const ChatWrapper = (
inputsForm={(startVariables || []) as any}
onRegenerate={doRegenerate}
onStopResponding={handleStop}
+ onHumanInputFormSubmit={doHumanInputFormSubmit}
+ getHumanInputNodeData={getHumanInputNodeData}
chatNode={(
<>
{showInputsFieldsPanel &&
}
@@ -182,7 +202,9 @@ const ChatWrapper = (
suggestedQuestions={suggestedQuestions}
showPromptLog
chatAnswerContainerInner="!pr-2"
- switchSibling={setTargetMessageId}
+ switchSibling={doSwitchSibling}
+ inputDisabled={inputDisabled}
+ hideAvatar
/>
{showConversationVariableModal && (
(null)
const configsMap = useHooksStore(s => s.configsMap)
const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId)
const { fetchInspectVars } = useSetWorkflowVarsWithValue()
@@ -67,6 +75,7 @@ export const useChat = (
setIterTimes,
setLoopTimes,
} = workflowStore.getState()
+ const store = useStoreApi()
const handleResponding = useCallback((isResponding: boolean) => {
setIsResponding(isResponding)
@@ -131,6 +140,29 @@ export const useChat = (
})
}, [])
+ type UpdateChatTreeNode = {
+ (id: string, fields: Partial): void
+ (id: string, update: (node: ChatItemInTree) => void): void
+ }
+
+ const updateChatTreeNode: UpdateChatTreeNode = useCallback((
+ id: string,
+ fieldsOrUpdate: Partial | ((node: ChatItemInTree) => void),
+ ) => {
+ const nextState = produceChatTreeNode(id, (node) => {
+ if (typeof fieldsOrUpdate === 'function') {
+ fieldsOrUpdate(node)
+ }
+ else {
+ Object.keys(fieldsOrUpdate).forEach((key) => {
+ (node as any)[key] = (fieldsOrUpdate as any)[key]
+ })
+ }
+ })
+ setChatTree(nextState)
+ chatTreeRef.current = nextState
+ }, [produceChatTreeNode])
+
const handleStop = useCallback(() => {
hasStopResponded.current = true
handleResponding(false)
@@ -140,6 +172,8 @@ export const useChat = (
setLoopTimes(DEFAULT_LOOP_TIMES)
if (suggestedQuestionsAbortControllerRef.current)
suggestedQuestionsAbortControllerRef.current.abort()
+ if (workflowEventsAbortControllerRef.current)
+ workflowEventsAbortControllerRef.current.abort()
}, [handleResponding, setIterTimes, setLoopTimes, stopChat])
const handleRestart = useCallback(() => {
@@ -206,6 +240,10 @@ export const useChat = (
return false
}
+ // Abort previous handleResume SSE connection if any
+ if (workflowEventsAbortControllerRef.current)
+ workflowEventsAbortControllerRef.current.abort()
+
const parentMessage = threadMessages.find(item => item.id === params.parent_message_id)
const placeholderQuestionId = `question-${Date.now()}`
@@ -243,6 +281,8 @@ export const useChat = (
isAnswer: true,
parentMessageId: questionItem.id,
siblingIndex: parentMessage?.children?.length ?? chatTree.length,
+ humanInputFormDataList: [],
+ humanInputFilledFormDataList: [],
}
handleResponding(true)
@@ -270,6 +310,9 @@ export const useChat = (
handleRun(
bodyParams,
{
+ getAbortController: (abortController) => {
+ workflowEventsAbortControllerRef.current = abortController
+ },
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
responseItem.content = responseItem.content + message
@@ -295,35 +338,38 @@ export const useChat = (
})
},
async onCompleted(hasError?: boolean, errorMessage?: string) {
+ const { workflowRunningData } = workflowStore.getState()
handleResponding(false)
- fetchInspectVars({})
- invalidAllLastRun()
+ if (workflowRunningData?.result.status !== WorkflowRunningStatus.Paused) {
+ fetchInspectVars({})
+ invalidAllLastRun()
- if (hasError) {
- if (errorMessage) {
- responseItem.content = errorMessage
- responseItem.isError = true
- updateCurrentQAOnTree({
- placeholderQuestionId,
- questionItem,
- responseItem,
- parentId: params.parent_message_id,
- })
+ if (hasError) {
+ if (errorMessage) {
+ responseItem.content = errorMessage
+ responseItem.isError = true
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: params.parent_message_id,
+ })
+ }
+ return
}
- return
- }
- if (config?.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
- try {
- const { data }: any = await onGetSuggestedQuestions(
- responseItem.id,
- newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
- )
- setSuggestQuestions(data)
- }
- // eslint-disable-next-line unused-imports/no-unused-vars
- catch (error) {
- setSuggestQuestions([])
+ if (config?.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
+ try {
+ const { data }: any = await onGetSuggestedQuestions(
+ responseItem.id,
+ newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
+ )
+ setSuggestQuestions(data)
+ }
+ // eslint-disable-next-line unused-imports/no-unused-vars
+ catch (error) {
+ setSuggestQuestions([])
+ }
}
}
},
@@ -345,12 +391,29 @@ export const useChat = (
onError() {
handleResponding(false)
},
- onWorkflowStarted: ({ workflow_run_id, task_id }) => {
- taskIdRef.current = task_id
- responseItem.workflow_run_id = workflow_run_id
- responseItem.workflowProcess = {
- status: WorkflowRunningStatus.Running,
- tracing: [],
+ onWorkflowStarted: ({ workflow_run_id, task_id, conversation_id, message_id }) => {
+ // If there are no streaming messages, we still need to set the conversation_id to avoid create a new conversation when regeneration in chat-flow.
+ if (conversation_id) {
+ conversationId.current = conversation_id
+ }
+ if (message_id && !hasSetResponseId) {
+ questionItem.id = `question-${message_id}`
+ responseItem.id = message_id
+ responseItem.parentMessageId = questionItem.id
+ hasSetResponseId = true
+ }
+
+ if (responseItem.workflowProcess && responseItem.workflowProcess.tracing.length > 0) {
+ handleResponding(true)
+ responseItem.workflowProcess.status = WorkflowRunningStatus.Running
+ }
+ else {
+ taskIdRef.current = task_id
+ responseItem.workflow_run_id = workflow_run_id
+ responseItem.workflowProcess = {
+ status: WorkflowRunningStatus.Running,
+ tracing: [],
+ }
}
updateCurrentQAOnTree({
placeholderQuestionId,
@@ -423,10 +486,19 @@ export const useChat = (
}
},
onNodeStarted: ({ data }) => {
- responseItem.workflowProcess!.tracing!.push({
- ...data,
- status: NodeRunningStatus.Running,
- } as any)
+ const currentIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === data.node_id)
+ if (currentIndex > -1) {
+ responseItem.workflowProcess!.tracing![currentIndex] = {
+ ...data,
+ status: NodeRunningStatus.Running,
+ }
+ }
+ else {
+ responseItem.workflowProcess!.tracing!.push({
+ ...data,
+ status: NodeRunningStatus.Running,
+ })
+ }
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
@@ -499,17 +571,383 @@ export const useChat = (
})
}
},
+ onHumanInputRequired: ({ data }) => {
+ if (!responseItem.humanInputFormDataList) {
+ responseItem.humanInputFormDataList = [data]
+ }
+ else {
+ const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === data.node_id)
+ if (currentFormIndex > -1) {
+ responseItem.humanInputFormDataList[currentFormIndex] = data
+ }
+ else {
+ responseItem.humanInputFormDataList.push(data)
+ }
+ }
+ const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === data.node_id)
+ if (currentTracingIndex > -1) {
+ responseItem.workflowProcess!.tracing[currentTracingIndex].status = NodeRunningStatus.Paused
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: params.parent_message_id,
+ })
+ }
+ },
+ onHumanInputFormFilled: ({ data }) => {
+ if (responseItem.humanInputFormDataList?.length) {
+ const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === data.node_id)
+ responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
+ }
+ if (!responseItem.humanInputFilledFormDataList) {
+ responseItem.humanInputFilledFormDataList = [data]
+ }
+ else {
+ responseItem.humanInputFilledFormDataList.push(data)
+ }
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: params.parent_message_id,
+ })
+ },
+ onHumanInputFormTimeout: ({ data }) => {
+ if (responseItem.humanInputFormDataList?.length) {
+ const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === data.node_id)
+ responseItem.humanInputFormDataList[currentFormIndex].expiration_time = data.expiration_time
+ }
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: params.parent_message_id,
+ })
+ },
+ onWorkflowPaused: ({ data: _data }) => {
+ responseItem.workflowProcess!.status = WorkflowRunningStatus.Paused
+ updateCurrentQAOnTree({
+ placeholderQuestionId,
+ questionItem,
+ responseItem,
+ parentId: params.parent_message_id,
+ })
+ },
},
)
- }, [threadMessages, chatTree.length, updateCurrentQAOnTree, handleResponding, formSettings?.inputsForm, handleRun, notify, t, config?.suggested_questions_after_answer?.enabled, fetchInspectVars, invalidAllLastRun])
+ }, [threadMessages, chatTree.length, updateCurrentQAOnTree, handleResponding, formSettings?.inputsForm, handleRun, notify, t, workflowStore, fetchInspectVars, invalidAllLastRun, config?.suggested_questions_after_answer?.enabled])
+
+ const handleSubmitHumanInputForm = async (formToken: string, formData: any) => {
+ await submitHumanInputForm(formToken, formData)
+ }
+
+ const getHumanInputNodeData = (nodeID: string) => {
+ const {
+ getNodes,
+ } = store.getState()
+ const nodes = getNodes().filter(node => node.type === CUSTOM_NODE)
+ const node = nodes.find(n => n.id === nodeID)
+ return node
+ }
+
+ const handleResume = useCallback((
+ messageId: string,
+ workflowRunId: string,
+ {
+ onGetSuggestedQuestions,
+ }: SendCallback,
+ ) => {
+ // Re-subscribe to workflow events for the specific message
+ const url = `/workflow/${workflowRunId}/events?include_state_snapshot=true`
+
+ const otherOptions: IOtherOptions = {
+ getAbortController: (abortController) => {
+ workflowEventsAbortControllerRef.current = abortController
+ },
+ onData: (message: string, _isFirstMessage: boolean, { conversationId: newConversationId, messageId: msgId, taskId }: any) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ responseItem.content = responseItem.content + message
+ if (msgId)
+ responseItem.id = msgId
+ })
+
+ if (newConversationId)
+ conversationId.current = newConversationId
+
+ if (taskId)
+ taskIdRef.current = taskId
+ },
+ async onCompleted(hasError?: boolean) {
+ const { workflowRunningData } = workflowStore.getState()
+ handleResponding(false)
+
+ if (workflowRunningData?.result.status !== WorkflowRunningStatus.Paused) {
+ fetchInspectVars({})
+ invalidAllLastRun()
+
+ if (hasError)
+ return
+
+ if (config?.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
+ try {
+ const { data }: any = await onGetSuggestedQuestions(
+ messageId,
+ newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
+ )
+ setSuggestQuestions(data)
+ }
+ catch {
+ setSuggestQuestions([])
+ }
+ }
+ }
+ },
+ onMessageEnd: (messageEnd) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ responseItem.citation = messageEnd.metadata?.retriever_resources || []
+ const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || [])
+ responseItem.allFiles = uniqBy([...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], 'id')
+ })
+ },
+ onMessageReplace: (messageReplace) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ responseItem.content = messageReplace.answer
+ })
+ },
+ onError() {
+ handleResponding(false)
+ },
+ onWorkflowStarted: ({ workflow_run_id, task_id }) => {
+ handleResponding(true)
+ hasStopResponded.current = false
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (responseItem.workflowProcess && responseItem.workflowProcess.tracing.length > 0) {
+ responseItem.workflowProcess.status = WorkflowRunningStatus.Running
+ }
+ else {
+ taskIdRef.current = task_id
+ responseItem.workflow_run_id = workflow_run_id
+ responseItem.workflowProcess = {
+ status: WorkflowRunningStatus.Running,
+ tracing: [],
+ }
+ }
+ })
+ },
+ onWorkflowFinished: ({ data: workflowFinishedData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (responseItem.workflowProcess)
+ responseItem.workflowProcess.status = workflowFinishedData.status as WorkflowRunningStatus
+ })
+ },
+ onIterationStart: ({ data: iterationStartedData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (!responseItem.workflowProcess)
+ return
+ if (!responseItem.workflowProcess.tracing)
+ responseItem.workflowProcess.tracing = []
+ responseItem.workflowProcess.tracing.push({
+ ...iterationStartedData,
+ status: WorkflowRunningStatus.Running,
+ })
+ })
+ },
+ onIterationFinish: ({ data: iterationFinishedData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (!responseItem.workflowProcess?.tracing)
+ return
+ const tracing = responseItem.workflowProcess.tracing
+ const iterationIndex = tracing.findIndex(item => item.node_id === iterationFinishedData.node_id
+ && (item.execution_metadata?.parallel_id === iterationFinishedData.execution_metadata?.parallel_id || item.parallel_id === iterationFinishedData.execution_metadata?.parallel_id))!
+ if (iterationIndex > -1) {
+ tracing[iterationIndex] = {
+ ...tracing[iterationIndex],
+ ...iterationFinishedData,
+ status: WorkflowRunningStatus.Succeeded,
+ }
+ }
+ })
+ },
+ onNodeStarted: ({ data: nodeStartedData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (!responseItem.workflowProcess)
+ return
+ if (!responseItem.workflowProcess.tracing)
+ responseItem.workflowProcess.tracing = []
+
+ const currentIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === nodeStartedData.node_id)
+ if (currentIndex > -1) {
+ responseItem.workflowProcess.tracing[currentIndex] = {
+ ...nodeStartedData,
+ status: NodeRunningStatus.Running,
+ }
+ }
+ else {
+ if (nodeStartedData.iteration_id)
+ return
+
+ responseItem.workflowProcess.tracing.push({
+ ...nodeStartedData,
+ status: WorkflowRunningStatus.Running,
+ })
+ }
+ })
+ },
+ onNodeFinished: ({ data: nodeFinishedData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (!responseItem.workflowProcess?.tracing)
+ return
+
+ if (nodeFinishedData.iteration_id)
+ return
+
+ const currentIndex = responseItem.workflowProcess.tracing.findIndex((item) => {
+ if (!item.execution_metadata?.parallel_id)
+ return item.id === nodeFinishedData.id
+
+ return item.id === nodeFinishedData.id && (item.execution_metadata?.parallel_id === nodeFinishedData.execution_metadata?.parallel_id)
+ })
+ if (currentIndex > -1)
+ responseItem.workflowProcess.tracing[currentIndex] = nodeFinishedData as any
+ })
+ },
+ onLoopStart: ({ data: loopStartedData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (!responseItem.workflowProcess)
+ return
+ if (!responseItem.workflowProcess.tracing)
+ responseItem.workflowProcess.tracing = []
+ responseItem.workflowProcess.tracing.push({
+ ...loopStartedData,
+ status: WorkflowRunningStatus.Running,
+ })
+ })
+ },
+ onLoopFinish: ({ data: loopFinishedData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (!responseItem.workflowProcess?.tracing)
+ return
+ const tracing = responseItem.workflowProcess.tracing
+ const loopIndex = tracing.findIndex(item => item.node_id === loopFinishedData.node_id
+ && (item.execution_metadata?.parallel_id === loopFinishedData.execution_metadata?.parallel_id || item.parallel_id === loopFinishedData.execution_metadata?.parallel_id))!
+ if (loopIndex > -1) {
+ tracing[loopIndex] = {
+ ...tracing[loopIndex],
+ ...loopFinishedData,
+ status: WorkflowRunningStatus.Succeeded,
+ }
+ }
+ })
+ },
+ onHumanInputRequired: ({ data: humanInputRequiredData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (!responseItem.humanInputFormDataList) {
+ responseItem.humanInputFormDataList = [humanInputRequiredData]
+ }
+ else {
+ const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === humanInputRequiredData.node_id)
+ if (currentFormIndex > -1) {
+ responseItem.humanInputFormDataList[currentFormIndex] = humanInputRequiredData
+ }
+ else {
+ responseItem.humanInputFormDataList.push(humanInputRequiredData)
+ }
+ }
+ if (responseItem.workflowProcess?.tracing) {
+ const currentTracingIndex = responseItem.workflowProcess.tracing.findIndex(item => item.node_id === humanInputRequiredData.node_id)
+ if (currentTracingIndex > -1)
+ responseItem.workflowProcess.tracing[currentTracingIndex].status = NodeRunningStatus.Paused
+ }
+ })
+ },
+ onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (responseItem.humanInputFormDataList?.length) {
+ const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === humanInputFilledFormData.node_id)
+ if (currentFormIndex > -1)
+ responseItem.humanInputFormDataList.splice(currentFormIndex, 1)
+ }
+ if (!responseItem.humanInputFilledFormDataList) {
+ responseItem.humanInputFilledFormDataList = [humanInputFilledFormData]
+ }
+ else {
+ responseItem.humanInputFilledFormDataList.push(humanInputFilledFormData)
+ }
+ })
+ },
+ onHumanInputFormTimeout: ({ data: humanInputFormTimeoutData }) => {
+ updateChatTreeNode(messageId, (responseItem) => {
+ if (responseItem.humanInputFormDataList?.length) {
+ const currentFormIndex = responseItem.humanInputFormDataList.findIndex(item => item.node_id === humanInputFormTimeoutData.node_id)
+ responseItem.humanInputFormDataList[currentFormIndex].expiration_time = humanInputFormTimeoutData.expiration_time
+ }
+ })
+ },
+ onWorkflowPaused: ({ data: workflowPausedData }) => {
+ const resumeUrl = `/workflow/${workflowPausedData.workflow_run_id}/events`
+ sseGet(
+ resumeUrl,
+ {},
+ otherOptions,
+ )
+ updateChatTreeNode(messageId, (responseItem) => {
+ responseItem.workflowProcess!.status = WorkflowRunningStatus.Paused
+ })
+ },
+ }
+
+ if (workflowEventsAbortControllerRef.current)
+ workflowEventsAbortControllerRef.current.abort()
+
+ sseGet(
+ url,
+ {},
+ otherOptions,
+ )
+ }, [updateChatTreeNode, handleResponding, workflowStore, fetchInspectVars, invalidAllLastRun, config?.suggested_questions_after_answer])
+
+ const handleSwitchSibling = useCallback((
+ siblingMessageId: string,
+ callbacks: SendCallback,
+ ) => {
+ setTargetMessageId(siblingMessageId)
+
+ // Helper to find message in tree
+ const findMessageInTree = (nodes: ChatItemInTree[], targetId: string): ChatItemInTree | undefined => {
+ for (const node of nodes) {
+ if (node.id === targetId)
+ return node
+ if (node.children) {
+ const found = findMessageInTree(node.children, targetId)
+ if (found)
+ return found
+ }
+ }
+ return undefined
+ }
+
+ const targetMessage = findMessageInTree(chatTreeRef.current, siblingMessageId)
+ if (targetMessage?.workflow_run_id && targetMessage.humanInputFormDataList && targetMessage.humanInputFormDataList.length > 0) {
+ handleResume(
+ targetMessage.id,
+ targetMessage.workflow_run_id,
+ callbacks,
+ )
+ }
+ }, [handleResume])
return {
conversationId: conversationId.current,
chatList,
setTargetMessageId,
+ handleSwitchSibling,
handleSend,
handleStop,
handleRestart,
+ handleResume,
+ handleSubmitHumanInputForm,
+ getHumanInputNodeData,
isResponding,
suggestedQuestions,
}
diff --git a/web/app/components/workflow/panel/human-input-filled-form-list.tsx b/web/app/components/workflow/panel/human-input-filled-form-list.tsx
new file mode 100644
index 0000000000..7f39e939fe
--- /dev/null
+++ b/web/app/components/workflow/panel/human-input-filled-form-list.tsx
@@ -0,0 +1,34 @@
+import type { HumanInputFilledFormData } from '@/types/workflow'
+import ContentWrapper from '@/app/components/base/chat/chat/answer/human-input-content/content-wrapper'
+import { SubmittedHumanInputContent } from '@/app/components/base/chat/chat/answer/human-input-content/submitted'
+
+type HumanInputFilledFormListProps = {
+ humanInputFilledFormDataList: HumanInputFilledFormData[]
+}
+
+const HumanInputFilledFormList = ({
+ humanInputFilledFormDataList,
+}: HumanInputFilledFormListProps) => {
+ return (
+
+ {
+ humanInputFilledFormDataList.map(formData => (
+
+
+
+ ))
+ }
+
+ )
+}
+
+export default HumanInputFilledFormList
diff --git a/web/app/components/workflow/panel/human-input-form-list.tsx b/web/app/components/workflow/panel/human-input-form-list.tsx
new file mode 100644
index 0000000000..e032bfbb89
--- /dev/null
+++ b/web/app/components/workflow/panel/human-input-form-list.tsx
@@ -0,0 +1,83 @@
+import type { DeliveryMethod } from '@/app/components/workflow/nodes/human-input/types'
+import type { HumanInputFormData } from '@/types/workflow'
+import { useCallback, useMemo } from 'react'
+import { useStoreApi } from 'reactflow'
+import ContentWrapper from '@/app/components/base/chat/chat/answer/human-input-content/content-wrapper'
+import { UnsubmittedHumanInputContent } from '@/app/components/base/chat/chat/answer/human-input-content/unsubmitted'
+import { CUSTOM_NODE } from '@/app/components/workflow/constants'
+import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
+
+type HumanInputFormListProps = {
+ humanInputFormDataList: HumanInputFormData[]
+ onHumanInputFormSubmit?: (formToken: string, formData: any) => Promise
+}
+
+const HumanInputFormList = ({
+ humanInputFormDataList,
+ onHumanInputFormSubmit,
+}: HumanInputFormListProps) => {
+ const store = useStoreApi()
+
+ const getHumanInputNodeData = useCallback((nodeID: string) => {
+ const {
+ getNodes,
+ } = store.getState()
+ const nodes = getNodes().filter(node => node.type === CUSTOM_NODE)
+ const node = nodes.find(n => n.id === nodeID)
+ return node
+ }, [store])
+
+ const deliveryMethodsConfig = useMemo((): Record => {
+ if (!humanInputFormDataList.length)
+ return {}
+ return humanInputFormDataList.reduce((acc, formData) => {
+ const deliveryMethodsConfig = getHumanInputNodeData(formData.node_id)?.data.delivery_methods || []
+ if (!deliveryMethodsConfig.length) {
+ acc[formData.node_id] = {
+ showEmailTip: false,
+ isEmailDebugMode: false,
+ showDebugModeTip: false,
+ }
+ return acc
+ }
+ const isWebappEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.WebApp && method.enabled)
+ const isEmailEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.enabled)
+ const isEmailDebugMode = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.config?.debug_mode)
+ acc[formData.node_id] = {
+ showEmailTip: isEmailEnabled,
+ isEmailDebugMode,
+ showDebugModeTip: !isWebappEnabled,
+ }
+ return acc
+ }, {} as Record)
+ }, [getHumanInputNodeData, humanInputFormDataList])
+
+ const filteredHumanInputFormDataList = useMemo(() => {
+ return humanInputFormDataList.filter(formData => formData.display_in_ui)
+ }, [humanInputFormDataList])
+
+ return (
+
+ {
+ filteredHumanInputFormDataList.map(formData => (
+
+
+
+ ))
+ }
+
+ )
+}
+
+export default HumanInputFormList
diff --git a/web/app/components/workflow/panel/inputs-panel.tsx b/web/app/components/workflow/panel/inputs-panel.tsx
index faf60944a1..4162526d22 100644
--- a/web/app/components/workflow/panel/inputs-panel.tsx
+++ b/web/app/components/workflow/panel/inputs-panel.tsx
@@ -44,15 +44,18 @@ const InputsPanel = ({ onRun }: Props) => {
const startVariables = startNode?.data.variables
const { checkInputsForm } = useCheckInputsForms()
- const initialInputs = { ...inputs }
- if (startVariables) {
- startVariables.forEach((variable) => {
- if (variable.default)
- initialInputs[variable.variable] = variable.default
- if (inputs[variable.variable] !== undefined)
- initialInputs[variable.variable] = inputs[variable.variable]
- })
- }
+ const initialInputs = useMemo(() => {
+ const result = { ...inputs }
+ if (startVariables) {
+ startVariables.forEach((variable) => {
+ if (variable.default)
+ result[variable.variable] = variable.default
+ if (inputs[variable.variable] !== undefined)
+ result[variable.variable] = inputs[variable.variable]
+ })
+ }
+ return result
+ }, [inputs, startVariables])
const variables = useMemo(() => {
const data = startVariables || []
@@ -97,10 +100,7 @@ const InputsPanel = ({ onRun }: Props) => {
}, [files, handleRun, initialInputs, onRun, variables, checkInputsForm])
const canRun = useMemo(() => {
- if (files?.some(item => (item.transfer_method as any) === TransferMethod.local_file && !item.upload_file_id))
- return false
-
- return true
+ return !(files?.some(item => (item.transfer_method as any) === TransferMethod.local_file && !item.upload_file_id))
}, [files])
return (
diff --git a/web/app/components/workflow/panel/workflow-preview.tsx b/web/app/components/workflow/panel/workflow-preview.tsx
index bb90f77748..7f23f5bc74 100644
--- a/web/app/components/workflow/panel/workflow-preview.tsx
+++ b/web/app/components/workflow/panel/workflow-preview.tsx
@@ -12,6 +12,7 @@ import {
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Loading from '@/app/components/base/loading'
+import { submitHumanInputForm } from '@/service/workflow'
import { cn } from '@/utils/classnames'
import Toast from '../../base/toast'
import {
@@ -25,6 +26,8 @@ import {
WorkflowRunningStatus,
} from '../types'
import { formatWorkflowRunIdentifier } from '../utils'
+import HumanInputFilledFormList from './human-input-filled-form-list'
+import HumanInputFormList from './human-input-form-list'
import InputsPanel from './inputs-panel'
const WorkflowPreview = () => {
@@ -37,6 +40,8 @@ const WorkflowPreview = () => {
const panelWidth = useStore(s => s.previewPanelWidth)
const setPreviewPanelWidth = useStore(s => s.setPreviewPanelWidth)
const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel)
+ const humanInputFormDataList = useStore(s => s.workflowRunningData?.humanInputFormDataList)
+ const humanInputFilledFormDataList = useStore(s => s.workflowRunningData?.humanInputFilledFormDataList)
const [currentTab, setCurrentTab] = useState(showInputsPanel ? 'INPUT' : 'TRACING')
const switchTab = async (tab: string) => {
@@ -45,7 +50,7 @@ const WorkflowPreview = () => {
useEffect(() => {
if (showDebugAndPreviewPanel && showInputsPanel)
- setCurrentTab('INPUT')
+ switchTab('INPUT')
}, [showDebugAndPreviewPanel, showInputsPanel])
useEffect(() => {
@@ -60,6 +65,8 @@ const WorkflowPreview = () => {
if ((status === WorkflowRunningStatus.Succeeded || status === WorkflowRunningStatus.Failed) && !workflowRunningData.resultText && !workflowRunningData.result.files?.length)
switchTab('DETAIL')
+ if (status === WorkflowRunningStatus.Paused)
+ switchTab('RESULT')
}, [workflowRunningData])
const [isResizing, setIsResizing] = useState(false)
@@ -94,6 +101,10 @@ const WorkflowPreview = () => {
}
}, [resize, stopResizing])
+ const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: any) => {
+ await submitHumanInputForm(formToken, formData)
+ }, [])
+
return (
{
switchTab('RESULT')} />
)}
{currentTab === 'RESULT' && (
- <>
+
+ {humanInputFormDataList && humanInputFormDataList.length > 0 && (
+
+ )}
+ {humanInputFilledFormDataList && humanInputFilledFormDataList.length > 0 && (
+
+ )}
{
{t('operation.copy', { ns: 'common' })}
)}
- >
+
)}
{currentTab === 'DETAIL' && (
= ({
steps={runDetail.total_steps}
exceptionCounts={runDetail.exceptions_count}
isListening={isListening}
+ workflowRunId={runDetail.id}
/>
)}
{!loading && currentTab === 'DETAIL' && !runDetail && isListening && (
diff --git a/web/app/components/workflow/run/meta.tsx b/web/app/components/workflow/run/meta.tsx
index 39f7ac314f..af01af454a 100644
--- a/web/app/components/workflow/run/meta.tsx
+++ b/web/app/components/workflow/run/meta.tsx
@@ -50,6 +50,9 @@ const MetaData: FC = ({
{status === 'stopped' && (
STOP
)}
+ {status === 'paused' && (
+ PENDING
+ )}
@@ -88,10 +91,10 @@ const MetaData: FC
= ({
{t('meta.tokens', { ns: 'runLog' })}
- {status === 'running' && (
-
+ {['running', 'paused'].includes(status) && (
+
)}
- {status !== 'running' && (
+ {!['running', 'paused'].includes(status) && (
{`${tokens || 0} Tokens`}
)}
diff --git a/web/app/components/workflow/run/node.tsx b/web/app/components/workflow/run/node.tsx
index c99ff6ce91..06899c4881 100644
--- a/web/app/components/workflow/run/node.tsx
+++ b/web/app/components/workflow/run/node.tsx
@@ -11,8 +11,9 @@ import {
RiAlertFill,
RiArrowRightSLine,
RiCheckboxCircleFill,
- RiErrorWarningLine,
+ RiErrorWarningFill,
RiLoader2Line,
+ RiPauseCircleFill,
} from '@remixicon/react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -144,7 +145,7 @@ const NodePanel: FC
= ({
{nodeInfo.title}
- {nodeInfo.status !== 'running' && !hideInfo && (
+ {!['running', 'paused'].includes(nodeInfo.status) && !hideInfo && (
{nodeInfo.execution_metadata?.total_tokens ? `${getTokenCount(nodeInfo.execution_metadata?.total_tokens || 0)} tokens · ` : ''}
{`${getTime(nodeInfo.elapsed_time || 0)}`}
@@ -154,11 +155,14 @@ const NodePanel: FC
= ({
)}
{nodeInfo.status === 'failed' && (
-
+
)}
{nodeInfo.status === 'stopped' && (
)}
+ {nodeInfo.status === 'paused' && (
+
+ )}
{nodeInfo.status === 'exception' && (
)}
@@ -229,6 +233,11 @@ const NodePanel: FC = ({
{nodeInfo.error}
)}
+ {(nodeInfo.status === 'paused') && (
+
+ {t('nodes.humanInput.log.reasonContent', { ns: 'workflow' })}
+
+ )}
{nodeInfo.inputs && (
diff --git a/web/app/components/workflow/run/result-panel.tsx b/web/app/components/workflow/run/result-panel.tsx
index 873be71b5b..58f783e6c4 100644
--- a/web/app/components/workflow/run/result-panel.tsx
+++ b/web/app/components/workflow/run/result-panel.tsx
@@ -41,6 +41,7 @@ export type ResultPanelProps = {
exceptionCounts?: number
execution_metadata?: any
isListening?: boolean
+ workflowRunId?: string
handleShowIterationResultList?: (detail: NodeTracing[][], iterDurationMap: any) => void
handleShowLoopResultList?: (detail: NodeTracing[][], loopDurationMap: any) => void
onShowRetryDetail?: (detail: NodeTracing[]) => void
@@ -67,6 +68,7 @@ const ResultPanel: FC = ({
exceptionCounts,
execution_metadata,
isListening = false,
+ workflowRunId,
handleShowIterationResultList,
handleShowLoopResultList,
onShowRetryDetail,
@@ -89,6 +91,7 @@ const ResultPanel: FC = ({
error={error}
exceptionCounts={exceptionCounts}
isListening={isListening}
+ workflowRunId={workflowRunId}
/>
diff --git a/web/app/components/workflow/run/result-text.tsx b/web/app/components/workflow/run/result-text.tsx
index 026e5b7c05..4169f452b2 100644
--- a/web/app/components/workflow/run/result-text.tsx
+++ b/web/app/components/workflow/run/result-text.tsx
@@ -9,6 +9,7 @@ import StatusContainer from '@/app/components/workflow/run/status-container'
type ResultTextProps = {
isRunning?: boolean
+ isPaused?: boolean
outputs?: any
error?: string
onClick?: () => void
@@ -17,6 +18,7 @@ type ResultTextProps = {
const ResultText: FC = ({
isRunning,
+ isPaused,
outputs,
error,
onClick,
@@ -37,7 +39,7 @@ const ResultText: FC = ({
)}
- {!isRunning && !outputs && !error && !allFiles?.length && (
+ {!isPaused && !isRunning && !outputs && !error && !allFiles?.length && (
{t('resultEmpty.title', { ns: 'runLog' })}
diff --git a/web/app/components/workflow/run/status-container.tsx b/web/app/components/workflow/run/status-container.tsx
index d935135231..fc33bd46a7 100644
--- a/web/app/components/workflow/run/status-container.tsx
+++ b/web/app/components/workflow/run/status-container.tsx
@@ -28,9 +28,9 @@ const StatusContainer: FC
= ({
status === 'failed' && 'border-[rgba(240,68,56,0.8)] bg-workflow-display-error-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-error.svg)] text-text-warning',
status === 'failed' && theme === Theme.light && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(240,68,56,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]',
status === 'failed' && theme === Theme.dark && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(240,68,56,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24, 24, 27, 0.95)]',
- status === 'stopped' && 'border-[rgba(247,144,9,0.8)] bg-workflow-display-warning-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-warning.svg)] text-text-destructive',
- status === 'stopped' && theme === Theme.light && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(247,144,9,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]',
- status === 'stopped' && theme === Theme.dark && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(247,144,9,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24, 24, 27, 0.95)]',
+ (status === 'stopped' || status === 'paused') && 'border-[rgba(247,144,9,0.8)] bg-workflow-display-warning-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-warning.svg)] text-text-destructive',
+ (status === 'stopped' || status === 'paused') && theme === Theme.light && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(247,144,9,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]',
+ (status === 'stopped' || status === 'paused') && theme === Theme.dark && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(247,144,9,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24, 24, 27, 0.95)]',
status === 'exception' && 'border-[rgba(247,144,9,0.8)] bg-workflow-display-warning-bg bg-[url(~@/app/components/workflow/run/assets/bg-line-warning.svg)] text-text-destructive',
status === 'exception' && theme === Theme.light && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.5),inset_0_1px_3px_0_rgba(0,0,0,0.12),inset_0_2px_24px_0_rgba(247,144,9,0.2),0_1px_2px_0_rgba(9,9,11,0.05),0_0_0_1px_rgba(0,0,0,0.05)]',
status === 'exception' && theme === Theme.dark && 'shadow-[inset_2px_2px_0_0_rgba(255,255,255,0.12),inset_0_1px_3px_0_rgba(0,0,0,0.4),inset_0_2px_24px_0_rgba(247,144,9,0.25),0_1px_2px_0_rgba(0,0,0,0.1),0_0_0_1px_rgba(24, 24, 27, 0.95)]',
diff --git a/web/app/components/workflow/run/status.tsx b/web/app/components/workflow/run/status.tsx
index 33799a6269..81ae3ede14 100644
--- a/web/app/components/workflow/run/status.tsx
+++ b/web/app/components/workflow/run/status.tsx
@@ -1,9 +1,11 @@
'use client'
import type { FC } from 'react'
+import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Indicator from '@/app/components/header/indicator'
import StatusContainer from '@/app/components/workflow/run/status-container'
import { useDocLink } from '@/context/i18n'
+import { useWorkflowPausedDetails } from '@/service/use-log'
import { cn } from '@/utils/classnames'
type ResultProps = {
@@ -13,6 +15,7 @@ type ResultProps = {
error?: string
exceptionCounts?: number
isListening?: boolean
+ workflowRunId?: string
}
const StatusPanel: FC = ({
@@ -22,9 +25,45 @@ const StatusPanel: FC = ({
error,
exceptionCounts,
isListening = false,
+ workflowRunId,
}) => {
const { t } = useTranslation()
const docLink = useDocLink()
+ const { data: pausedDetails } = useWorkflowPausedDetails({
+ workflowRunId: workflowRunId || '',
+ enabled: status === 'paused',
+ })
+
+ const pausedReasons = useMemo(() => {
+ const reasons: string[] = []
+ if (!pausedDetails)
+ return reasons
+ const hasHumanInputNode = pausedDetails.paused_nodes.some(
+ node => node.pause_type.type === 'human_input',
+ )
+ if (hasHumanInputNode) {
+ reasons.push(t('nodes.humanInput.log.reasonContent', { ns: 'workflow' }))
+ }
+ return reasons
+ }, [pausedDetails, t])
+
+ const pausedInputURLs = useMemo(() => {
+ const inputURLs: string[] = []
+ if (!pausedDetails)
+ return inputURLs
+ const { paused_nodes } = pausedDetails
+ const hasHumanInputNode = paused_nodes.some(
+ node => node.pause_type.type === 'human_input',
+ )
+ if (hasHumanInputNode) {
+ paused_nodes.forEach((node) => {
+ if (node.pause_type.type === 'human_input') {
+ inputURLs.push(node.pause_type.backstage_input_url)
+ }
+ })
+ }
+ return inputURLs
+ }, [pausedDetails])
return (
@@ -41,7 +80,7 @@ const StatusPanel: FC = ({
status === 'succeeded' && 'text-util-colors-green-green-600',
status === 'partial-succeeded' && 'text-util-colors-green-green-600',
status === 'failed' && 'text-util-colors-red-red-600',
- status === 'stopped' && 'text-util-colors-warning-warning-600',
+ (status === 'stopped' || status === 'paused') && 'text-util-colors-warning-warning-600',
status === 'running' && 'text-util-colors-blue-light-blue-light-600',
)}
>
@@ -81,15 +120,21 @@ const StatusPanel: FC = ({
STOP
>
)}
+ {status === 'paused' && (
+ <>
+
+ PENDING
+ >
+ )}
{t('resultPanel.time', { ns: 'runLog' })}
- {status === 'running' && (
-
+ {(status === 'running' || status === 'paused') && (
+
)}
- {status !== 'running' && (
+ {status !== 'running' && status !== 'paused' && (
{time ? `${time?.toFixed(3)}s` : '-'}
)}
@@ -97,10 +142,10 @@ const StatusPanel: FC
= ({
{t('resultPanel.tokens', { ns: 'runLog' })}
- {status === 'running' && (
-
+ {(status === 'running' || status === 'paused') && (
+
)}
- {status !== 'running' && (
+ {status !== 'running' && status !== 'paused' && (
{`${tokens || 0} Tokens`}
)}
@@ -149,6 +194,40 @@ const StatusPanel: FC
= ({
>
)
}
+ {status === 'paused' && (
+ <>
+
+
+
+
{t('nodes.humanInput.log.reason', { ns: 'workflow' })}
+ {
+ pausedReasons.length > 0
+ ? pausedReasons.map(reason => (
+
{reason}
+ ))
+ : (
+
+ )
+ }
+
+ {pausedInputURLs.length > 0 && (
+
+
{t('nodes.humanInput.log.backstageInputURL', { ns: 'workflow' })}
+ {pausedInputURLs.map(url => (
+
+ {url}
+
+ ))}
+
+ )}
+
+ >
+ )}
)
}
diff --git a/web/app/components/workflow/run/utils/format-log/human-input/index.ts b/web/app/components/workflow/run/utils/format-log/human-input/index.ts
new file mode 100644
index 0000000000..f8fbe7974c
--- /dev/null
+++ b/web/app/components/workflow/run/utils/format-log/human-input/index.ts
@@ -0,0 +1,59 @@
+import type { NodeTracing } from '@/types/workflow'
+import { BlockEnum } from '@/app/components/workflow/types'
+
+/**
+ * Format human-input nodes to ensure only the latest status is kept for each node.
+ * Human-input nodes can have multiple log entries as their status changes
+ * (e.g., running -> paused -> succeeded/failed).
+ * This function keeps only the entry with the latest index for each unique node_id.
+ */
+const formatHumanInputNode = (list: NodeTracing[]): NodeTracing[] => {
+ // Group human-input nodes by node_id
+ const humanInputNodeMap = new Map()
+
+ // Track which node_ids are human-input type
+ const humanInputNodeIds = new Set()
+
+ // First pass: identify human-input nodes and keep the one with the highest index
+ list.forEach((item) => {
+ if (item.node_type === BlockEnum.HumanInput) {
+ humanInputNodeIds.add(item.node_id)
+
+ const existingNode = humanInputNodeMap.get(item.node_id)
+ if (!existingNode || item.index > existingNode.index) {
+ humanInputNodeMap.set(item.node_id, item)
+ }
+ }
+ })
+
+ // If no human-input nodes, return the list as is
+ if (humanInputNodeIds.size === 0)
+ return list
+
+ // Second pass: filter the list to remove duplicate human-input nodes
+ // and keep only the latest one for each node_id
+ const result: NodeTracing[] = []
+ const addedHumanInputNodeIds = new Set()
+
+ list.forEach((item) => {
+ if (item.node_type === BlockEnum.HumanInput) {
+ // Only add the human-input node with the highest index
+ if (!addedHumanInputNodeIds.has(item.node_id)) {
+ const latestNode = humanInputNodeMap.get(item.node_id)
+ if (latestNode) {
+ result.push(latestNode)
+ addedHumanInputNodeIds.add(item.node_id)
+ }
+ }
+ // Skip duplicate human-input nodes
+ }
+ else {
+ // Keep all non-human-input nodes
+ result.push(item)
+ }
+ })
+
+ return result
+}
+
+export default formatHumanInputNode
diff --git a/web/app/components/workflow/run/utils/format-log/index.ts b/web/app/components/workflow/run/utils/format-log/index.ts
index c152a5156a..2393f9837f 100644
--- a/web/app/components/workflow/run/utils/format-log/index.ts
+++ b/web/app/components/workflow/run/utils/format-log/index.ts
@@ -2,6 +2,7 @@ import type { NodeTracing } from '@/types/workflow'
import { cloneDeep } from 'es-toolkit/object'
import { BlockEnum } from '../../../types'
import formatAgentNode from './agent'
+import formatHumanInputNode from './human-input'
import { addChildrenToIterationNode } from './iteration'
import { addChildrenToLoopNode } from './loop'
import formatParallelNode from './parallel'
@@ -83,7 +84,8 @@ const formatToTracingNodeList = (list: NodeTracing[], t: any) => {
* Because Handle struct node will put the node in different
*/
const formattedAgentList = formatAgentNode(allItems)
- const formattedRetryList = formatRetryNode(formattedAgentList) // retry one node
+ const formattedHumanInputList = formatHumanInputNode(formattedAgentList) // Keep only latest status for human-input nodes
+ const formattedRetryList = formatRetryNode(formattedHumanInputList) // retry one node
// would change the structure of the list. Iteration and parallel can include each other.
const formattedLoopAndIterationList = formatIterationAndLoopNode(formattedRetryList, t)
const formattedParallelList = formatParallelNode(formattedLoopAndIterationList, t)
diff --git a/web/app/components/workflow/store/workflow/workflow-slice.ts b/web/app/components/workflow/store/workflow/workflow-slice.ts
index df24058975..5a479d3e44 100644
--- a/web/app/components/workflow/store/workflow/workflow-slice.ts
+++ b/web/app/components/workflow/store/workflow/workflow-slice.ts
@@ -9,6 +9,8 @@ import type { FileUploadConfigResponse } from '@/models/common'
type PreviewRunningData = WorkflowRunningData & {
resultTabActive?: boolean
resultText?: string
+ // human input form schema or data cached when node is in 'Paused' status
+ extraContentAndFormData?: Record
}
export type WorkflowSliceShape = {
diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts
index be1254d33c..871ca30127 100644
--- a/web/app/components/workflow/types.ts
+++ b/web/app/components/workflow/types.ts
@@ -17,7 +17,13 @@ import type { VarType as VarKindType } from '@/app/components/workflow/nodes/too
import type { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
import type { SchemaTypeDefinition } from '@/service/use-common'
import type { Resolution, TransferMethod } from '@/types/app'
-import type { FileResponse, NodeTracing, PanelProps } from '@/types/workflow'
+import type {
+ FileResponse,
+ HumanInputFilledFormData,
+ HumanInputFormData,
+ NodeTracing,
+ PanelProps,
+} from '@/types/workflow'
export enum BlockEnum {
Start = 'start',
@@ -43,6 +49,7 @@ export enum BlockEnum {
Loop = 'loop',
LoopStart = 'loop-start',
LoopEnd = 'loop-end',
+ HumanInput = 'human-input',
DataSource = 'datasource',
DataSourceEmpty = 'datasource-empty',
KnowledgeBase = 'knowledge-index',
@@ -191,7 +198,6 @@ export enum InputVarType {
paragraph = 'paragraph',
select = 'select',
number = 'number',
- checkbox = 'checkbox',
url = 'url',
files = 'files',
json = 'json', // obj, array
@@ -201,6 +207,7 @@ export enum InputVarType {
singleFile = 'file',
multiFiles = 'file-list',
loop = 'loop', // loop input
+ checkbox = 'checkbox',
}
export type InputVar = {
@@ -350,6 +357,7 @@ export enum WorkflowRunningStatus {
Succeeded = 'succeeded',
Failed = 'failed',
Stopped = 'stopped',
+ Paused = 'paused',
}
export enum WorkflowVersion {
@@ -367,6 +375,7 @@ export enum NodeRunningStatus {
Exception = 'exception',
Retry = 'retry',
Stopped = 'stopped',
+ Paused = 'paused',
}
export type OnNodeAdd = (
@@ -426,6 +435,8 @@ export type WorkflowRunningData = {
exceptions_count?: number
}
tracing?: NodeTracing[]
+ humanInputFormDataList?: HumanInputFormData[]
+ humanInputFilledFormDataList?: HumanInputFilledFormData[]
}
export type HistoryWorkflowData = {
diff --git a/web/app/components/workflow/utils/elk-layout.ts b/web/app/components/workflow/utils/elk-layout.ts
index c3b37c8f16..280d0f7b1d 100644
--- a/web/app/components/workflow/utils/elk-layout.ts
+++ b/web/app/components/workflow/utils/elk-layout.ts
@@ -1,4 +1,5 @@
import type { ElkNode, LayoutOptions } from 'elkjs/lib/elk-api'
+import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
import type { CaseItem, IfElseNodeType } from '@/app/components/workflow/nodes/if-else/types'
import type {
Edge,
@@ -345,6 +346,76 @@ const buildIfElseWithPorts = (
}
}
+/**
+ * Build Human Input node with ELK native Ports for multiple branches
+ * Handles user actions as branches with __timeout as the last fixed branch
+ */
+const buildHumanInputWithPorts = (
+ humanInputNode: Node,
+ edges: Edge[],
+): { node: ElkNodeShape, portMap: Map } | null => {
+ const childEdges = edges.filter(edge => edge.source === humanInputNode.id)
+
+ if (childEdges.length <= 1)
+ return null
+
+ // Sort child edges: user actions first (by order), then __timeout last
+ const sortedChildEdges = [...childEdges].sort((edgeA, edgeB) => {
+ const handleA = edgeA.sourceHandle
+ const handleB = edgeB.sourceHandle
+
+ if (handleA && handleB) {
+ const userActions = (humanInputNode.data as HumanInputNodeType).user_actions || []
+ const isATimeout = handleA === '__timeout'
+ const isBTimeout = handleB === '__timeout'
+
+ // __timeout should always be last
+ if (isATimeout)
+ return 1
+ if (isBTimeout)
+ return -1
+
+ // Sort by user_actions order
+ const indexA = userActions.findIndex(action => action.id === handleA)
+ const indexB = userActions.findIndex(action => action.id === handleB)
+
+ if (indexA !== -1 && indexB !== -1)
+ return indexA - indexB
+ }
+
+ return 0
+ })
+
+ // Create ELK ports for each branch
+ const ports: ElkPortShape[] = sortedChildEdges.map((edge, index) => ({
+ id: `${humanInputNode.id}-port-${edge.sourceHandle || index}`,
+ layoutOptions: {
+ 'port.side': 'EAST',
+ 'port.index': String(index),
+ },
+ }))
+
+ // Build port mapping: edge.id -> portId
+ const portMap = new Map()
+ sortedChildEdges.forEach((edge, index) => {
+ const portId = `${humanInputNode.id}-port-${edge.sourceHandle || index}`
+ portMap.set(edge.id, portId)
+ })
+
+ return {
+ node: {
+ id: humanInputNode.id,
+ width: humanInputNode.width ?? DEFAULT_NODE_WIDTH,
+ height: humanInputNode.height ?? DEFAULT_NODE_HEIGHT,
+ ports,
+ layoutOptions: {
+ 'elk.portConstraints': 'FIXED_ORDER',
+ },
+ },
+ portMap,
+ }
+}
+
const normaliseBounds = (layout: LayoutResult): LayoutResult => {
const {
nodes,
@@ -388,7 +459,7 @@ export const getLayoutByDagre = async (originNodes: Node[], originEdges: Edge[])
// Track which edges have been processed for If/Else nodes with ports
const edgeToPortMap = new Map()
- // Build nodes with ports for If/Else nodes
+ // Build nodes with ports for If/Else and Human Input nodes
nodes.forEach((node) => {
if (node.data.type === BlockEnum.IfElse) {
const portsResult = buildIfElseWithPorts(node, edges)
@@ -405,6 +476,21 @@ export const getLayoutByDagre = async (originNodes: Node[], originEdges: Edge[])
elkNodes.push(toElkNode(node))
}
}
+ else if (node.data.type === BlockEnum.HumanInput) {
+ const portsResult = buildHumanInputWithPorts(node, edges)
+ if (portsResult) {
+ // Use node with ports
+ elkNodes.push(portsResult.node)
+ // Store port mappings for edges
+ portsResult.portMap.forEach((portId, edgeId) => {
+ edgeToPortMap.set(edgeId, portId)
+ })
+ }
+ else {
+ // No multiple branches, use normal node
+ elkNodes.push(toElkNode(node))
+ }
+ }
else {
elkNodes.push(toElkNode(node))
}
diff --git a/web/app/components/workflow/utils/workflow.ts b/web/app/components/workflow/utils/workflow.ts
index 7fabc51a45..a69ab54ec1 100644
--- a/web/app/components/workflow/utils/workflow.ts
+++ b/web/app/components/workflow/utils/workflow.ts
@@ -33,6 +33,7 @@ export const canRunBySingle = (nodeType: BlockEnum, isChildNode: boolean) => {
|| nodeType === BlockEnum.IfElse
|| nodeType === BlockEnum.VariableAggregator
|| nodeType === BlockEnum.Assigner
+ || nodeType === BlockEnum.HumanInput
|| nodeType === BlockEnum.DataSource
|| nodeType === BlockEnum.TriggerSchedule
|| nodeType === BlockEnum.TriggerWebhook
diff --git a/web/app/components/workflow/workflow-preview/components/nodes/base.tsx b/web/app/components/workflow/workflow-preview/components/nodes/base.tsx
index d75964b525..53a3fb5591 100644
--- a/web/app/components/workflow/workflow-preview/components/nodes/base.tsx
+++ b/web/app/components/workflow/workflow-preview/components/nodes/base.tsx
@@ -68,7 +68,7 @@ const BaseCard = ({
handleId="target"
/>
{
- data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && (
+ data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && data.type !== BlockEnum.HumanInput && (
= {
[BlockEnum.QuestionClassifier]: QuestionClassifierNode,
[BlockEnum.IfElse]: IfElseNode,
diff --git a/web/config/index.ts b/web/config/index.ts
index 08ce14b264..c3a4c5c3b1 100644
--- a/web/config/index.ts
+++ b/web/config/index.ts
@@ -341,8 +341,10 @@ export const VAR_REGEX
export const resetReg = () => (VAR_REGEX.lastIndex = 0)
-export const DISABLE_UPLOAD_IMAGE_AS_ICON
- = process.env.NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON === 'true'
+export const HITL_INPUT_REG = /\{\{(#\$output\.(?:[a-z_]\w{0,29}){1,10}#)\}\}/gi
+export const resetHITLInputReg = () => HITL_INPUT_REG.lastIndex = 0
+
+export const DISABLE_UPLOAD_IMAGE_AS_ICON = process.env.NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON === 'true'
export const GITHUB_ACCESS_TOKEN
= process.env.NEXT_PUBLIC_GITHUB_ACCESS_TOKEN || ''
diff --git a/web/context/provider-context.tsx b/web/context/provider-context.tsx
index d350d08b4a..2a71d9cf93 100644
--- a/web/context/provider-context.tsx
+++ b/web/context/provider-context.tsx
@@ -64,6 +64,7 @@ export type ProviderContextState = {
refreshLicenseLimit: () => void
isAllowTransferWorkspace: boolean
isAllowPublishAsCustomKnowledgePipelineTemplate: boolean
+ humanInputEmailDeliveryEnabled: boolean
}
export const baseProviderContextValue: ProviderContextState = {
@@ -96,6 +97,7 @@ export const baseProviderContextValue: ProviderContextState = {
refreshLicenseLimit: noop,
isAllowTransferWorkspace: false,
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
+ humanInputEmailDeliveryEnabled: false,
}
const ProviderContext = createContext(baseProviderContextValue)
@@ -137,6 +139,7 @@ export const ProviderContextProvider = ({
const { data: educationAccountInfo, isLoading: isLoadingEducationAccountInfo, isFetching: isFetchingEducationAccountInfo, isFetchedAfterMount: isEducationDataFetchedAfterMount } = useEducationStatus(!enableEducationPlan)
const [isAllowTransferWorkspace, setIsAllowTransferWorkspace] = useState(false)
const [isAllowPublishAsCustomKnowledgePipelineTemplate, setIsAllowPublishAsCustomKnowledgePipelineTemplate] = useState(false)
+ const [humanInputEmailDeliveryEnabled, setHumanInputEmailDeliveryEnabled] = useState(false)
const refreshModelProviders = () => {
queryClient.invalidateQueries({ queryKey: ['common', 'model-providers'] })
@@ -173,6 +176,8 @@ export const ProviderContextProvider = ({
setIsAllowTransferWorkspace(data.is_allow_transfer_workspace)
if (data.knowledge_pipeline?.publish_enabled)
setIsAllowPublishAsCustomKnowledgePipelineTemplate(data.knowledge_pipeline?.publish_enabled)
+ if (data.human_input_email_delivery_enabled)
+ setHumanInputEmailDeliveryEnabled(data.human_input_email_delivery_enabled)
}
catch (error) {
console.error('Failed to fetch plan info:', error)
@@ -250,6 +255,7 @@ export const ProviderContextProvider = ({
refreshLicenseLimit: fetchPlan,
isAllowTransferWorkspace,
isAllowPublishAsCustomKnowledgePipelineTemplate,
+ humanInputEmailDeliveryEnabled,
}}
>
{children}
diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json
index 63f10d238c..2662e979c1 100644
--- a/web/eslint-suppressions.json
+++ b/web/eslint-suppressions.json
@@ -99,6 +99,11 @@
"count": 1
}
},
+ "app/(humanInputLayout)/form/[token]/form.tsx": {
+ "react-hooks-extra/no-direct-set-state-in-use-effect": {
+ "count": 1
+ }
+ },
"app/(shareLayout)/components/splash.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -245,9 +250,6 @@
}
},
"app/components/app/app-publisher/index.tsx": {
- "react-hooks-extra/no-direct-set-state-in-use-effect": {
- "count": 3
- },
"ts/no-explicit-any": {
"count": 6
}
@@ -740,7 +742,7 @@
},
"app/components/base/chat/chat-with-history/chat-wrapper.tsx": {
"ts/no-explicit-any": {
- "count": 6
+ "count": 7
}
},
"app/components/base/chat/chat-with-history/context.tsx": {
@@ -789,9 +791,17 @@
"count": 1
}
},
+ "app/components/base/chat/chat/answer/human-input-content/utils.ts": {
+ "ts/no-explicit-any": {
+ "count": 1
+ }
+ },
"app/components/base/chat/chat/answer/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
- "count": 2
+ "count": 3
+ },
+ "ts/no-explicit-any": {
+ "count": 1
}
},
"app/components/base/chat/chat/answer/workflow-process.tsx": {
@@ -822,7 +832,7 @@
"count": 2
},
"ts/no-explicit-any": {
- "count": 14
+ "count": 17
}
},
"app/components/base/chat/chat/index.tsx": {
@@ -830,7 +840,7 @@
"count": 1
},
"ts/no-explicit-any": {
- "count": 1
+ "count": 3
}
},
"app/components/base/chat/chat/type.ts": {
@@ -845,7 +855,7 @@
},
"app/components/base/chat/embedded-chatbot/chat-wrapper.tsx": {
"ts/no-explicit-any": {
- "count": 6
+ "count": 7
}
},
"app/components/base/chat/embedded-chatbot/context.tsx": {
@@ -1225,7 +1235,7 @@
},
"app/components/base/markdown/react-markdown-wrapper.tsx": {
"ts/no-explicit-any": {
- "count": 8
+ "count": 9
}
},
"app/components/base/mermaid/index.tsx": {
@@ -1331,7 +1341,7 @@
},
"app/components/base/prompt-editor/index.tsx": {
"ts/no-explicit-any": {
- "count": 2
+ "count": 4
}
},
"app/components/base/prompt-editor/plugins/component-picker-block/index.tsx": {
@@ -1344,16 +1354,31 @@
"count": 1
}
},
+ "app/components/base/prompt-editor/plugins/draggable-plugin/index.tsx": {
+ "ts/no-explicit-any": {
+ "count": 2
+ }
+ },
"app/components/base/prompt-editor/plugins/history-block/component.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
+ "app/components/base/prompt-editor/plugins/hitl-input-block/hitl-input-block-replacement-block.tsx": {
+ "ts/no-explicit-any": {
+ "count": 1
+ }
+ },
"app/components/base/prompt-editor/plugins/on-blur-or-focus-block.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
+ "app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx": {
+ "ts/no-explicit-any": {
+ "count": 2
+ }
+ },
"app/components/base/prompt-editor/plugins/update-block.tsx": {
"ts/no-explicit-any": {
"count": 2
@@ -2918,7 +2943,7 @@
},
"app/components/workflow/nodes/_base/components/before-run-form/index.tsx": {
"ts/no-explicit-any": {
- "count": 8
+ "count": 11
}
},
"app/components/workflow/nodes/_base/components/editor/code-editor/editor-support-vars.tsx": {
@@ -3253,6 +3278,27 @@
"count": 5
}
},
+ "app/components/workflow/nodes/human-input/components/delivery-method/test-email-sender.tsx": {
+ "ts/no-explicit-any": {
+ "count": 2
+ }
+ },
+ "app/components/workflow/nodes/human-input/components/form-content.tsx": {
+ "react-hooks-extra/no-direct-set-state-in-use-effect": {
+ "count": 1
+ },
+ "react/no-nested-component-definitions": {
+ "count": 1
+ },
+ "ts/no-explicit-any": {
+ "count": 3
+ }
+ },
+ "app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx": {
+ "ts/no-explicit-any": {
+ "count": 8
+ }
+ },
"app/components/workflow/nodes/if-else/components/condition-list/condition-input.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -3738,7 +3784,7 @@
},
"app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx": {
"ts/no-explicit-any": {
- "count": 5
+ "count": 6
}
},
"app/components/workflow/panel/debug-and-preview/conversation-variable-modal.tsx": {
@@ -3748,7 +3794,7 @@
},
"app/components/workflow/panel/debug-and-preview/hooks.ts": {
"ts/no-explicit-any": {
- "count": 7
+ "count": 12
}
},
"app/components/workflow/panel/env-panel/variable-modal.tsx": {
@@ -3759,6 +3805,11 @@
"count": 1
}
},
+ "app/components/workflow/panel/human-input-form-list.tsx": {
+ "ts/no-explicit-any": {
+ "count": 1
+ }
+ },
"app/components/workflow/panel/inputs-panel.tsx": {
"ts/no-explicit-any": {
"count": 4
@@ -3770,11 +3821,8 @@
}
},
"app/components/workflow/panel/workflow-preview.tsx": {
- "react-hooks-extra/no-direct-set-state-in-use-effect": {
- "count": 1
- },
"ts/no-explicit-any": {
- "count": 1
+ "count": 2
}
},
"app/components/workflow/run/hooks.ts": {
@@ -3888,6 +3936,11 @@
"count": 1
}
},
+ "app/components/workflow/store/workflow/workflow-slice.ts": {
+ "ts/no-explicit-any": {
+ "count": 1
+ }
+ },
"app/components/workflow/types.ts": {
"ts/no-empty-object-type": {
"count": 3
@@ -4305,7 +4358,7 @@
},
"service/use-workflow.ts": {
"ts/no-explicit-any": {
- "count": 2
+ "count": 3
}
},
"service/utils.spec.ts": {
@@ -4345,7 +4398,7 @@
},
"types/workflow.ts": {
"ts/no-explicit-any": {
- "count": 15
+ "count": 17
}
},
"utils/clipboard.ts": {
diff --git a/web/i18n/en-US/common.json b/web/i18n/en-US/common.json
index d6e329c3df..9170472642 100644
--- a/web/i18n/en-US/common.json
+++ b/web/i18n/en-US/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "Write your prompt word here, enter '{' to insert a variable, enter '/' to insert a prompt content block",
"promptEditor.query.item.desc": "Insert user query template",
"promptEditor.query.item.title": "Query",
+ "promptEditor.requestURL.item.desc": "Insert request URL",
+ "promptEditor.requestURL.item.title": "Request URL",
"promptEditor.variable.item.desc": "Insert Variables & External Tools",
"promptEditor.variable.item.title": "Variables & External Tools",
"promptEditor.variable.modal.add": "New variable",
diff --git a/web/i18n/en-US/share.json b/web/i18n/en-US/share.json
index 3b417f316e..adb75ce181 100644
--- a/web/i18n/en-US/share.json
+++ b/web/i18n/en-US/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "Run Once",
"generation.tabs.saved": "Saved",
"generation.title": "AI Completion",
+ "humanInput.completed": "Seems like this request was dealt with elsewhere.",
+ "humanInput.expirationTimeNowOrFuture": "This action will expire {{relativeTime}}.",
+ "humanInput.expired": "Seems like this request has expired.",
+ "humanInput.expiredTip": "This action has expired.",
+ "humanInput.formNotFound": "Form not found.",
+ "humanInput.rateLimitExceeded": "Too many requests, please try again later.",
+ "humanInput.recorded": "Your input has been recorded.",
+ "humanInput.sorry": "Sorry!",
+ "humanInput.submissionID": "submission_id: {{id}}",
+ "humanInput.thanks": "Thanks!",
"login.backToHome": "Back to Home"
}
diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json
index 107dad5b28..4d9f5adbac 100644
--- a/web/i18n/en-US/workflow.json
+++ b/web/i18n/en-US/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "Doc Extractor",
"blocks.end": "Output",
"blocks.http-request": "HTTP Request",
+ "blocks.human-input": "Human Input",
"blocks.if-else": "IF/ELSE",
"blocks.iteration": "Iteration",
"blocks.iteration-start": "Iteration Start",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "Used to parse uploaded documents into text content that is easily understandable by LLM.",
"blocksAbout.end": "Define the output and result type of a workflow",
"blocksAbout.http-request": "Allow server requests to be sent over the HTTP protocol",
+ "blocksAbout.human-input": "Ask for human to confirm before generating the next step",
"blocksAbout.if-else": "Allows you to split the workflow into two branches based on if/else conditions",
"blocksAbout.iteration": "Perform multiple steps on a list object until all results are outputted.",
"blocksAbout.iteration-start": "Iteration Start node",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "Image upload features have been upgraded to file upload. ",
"common.goBackToEdit": "Go back to editor",
"common.handMode": "Hand Mode",
+ "common.humanInputEmailTip": "Email (Delivery Method) sent to your configured recipients",
+ "common.humanInputEmailTipInDebugMode": "Email (Delivery Method) sent to {{email}} ",
+ "common.humanInputWebappTip": "Debug preview only, user will not see this in web app.",
"common.importDSL": "Import DSL",
"common.importDSLTip": "Current draft will be overwritten.\nExport workflow as backup before importing.",
"common.importFailure": "Import Failed",
@@ -500,6 +505,104 @@
"nodes.http.value": "Value",
"nodes.http.verifySSL.title": "Verify SSL Certificate",
"nodes.http.verifySSL.warningTooltip": "Disabling SSL verification is not recommended for production environments. This should only be used in development or testing, as it makes the connection vulnerable to security threats like man-in-the-middle attacks.",
+ "nodes.humanInput.deliveryMethod.added": "Added",
+ "nodes.humanInput.deliveryMethod.contactTip1": "Missing a delivery method you need?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "Tell us at support@dify.ai .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "All members ({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "Body",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "Enter email body",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "Debug Mode",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "In debug mode, the email will only be sent to your account email {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "The production environment is not affected.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "Send request for input via email",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ Add",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "Added",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "Email, comma separated",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "Add workspace members or external recipients",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "Select",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "Recipient",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "The request URL variable is the trigger entry for human input.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "Subject",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "Enter email subject",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "Email Configuration",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "A test email has been sent to {{email}} . Please check your inbox.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "Debug mode is enabled.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "Email will be sent to {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "Email Sent",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(optional)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "Send Email",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "Send test emails to your configured recipients",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "Send a test email to {{email}}",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "It is recommended to enable Debug Mode for testing email delivery.",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "Test Email Sender",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "Variables in Form Content",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "Fill in form variables to emulate what recipients actually see.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "Email has been sent to {{team}} members and the following email addresses:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "Email has been sent to {{team}} members.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "Email has been sent to the following email addresses:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "Email will be sent to {{team}} members and the following email addresses:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "Email will be sent to {{team}} members.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "Email will be sent to the following email addresses:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "No delivery method added, the operation cannot be triggered.",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "Not available",
+ "nodes.humanInput.deliveryMethod.notConfigured": "Not configured",
+ "nodes.humanInput.deliveryMethod.title": "Delivery Method",
+ "nodes.humanInput.deliveryMethod.tooltip": "How the human input form is delivered to the user.",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "Send request for input via Discord",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "Send request for input via email",
+ "nodes.humanInput.deliveryMethod.types.email.title": "Email",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "Send request for input via Slack",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "Send request for input via Teams",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "Display to end-user in webapp",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "Webapp",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "Unlock Email delivery for Human Input",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "Send confirmation requests via email before agents take action — useful for publishing and approval workflows.",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "Dismiss",
+ "nodes.humanInput.editor.previewTip": "In preview mode, action buttons are not functional.",
+ "nodes.humanInput.errorMsg.duplicateActionId": "Duplicate action ID found in user actions",
+ "nodes.humanInput.errorMsg.emptyActionId": "Action ID cannot be empty",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "Action title cannot be empty",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "Please select at least one delivery method",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "Please enable at least one delivery method",
+ "nodes.humanInput.errorMsg.noUserActions": "Please add at least one user action",
+ "nodes.humanInput.formContent.hotkeyTip": "Press to insert variable, to insert input field",
+ "nodes.humanInput.formContent.placeholder": "Type content here",
+ "nodes.humanInput.formContent.preview": "Preview",
+ "nodes.humanInput.formContent.title": "Form Content",
+ "nodes.humanInput.formContent.tooltip": "What users will see after opening the form. Supports Markdown formatting.",
+ "nodes.humanInput.insertInputField.insert": "Insert",
+ "nodes.humanInput.insertInputField.prePopulateField": "Pre-populate Field",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": "Add or users will see this content initially, or leave empty.",
+ "nodes.humanInput.insertInputField.saveResponseAs": "Save Response As",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "Name this variable for later reference",
+ "nodes.humanInput.insertInputField.staticContent": "Static Content",
+ "nodes.humanInput.insertInputField.title": "Insert Input Field",
+ "nodes.humanInput.insertInputField.useConstantInstead": "Use Constant Instead",
+ "nodes.humanInput.insertInputField.useVarInstead": "Use Variable Instead",
+ "nodes.humanInput.insertInputField.variable": "variable",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "Variable name can only contain letters, numbers, and underscores, and cannot start with a number",
+ "nodes.humanInput.log.backstageInputURL": "Backstage input URL:",
+ "nodes.humanInput.log.reason": "Reason:",
+ "nodes.humanInput.log.reasonContent": "Human input required to proceed",
+ "nodes.humanInput.singleRun.back": "Back",
+ "nodes.humanInput.singleRun.button": "Generate Form",
+ "nodes.humanInput.singleRun.label": "Form variables",
+ "nodes.humanInput.timeout.days": "Days",
+ "nodes.humanInput.timeout.hours": "Hours",
+ "nodes.humanInput.timeout.title": "Timeout",
+ "nodes.humanInput.userActions.actionIdFormatTip": "Action ID must start with a letter or underscores, followed by letters, numbers, or underscores",
+ "nodes.humanInput.userActions.actionIdTooLong": "Action ID must be {{maxLength}} characters or less",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "Action Name",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "Button display Text",
+ "nodes.humanInput.userActions.buttonTextTooLong": "Button text must be {{maxLength}} characters or less",
+ "nodes.humanInput.userActions.chooseStyle": "Choose a button style",
+ "nodes.humanInput.userActions.emptyTip": "Click the '+' button to add user actions",
+ "nodes.humanInput.userActions.title": "User Actions",
+ "nodes.humanInput.userActions.tooltip": "Define buttons that users can click to respond to this form. Each button can trigger different workflow paths. Action ID must start with a letter or underscores, followed by letters, numbers, or underscores.",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} has been triggered",
"nodes.ifElse.addCondition": "Add Condition",
"nodes.ifElse.addSubVariable": "Sub Variable",
"nodes.ifElse.and": "and",
diff --git a/web/i18n/zh-Hans/common.json b/web/i18n/zh-Hans/common.json
index ebc7c00037..8e521c2f20 100644
--- a/web/i18n/zh-Hans/common.json
+++ b/web/i18n/zh-Hans/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "在这里写你的提示词,输入'{' 插入变量、输入'/' 插入提示内容块",
"promptEditor.query.item.desc": "插入用户查询模板",
"promptEditor.query.item.title": "查询内容",
+ "promptEditor.requestURL.item.desc": "插入请求 URL",
+ "promptEditor.requestURL.item.title": "请求 URL",
"promptEditor.variable.item.desc": "插入变量和外部工具",
"promptEditor.variable.item.title": "变量 & 外部工具",
"promptEditor.variable.modal.add": "添加新变量",
diff --git a/web/i18n/zh-Hans/share.json b/web/i18n/zh-Hans/share.json
index eb884396c3..0a16583d22 100644
--- a/web/i18n/zh-Hans/share.json
+++ b/web/i18n/zh-Hans/share.json
@@ -58,5 +58,14 @@
"generation.tabs.create": "运行一次",
"generation.tabs.saved": "已保存",
"generation.title": "AI 智能书写",
+ "humanInput.completed": "此请求似乎在其他地方得到了处理。",
+ "humanInput.expirationTimeNowOrFuture": "此操作将在 {{relativeTime}}过期。",
+ "humanInput.expired": "此请求似乎已过期。",
+ "humanInput.expiredTip": "此操作已过期。",
+ "humanInput.formNotFound": "表单不存在。",
+ "humanInput.rateLimitExceeded": "请求过于频繁,请稍后再试。",
+ "humanInput.recorded": "您的输入已被记录。",
+ "humanInput.sorry": "抱歉!",
+ "humanInput.thanks": "谢谢!",
"login.backToHome": "返回首页"
}
diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json
index 7787c9db4b..acda7db2fc 100644
--- a/web/i18n/zh-Hans/workflow.json
+++ b/web/i18n/zh-Hans/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "文档提取器",
"blocks.end": "输出",
"blocks.http-request": "HTTP 请求",
+ "blocks.human-input": "人工介入",
"blocks.if-else": "条件分支",
"blocks.iteration": "迭代",
"blocks.iteration-start": "迭代开始",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "用于将用户上传的文档解析为 LLM 便于理解的文本内容。",
"blocksAbout.end": "定义一个 workflow 流程的输出和结果类型",
"blocksAbout.http-request": "允许通过 HTTP 协议发送服务器请求",
+ "blocksAbout.human-input": "人工输入,确认后生成下一步",
"blocksAbout.if-else": "允许你根据 if/else 条件将 workflow 拆分成两个分支",
"blocksAbout.iteration": "对列表对象执行多次步骤直至输出所有结果。",
"blocksAbout.iteration-start": "迭代开始节点",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "图片上传功能已扩展为文件上传。",
"common.goBackToEdit": "返回编辑模式",
"common.handMode": "手模式",
+ "common.humanInputEmailTip": "电子邮件(传递方式)发送到您配置的收件人。",
+ "common.humanInputEmailTipInDebugMode": "电子邮件(传递方式)发送到 {{email}} ",
+ "common.humanInputWebappTip": "仅调试预览,用户在 Web 应用中看不到此内容。",
"common.importDSL": "导入 DSL",
"common.importDSLTip": "当前草稿将被覆盖。在导入之前请导出工作流作为备份。",
"common.importFailure": "导入失败",
@@ -500,6 +505,104 @@
"nodes.http.value": "值",
"nodes.http.verifySSL.title": "验证 SSL 证书",
"nodes.http.verifySSL.warningTooltip": "不建议在生产环境中禁用 SSL 验证。这仅应在开发或测试中使用,因为它会使连接容易受到诸如中间人攻击等安全威胁。",
+ "nodes.humanInput.deliveryMethod.added": "已添加",
+ "nodes.humanInput.deliveryMethod.contactTip1": "缺少所需的提交方式?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "请告诉我们 support@dify.ai ",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "所有成员({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "邮件正文",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "输入邮件正文",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "调试模式",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "在调试模式下,电子邮件将仅发送到您的帐户电子邮件 {{email}} 。",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "生产环境不受影响。",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "通过电子邮件发送输入请求",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ 添加",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "已添加",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "电子邮件,以逗号分隔",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "添加工作区成员或外部收件人",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "选择",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "收件人",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "请求 URL 变量是人工介入的触发入口。",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "邮件主题",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "输入邮件主题",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "电子邮件配置",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "测试邮件已发送到 {{email}} 。请检查您的收件箱。",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "调试模式已启用。",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "邮件将发送到 {{email}} 。",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "邮件已发送",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(可选)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "发送邮件",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "发送测试邮件到您的配置收件人",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "发送测试邮件到 {{email}}",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "建议为测试邮件发送启用 调试模式 。",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "测试邮件发送器",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "表单内容中的变量",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "填写表单变量以模拟收件人实际看到的内容。",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "邮件已发送给 {{team}} 成员和以下邮件地址:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "邮件已发送给 {{team}} 成员。",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "邮件已发送到以下邮件地址:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "邮件将发送给 {{team}} 成员和以下邮件地址:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "邮件将发送给 {{team}} 成员。",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "邮件将发送到以下邮件地址:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "未添加提交方式,无法触发操作。",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "不可用",
+ "nodes.humanInput.deliveryMethod.notConfigured": "未配置",
+ "nodes.humanInput.deliveryMethod.title": "提交方式",
+ "nodes.humanInput.deliveryMethod.tooltip": "人工介入表单如何传递给用户。",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "通过 Discord 发送输入请求",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "通过电子邮件发送输入请求",
+ "nodes.humanInput.deliveryMethod.types.email.title": "Email",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "通过 Slack 发送输入请求",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "通过 Teams 发送输入请求",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "在 Web 应用中显示给最终用户",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "Webapp",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "解锁人工介入的电子邮件发送功能",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "在 Agent 采取行动之前,通过电子邮件发送确认请求——适用于发布和审批工作流。",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "关闭",
+ "nodes.humanInput.editor.previewTip": "在预览模式下,操作按钮无法使用。",
+ "nodes.humanInput.errorMsg.duplicateActionId": "用户操作中存在重复的操作 ID",
+ "nodes.humanInput.errorMsg.emptyActionId": "操作 ID 不能为空",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "操作标题不能为空",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "请至少选择一种提交方式",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "请至少启用一种提交方式",
+ "nodes.humanInput.errorMsg.noUserActions": "请添加至少一个用户操作",
+ "nodes.humanInput.formContent.hotkeyTip": "按 插入变量,按 插入输入字段",
+ "nodes.humanInput.formContent.placeholder": "在此输入内容",
+ "nodes.humanInput.formContent.preview": "预览",
+ "nodes.humanInput.formContent.title": "表单内容",
+ "nodes.humanInput.formContent.tooltip": "用户打开表单后看到的内容。支持 Markdown 格式。",
+ "nodes.humanInput.insertInputField.insert": "插入",
+ "nodes.humanInput.insertInputField.prePopulateField": "预填充字段",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": "添加 或 用户将最初看到此内容,或留空。",
+ "nodes.humanInput.insertInputField.saveResponseAs": "保存响应为",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "为此变量命名以便将来引用",
+ "nodes.humanInput.insertInputField.staticContent": "静态内容",
+ "nodes.humanInput.insertInputField.title": "插入输入字段",
+ "nodes.humanInput.insertInputField.useConstantInstead": "使用常量代替",
+ "nodes.humanInput.insertInputField.useVarInstead": "使用变量代替",
+ "nodes.humanInput.insertInputField.variable": "变量",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "只能包含字母、数字和下划线,且不能以数字开头",
+ "nodes.humanInput.log.backstageInputURL": "表单输入 URL:",
+ "nodes.humanInput.log.reason": "原因:",
+ "nodes.humanInput.log.reasonContent": "需要人工介入才能继续",
+ "nodes.humanInput.singleRun.back": "返回",
+ "nodes.humanInput.singleRun.button": "生成表单",
+ "nodes.humanInput.singleRun.label": "表单变量",
+ "nodes.humanInput.timeout.days": "日",
+ "nodes.humanInput.timeout.hours": "小时",
+ "nodes.humanInput.timeout.title": "超时设置",
+ "nodes.humanInput.userActions.actionIdFormatTip": "操作 ID 必须以字母或下划线开头,后跟字母、数字或下划线",
+ "nodes.humanInput.userActions.actionIdTooLong": "操作 ID 不能超过 {{maxLength}} 个字符",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "操作名称",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "按钮显示文本",
+ "nodes.humanInput.userActions.buttonTextTooLong": "按钮文本不能超过 {{maxLength}} 个字符",
+ "nodes.humanInput.userActions.chooseStyle": "选择按钮样式",
+ "nodes.humanInput.userActions.emptyTip": "点击 '+' 按钮添加用户操作",
+ "nodes.humanInput.userActions.title": "用户操作",
+ "nodes.humanInput.userActions.tooltip": "定义用户可以点击以响应此表单的按钮。每个按钮都可以触发不同的工作流路径。操作 ID 必须以字母或下划线开头,后跟字母、数字或下划线。",
+ "nodes.humanInput.userActions.triggered": "已触发{{actionName}} ",
"nodes.ifElse.addCondition": "添加条件",
"nodes.ifElse.addSubVariable": "添加子变量",
"nodes.ifElse.and": "and",
diff --git a/web/models/log.ts b/web/models/log.ts
index d15d6d6688..ab1282b8af 100644
--- a/web/models/log.ts
+++ b/web/models/log.ts
@@ -93,7 +93,7 @@ export type MessageContent = {
export type CompletionConversationGeneralDetail = {
id: string
- status: 'normal' | 'finished'
+ status: 'normal' | 'finished' | 'paused'
from_source: 'api' | 'console'
from_end_user_id: string
from_end_user_session_id: string
@@ -367,3 +367,22 @@ export type AgentLogDetailResponse = {
iterations: AgentIteration[]
files: AgentLogFile[]
}
+
+export type PauseType = {
+ type: 'human_input'
+ form_id: string
+ backstage_input_url: string
+} | {
+ type: 'breakpoint'
+}
+
+export type PauseDetail = {
+ node_id: string
+ node_title: string
+ pause_type: PauseType
+}
+
+export type WorkflowPausedDetailsResponse = {
+ paused_at: string
+ paused_nodes: PauseDetail[]
+}
diff --git a/web/service/base.ts b/web/service/base.ts
index fb32ce6bcf..fb8999d7a5 100644
--- a/web/service/base.ts
+++ b/web/service/base.ts
@@ -8,6 +8,9 @@ import type {
} from '@/types/pipeline'
import type {
AgentLogResponse,
+ HumanInputFormFilledResponse,
+ HumanInputFormTimeoutResponse,
+ HumanInputRequiredResponse,
IterationFinishedResponse,
IterationNextResponse,
IterationStartedResponse,
@@ -21,6 +24,7 @@ import type {
TextChunkResponse,
TextReplaceResponse,
WorkflowFinishedResponse,
+ WorkflowPausedResponse,
WorkflowStartedResponse,
} from '@/types/workflow'
import Cookies from 'js-cookie'
@@ -29,7 +33,7 @@ import { API_PREFIX, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, IS_CE_EDITION, PASSPORT
import { asyncRunSafe } from '@/utils'
import { basePath } from '@/utils/var'
import { base, ContentType, getBaseOptions } from './fetch'
-import { refreshAccessTokenOrRelogin } from './refresh-token'
+import { refreshAccessTokenOrReLogin } from './refresh-token'
import { getWebAppPassport } from './webapp-auth'
const TIME_OUT = 100000
@@ -70,6 +74,10 @@ export type IOnLoopNext = (workflowStarted: LoopNextResponse) => void
export type IOnLoopFinished = (workflowFinished: LoopFinishedResponse) => void
export type IOnAgentLog = (agentLog: AgentLogResponse) => void
+export type IOHumanInputRequired = (humanInputRequired: HumanInputRequiredResponse) => void
+export type IOnHumanInputFormFilled = (humanInputFormFilled: HumanInputFormFilledResponse) => void
+export type IOnHumanInputFormTimeout = (humanInputFormTimeout: HumanInputFormTimeoutResponse) => void
+export type IOWorkflowPaused = (workflowPaused: WorkflowPausedResponse) => void
export type IOnDataSourceNodeProcessing = (dataSourceNodeProcessing: DataSourceNodeProcessingResponse) => void
export type IOnDataSourceNodeCompleted = (dataSourceNodeCompleted: DataSourceNodeCompletedResponse) => void
export type IOnDataSourceNodeError = (dataSourceNodeError: DataSourceNodeErrorResponse) => void
@@ -113,6 +121,10 @@ export type IOtherOptions = {
onLoopNext?: IOnLoopNext
onLoopFinish?: IOnLoopFinished
onAgentLog?: IOnAgentLog
+ onHumanInputRequired?: IOHumanInputRequired
+ onHumanInputFormFilled?: IOnHumanInputFormFilled
+ onHumanInputFormTimeout?: IOnHumanInputFormTimeout
+ onWorkflowPaused?: IOWorkflowPaused
// Pipeline data source node run
onDataSourceNodeProcessing?: IOnDataSourceNodeProcessing
@@ -153,6 +165,14 @@ function requiredWebSSOLogin(message?: string, code?: number) {
globalThis.location.href = `${globalThis.location.origin}${basePath}${WBB_APP_LOGIN_PATH}?${params.toString()}`
}
+function formatURL(url: string, isPublicAPI: boolean) {
+ const urlPrefix = isPublicAPI ? PUBLIC_API_PREFIX : API_PREFIX
+ if (url.startsWith('http://') || url.startsWith('https://'))
+ return url
+ const urlWithoutProtocol = url.startsWith('/') ? url : `/${url}`
+ return `${urlPrefix}${urlWithoutProtocol}`
+}
+
export function format(text: string) {
let res = text.trim()
if (res.startsWith('\n'))
@@ -187,6 +207,10 @@ export const handleStream = (
onTTSEnd?: IOnTTSEnd,
onTextReplace?: IOnTextReplace,
onAgentLog?: IOnAgentLog,
+ onHumanInputRequired?: IOHumanInputRequired,
+ onHumanInputFormFilled?: IOnHumanInputFormFilled,
+ onHumanInputFormTimeout?: IOnHumanInputFormTimeout,
+ onWorkflowPaused?: IOWorkflowPaused,
onDataSourceNodeProcessing?: IOnDataSourceNodeProcessing,
onDataSourceNodeCompleted?: IOnDataSourceNodeCompleted,
onDataSourceNodeError?: IOnDataSourceNodeError,
@@ -319,6 +343,18 @@ export const handleStream = (
else if (bufferObj.event === 'tts_message_end') {
onTTSEnd?.(bufferObj.message_id, bufferObj.audio)
}
+ else if (bufferObj.event === 'human_input_required') {
+ onHumanInputRequired?.(bufferObj as HumanInputRequiredResponse)
+ }
+ else if (bufferObj.event === 'human_input_form_filled') {
+ onHumanInputFormFilled?.(bufferObj as HumanInputFormFilledResponse)
+ }
+ else if (bufferObj.event === 'human_input_form_timeout') {
+ onHumanInputFormTimeout?.(bufferObj as HumanInputFormTimeoutResponse)
+ }
+ else if (bufferObj.event === 'workflow_paused') {
+ onWorkflowPaused?.(bufferObj as WorkflowPausedResponse)
+ }
else if (bufferObj.event === 'datasource_processing') {
onDataSourceNodeProcessing?.(bufferObj as DataSourceNodeProcessingResponse)
}
@@ -441,6 +477,10 @@ export const ssePost = async (
onLoopStart,
onLoopNext,
onLoopFinish,
+ onHumanInputRequired,
+ onHumanInputFormFilled,
+ onHumanInputFormTimeout,
+ onWorkflowPaused,
onDataSourceNodeProcessing,
onDataSourceNodeCompleted,
onDataSourceNodeError,
@@ -467,10 +507,7 @@ export const ssePost = async (
getAbortController?.(abortController)
- const urlPrefix = isPublicAPI ? PUBLIC_API_PREFIX : API_PREFIX
- const urlWithPrefix = (url.startsWith('http://') || url.startsWith('https://'))
- ? url
- : `${urlPrefix}${url.startsWith('/') ? url : `/${url}`}`
+ const urlWithPrefix = formatURL(url, isPublicAPI)
const { body } = options
if (body)
@@ -495,7 +532,7 @@ export const ssePost = async (
})
}
else {
- refreshAccessTokenOrRelogin(TIME_OUT).then(() => {
+ refreshAccessTokenOrReLogin(TIME_OUT).then(() => {
ssePost(url, fetchOptions, otherOptions)
}).catch((err) => {
console.error(err)
@@ -545,6 +582,157 @@ export const ssePost = async (
onTTSEnd,
onTextReplace,
onAgentLog,
+ onHumanInputRequired,
+ onHumanInputFormFilled,
+ onHumanInputFormTimeout,
+ onWorkflowPaused,
+ onDataSourceNodeProcessing,
+ onDataSourceNodeCompleted,
+ onDataSourceNodeError,
+ )
+ })
+ .catch((e) => {
+ if (e.toString() !== 'AbortError: The user aborted a request.' && !e.toString().errorMessage.includes('TypeError: Cannot assign to read only property'))
+ Toast.notify({ type: 'error', message: e })
+ onError?.(e)
+ })
+}
+
+export const sseGet = async (
+ url: string,
+ fetchOptions: FetchOptionType,
+ otherOptions: IOtherOptions,
+) => {
+ const {
+ isPublicAPI = false,
+ onData,
+ onCompleted,
+ onThought,
+ onFile,
+ onMessageEnd,
+ onMessageReplace,
+ onWorkflowStarted,
+ onWorkflowFinished,
+ onNodeStarted,
+ onNodeFinished,
+ onIterationStart,
+ onIterationNext,
+ onIterationFinish,
+ onNodeRetry,
+ onParallelBranchStarted,
+ onParallelBranchFinished,
+ onTextChunk,
+ onTTSChunk,
+ onTTSEnd,
+ onTextReplace,
+ onAgentLog,
+ onError,
+ getAbortController,
+ onLoopStart,
+ onLoopNext,
+ onLoopFinish,
+ onHumanInputRequired,
+ onHumanInputFormFilled,
+ onHumanInputFormTimeout,
+ onWorkflowPaused,
+ onDataSourceNodeProcessing,
+ onDataSourceNodeCompleted,
+ onDataSourceNodeError,
+ } = otherOptions
+ const abortController = new AbortController()
+
+ const baseOptions = getBaseOptions()
+ const shareCode = globalThis.location.pathname.split('/').slice(-1)[0]
+ const options = Object.assign({}, baseOptions, {
+ signal: abortController.signal,
+ headers: new Headers({
+ [CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME()) || '',
+ [WEB_APP_SHARE_CODE_HEADER_NAME]: shareCode,
+ [PASSPORT_HEADER_NAME]: getWebAppPassport(shareCode),
+ }),
+ } as RequestInit, fetchOptions)
+
+ const contentType = (options.headers as Headers).get('Content-Type')
+ if (!contentType)
+ (options.headers as Headers).set('Content-Type', ContentType.json)
+
+ getAbortController?.(abortController)
+
+ const urlWithPrefix = formatURL(url, isPublicAPI)
+
+ globalThis.fetch(urlWithPrefix, options as RequestInit)
+ .then((res) => {
+ if (!/^[23]\d{2}$/.test(String(res.status))) {
+ if (res.status === 401) {
+ if (isPublicAPI) {
+ res.json().then((data: { code?: string, message?: string }) => {
+ if (isPublicAPI) {
+ if (data.code === 'web_app_access_denied')
+ requiredWebSSOLogin(data.message, 403)
+
+ if (data.code === 'web_sso_auth_required')
+ requiredWebSSOLogin()
+
+ if (data.code === 'unauthorized')
+ requiredWebSSOLogin()
+ }
+ })
+ }
+ else {
+ refreshAccessTokenOrReLogin(TIME_OUT).then(() => {
+ sseGet(url, fetchOptions, otherOptions)
+ }).catch((err) => {
+ console.error(err)
+ })
+ }
+ }
+ else {
+ res.json().then((data) => {
+ Toast.notify({ type: 'error', message: data.message || 'Server Error' })
+ })
+ onError?.('Server Error')
+ }
+ return
+ }
+ return handleStream(
+ res,
+ (str: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => {
+ if (moreInfo.errorMessage) {
+ onError?.(moreInfo.errorMessage, moreInfo.errorCode)
+ // TypeError: Cannot assign to read only property ... will happen in page leave, so it should be ignored.
+ if (moreInfo.errorMessage !== 'AbortError: The user aborted a request.' && !moreInfo.errorMessage.includes('TypeError: Cannot assign to read only property'))
+ Toast.notify({ type: 'error', message: moreInfo.errorMessage })
+ return
+ }
+ onData?.(str, isFirstMessage, moreInfo)
+ },
+ onCompleted,
+ onThought,
+ onMessageEnd,
+ onMessageReplace,
+ onFile,
+ onWorkflowStarted,
+ onWorkflowFinished,
+ onNodeStarted,
+ onNodeFinished,
+ onIterationStart,
+ onIterationNext,
+ onIterationFinish,
+ onLoopStart,
+ onLoopNext,
+ onLoopFinish,
+ onNodeRetry,
+ onParallelBranchStarted,
+ onParallelBranchFinished,
+ onTextChunk,
+ onTTSChunk,
+ onTTSEnd,
+ onTextReplace,
+ onAgentLog,
+ onHumanInputRequired,
+ onHumanInputFormFilled,
+ onHumanInputFormTimeout,
+ onWorkflowPaused,
onDataSourceNodeProcessing,
onDataSourceNodeCompleted,
onDataSourceNodeError,
@@ -612,7 +800,7 @@ export const request = async(url: string, options = {}, otherOptions?: IOther
}
// refresh token
- const [refreshErr] = await asyncRunSafe(refreshAccessTokenOrRelogin(TIME_OUT))
+ const [refreshErr] = await asyncRunSafe(refreshAccessTokenOrReLogin(TIME_OUT))
if (refreshErr === null)
return baseFetch(url, options, otherOptionsForBaseFetch)
if (location.pathname !== `${basePath}/signin` || !IS_CE_EDITION) {
diff --git a/web/service/refresh-token.ts b/web/service/refresh-token.ts
index 998b7f2461..b00a46eb6e 100644
--- a/web/service/refresh-token.ts
+++ b/web/service/refresh-token.ts
@@ -80,7 +80,7 @@ function releaseRefreshLock() {
globalThis.removeEventListener('beforeunload', releaseRefreshLock)
}
-export async function refreshAccessTokenOrRelogin(timeout: number) {
+export async function refreshAccessTokenOrReLogin(timeout: number) {
return Promise.race([new Promise((resolve, reject) => setTimeout(() => {
releaseRefreshLock()
reject(new Error('request timeout'))
diff --git a/web/service/share.ts b/web/service/share.ts
index add1256f30..4726ded7d1 100644
--- a/web/service/share.ts
+++ b/web/service/share.ts
@@ -2,20 +2,10 @@ import type {
IOnCompleted,
IOnData,
IOnError,
- IOnIterationFinished,
- IOnIterationNext,
- IOnIterationStarted,
- IOnLoopFinished,
- IOnLoopNext,
- IOnLoopStarted,
IOnMessageReplace,
- IOnNodeFinished,
- IOnNodeStarted,
- IOnTextChunk,
- IOnTextReplace,
- IOnWorkflowFinished,
- IOnWorkflowStarted,
+ IOtherOptions,
} from './base'
+import type { FormData as HumanInputFormData } from '@/app/(humanInputLayout)/form/[token]/form'
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { AccessMode } from '@/models/access-control'
@@ -95,33 +85,7 @@ export const sendCompletionMessage = async (body: Record, { onData,
export const sendWorkflowMessage = async (
body: Record,
- {
- onWorkflowStarted,
- onNodeStarted,
- onNodeFinished,
- onWorkflowFinished,
- onIterationStart,
- onIterationNext,
- onIterationFinish,
- onLoopStart,
- onLoopNext,
- onLoopFinish,
- onTextChunk,
- onTextReplace,
- }: {
- onWorkflowStarted: IOnWorkflowStarted
- onNodeStarted: IOnNodeStarted
- onNodeFinished: IOnNodeFinished
- onWorkflowFinished: IOnWorkflowFinished
- onIterationStart: IOnIterationStarted
- onIterationNext: IOnIterationNext
- onIterationFinish: IOnIterationFinished
- onLoopStart: IOnLoopStarted
- onLoopNext: IOnLoopNext
- onLoopFinish: IOnLoopFinished
- onTextChunk: IOnTextChunk
- onTextReplace: IOnTextReplace
- },
+ otherOptions: IOtherOptions,
appSourceType: AppSourceType,
appId = '',
) => {
@@ -131,19 +95,8 @@ export const sendWorkflowMessage = async (
response_mode: 'streaming',
},
}, {
- onNodeStarted,
- onWorkflowStarted,
- onWorkflowFinished,
+ ...otherOptions,
isPublicAPI: getIsPublicAPI(appSourceType),
- onNodeFinished,
- onIterationStart,
- onIterationNext,
- onIterationFinish,
- onLoopStart,
- onLoopNext,
- onLoopFinish,
- onTextChunk,
- onTextReplace,
})
}
@@ -320,3 +273,14 @@ export const getUserCanAccess = (appId: string, isInstalledApp: boolean) => {
export const getAppAccessModeByAppCode = (appCode: string) => {
return get<{ accessMode: AccessMode }>(`/webapp/access-mode?appCode=${appCode}`)
}
+
+export const getHumanInputForm = (token: string) => {
+ return get(`/form/human_input/${token}`)
+}
+
+export const submitHumanInputForm = (token: string, data: {
+ inputs: Record
+ action: string
+}) => {
+ return post(`/form/human_input/${token}`, { body: data })
+}
diff --git a/web/service/use-common.ts b/web/service/use-common.ts
index ca0845d95a..002d154d1d 100644
--- a/web/service/use-common.ts
+++ b/web/service/use-common.ts
@@ -108,7 +108,7 @@ export const useLangGeniusVersion = (currentVersion?: string | null, enabled?: b
export const useCurrentWorkspace = () => {
return useQuery({
queryKey: commonQueryKeys.currentWorkspace,
- queryFn: () => post('/workspaces/current', { body: {} }),
+ queryFn: () => post('/workspaces/current'),
})
}
diff --git a/web/service/use-log.ts b/web/service/use-log.ts
index b120adda2f..310c5000d6 100644
--- a/web/service/use-log.ts
+++ b/web/service/use-log.ts
@@ -7,6 +7,7 @@ import type {
CompletionConversationsRequest,
CompletionConversationsResponse,
WorkflowLogsResponse,
+ WorkflowPausedDetailsResponse,
} from '@/models/log'
import { useQuery } from '@tanstack/react-query'
import { get } from './base'
@@ -87,3 +88,18 @@ export const useWorkflowLogs = ({ appId, params }: WorkflowLogsParams) => {
enabled: !!appId,
})
}
+
+// ============ Workflow Pause Details ============
+
+type WorkflowPausedDetailsParams = {
+ workflowRunId: string
+ enabled?: boolean
+}
+
+export const useWorkflowPausedDetails = ({ workflowRunId, enabled = true }: WorkflowPausedDetailsParams) => {
+ return useQuery({
+ queryKey: [NAME_SPACE, 'workflow-paused-details', workflowRunId],
+ queryFn: () => get(`/workflow/${workflowRunId}/pause-details`),
+ enabled: enabled && !!workflowRunId,
+ })
+}
diff --git a/web/service/use-share.ts b/web/service/use-share.ts
index cb99525a78..68e9229c1b 100644
--- a/web/service/use-share.ts
+++ b/web/service/use-share.ts
@@ -1,5 +1,6 @@
+import type { FormData as HumanInputFormData } from '@/app/(humanInputLayout)/form/[token]/form'
import type { AppConversationData, ConversationItem } from '@/models/share'
-import { useQuery } from '@tanstack/react-query'
+import { useMutation, useQuery } from '@tanstack/react-query'
import {
AppSourceType,
fetchAppInfo,
@@ -9,6 +10,8 @@ import {
fetchConversations,
generationConversationName,
getAppAccessModeByAppCode,
+ getHumanInputForm,
+ submitHumanInputForm,
} from './share'
import { useInvalid } from './use-base'
@@ -49,6 +52,7 @@ export const shareQueryKeys = {
conversationList: (params: ShareConversationsParams) => [NAME_SPACE, 'conversations', params] as const,
chatList: (params: ShareChatListParams) => [NAME_SPACE, 'chatList', params] as const,
conversationName: (params: ShareConversationNameParams) => [NAME_SPACE, 'conversationName', params] as const,
+ humanInputForm: (token: string) => [NAME_SPACE, 'humanInputForm', token] as const,
}
export const useGetWebAppAccessModeByCode = (code: string | null) => {
@@ -149,3 +153,60 @@ export const useShareConversationName = (params: ShareConversationNameParams, op
export const useInvalidateShareConversations = () => {
return useInvalid(shareQueryKeys.conversations)
}
+
+export class HumanInputFormError extends Error {
+ code: string
+ status: number
+
+ constructor(code: string, message: string, status: number) {
+ super(message)
+ this.name = 'HumanInputFormError'
+ this.code = code
+ this.status = status
+ }
+}
+
+export const useGetHumanInputForm = (token: string, options: ShareQueryOptions = {}) => {
+ const {
+ enabled = true,
+ refetchOnReconnect,
+ refetchOnWindowFocus,
+ } = options
+ return useQuery({
+ queryKey: shareQueryKeys.humanInputForm(token),
+ queryFn: async () => {
+ try {
+ return await getHumanInputForm(token)
+ }
+ catch (error) {
+ const response = error as Response
+ if (response.status && response.json) {
+ const errorData = await response.json() as { code: string, message: string }
+ throw new HumanInputFormError(errorData.code, errorData.message, response.status)
+ }
+ throw error
+ }
+ },
+ enabled: enabled && !!token,
+ refetchOnReconnect,
+ refetchOnWindowFocus,
+ retry: false,
+ })
+}
+
+export type SubmitHumanInputFormParams = {
+ token: string
+ data: {
+ inputs: Record
+ action: string
+ }
+}
+
+export const useSubmitHumanInputForm = () => {
+ return useMutation({
+ mutationKey: [NAME_SPACE, 'submit-human-input-form'],
+ mutationFn: ({ token, data }: SubmitHumanInputFormParams) => {
+ return submitHumanInputForm(token, data)
+ },
+ })
+}
diff --git a/web/service/use-workflow.ts b/web/service/use-workflow.ts
index 754fb6b003..2f9f5d2fb7 100644
--- a/web/service/use-workflow.ts
+++ b/web/service/use-workflow.ts
@@ -227,3 +227,18 @@ export const useEditInspectorVar = (flowType: FlowType, flowId: string) => {
},
})
}
+
+export const useTestEmailSender = () => {
+ return useMutation({
+ mutationKey: [NAME_SPACE, 'test email sender'],
+ mutationFn: async (data: { appID: string, nodeID: string, deliveryID: string, inputs: Record }) => {
+ const { appID, nodeID, deliveryID, inputs } = data
+ return post(`/apps/${appID}/workflows/draft/human-input/nodes/${nodeID}/delivery-test`, {
+ body: {
+ delivery_method_id: deliveryID,
+ inputs,
+ },
+ })
+ },
+ })
+}
diff --git a/web/service/workflow.ts b/web/service/workflow.ts
index 3a37db791b..bf0075a761 100644
--- a/web/service/workflow.ts
+++ b/web/service/workflow.ts
@@ -4,6 +4,7 @@ import type { FlowType } from '@/types/common'
import type {
ConversationVariableResponse,
FetchWorkflowDraftResponse,
+ HumanInputFormData,
NodesDefaultConfigsResponse,
VarInInspect,
} from '@/types/workflow'
@@ -96,3 +97,30 @@ export const fetchNodeInspectVars = async (flowType: FlowType, flowId: string, n
const { items } = (await get(`${getFlowPrefix(flowType)}/${flowId}/workflows/draft/nodes/${nodeId}/variables`)) as { items: VarInInspect[] }
return items
}
+
+export const submitHumanInputForm = (token: string, data: {
+ inputs: Record
+ action: string
+}) => {
+ return post(`/form/human_input/${token}`, { body: data })
+}
+
+export const fetchHumanInputNodeStepRunForm = (
+ url: string,
+ data: {
+ inputs: Record
+ },
+) => {
+ return post(`${url}/preview`, { body: data })
+}
+
+export const submitHumanInputNodeStepRunForm = (
+ url: string,
+ data: {
+ inputs: Record | undefined
+ form_inputs: Record | undefined
+ action: string
+ },
+) => {
+ return post(`${url}/run`, { body: data })
+}
diff --git a/web/tailwind-common-config.ts b/web/tailwind-common-config.ts
index 2c3128d507..59ae5e730f 100644
--- a/web/tailwind-common-config.ts
+++ b/web/tailwind-common-config.ts
@@ -227,6 +227,7 @@ const config = {
'chat-bubble-bg': 'var(--color-chat-bubble-bg)',
'chat-input-mask': 'var(--color-chat-input-mask)',
'workflow-process-bg': 'var(--color-workflow-process-bg)',
+ 'workflow-process-paused-bg': 'var(--color-workflow-process-paused-bg)',
'workflow-run-failed-bg': 'var(--color-workflow-run-failed-bg)',
'workflow-batch-failed-bg': 'var(--color-workflow-batch-failed-bg)',
'mask-top2bottom-gray-50-to-transparent': 'var(--mask-top2bottom-gray-50-to-transparent)',
@@ -263,6 +264,7 @@ const config = {
'billing-plan-card-enterprise-bg': 'var(--color-billing-plan-card-enterprise-bg)',
'knowledge-pipeline-creation-footer-bg': 'var(--color-knowledge-pipeline-creation-footer-bg)',
'progress-bar-indeterminate-stripe': 'var(--color-progress-bar-indeterminate-stripe)',
+ 'chat-answer-human-input-form-divider-bg': 'var(--color-chat-answer-human-input-form-divider-bg)',
},
animation: {
'spin-slow': 'spin 2s linear infinite',
diff --git a/web/themes/manual-dark.css b/web/themes/manual-dark.css
index eb33f93030..8a114204d8 100644
--- a/web/themes/manual-dark.css
+++ b/web/themes/manual-dark.css
@@ -11,6 +11,12 @@ html[data-theme="dark"] {
--color-workflow-process-bg: linear-gradient(90deg,
rgba(24, 24, 27, 0.25) 0%,
rgba(24, 24, 27, 0.04) 100%);
+ --color-workflow-process-paused-bg: linear-gradient(90deg,
+ rgba(247, 144, 9, 0.14) 0%,
+ rgba(247, 144, 9, 0.00) 100%);
+ --color-workflow-process-failed-bg: linear-gradient(90deg,
+ rgba(240, 68, 56, 0.14) 0%,
+ rgba(240, 68, 56, 0.00) 100%);
--color-workflow-run-failed-bg: linear-gradient(98deg,
rgba(240, 68, 56, 0.12) 0%,
rgba(0, 0, 0, 0) 26.01%);
@@ -75,4 +81,5 @@ html[data-theme="dark"] {
--color-billing-plan-card-enterprise-bg: linear-gradient(180deg, #03F 0%, rgba(0, 51, 255, 0.00) 100%);
--color-knowledge-pipeline-creation-footer-bg: linear-gradient(90deg, rgba(34, 34, 37, 1) 4.89%, rgba(0, 0, 0, 0) 100%);
--color-progress-bar-indeterminate-stripe: repeating-linear-gradient(-55deg, #3A3A40, #3A3A40 2px, transparent 2px, transparent 5px);
+ --color-chat-answer-human-input-form-divider-bg: linear-gradient(0deg, rgba(200, 206, 218, 0.14) 0%, rgba(0, 0, 0, 0) 212.5%);
}
diff --git a/web/themes/manual-light.css b/web/themes/manual-light.css
index 62ff4b2178..4cc633d3bc 100644
--- a/web/themes/manual-light.css
+++ b/web/themes/manual-light.css
@@ -11,6 +11,12 @@ html[data-theme="light"] {
--color-workflow-process-bg: linear-gradient(90deg,
rgba(200, 206, 218, 0.2) 0%,
rgba(200, 206, 218, 0.04) 100%);
+ --color-workflow-process-paused-bg: linear-gradient(90deg,
+ #FFFAEB 0%,
+ rgba(255, 250, 235, 0.00) 100%);
+ --color-workflow-process-failed-bg: linear-gradient(90deg,
+ #FEF3F2 0%,
+ rgba(254, 243, 242, 0.00) 100%);
--color-workflow-run-failed-bg: linear-gradient(98deg,
rgba(240, 68, 56, 0.10) 0%,
rgba(255, 255, 255, 0) 26.01%);
@@ -75,4 +81,5 @@ html[data-theme="light"] {
--color-billing-plan-card-enterprise-bg: linear-gradient(180deg, #03F 0%, rgba(0, 51, 255, 0.00) 100%);
--color-knowledge-pipeline-creation-footer-bg: linear-gradient(90deg, #FCFCFD 4.89%, rgba(255, 255, 255, 0.00) 100%);
--color-progress-bar-indeterminate-stripe: repeating-linear-gradient(-55deg, #D0D5DD, #D0D5DD 2px, transparent 2px, transparent 5px);
+ --color-chat-answer-human-input-form-divider-bg: linear-gradient(0deg, rgba(16, 24, 40, 0.08) 0%, rgba(255, 255, 255, 0.00) 212.5%);
}
diff --git a/web/types/workflow.ts b/web/types/workflow.ts
index 5f74ef2c12..156a704b48 100644
--- a/web/types/workflow.ts
+++ b/web/types/workflow.ts
@@ -2,6 +2,7 @@ import type { RefObject } from 'react'
import type { Viewport } from 'reactflow'
import type { BeforeRunFormProps } from '@/app/components/workflow/nodes/_base/components/before-run-form'
import type { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
+import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
import type { SpecialResultPanelProps } from '@/app/components/workflow/run/special-result-panel'
import type { BlockEnum, CommonNodeType, ConversationVariable, Edge, EnvironmentVariable, InputVar, Node, ValueSelector, Variable, VarType } from '@/app/components/workflow/types'
import type { RAGPipelineVariables } from '@/models/pipeline'
@@ -165,6 +166,20 @@ export type WorkflowStartedResponse = {
workflow_id: string
created_at: number
}
+ conversation_id?: string // only in chatflow
+ message_id?: string // only in chatflow
+}
+
+export type WorkflowPausedResponse = {
+ task_id: string
+ workflow_run_id: string
+ event: string
+ data: {
+ outputs: any // todo: remove any
+ paused_nodes: string[]
+ reasons: any[] // todo: remove any
+ workflow_run_id: string
+ }
}
export type WorkflowFinishedResponse = {
@@ -298,6 +313,54 @@ export type AgentLogResponse = {
data: AgentLogItemWithChildren
}
+export type HumanInputFormData = {
+ form_id: string
+ node_id: string
+ node_title: string
+ form_content: string
+ inputs: FormInputItem[]
+ actions: UserAction[]
+ form_token: string
+ resolved_default_values: Record
+ display_in_ui: boolean
+ expiration_time: number
+}
+
+export type HumanInputRequiredResponse = {
+ task_id: string
+ workflow_run_id: string
+ event: string
+ data: HumanInputFormData
+}
+
+export type HumanInputFilledFormData = {
+ node_id: string
+ node_title: string
+ rendered_content: string
+ action_id: string
+ action_text: string
+}
+
+export type HumanInputFormFilledResponse = {
+ task_id: string
+ workflow_run_id: string
+ event: string
+ data: HumanInputFilledFormData
+}
+
+export type HumanInputFormTimeoutData = {
+ node_id: string
+ node_title: string
+ expiration_time: number
+}
+
+export type HumanInputFormTimeoutResponse = {
+ task_id: string
+ workflow_run_id: string
+ event: string
+ data: HumanInputFormTimeoutData
+}
+
export type WorkflowRunHistory = {
id: string
version: string
From 03e3acfc710750e18cc30d9cd0b8af3d93a26e2b Mon Sep 17 00:00:00 2001
From: QuantumGhost
Date: Fri, 30 Jan 2026 10:18:49 +0800
Subject: [PATCH 08/14] feat(api): Human Input Node (backend part) (#31646)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The backend part of the human in the loop (HITL) feature and relevant architecture / workflow engine changes.
Signed-off-by: yihong0618
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: -LAN-
Co-authored-by: 盐粒 Yanli
Co-authored-by: CrabSAMA <40541269+CrabSAMA@users.noreply.github.com>
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: yihong
Co-authored-by: Joel
---
.github/workflows/build-push.yml | 1 +
api/.env.example | 25 +
api/.importlinter | 8 +
api/configs/feature/__init__.py | 25 +
api/configs/middleware/__init__.py | 2 +
.../middleware/cache/redis_pubsub_config.py | 96 +++
api/controllers/console/__init__.py | 2 +
api/controllers/console/app/conversation.py | 1 +
api/controllers/console/app/message.py | 5 +-
api/controllers/console/app/workflow.py | 173 +++++
api/controllers/console/app/workflow_run.py | 77 +++
api/controllers/console/human_input_form.py | 217 +++++++
api/controllers/service_api/app/workflow.py | 24 +-
api/controllers/web/__init__.py | 4 +
api/controllers/web/error.py | 6 +
api/controllers/web/human_input_form.py | 164 +++++
api/controllers/web/site.py | 17 +-
api/controllers/web/workflow_events.py | 112 ++++
.../app/apps/advanced_chat/app_generator.py | 110 +++-
api/core/app/apps/advanced_chat/app_runner.py | 18 +-
.../advanced_chat/generate_task_pipeline.py | 122 +++-
.../common/workflow_response_converter.py | 177 +++++-
.../app/apps/message_based_app_generator.py | 37 +-
api/core/app/apps/message_generator.py | 36 ++
api/core/app/apps/streaming_utils.py | 70 ++
api/core/app/apps/workflow/app_generator.py | 69 +-
api/core/app/apps/workflow/app_runner.py | 40 +-
api/core/app/apps/workflow/errors.py | 7 +
.../apps/workflow/generate_task_pipeline.py | 69 +-
api/core/app/apps/workflow_app_runner.py | 59 +-
api/core/app/entities/app_invoke_entities.py | 3 +-
api/core/app/entities/queue_entities.py | 47 ++
api/core/app/entities/task_entities.py | 91 ++-
.../app/features/rate_limiting/rate_limit.py | 9 +
.../app/layers/pause_state_persist_layer.py | 9 +
.../task_pipeline/message_cycle_manager.py | 8 +-
api/core/entities/execution_extra_content.py | 54 ++
api/core/entities/provider_configuration.py | 2 +-
api/core/ops/ops_trace_manager.py | 9 +-
api/core/ops/utils.py | 2 +-
api/core/plugin/backwards_invocation/app.py | 2 +
api/core/repositories/__init__.py | 17 +-
.../repositories/human_input_repository.py | 553 ++++++++++++++++
...hemy_workflow_node_execution_repository.py | 1 +
api/core/tools/errors.py | 7 +
.../utils/workflow_configuration_sync.py | 9 +
api/core/workflow/entities/__init__.py | 2 +
.../workflow/entities/graph_init_params.py | 10 +
api/core/workflow/entities/pause_reason.py | 30 +-
.../entities/workflow_start_reason.py | 8 +
.../workflow/graph_engine/_engine_utils.py | 15 +
api/core/workflow/graph_engine/config.py | 4 +-
.../event_management/event_handlers.py | 10 +-
.../workflow/graph_engine/graph_engine.py | 26 +-
.../graph_engine/graph_state_manager.py | 2 +
.../graph_engine/orchestration/dispatcher.py | 42 +-
.../orchestration/execution_coordinator.py | 8 +
api/core/workflow/graph_events/__init__.py | 4 +
api/core/workflow/graph_events/graph.py | 7 +-
api/core/workflow/graph_events/human_input.py | 0
api/core/workflow/graph_events/node.py | 16 +
api/core/workflow/node_events/__init__.py | 4 +
api/core/workflow/node_events/node.py | 16 +
api/core/workflow/nodes/base/node.py | 62 +-
.../workflow/nodes/human_input/__init__.py | 6 +-
.../workflow/nodes/human_input/entities.py | 348 +++++++++-
api/core/workflow/nodes/human_input/enums.py | 72 +++
.../nodes/human_input/human_input_node.py | 321 ++++++++--
.../human_input_form_repository.py | 152 +++++
.../workflow/runtime/graph_runtime_state.py | 163 ++++-
api/core/workflow/workflow_type_encoder.py | 12 +-
api/docker/entrypoint.sh | 6 +-
api/extensions/ext_celery.py | 6 +
api/extensions/ext_redis.py | 30 +
..._api_workflow_node_execution_repository.py | 9 +-
api/fields/conversation_fields.py | 1 +
api/fields/message_fields.py | 2 +
.../broadcast_channel/redis/_subscription.py | 2 +-
.../redis/sharded_channel.py | 9 +-
api/libs/email_template_renderer.py | 49 ++
api/libs/flask_utils.py | 9 +-
api/libs/helper.py | 66 +-
...46151_add_human_input_related_db_models.py | 99 +++
api/models/__init__.py | 5 +
api/models/base.py | 2 +-
api/models/enums.py | 1 +
api/models/execution_extra_content.py | 78 +++
api/models/human_input.py | 237 +++++++
api/models/model.py | 12 +-
api/models/workflow.py | 26 +-
.../api_workflow_node_execution_repository.py | 45 ++
.../api_workflow_run_repository.py | 23 +
api/repositories/entities/workflow_pause.py | 8 +-
.../execution_extra_content_repository.py | 13 +
..._api_workflow_node_execution_repository.py | 85 ++-
.../sqlalchemy_api_workflow_run_repository.py | 141 ++++-
...hemy_execution_extra_content_repository.py | 200 ++++++
...alchemy_workflow_trigger_log_repository.py | 10 +
.../workflow_trigger_log_repository.py | 12 +
api/services/app_dsl_service.py | 2 +-
api/services/app_generate_service.py | 123 +++-
api/services/audio_service.py | 2 +-
api/services/feature_service.py | 18 +
.../human_input_delivery_test_service.py | 249 ++++++++
api/services/human_input_service.py | 250 ++++++++
api/services/message_service.py | 26 +
.../tools/workflow_tools_manage_service.py | 4 +
api/services/workflow/entities.py | 6 +
.../workflow_event_snapshot_service.py | 460 ++++++++++++++
api/services/workflow_service.py | 389 +++++++++++-
api/tasks/app_generate/__init__.py | 3 +
.../app_generate/workflow_execute_task.py | 491 ++++++++++++++
api/tasks/async_workflow_tasks.py | 170 ++++-
api/tasks/human_input_timeout_tasks.py | 113 ++++
api/tasks/mail_human_input_delivery_task.py | 190 ++++++
api/tests/integration_tests/conftest.py | 27 +-
.../broadcast_channel/redis/utils/__init__.py | 36 ++
.../redis/utils/test_data.py | 315 +++++++++
.../redis/utils/test_helpers.py | 396 ++++++++++++
...test_chat_conversation_status_count_api.py | 166 +++++
.../test_human_input_form_repository_impl.py | 240 +++++++
.../test_human_input_resume_node_execution.py | 336 ++++++++++
.../helpers/__init__.py | 1 +
.../helpers/execution_extra_content.py | 154 +++++
.../redis/test_sharded_channel.py | 93 +++
.../libs/test_rate_limiter_integration.py | 25 +
.../models/test_account.py | 79 +++
...test_execution_extra_content_repository.py | 27 +
.../services/test_account_service.py | 8 +-
.../services/test_app_generate_service.py | 54 +-
.../test_human_input_delivery_test.py | 112 ++++
...message_service_execution_extra_content.py | 38 ++
.../services/test_workflow_run_service.py | 32 +-
.../test_workflow_tools_manage_service.py | 131 ++++
.../test_mail_human_input_delivery_task.py | 214 +++++++
.../test_workflow_pause_integration.py | 5 -
.../unit_tests/configs/test_dify_config.py | 56 ++
api/tests/unit_tests/conftest.py | 7 +-
.../console/app/test_app_response_models.py | 59 +-
.../test_workflow_human_input_debug_api.py | 229 +++++++
.../app/test_workflow_pause_details_api.py | 91 +++
.../service_api/app/test_workflow_fields.py | 25 +
.../controllers/web/test_human_input_form.py | 456 +++++++++++++
.../controllers/web/test_message_list.py | 11 +
...t_generate_task_pipeline_extra_contents.py | 187 ++++++
...workflow_response_converter_human_input.py | 87 +++
..._workflow_response_converter_resumption.py | 56 ++
..._workflow_response_converter_truncation.py | 37 +-
.../apps/test_advanced_chat_app_generator.py | 139 ++++
.../apps/test_message_based_app_generator.py | 127 ++++
.../core/app/apps/test_pause_resume.py | 287 +++++++++
.../core/app/apps/test_streaming_utils.py | 80 +++
.../app/apps/test_workflow_app_generator.py | 193 ++++++
.../test_workflow_app_runner_notifications.py | 59 ++
.../app/apps/test_workflow_pause_events.py | 183 ++++++
.../workflow/test_generate_task_pipeline.py | 96 +++
.../app/entities/test_app_invoke_entities.py | 143 +++++
.../test_human_input_form_repository_impl.py | 574 +++++++++++++++++
.../utils/test_workflow_configuration_sync.py | 33 +
.../entities/test_graph_runtime_state.py | 1 -
.../workflow/entities/test_pause_reason.py | 88 +++
.../graph_engine/human_input_test_utils.py | 131 ++++
.../test_dispatcher_pause_drain.py | 74 +++
.../test_execution_coordinator.py | 12 +
.../graph_engine/test_graph_state_snapshot.py | 189 ++++++
.../test_human_input_pause_multi_branch.py | 99 ++-
.../test_human_input_pause_single_branch.py | 81 ++-
.../test_parallel_human_input_join_resume.py | 270 ++++++++
...rallel_human_input_pause_missing_finish.py | 333 ++++++++++
.../test_pause_deferred_ready_nodes.py | 309 +++++++++
.../graph_engine/test_pause_resume_state.py | 217 +++++++
.../workflow/nodes/base/test_base_node.py | 5 +-
.../workflow/nodes/human_input/__init__.py | 1 +
.../human_input/test_email_delivery_config.py | 16 +
.../nodes/human_input/test_entities.py | 597 ++++++++++++++++++
.../test_human_input_form_filled_event.py | 172 +++++
.../workflow/test_variable_pool_conver.py | 0
.../unit_tests/extensions/test_celery_ssl.py | 1 +
.../extensions/test_pubsub_channel.py | 20 +
.../unit_tests/libs/_human_input/__init__.py | 1 +
.../unit_tests/libs/_human_input/support.py | 249 ++++++++
.../libs/_human_input/test_form_service.py | 326 ++++++++++
.../libs/_human_input/test_models.py | 232 +++++++
api/tests/unit_tests/libs/test_helper.py | 17 +-
.../unit_tests/libs/test_rate_limiter.py | 68 ++
.../unit_tests/models/test_app_models.py | 56 ++
..._api_workflow_node_execution_repository.py | 40 ++
..._sqlalchemy_api_workflow_run_repository.py | 63 +-
...hemy_execution_extra_content_repository.py | 180 ++++++
.../services/test_conversation_service.py | 26 +-
...ture_service_human_input_email_delivery.py | 104 +++
.../test_human_input_delivery_test_service.py | 97 +++
.../services/test_human_input_service.py | 290 +++++++++
.../test_message_service_extra_contents.py | 61 ++
.../test_workflow_run_service_pause.py | 2 -
.../test_workflow_tools_manage_service.py | 158 +++++
.../test_workflow_event_snapshot_service.py | 226 +++++++
.../test_workflow_human_input_delivery.py | 184 ++++++
...kflow_node_execution_service_repository.py | 26 +-
.../workflow/test_workflow_service.py | 123 ++++
.../tasks/test_human_input_timeout_tasks.py | 210 ++++++
.../test_mail_human_input_delivery_task.py | 123 ++++
.../tasks/test_workflow_execute_task.py | 39 ++
.../test_workflow_node_execution_tasks.py | 488 ++++++++++++++
api/ty.toml | 16 +
docker/.env.example | 31 +-
docker/docker-compose.yaml | 5 +
207 files changed, 19006 insertions(+), 373 deletions(-)
create mode 100644 api/configs/middleware/cache/redis_pubsub_config.py
create mode 100644 api/controllers/console/human_input_form.py
create mode 100644 api/controllers/web/human_input_form.py
create mode 100644 api/controllers/web/workflow_events.py
create mode 100644 api/core/app/apps/message_generator.py
create mode 100644 api/core/app/apps/streaming_utils.py
create mode 100644 api/core/app/apps/workflow/errors.py
create mode 100644 api/core/entities/execution_extra_content.py
create mode 100644 api/core/repositories/human_input_repository.py
create mode 100644 api/core/workflow/entities/workflow_start_reason.py
create mode 100644 api/core/workflow/graph_engine/_engine_utils.py
create mode 100644 api/core/workflow/graph_events/human_input.py
create mode 100644 api/core/workflow/nodes/human_input/enums.py
create mode 100644 api/core/workflow/repositories/human_input_form_repository.py
create mode 100644 api/libs/email_template_renderer.py
create mode 100644 api/migrations/versions/2026_01_29_1415-e8c3b3c46151_add_human_input_related_db_models.py
create mode 100644 api/models/execution_extra_content.py
create mode 100644 api/models/human_input.py
create mode 100644 api/repositories/execution_extra_content_repository.py
create mode 100644 api/repositories/sqlalchemy_execution_extra_content_repository.py
create mode 100644 api/services/human_input_delivery_test_service.py
create mode 100644 api/services/human_input_service.py
create mode 100644 api/services/workflow_event_snapshot_service.py
create mode 100644 api/tasks/app_generate/__init__.py
create mode 100644 api/tasks/app_generate/workflow_execute_task.py
create mode 100644 api/tasks/human_input_timeout_tasks.py
create mode 100644 api/tasks/mail_human_input_delivery_task.py
create mode 100644 api/tests/integration_tests/libs/broadcast_channel/redis/utils/__init__.py
create mode 100644 api/tests/integration_tests/libs/broadcast_channel/redis/utils/test_data.py
create mode 100644 api/tests/integration_tests/libs/broadcast_channel/redis/utils/test_helpers.py
create mode 100644 api/tests/test_containers_integration_tests/controllers/console/app/test_chat_conversation_status_count_api.py
create mode 100644 api/tests/test_containers_integration_tests/core/repositories/test_human_input_form_repository_impl.py
create mode 100644 api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py
create mode 100644 api/tests/test_containers_integration_tests/helpers/__init__.py
create mode 100644 api/tests/test_containers_integration_tests/helpers/execution_extra_content.py
create mode 100644 api/tests/test_containers_integration_tests/libs/test_rate_limiter_integration.py
create mode 100644 api/tests/test_containers_integration_tests/models/test_account.py
create mode 100644 api/tests/test_containers_integration_tests/repositories/test_execution_extra_content_repository.py
create mode 100644 api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py
create mode 100644 api/tests/test_containers_integration_tests/services/test_message_service_execution_extra_content.py
create mode 100644 api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py
create mode 100644 api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py
create mode 100644 api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py
create mode 100644 api/tests/unit_tests/controllers/service_api/app/test_workflow_fields.py
create mode 100644 api/tests/unit_tests/controllers/web/test_human_input_form.py
create mode 100644 api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_extra_contents.py
create mode 100644 api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py
create mode 100644 api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_resumption.py
create mode 100644 api/tests/unit_tests/core/app/apps/test_advanced_chat_app_generator.py
create mode 100644 api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py
create mode 100644 api/tests/unit_tests/core/app/apps/test_pause_resume.py
create mode 100644 api/tests/unit_tests/core/app/apps/test_streaming_utils.py
create mode 100644 api/tests/unit_tests/core/app/apps/test_workflow_app_runner_notifications.py
create mode 100644 api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py
create mode 100644 api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py
create mode 100644 api/tests/unit_tests/core/app/entities/test_app_invoke_entities.py
create mode 100644 api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py
create mode 100644 api/tests/unit_tests/core/tools/utils/test_workflow_configuration_sync.py
create mode 100644 api/tests/unit_tests/core/workflow/entities/test_pause_reason.py
create mode 100644 api/tests/unit_tests/core/workflow/graph_engine/human_input_test_utils.py
create mode 100644 api/tests/unit_tests/core/workflow/graph_engine/test_dispatcher_pause_drain.py
create mode 100644 api/tests/unit_tests/core/workflow/graph_engine/test_graph_state_snapshot.py
create mode 100644 api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py
create mode 100644 api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_pause_missing_finish.py
create mode 100644 api/tests/unit_tests/core/workflow/graph_engine/test_pause_deferred_ready_nodes.py
create mode 100644 api/tests/unit_tests/core/workflow/graph_engine/test_pause_resume_state.py
create mode 100644 api/tests/unit_tests/core/workflow/nodes/human_input/__init__.py
create mode 100644 api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py
create mode 100644 api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py
create mode 100644 api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py
create mode 100644 api/tests/unit_tests/core/workflow/test_variable_pool_conver.py
create mode 100644 api/tests/unit_tests/extensions/test_pubsub_channel.py
create mode 100644 api/tests/unit_tests/libs/_human_input/__init__.py
create mode 100644 api/tests/unit_tests/libs/_human_input/support.py
create mode 100644 api/tests/unit_tests/libs/_human_input/test_form_service.py
create mode 100644 api/tests/unit_tests/libs/_human_input/test_models.py
create mode 100644 api/tests/unit_tests/libs/test_rate_limiter.py
create mode 100644 api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_node_execution_repository.py
create mode 100644 api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py
create mode 100644 api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py
create mode 100644 api/tests/unit_tests/services/test_human_input_delivery_test_service.py
create mode 100644 api/tests/unit_tests/services/test_human_input_service.py
create mode 100644 api/tests/unit_tests/services/test_message_service_extra_contents.py
create mode 100644 api/tests/unit_tests/services/tools/test_workflow_tools_manage_service.py
create mode 100644 api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py
create mode 100644 api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py
create mode 100644 api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py
create mode 100644 api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py
create mode 100644 api/tests/unit_tests/tasks/test_workflow_execute_task.py
create mode 100644 api/tests/unit_tests/tasks/test_workflow_node_execution_tasks.py
diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml
index 704d896192..ac7f3a6b48 100644
--- a/.github/workflows/build-push.yml
+++ b/.github/workflows/build-push.yml
@@ -8,6 +8,7 @@ on:
- "build/**"
- "release/e-*"
- "hotfix/**"
+ - "feat/hitl-backend"
tags:
- "*"
diff --git a/api/.env.example b/api/.env.example
index 8bd2c706c1..fcadfa1c3b 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -717,3 +717,28 @@ SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL=90000
+
+
+# Redis URL used for PubSub between API and
+# celery worker
+# defaults to url constructed from `REDIS_*`
+# configurations
+PUBSUB_REDIS_URL=
+# Pub/sub channel type for streaming events.
+# valid options are:
+#
+# - pubsub: for normal Pub/Sub
+# - sharded: for sharded Pub/Sub
+#
+# It's highly recommended to use sharded Pub/Sub AND redis cluster
+# for large deployments.
+PUBSUB_REDIS_CHANNEL_TYPE=pubsub
+# Whether to use Redis cluster mode while running
+# PubSub.
+# It's highly recommended to enable this for large deployments.
+PUBSUB_REDIS_USE_CLUSTERS=false
+
+# Whether to Enable human input timeout check task
+ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
+# Human input timeout check interval in minutes
+HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1
diff --git a/api/.importlinter b/api/.importlinter
index 9dad254560..98f87710ed 100644
--- a/api/.importlinter
+++ b/api/.importlinter
@@ -36,6 +36,8 @@ ignore_imports =
core.workflow.nodes.loop.loop_node -> core.workflow.graph_engine
core.workflow.nodes.loop.loop_node -> core.workflow.graph
core.workflow.nodes.loop.loop_node -> core.workflow.graph_engine.command_channels
+ # TODO(QuantumGhost): fix the import violation later
+ core.workflow.entities.pause_reason -> core.workflow.nodes.human_input.entities
[importlinter:contract:workflow-infrastructure-dependencies]
name = Workflow Infrastructure Dependencies
@@ -58,6 +60,8 @@ ignore_imports =
core.workflow.graph_engine.command_channels.redis_channel -> extensions.ext_redis
core.workflow.graph_engine.manager -> extensions.ext_redis
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> extensions.ext_redis
+ # TODO(QuantumGhost): use DI to avoid depending on global DB.
+ core.workflow.nodes.human_input.human_input_node -> extensions.ext_database
[importlinter:contract:workflow-external-imports]
name = Workflow External Imports
@@ -145,6 +149,7 @@ ignore_imports =
core.workflow.nodes.agent.agent_node -> core.agent.entities
core.workflow.nodes.agent.agent_node -> core.agent.plugin_entities
core.workflow.nodes.base.node -> core.app.entities.app_invoke_entities
+ core.workflow.nodes.human_input.human_input_node -> core.app.entities.app_invoke_entities
core.workflow.nodes.knowledge_index.knowledge_index_node -> core.app.entities.app_invoke_entities
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.app.app_config.entities
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.app.entities.app_invoke_entities
@@ -248,6 +253,7 @@ ignore_imports =
core.workflow.nodes.document_extractor.node -> core.variables.segments
core.workflow.nodes.http_request.executor -> core.variables.segments
core.workflow.nodes.http_request.node -> core.variables.segments
+ core.workflow.nodes.human_input.entities -> core.variables.consts
core.workflow.nodes.iteration.iteration_node -> core.variables
core.workflow.nodes.iteration.iteration_node -> core.variables.segments
core.workflow.nodes.iteration.iteration_node -> core.variables.variables
@@ -294,6 +300,8 @@ ignore_imports =
core.workflow.nodes.llm.llm_utils -> extensions.ext_database
core.workflow.nodes.llm.node -> extensions.ext_database
core.workflow.nodes.tool.tool_node -> extensions.ext_database
+ core.workflow.nodes.human_input.human_input_node -> extensions.ext_database
+ core.workflow.nodes.human_input.human_input_node -> core.repositories.human_input_repository
core.workflow.workflow_entry -> extensions.otel.runtime
core.workflow.nodes.agent.agent_node -> models
core.workflow.nodes.base.node -> models.enums
diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py
index d97e9a0440..8295e1739c 100644
--- a/api/configs/feature/__init__.py
+++ b/api/configs/feature/__init__.py
@@ -1,3 +1,4 @@
+from datetime import timedelta
from enum import StrEnum
from typing import Literal
@@ -48,6 +49,16 @@ class SecurityConfig(BaseSettings):
default=5,
)
+ WEB_FORM_SUBMIT_RATE_LIMIT_MAX_ATTEMPTS: PositiveInt = Field(
+ description="Maximum number of web form submissions allowed per IP within the rate limit window",
+ default=30,
+ )
+
+ WEB_FORM_SUBMIT_RATE_LIMIT_WINDOW_SECONDS: PositiveInt = Field(
+ description="Time window in seconds for web form submission rate limiting",
+ default=60,
+ )
+
LOGIN_DISABLED: bool = Field(
description="Whether to disable login checks",
default=False,
@@ -82,6 +93,12 @@ class AppExecutionConfig(BaseSettings):
default=0,
)
+ HITL_GLOBAL_TIMEOUT_SECONDS: PositiveInt = Field(
+ description="Maximum seconds a workflow run can stay paused waiting for human input before global timeout.",
+ default=int(timedelta(days=3).total_seconds()),
+ ge=1,
+ )
+
class CodeExecutionSandboxConfig(BaseSettings):
"""
@@ -1134,6 +1151,14 @@ class CeleryScheduleTasksConfig(BaseSettings):
description="Enable queue monitor task",
default=False,
)
+ ENABLE_HUMAN_INPUT_TIMEOUT_TASK: bool = Field(
+ description="Enable human input timeout check task",
+ default=True,
+ )
+ HUMAN_INPUT_TIMEOUT_TASK_INTERVAL: PositiveInt = Field(
+ description="Human input timeout check interval in minutes",
+ default=1,
+ )
ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK: bool = Field(
description="Enable check upgradable plugin task",
default=True,
diff --git a/api/configs/middleware/__init__.py b/api/configs/middleware/__init__.py
index 63f75924bf..a15e42babf 100644
--- a/api/configs/middleware/__init__.py
+++ b/api/configs/middleware/__init__.py
@@ -6,6 +6,7 @@ from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveFloat, Pos
from pydantic_settings import BaseSettings
from .cache.redis_config import RedisConfig
+from .cache.redis_pubsub_config import RedisPubSubConfig
from .storage.aliyun_oss_storage_config import AliyunOSSStorageConfig
from .storage.amazon_s3_storage_config import S3StorageConfig
from .storage.azure_blob_storage_config import AzureBlobStorageConfig
@@ -317,6 +318,7 @@ class MiddlewareConfig(
CeleryConfig, # Note: CeleryConfig already inherits from DatabaseConfig
KeywordStoreConfig,
RedisConfig,
+ RedisPubSubConfig,
# configs of storage and storage providers
StorageConfig,
AliyunOSSStorageConfig,
diff --git a/api/configs/middleware/cache/redis_pubsub_config.py b/api/configs/middleware/cache/redis_pubsub_config.py
new file mode 100644
index 0000000000..a72e1dd28f
--- /dev/null
+++ b/api/configs/middleware/cache/redis_pubsub_config.py
@@ -0,0 +1,96 @@
+from typing import Literal, Protocol
+from urllib.parse import quote_plus, urlunparse
+
+from pydantic import Field
+from pydantic_settings import BaseSettings
+
+
+class RedisConfigDefaults(Protocol):
+ REDIS_HOST: str
+ REDIS_PORT: int
+ REDIS_USERNAME: str | None
+ REDIS_PASSWORD: str | None
+ REDIS_DB: int
+ REDIS_USE_SSL: bool
+ REDIS_USE_SENTINEL: bool | None
+ REDIS_USE_CLUSTERS: bool
+
+
+class RedisConfigDefaultsMixin:
+ def _redis_defaults(self: RedisConfigDefaults) -> RedisConfigDefaults:
+ return self
+
+
+class RedisPubSubConfig(BaseSettings, RedisConfigDefaultsMixin):
+ """
+ Configuration settings for Redis pub/sub streaming.
+ """
+
+ PUBSUB_REDIS_URL: str | None = Field(
+ alias="PUBSUB_REDIS_URL",
+ description=(
+ "Redis connection URL for pub/sub streaming events between API "
+ "and celery worker, defaults to url constructed from "
+ "`REDIS_*` configurations"
+ ),
+ default=None,
+ )
+
+ PUBSUB_REDIS_USE_CLUSTERS: bool = Field(
+ description=(
+ "Enable Redis Cluster mode for pub/sub streaming. It's highly "
+ "recommended to enable this for large deployments."
+ ),
+ default=False,
+ )
+
+ PUBSUB_REDIS_CHANNEL_TYPE: Literal["pubsub", "sharded"] = Field(
+ description=(
+ "Pub/sub channel type for streaming events. "
+ "Valid options are:\n"
+ "\n"
+ " - pubsub: for normal Pub/Sub\n"
+ " - sharded: for sharded Pub/Sub\n"
+ "\n"
+ "It's highly recommended to use sharded Pub/Sub AND redis cluster "
+ "for large deployments."
+ ),
+ default="pubsub",
+ )
+
+ def _build_default_pubsub_url(self) -> str:
+ defaults = self._redis_defaults()
+ if not defaults.REDIS_HOST or not defaults.REDIS_PORT:
+ raise ValueError("PUBSUB_REDIS_URL must be set when default Redis URL cannot be constructed")
+
+ scheme = "rediss" if defaults.REDIS_USE_SSL else "redis"
+ username = defaults.REDIS_USERNAME or None
+ password = defaults.REDIS_PASSWORD or None
+
+ userinfo = ""
+ if username:
+ userinfo = quote_plus(username)
+ if password:
+ password_part = quote_plus(password)
+ userinfo = f"{userinfo}:{password_part}" if userinfo else f":{password_part}"
+ if userinfo:
+ userinfo = f"{userinfo}@"
+
+ host = defaults.REDIS_HOST
+ port = defaults.REDIS_PORT
+ db = defaults.REDIS_DB
+
+ netloc = f"{userinfo}{host}:{port}"
+ return urlunparse((scheme, netloc, f"/{db}", "", "", ""))
+
+ @property
+ def normalized_pubsub_redis_url(self) -> str:
+ pubsub_redis_url = self.PUBSUB_REDIS_URL
+ if pubsub_redis_url:
+ cleaned = pubsub_redis_url.strip()
+ pubsub_redis_url = cleaned or None
+
+ if pubsub_redis_url:
+ return pubsub_redis_url
+
+ return self._build_default_pubsub_url()
diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py
index fdc9aabc83..902d67174b 100644
--- a/api/controllers/console/__init__.py
+++ b/api/controllers/console/__init__.py
@@ -37,6 +37,7 @@ from . import (
apikey,
extension,
feature,
+ human_input_form,
init_validate,
ping,
setup,
@@ -171,6 +172,7 @@ __all__ = [
"forgot_password",
"generator",
"hit_testing",
+ "human_input_form",
"init_validate",
"installed_app",
"load_balancing_config",
diff --git a/api/controllers/console/app/conversation.py b/api/controllers/console/app/conversation.py
index 55fdcb51e4..14910c5895 100644
--- a/api/controllers/console/app/conversation.py
+++ b/api/controllers/console/app/conversation.py
@@ -89,6 +89,7 @@ status_count_model = console_ns.model(
"success": fields.Integer,
"failed": fields.Integer,
"partial_success": fields.Integer,
+ "paused": fields.Integer,
},
)
diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py
index 12ada8b798..ab1628d5d4 100644
--- a/api/controllers/console/app/message.py
+++ b/api/controllers/console/app/message.py
@@ -32,7 +32,7 @@ from libs.login import current_account_with_tenant, login_required
from models.model import AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
-from services.message_service import MessageService
+from services.message_service import MessageService, attach_message_extra_contents
logger = logging.getLogger(__name__)
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
@@ -198,6 +198,7 @@ message_detail_model = console_ns.model(
"created_at": TimestampField,
"agent_thoughts": fields.List(fields.Nested(agent_thought_model)),
"message_files": fields.List(fields.Nested(message_file_model)),
+ "extra_contents": fields.List(fields.Raw),
"metadata": fields.Raw(attribute="message_metadata_dict"),
"status": fields.String,
"error": fields.String,
@@ -290,6 +291,7 @@ class ChatMessageListApi(Resource):
has_more = False
history_messages = list(reversed(history_messages))
+ attach_message_extra_contents(history_messages)
return InfiniteScrollPagination(data=history_messages, limit=args.limit, has_more=has_more)
@@ -474,4 +476,5 @@ class MessageApi(Resource):
if not message:
raise NotFound("Message Not Exists.")
+ attach_message_extra_contents([message])
return message
diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py
index 755463cb70..27e1d01af6 100644
--- a/api/controllers/console/app/workflow.py
+++ b/api/controllers/console/app/workflow.py
@@ -507,6 +507,179 @@ class WorkflowDraftRunLoopNodeApi(Resource):
raise InternalServerError()
+class HumanInputFormPreviewPayload(BaseModel):
+ inputs: dict[str, Any] = Field(
+ default_factory=dict,
+ description="Values used to fill missing upstream variables referenced in form_content",
+ )
+
+
+class HumanInputFormSubmitPayload(BaseModel):
+ form_inputs: dict[str, Any] = Field(..., description="Values the user provides for the form's own fields")
+ inputs: dict[str, Any] = Field(
+ ...,
+ description="Values used to fill missing upstream variables referenced in form_content",
+ )
+ action: str = Field(..., description="Selected action ID")
+
+
+class HumanInputDeliveryTestPayload(BaseModel):
+ delivery_method_id: str = Field(..., description="Delivery method ID")
+ inputs: dict[str, Any] = Field(
+ default_factory=dict,
+ description="Values used to fill missing upstream variables referenced in form_content",
+ )
+
+
+reg(HumanInputFormPreviewPayload)
+reg(HumanInputFormSubmitPayload)
+reg(HumanInputDeliveryTestPayload)
+
+
+@console_ns.route("/apps//advanced-chat/workflows/draft/human-input/nodes//form/preview")
+class AdvancedChatDraftHumanInputFormPreviewApi(Resource):
+ @console_ns.doc("get_advanced_chat_draft_human_input_form")
+ @console_ns.doc(description="Get human input form preview for advanced chat workflow")
+ @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
+ @console_ns.expect(console_ns.models[HumanInputFormPreviewPayload.__name__])
+ @setup_required
+ @login_required
+ @account_initialization_required
+ @get_app_model(mode=[AppMode.ADVANCED_CHAT])
+ @edit_permission_required
+ def post(self, app_model: App, node_id: str):
+ """
+ Preview human input form content and placeholders
+ """
+ current_user, _ = current_account_with_tenant()
+ args = HumanInputFormPreviewPayload.model_validate(console_ns.payload or {})
+ inputs = args.inputs
+
+ workflow_service = WorkflowService()
+ preview = workflow_service.get_human_input_form_preview(
+ app_model=app_model,
+ account=current_user,
+ node_id=node_id,
+ inputs=inputs,
+ )
+ return jsonable_encoder(preview)
+
+
+@console_ns.route("/apps//advanced-chat/workflows/draft/human-input/nodes//form/run")
+class AdvancedChatDraftHumanInputFormRunApi(Resource):
+ @console_ns.doc("submit_advanced_chat_draft_human_input_form")
+ @console_ns.doc(description="Submit human input form preview for advanced chat workflow")
+ @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
+ @console_ns.expect(console_ns.models[HumanInputFormSubmitPayload.__name__])
+ @setup_required
+ @login_required
+ @account_initialization_required
+ @get_app_model(mode=[AppMode.ADVANCED_CHAT])
+ @edit_permission_required
+ def post(self, app_model: App, node_id: str):
+ """
+ Submit human input form preview
+ """
+ current_user, _ = current_account_with_tenant()
+ args = HumanInputFormSubmitPayload.model_validate(console_ns.payload or {})
+ workflow_service = WorkflowService()
+ result = workflow_service.submit_human_input_form_preview(
+ app_model=app_model,
+ account=current_user,
+ node_id=node_id,
+ form_inputs=args.form_inputs,
+ inputs=args.inputs,
+ action=args.action,
+ )
+ return jsonable_encoder(result)
+
+
+@console_ns.route("/apps//workflows/draft/human-input/nodes//form/preview")
+class WorkflowDraftHumanInputFormPreviewApi(Resource):
+ @console_ns.doc("get_workflow_draft_human_input_form")
+ @console_ns.doc(description="Get human input form preview for workflow")
+ @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
+ @console_ns.expect(console_ns.models[HumanInputFormPreviewPayload.__name__])
+ @setup_required
+ @login_required
+ @account_initialization_required
+ @get_app_model(mode=[AppMode.WORKFLOW])
+ @edit_permission_required
+ def post(self, app_model: App, node_id: str):
+ """
+ Preview human input form content and placeholders
+ """
+ current_user, _ = current_account_with_tenant()
+ args = HumanInputFormPreviewPayload.model_validate(console_ns.payload or {})
+ inputs = args.inputs
+
+ workflow_service = WorkflowService()
+ preview = workflow_service.get_human_input_form_preview(
+ app_model=app_model,
+ account=current_user,
+ node_id=node_id,
+ inputs=inputs,
+ )
+ return jsonable_encoder(preview)
+
+
+@console_ns.route("/apps//workflows/draft/human-input/nodes//form/run")
+class WorkflowDraftHumanInputFormRunApi(Resource):
+ @console_ns.doc("submit_workflow_draft_human_input_form")
+ @console_ns.doc(description="Submit human input form preview for workflow")
+ @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
+ @console_ns.expect(console_ns.models[HumanInputFormSubmitPayload.__name__])
+ @setup_required
+ @login_required
+ @account_initialization_required
+ @get_app_model(mode=[AppMode.WORKFLOW])
+ @edit_permission_required
+ def post(self, app_model: App, node_id: str):
+ """
+ Submit human input form preview
+ """
+ current_user, _ = current_account_with_tenant()
+ workflow_service = WorkflowService()
+ args = HumanInputFormSubmitPayload.model_validate(console_ns.payload or {})
+ result = workflow_service.submit_human_input_form_preview(
+ app_model=app_model,
+ account=current_user,
+ node_id=node_id,
+ form_inputs=args.form_inputs,
+ inputs=args.inputs,
+ action=args.action,
+ )
+ return jsonable_encoder(result)
+
+
+@console_ns.route("/apps//workflows/draft/human-input/nodes//delivery-test")
+class WorkflowDraftHumanInputDeliveryTestApi(Resource):
+ @console_ns.doc("test_workflow_draft_human_input_delivery")
+ @console_ns.doc(description="Test human input delivery for workflow")
+ @console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
+ @console_ns.expect(console_ns.models[HumanInputDeliveryTestPayload.__name__])
+ @setup_required
+ @login_required
+ @account_initialization_required
+ @get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
+ @edit_permission_required
+ def post(self, app_model: App, node_id: str):
+ """
+ Test human input delivery
+ """
+ current_user, _ = current_account_with_tenant()
+ workflow_service = WorkflowService()
+ args = HumanInputDeliveryTestPayload.model_validate(console_ns.payload or {})
+ workflow_service.test_human_input_delivery(
+ app_model=app_model,
+ account=current_user,
+ node_id=node_id,
+ delivery_method_id=args.delivery_method_id,
+ inputs=args.inputs,
+ )
+ return jsonable_encoder({})
+
+
@console_ns.route("/apps//workflows/draft/run")
class DraftWorkflowRunApi(Resource):
@console_ns.doc("run_draft_workflow")
diff --git a/api/controllers/console/app/workflow_run.py b/api/controllers/console/app/workflow_run.py
index fa74f8aea1..d9a5dde55a 100644
--- a/api/controllers/console/app/workflow_run.py
+++ b/api/controllers/console/app/workflow_run.py
@@ -5,10 +5,15 @@ from flask import request
from flask_restx import Resource, fields, marshal_with
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
+from sqlalchemy.orm import sessionmaker
+from configs import dify_config
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required
+from controllers.web.error import NotFoundError
+from core.workflow.entities.pause_reason import HumanInputRequired
+from core.workflow.enums import WorkflowExecutionStatus
from extensions.ext_database import db
from fields.end_user_fields import simple_end_user_fields
from fields.member_fields import simple_account_fields
@@ -27,9 +32,21 @@ from libs.custom_inputs import time_duration
from libs.helper import uuid_value
from libs.login import current_user, login_required
from models import Account, App, AppMode, EndUser, WorkflowArchiveLog, WorkflowRunTriggeredFrom
+from models.workflow import WorkflowRun
+from repositories.factory import DifyAPIRepositoryFactory
from services.retention.workflow_run.constants import ARCHIVE_BUNDLE_NAME
from services.workflow_run_service import WorkflowRunService
+
+def _build_backstage_input_url(form_token: str | None) -> str | None:
+ if not form_token:
+ return None
+ base_url = dify_config.APP_WEB_URL
+ if not base_url:
+ return None
+ return f"{base_url.rstrip('/')}/form/{form_token}"
+
+
# Workflow run status choices for filtering
WORKFLOW_RUN_STATUS_CHOICES = ["running", "succeeded", "failed", "stopped", "partial-succeeded"]
EXPORT_SIGNED_URL_EXPIRE_SECONDS = 3600
@@ -440,3 +457,63 @@ class WorkflowRunNodeExecutionListApi(Resource):
)
return {"data": node_executions}
+
+
+@console_ns.route("/workflow//pause-details")
+class ConsoleWorkflowPauseDetailsApi(Resource):
+ """Console API for getting workflow pause details."""
+
+ @account_initialization_required
+ @login_required
+ def get(self, workflow_run_id: str):
+ """
+ Get workflow pause details.
+
+ GET /console/api/workflow//pause-details
+
+ Returns information about why and where the workflow is paused.
+ """
+
+ # Query WorkflowRun to determine if workflow is suspended
+ session_maker = sessionmaker(bind=db.engine)
+ workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker=session_maker)
+ workflow_run = db.session.get(WorkflowRun, workflow_run_id)
+ if not workflow_run:
+ raise NotFoundError("Workflow run not found")
+
+ # Check if workflow is suspended
+ is_paused = workflow_run.status == WorkflowExecutionStatus.PAUSED
+ if not is_paused:
+ return {
+ "paused_at": None,
+ "paused_nodes": [],
+ }, 200
+
+ pause_entity = workflow_run_repo.get_workflow_pause(workflow_run_id)
+ pause_reasons = pause_entity.get_pause_reasons() if pause_entity else []
+
+ # Build response
+ paused_at = pause_entity.paused_at if pause_entity else None
+ paused_nodes = []
+ response = {
+ "paused_at": paused_at.isoformat() + "Z" if paused_at else None,
+ "paused_nodes": paused_nodes,
+ }
+
+ for reason in pause_reasons:
+ if isinstance(reason, HumanInputRequired):
+ paused_nodes.append(
+ {
+ "node_id": reason.node_id,
+ "node_title": reason.node_title,
+ "pause_type": {
+ "type": "human_input",
+ "form_id": reason.form_id,
+ "backstage_input_url": _build_backstage_input_url(reason.form_token),
+ },
+ }
+ )
+ else:
+ raise AssertionError("unimplemented.")
+
+ return response, 200
diff --git a/api/controllers/console/human_input_form.py b/api/controllers/console/human_input_form.py
new file mode 100644
index 0000000000..7207f7fd1d
--- /dev/null
+++ b/api/controllers/console/human_input_form.py
@@ -0,0 +1,217 @@
+"""
+Console/Studio Human Input Form APIs.
+"""
+
+import json
+import logging
+from collections.abc import Generator
+
+from flask import Response, jsonify, request
+from flask_restx import Resource, reqparse
+from sqlalchemy import select
+from sqlalchemy.orm import Session, sessionmaker
+
+from controllers.console import console_ns
+from controllers.console.wraps import account_initialization_required, setup_required
+from controllers.web.error import InvalidArgumentError, NotFoundError
+from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
+from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
+from core.app.apps.message_generator import MessageGenerator
+from core.app.apps.workflow.app_generator import WorkflowAppGenerator
+from extensions.ext_database import db
+from libs.login import current_account_with_tenant, login_required
+from models import App
+from models.enums import CreatorUserRole
+from models.human_input import RecipientType
+from models.model import AppMode
+from models.workflow import WorkflowRun
+from repositories.factory import DifyAPIRepositoryFactory
+from services.human_input_service import Form, HumanInputService
+from services.workflow_event_snapshot_service import build_workflow_event_stream
+
+logger = logging.getLogger(__name__)
+
+
+def _jsonify_form_definition(form: Form) -> Response:
+ payload = form.get_definition().model_dump()
+ payload["expiration_time"] = int(form.expiration_time.timestamp())
+ return Response(json.dumps(payload, ensure_ascii=False), mimetype="application/json")
+
+
+@console_ns.route("/form/human_input/")
+class ConsoleHumanInputFormApi(Resource):
+ """Console API for getting human input form definition."""
+
+ @staticmethod
+ def _ensure_console_access(form: Form):
+ _, current_tenant_id = current_account_with_tenant()
+
+ if form.tenant_id != current_tenant_id:
+ raise NotFoundError("App not found")
+
+ @setup_required
+ @login_required
+ @account_initialization_required
+ def get(self, form_token: str):
+ """
+ Get human input form definition by form token.
+
+ GET /console/api/form/human_input/
+ """
+ service = HumanInputService(db.engine)
+ form = service.get_form_definition_by_token_for_console(form_token)
+ if form is None:
+ raise NotFoundError(f"form not found, token={form_token}")
+
+ self._ensure_console_access(form)
+
+ return _jsonify_form_definition(form)
+
+ @account_initialization_required
+ @login_required
+ def post(self, form_token: str):
+ """
+ Submit human input form by form token.
+
+ POST /console/api/form/human_input/
+
+ Request body:
+ {
+ "inputs": {
+ "content": "User input content"
+ },
+ "action": "Approve"
+ }
+ """
+ parser = reqparse.RequestParser()
+ parser.add_argument("inputs", type=dict, required=True, location="json")
+ parser.add_argument("action", type=str, required=True, location="json")
+ args = parser.parse_args()
+ current_user, _ = current_account_with_tenant()
+
+ service = HumanInputService(db.engine)
+ form = service.get_form_by_token(form_token)
+ if form is None:
+ raise NotFoundError(f"form not found, token={form_token}")
+
+ self._ensure_console_access(form)
+
+ recipient_type = form.recipient_type
+ if recipient_type not in {RecipientType.CONSOLE, RecipientType.BACKSTAGE}:
+ raise NotFoundError(f"form not found, token={form_token}")
+ # The type checker is not smart enought to validate the following invariant.
+ # So we need to assert it manually.
+ assert recipient_type is not None, "recipient_type cannot be None here."
+
+ service.submit_form_by_token(
+ recipient_type=recipient_type,
+ form_token=form_token,
+ selected_action_id=args["action"],
+ form_data=args["inputs"],
+ submission_user_id=current_user.id,
+ )
+
+ return jsonify({})
+
+
+@console_ns.route("/workflow//events")
+class ConsoleWorkflowEventsApi(Resource):
+ """Console API for getting workflow execution events after resume."""
+
+ @account_initialization_required
+ @login_required
+ def get(self, workflow_run_id: str):
+ """
+ Get workflow execution events stream after resume.
+
+ GET /console/api/workflow//events
+
+ Returns Server-Sent Events stream.
+ """
+
+ user, tenant_id = current_account_with_tenant()
+ session_maker = sessionmaker(db.engine)
+ repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker)
+ workflow_run = repo.get_workflow_run_by_id_and_tenant_id(
+ tenant_id=tenant_id,
+ run_id=workflow_run_id,
+ )
+ if workflow_run is None:
+ raise NotFoundError(f"WorkflowRun not found, id={workflow_run_id}")
+
+ if workflow_run.created_by_role != CreatorUserRole.ACCOUNT:
+ raise NotFoundError(f"WorkflowRun not created by account, id={workflow_run_id}")
+
+ if workflow_run.created_by != user.id:
+ raise NotFoundError(f"WorkflowRun not created by the current account, id={workflow_run_id}")
+
+ with Session(expire_on_commit=False, bind=db.engine) as session:
+ app = _retrieve_app_for_workflow_run(session, workflow_run)
+
+ if workflow_run.finished_at is not None:
+ # TODO(QuantumGhost): should we modify the handling for finished workflow run here?
+ response = WorkflowResponseConverter.workflow_run_result_to_finish_response(
+ task_id=workflow_run.id,
+ workflow_run=workflow_run,
+ creator_user=user,
+ )
+
+ payload = response.model_dump(mode="json")
+ payload["event"] = response.event.value
+
+ def _generate_finished_events() -> Generator[str, None, None]:
+ yield f"data: {json.dumps(payload)}\n\n"
+
+ event_generator = _generate_finished_events
+
+ else:
+ msg_generator = MessageGenerator()
+ if app.mode == AppMode.ADVANCED_CHAT:
+ generator = AdvancedChatAppGenerator()
+ elif app.mode == AppMode.WORKFLOW:
+ generator = WorkflowAppGenerator()
+ else:
+ raise InvalidArgumentError(f"cannot subscribe to workflow run, workflow_run_id={workflow_run.id}")
+
+ include_state_snapshot = request.args.get("include_state_snapshot", "false").lower() == "true"
+
+ def _generate_stream_events():
+ if include_state_snapshot:
+ return generator.convert_to_event_stream(
+ build_workflow_event_stream(
+ app_mode=AppMode(app.mode),
+ workflow_run=workflow_run,
+ tenant_id=workflow_run.tenant_id,
+ app_id=workflow_run.app_id,
+ session_maker=session_maker,
+ )
+ )
+ return generator.convert_to_event_stream(
+ msg_generator.retrieve_events(AppMode(app.mode), workflow_run.id),
+ )
+
+ event_generator = _generate_stream_events
+
+ return Response(
+ event_generator(),
+ mimetype="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ },
+ )
+
+
+def _retrieve_app_for_workflow_run(session: Session, workflow_run: WorkflowRun):
+ query = select(App).where(
+ App.id == workflow_run.app_id,
+ App.tenant_id == workflow_run.tenant_id,
+ )
+ app = session.scalars(query).first()
+ if app is None:
+ raise AssertionError(
+ f"App not found for WorkflowRun, workflow_run_id={workflow_run.id}, "
+ f"app_id={workflow_run.app_id}, tenant_id={workflow_run.tenant_id}"
+ )
+
+ return app
diff --git a/api/controllers/service_api/app/workflow.py b/api/controllers/service_api/app/workflow.py
index 6a549fc926..6088b142c2 100644
--- a/api/controllers/service_api/app/workflow.py
+++ b/api/controllers/service_api/app/workflow.py
@@ -33,8 +33,9 @@ from core.workflow.graph_engine.manager import GraphEngineManager
from extensions.ext_database import db
from fields.workflow_app_log_fields import build_workflow_app_log_pagination_model
from libs import helper
-from libs.helper import TimestampField
+from libs.helper import OptionalTimestampField, TimestampField
from models.model import App, AppMode, EndUser
+from models.workflow import WorkflowRun
from repositories.factory import DifyAPIRepositoryFactory
from services.app_generate_service import AppGenerateService
from services.errors.app import IsDraftWorkflowError, WorkflowIdFormatError, WorkflowNotFoundError
@@ -63,17 +64,32 @@ class WorkflowLogQuery(BaseModel):
register_schema_models(service_api_ns, WorkflowRunPayload, WorkflowLogQuery)
+
+class WorkflowRunStatusField(fields.Raw):
+ def output(self, key, obj: WorkflowRun, **kwargs):
+ return obj.status.value
+
+
+class WorkflowRunOutputsField(fields.Raw):
+ def output(self, key, obj: WorkflowRun, **kwargs):
+ if obj.status == WorkflowExecutionStatus.PAUSED:
+ return {}
+
+ outputs = obj.outputs_dict
+ return outputs or {}
+
+
workflow_run_fields = {
"id": fields.String,
"workflow_id": fields.String,
- "status": fields.String,
+ "status": WorkflowRunStatusField,
"inputs": fields.Raw,
- "outputs": fields.Raw,
+ "outputs": WorkflowRunOutputsField,
"error": fields.String,
"total_steps": fields.Integer,
"total_tokens": fields.Integer,
"created_at": TimestampField,
- "finished_at": TimestampField,
+ "finished_at": OptionalTimestampField,
"elapsed_time": fields.Float,
}
diff --git a/api/controllers/web/__init__.py b/api/controllers/web/__init__.py
index 1d22954308..cfa39e0dfd 100644
--- a/api/controllers/web/__init__.py
+++ b/api/controllers/web/__init__.py
@@ -23,6 +23,7 @@ from . import (
feature,
files,
forgot_password,
+ human_input_form,
login,
message,
passport,
@@ -30,6 +31,7 @@ from . import (
saved_message,
site,
workflow,
+ workflow_events,
)
api.add_namespace(web_ns)
@@ -44,6 +46,7 @@ __all__ = [
"feature",
"files",
"forgot_password",
+ "human_input_form",
"login",
"message",
"passport",
@@ -52,4 +55,5 @@ __all__ = [
"site",
"web_ns",
"workflow",
+ "workflow_events",
]
diff --git a/api/controllers/web/error.py b/api/controllers/web/error.py
index 196a27e348..d1f936768e 100644
--- a/api/controllers/web/error.py
+++ b/api/controllers/web/error.py
@@ -117,6 +117,12 @@ class InvokeRateLimitError(BaseHTTPException):
code = 429
+class WebFormRateLimitExceededError(BaseHTTPException):
+ error_code = "web_form_rate_limit_exceeded"
+ description = "Too many form requests. Please try again later."
+ code = 429
+
+
class NotFoundError(BaseHTTPException):
error_code = "not_found"
code = 404
diff --git a/api/controllers/web/human_input_form.py b/api/controllers/web/human_input_form.py
new file mode 100644
index 0000000000..c3989b1965
--- /dev/null
+++ b/api/controllers/web/human_input_form.py
@@ -0,0 +1,164 @@
+"""
+Web App Human Input Form APIs.
+"""
+
+import json
+import logging
+from datetime import datetime
+
+from flask import Response, request
+from flask_restx import Resource, reqparse
+from werkzeug.exceptions import Forbidden
+
+from configs import dify_config
+from controllers.web import web_ns
+from controllers.web.error import NotFoundError, WebFormRateLimitExceededError
+from controllers.web.site import serialize_app_site_payload
+from extensions.ext_database import db
+from libs.helper import RateLimiter, extract_remote_ip
+from models.account import TenantStatus
+from models.model import App, Site
+from services.human_input_service import Form, FormNotFoundError, HumanInputService
+
+logger = logging.getLogger(__name__)
+
+_FORM_SUBMIT_RATE_LIMITER = RateLimiter(
+ prefix="web_form_submit_rate_limit",
+ max_attempts=dify_config.WEB_FORM_SUBMIT_RATE_LIMIT_MAX_ATTEMPTS,
+ time_window=dify_config.WEB_FORM_SUBMIT_RATE_LIMIT_WINDOW_SECONDS,
+)
+_FORM_ACCESS_RATE_LIMITER = RateLimiter(
+ prefix="web_form_access_rate_limit",
+ max_attempts=dify_config.WEB_FORM_SUBMIT_RATE_LIMIT_MAX_ATTEMPTS,
+ time_window=dify_config.WEB_FORM_SUBMIT_RATE_LIMIT_WINDOW_SECONDS,
+)
+
+
+def _stringify_default_values(values: dict[str, object]) -> dict[str, str]:
+ result: dict[str, str] = {}
+ for key, value in values.items():
+ if value is None:
+ result[key] = ""
+ elif isinstance(value, (dict, list)):
+ result[key] = json.dumps(value, ensure_ascii=False)
+ else:
+ result[key] = str(value)
+ return result
+
+
+def _to_timestamp(value: datetime) -> int:
+ return int(value.timestamp())
+
+
+def _jsonify_form_definition(form: Form, site_payload: dict | None = None) -> Response:
+ """Return the form payload (optionally with site) as a JSON response."""
+ definition_payload = form.get_definition().model_dump()
+ payload = {
+ "form_content": definition_payload["rendered_content"],
+ "inputs": definition_payload["inputs"],
+ "resolved_default_values": _stringify_default_values(definition_payload["default_values"]),
+ "user_actions": definition_payload["user_actions"],
+ "expiration_time": _to_timestamp(form.expiration_time),
+ }
+ if site_payload is not None:
+ payload["site"] = site_payload
+ return Response(json.dumps(payload, ensure_ascii=False), mimetype="application/json")
+
+
+# TODO(QuantumGhost): disable authorization for web app
+# form api temporarily
+
+
+@web_ns.route("/form/human_input/")
+# class HumanInputFormApi(WebApiResource):
+class HumanInputFormApi(Resource):
+ """API for getting and submitting human input forms via the web app."""
+
+ # def get(self, _app_model: App, _end_user: EndUser, form_token: str):
+ def get(self, form_token: str):
+ """
+ Get human input form definition by token.
+
+ GET /api/form/human_input/
+ """
+ ip_address = extract_remote_ip(request)
+ if _FORM_ACCESS_RATE_LIMITER.is_rate_limited(ip_address):
+ raise WebFormRateLimitExceededError()
+ _FORM_ACCESS_RATE_LIMITER.increment_rate_limit(ip_address)
+
+ service = HumanInputService(db.engine)
+ # TODO(QuantumGhost): forbid submision for form tokens
+ # that are only for console.
+ form = service.get_form_by_token(form_token)
+
+ if form is None:
+ raise NotFoundError("Form not found")
+
+ service.ensure_form_active(form)
+ app_model, site = _get_app_site_from_form(form)
+
+ return _jsonify_form_definition(form, site_payload=serialize_app_site_payload(app_model, site, None))
+
+ # def post(self, _app_model: App, _end_user: EndUser, form_token: str):
+ def post(self, form_token: str):
+ """
+ Submit human input form by token.
+
+ POST /api/form/human_input/
+
+ Request body:
+ {
+ "inputs": {
+ "content": "User input content"
+ },
+ "action": "Approve"
+ }
+ """
+ parser = reqparse.RequestParser()
+ parser.add_argument("inputs", type=dict, required=True, location="json")
+ parser.add_argument("action", type=str, required=True, location="json")
+ args = parser.parse_args()
+
+ ip_address = extract_remote_ip(request)
+ if _FORM_SUBMIT_RATE_LIMITER.is_rate_limited(ip_address):
+ raise WebFormRateLimitExceededError()
+ _FORM_SUBMIT_RATE_LIMITER.increment_rate_limit(ip_address)
+
+ service = HumanInputService(db.engine)
+ form = service.get_form_by_token(form_token)
+ if form is None:
+ raise NotFoundError("Form not found")
+
+ if (recipient_type := form.recipient_type) is None:
+ logger.warning("Recipient type is None for form, form_id=%", form.id)
+ raise AssertionError("Recipient type is None")
+
+ try:
+ service.submit_form_by_token(
+ recipient_type=recipient_type,
+ form_token=form_token,
+ selected_action_id=args["action"],
+ form_data=args["inputs"],
+ submission_end_user_id=None,
+ # submission_end_user_id=_end_user.id,
+ )
+ except FormNotFoundError:
+ raise NotFoundError("Form not found")
+
+ return {}, 200
+
+
+def _get_app_site_from_form(form: Form) -> tuple[App, Site]:
+ """Resolve App/Site for the form's app and validate tenant status."""
+ app_model = db.session.query(App).where(App.id == form.app_id).first()
+ if app_model is None or app_model.tenant_id != form.tenant_id:
+ raise NotFoundError("Form not found")
+
+ site = db.session.query(Site).where(Site.app_id == app_model.id).first()
+ if site is None:
+ raise Forbidden()
+
+ if app_model.tenant and app_model.tenant.status == TenantStatus.ARCHIVE:
+ raise Forbidden()
+
+ return app_model, site
diff --git a/api/controllers/web/site.py b/api/controllers/web/site.py
index b01aaba357..f957229ece 100644
--- a/api/controllers/web/site.py
+++ b/api/controllers/web/site.py
@@ -1,4 +1,6 @@
-from flask_restx import fields, marshal_with
+from typing import cast
+
+from flask_restx import fields, marshal, marshal_with
from werkzeug.exceptions import Forbidden
from configs import dify_config
@@ -7,7 +9,7 @@ from controllers.web.wraps import WebApiResource
from extensions.ext_database import db
from libs.helper import AppIconUrlField
from models.account import TenantStatus
-from models.model import Site
+from models.model import App, Site
from services.feature_service import FeatureService
@@ -108,3 +110,14 @@ class AppSiteInfo:
"remove_webapp_brand": remove_webapp_brand,
"replace_webapp_logo": replace_webapp_logo,
}
+
+
+def serialize_site(site: Site) -> dict:
+ """Serialize Site model using the same schema as AppSiteApi."""
+ return cast(dict, marshal(site, AppSiteApi.site_fields))
+
+
+def serialize_app_site_payload(app_model: App, site: Site, end_user_id: str | None) -> dict:
+ can_replace_logo = FeatureService.get_features(app_model.tenant_id).can_replace_logo
+ app_site_info = AppSiteInfo(app_model.tenant, app_model, site, end_user_id, can_replace_logo)
+ return cast(dict, marshal(app_site_info, AppSiteApi.app_fields))
diff --git a/api/controllers/web/workflow_events.py b/api/controllers/web/workflow_events.py
new file mode 100644
index 0000000000..61568e70e6
--- /dev/null
+++ b/api/controllers/web/workflow_events.py
@@ -0,0 +1,112 @@
+"""
+Web App Workflow Resume APIs.
+"""
+
+import json
+from collections.abc import Generator
+
+from flask import Response, request
+from sqlalchemy.orm import sessionmaker
+
+from controllers.web import api
+from controllers.web.error import InvalidArgumentError, NotFoundError
+from controllers.web.wraps import WebApiResource
+from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
+from core.app.apps.base_app_generator import BaseAppGenerator
+from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
+from core.app.apps.message_generator import MessageGenerator
+from core.app.apps.workflow.app_generator import WorkflowAppGenerator
+from extensions.ext_database import db
+from models.enums import CreatorUserRole
+from models.model import App, AppMode, EndUser
+from repositories.factory import DifyAPIRepositoryFactory
+from services.workflow_event_snapshot_service import build_workflow_event_stream
+
+
+class WorkflowEventsApi(WebApiResource):
+ """API for getting workflow execution events after resume."""
+
+ def get(self, app_model: App, end_user: EndUser, task_id: str):
+ """
+ Get workflow execution events stream after resume.
+
+ GET /api/workflow//events
+
+ Returns Server-Sent Events stream.
+ """
+ workflow_run_id = task_id
+ session_maker = sessionmaker(db.engine)
+ repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker)
+ workflow_run = repo.get_workflow_run_by_id_and_tenant_id(
+ tenant_id=app_model.tenant_id,
+ run_id=workflow_run_id,
+ )
+
+ if workflow_run is None:
+ raise NotFoundError(f"WorkflowRun not found, id={workflow_run_id}")
+
+ if workflow_run.app_id != app_model.id:
+ raise NotFoundError(f"WorkflowRun not found, id={workflow_run_id}")
+
+ if workflow_run.created_by_role != CreatorUserRole.END_USER:
+ raise NotFoundError(f"WorkflowRun not created by end user, id={workflow_run_id}")
+
+ if workflow_run.created_by != end_user.id:
+ raise NotFoundError(f"WorkflowRun not created by the current end user, id={workflow_run_id}")
+
+ if workflow_run.finished_at is not None:
+ response = WorkflowResponseConverter.workflow_run_result_to_finish_response(
+ task_id=workflow_run.id,
+ workflow_run=workflow_run,
+ creator_user=end_user,
+ )
+
+ payload = response.model_dump(mode="json")
+ payload["event"] = response.event.value
+
+ def _generate_finished_events() -> Generator[str, None, None]:
+ yield f"data: {json.dumps(payload)}\n\n"
+
+ event_generator = _generate_finished_events
+ else:
+ app_mode = AppMode.value_of(app_model.mode)
+ msg_generator = MessageGenerator()
+ generator: BaseAppGenerator
+ if app_mode == AppMode.ADVANCED_CHAT:
+ generator = AdvancedChatAppGenerator()
+ elif app_mode == AppMode.WORKFLOW:
+ generator = WorkflowAppGenerator()
+ else:
+ raise InvalidArgumentError(f"cannot subscribe to workflow run, workflow_run_id={workflow_run.id}")
+
+ include_state_snapshot = request.args.get("include_state_snapshot", "false").lower() == "true"
+
+ def _generate_stream_events():
+ if include_state_snapshot:
+ return generator.convert_to_event_stream(
+ build_workflow_event_stream(
+ app_mode=app_mode,
+ workflow_run=workflow_run,
+ tenant_id=app_model.tenant_id,
+ app_id=app_model.id,
+ session_maker=session_maker,
+ )
+ )
+ return generator.convert_to_event_stream(
+ msg_generator.retrieve_events(app_mode, workflow_run.id),
+ )
+
+ event_generator = _generate_stream_events
+
+ return Response(
+ event_generator(),
+ mimetype="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ },
+ )
+
+
+# Register the APIs
+api.add_resource(WorkflowEventsApi, "/workflow//events")
diff --git a/api/core/app/apps/advanced_chat/app_generator.py b/api/core/app/apps/advanced_chat/app_generator.py
index 528c45f6c8..2891d3ceeb 100644
--- a/api/core/app/apps/advanced_chat/app_generator.py
+++ b/api/core/app/apps/advanced_chat/app_generator.py
@@ -4,8 +4,8 @@ import contextvars
import logging
import threading
import uuid
-from collections.abc import Generator, Mapping
-from typing import TYPE_CHECKING, Any, Literal, Union, overload
+from collections.abc import Generator, Mapping, Sequence
+from typing import TYPE_CHECKING, Any, Literal, TypeVar, Union, overload
from flask import Flask, current_app
from pydantic import ValidationError
@@ -29,21 +29,25 @@ from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom
from core.app.entities.task_entities import ChatbotAppBlockingResponse, ChatbotAppStreamResponse
+from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, PauseStatePersistenceLayer
from core.helper.trace_id_helper import extract_external_trace_id_from_args
from core.model_runtime.errors.invoke import InvokeAuthorizationError
from core.ops.ops_trace_manager import TraceQueueManager
from core.prompt.utils.get_thread_messages_length import get_thread_messages_length
from core.repositories import DifyCoreRepositoryFactory
+from core.workflow.graph_engine.layers.base import GraphEngineLayer
from core.workflow.repositories.draft_variable_repository import (
DraftVariableSaverFactory,
)
from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository
from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository
+from core.workflow.runtime import GraphRuntimeState
from core.workflow.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader
from extensions.ext_database import db
from factories import file_factory
from libs.flask_utils import preserve_flask_contexts
from models import Account, App, Conversation, EndUser, Message, Workflow, WorkflowNodeExecutionTriggeredFrom
+from models.base import Base
from models.enums import WorkflowRunTriggeredFrom
from services.conversation_service import ConversationService
from services.workflow_draft_variable_service import (
@@ -65,7 +69,9 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
user: Union[Account, EndUser],
args: Mapping[str, Any],
invoke_from: InvokeFrom,
+ workflow_run_id: str,
streaming: Literal[False],
+ pause_state_config: PauseStateLayerConfig | None = None,
) -> Mapping[str, Any]: ...
@overload
@@ -74,9 +80,11 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
app_model: App,
workflow: Workflow,
user: Union[Account, EndUser],
- args: Mapping,
+ args: Mapping[str, Any],
invoke_from: InvokeFrom,
+ workflow_run_id: str,
streaming: Literal[True],
+ pause_state_config: PauseStateLayerConfig | None = None,
) -> Generator[Mapping | str, None, None]: ...
@overload
@@ -85,9 +93,11 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
app_model: App,
workflow: Workflow,
user: Union[Account, EndUser],
- args: Mapping,
+ args: Mapping[str, Any],
invoke_from: InvokeFrom,
+ workflow_run_id: str,
streaming: bool,
+ pause_state_config: PauseStateLayerConfig | None = None,
) -> Mapping[str, Any] | Generator[str | Mapping, None, None]: ...
def generate(
@@ -95,9 +105,11 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
app_model: App,
workflow: Workflow,
user: Union[Account, EndUser],
- args: Mapping,
+ args: Mapping[str, Any],
invoke_from: InvokeFrom,
+ workflow_run_id: str,
streaming: bool = True,
+ pause_state_config: PauseStateLayerConfig | None = None,
) -> Mapping[str, Any] | Generator[str | Mapping, None, None]:
"""
Generate App response.
@@ -161,7 +173,6 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
# always enable retriever resource in debugger mode
app_config.additional_features.show_retrieve_source = True # type: ignore
- workflow_run_id = str(uuid.uuid4())
# init application generate entity
application_generate_entity = AdvancedChatAppGenerateEntity(
task_id=str(uuid.uuid4()),
@@ -179,7 +190,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
invoke_from=invoke_from,
extras=extras,
trace_manager=trace_manager,
- workflow_run_id=workflow_run_id,
+ workflow_run_id=str(workflow_run_id),
)
contexts.plugin_tool_providers.set({})
contexts.plugin_tool_providers_lock.set(threading.Lock())
@@ -216,6 +227,38 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
workflow_node_execution_repository=workflow_node_execution_repository,
conversation=conversation,
stream=streaming,
+ pause_state_config=pause_state_config,
+ )
+
+ def resume(
+ self,
+ *,
+ app_model: App,
+ workflow: Workflow,
+ user: Union[Account, EndUser],
+ conversation: Conversation,
+ message: Message,
+ application_generate_entity: AdvancedChatAppGenerateEntity,
+ workflow_execution_repository: WorkflowExecutionRepository,
+ workflow_node_execution_repository: WorkflowNodeExecutionRepository,
+ graph_runtime_state: GraphRuntimeState,
+ pause_state_config: PauseStateLayerConfig | None = None,
+ ):
+ """
+ Resume a paused advanced chat execution.
+ """
+ return self._generate(
+ workflow=workflow,
+ user=user,
+ invoke_from=application_generate_entity.invoke_from,
+ application_generate_entity=application_generate_entity,
+ workflow_execution_repository=workflow_execution_repository,
+ workflow_node_execution_repository=workflow_node_execution_repository,
+ conversation=conversation,
+ message=message,
+ stream=application_generate_entity.stream,
+ pause_state_config=pause_state_config,
+ graph_runtime_state=graph_runtime_state,
)
def single_iteration_generate(
@@ -396,8 +439,12 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
workflow_execution_repository: WorkflowExecutionRepository,
workflow_node_execution_repository: WorkflowNodeExecutionRepository,
conversation: Conversation | None = None,
+ message: Message | None = None,
stream: bool = True,
variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER,
+ pause_state_config: PauseStateLayerConfig | None = None,
+ graph_runtime_state: GraphRuntimeState | None = None,
+ graph_engine_layers: Sequence[GraphEngineLayer] = (),
) -> Mapping[str, Any] | Generator[str | Mapping[str, Any], Any, None]:
"""
Generate App response.
@@ -411,12 +458,12 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
:param conversation: conversation
:param stream: is stream
"""
- is_first_conversation = False
- if not conversation:
- is_first_conversation = True
+ is_first_conversation = conversation is None
- # init generate records
- (conversation, message) = self._init_generate_records(application_generate_entity, conversation)
+ if conversation is not None and message is not None:
+ pass
+ else:
+ conversation, message = self._init_generate_records(application_generate_entity, conversation)
if is_first_conversation:
# update conversation features
@@ -439,6 +486,16 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
message_id=message.id,
)
+ graph_layers: list[GraphEngineLayer] = list(graph_engine_layers)
+ if pause_state_config is not None:
+ graph_layers.append(
+ PauseStatePersistenceLayer(
+ session_factory=pause_state_config.session_factory,
+ generate_entity=application_generate_entity,
+ state_owner_user_id=pause_state_config.state_owner_user_id,
+ )
+ )
+
# new thread with request context and contextvars
context = contextvars.copy_context()
@@ -454,14 +511,25 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
"variable_loader": variable_loader,
"workflow_execution_repository": workflow_execution_repository,
"workflow_node_execution_repository": workflow_node_execution_repository,
+ "graph_engine_layers": tuple(graph_layers),
+ "graph_runtime_state": graph_runtime_state,
},
)
worker_thread.start()
# release database connection, because the following new thread operations may take a long time
- db.session.refresh(workflow)
- db.session.refresh(message)
+ with Session(bind=db.engine, expire_on_commit=False) as session:
+ workflow = _refresh_model(session, workflow)
+ message = _refresh_model(session, message)
+ # workflow_ = session.get(Workflow, workflow.id)
+ # assert workflow_ is not None
+ # workflow = workflow_
+ # message_ = session.get(Message, message.id)
+ # assert message_ is not None
+ # message = message_
+ # db.session.refresh(workflow)
+ # db.session.refresh(message)
# db.session.refresh(user)
db.session.close()
@@ -490,6 +558,8 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
variable_loader: VariableLoader,
workflow_execution_repository: WorkflowExecutionRepository,
workflow_node_execution_repository: WorkflowNodeExecutionRepository,
+ graph_engine_layers: Sequence[GraphEngineLayer] = (),
+ graph_runtime_state: GraphRuntimeState | None = None,
):
"""
Generate worker in a new thread.
@@ -547,6 +617,8 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
app=app,
workflow_execution_repository=workflow_execution_repository,
workflow_node_execution_repository=workflow_node_execution_repository,
+ graph_engine_layers=graph_engine_layers,
+ graph_runtime_state=graph_runtime_state,
)
try:
@@ -614,3 +686,13 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
else:
logger.exception("Failed to process generate task pipeline, conversation_id: %s", conversation.id)
raise e
+
+
+_T = TypeVar("_T", bound=Base)
+
+
+def _refresh_model(session, model: _T) -> _T:
+ with Session(bind=db.engine, expire_on_commit=False) as session:
+ detach_model = session.get(type(model), model.id)
+ assert detach_model is not None
+ return detach_model
diff --git a/api/core/app/apps/advanced_chat/app_runner.py b/api/core/app/apps/advanced_chat/app_runner.py
index d702db0908..8b20442eab 100644
--- a/api/core/app/apps/advanced_chat/app_runner.py
+++ b/api/core/app/apps/advanced_chat/app_runner.py
@@ -66,6 +66,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
workflow_execution_repository: WorkflowExecutionRepository,
workflow_node_execution_repository: WorkflowNodeExecutionRepository,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
+ graph_runtime_state: GraphRuntimeState | None = None,
):
super().__init__(
queue_manager=queue_manager,
@@ -82,6 +83,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
self._app = app
self._workflow_execution_repository = workflow_execution_repository
self._workflow_node_execution_repository = workflow_node_execution_repository
+ self._resume_graph_runtime_state = graph_runtime_state
@trace_span(WorkflowAppRunnerHandler)
def run(self):
@@ -110,7 +112,21 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
invoke_from = InvokeFrom.DEBUGGER
user_from = self._resolve_user_from(invoke_from)
- if self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run:
+ resume_state = self._resume_graph_runtime_state
+
+ if resume_state is not None:
+ graph_runtime_state = resume_state
+ variable_pool = graph_runtime_state.variable_pool
+ graph = self._init_graph(
+ graph_config=self._workflow.graph_dict,
+ graph_runtime_state=graph_runtime_state,
+ workflow_id=self._workflow.id,
+ tenant_id=self._workflow.tenant_id,
+ user_id=self.application_generate_entity.user_id,
+ invoke_from=invoke_from,
+ user_from=user_from,
+ )
+ elif self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run:
# Handle single iteration or single loop run
graph, variable_pool, graph_runtime_state = self._prepare_single_node_execution(
workflow=self._workflow,
diff --git a/api/core/app/apps/advanced_chat/generate_task_pipeline.py b/api/core/app/apps/advanced_chat/generate_task_pipeline.py
index da1e9f19b6..00a6a3d9af 100644
--- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py
+++ b/api/core/app/apps/advanced_chat/generate_task_pipeline.py
@@ -24,6 +24,8 @@ from core.app.entities.queue_entities import (
QueueAgentLogEvent,
QueueAnnotationReplyEvent,
QueueErrorEvent,
+ QueueHumanInputFormFilledEvent,
+ QueueHumanInputFormTimeoutEvent,
QueueIterationCompletedEvent,
QueueIterationNextEvent,
QueueIterationStartEvent,
@@ -42,6 +44,7 @@ from core.app.entities.queue_entities import (
QueueTextChunkEvent,
QueueWorkflowFailedEvent,
QueueWorkflowPartialSuccessEvent,
+ QueueWorkflowPausedEvent,
QueueWorkflowStartedEvent,
QueueWorkflowSucceededEvent,
WorkflowQueueMessage,
@@ -63,6 +66,8 @@ from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk
from core.model_runtime.entities.llm_entities import LLMUsage
from core.model_runtime.utils.encoders import jsonable_encoder
from core.ops.ops_trace_manager import TraceQueueManager
+from core.repositories.human_input_repository import HumanInputFormRepositoryImpl
+from core.workflow.entities.pause_reason import HumanInputRequired
from core.workflow.enums import WorkflowExecutionStatus
from core.workflow.nodes import NodeType
from core.workflow.repositories.draft_variable_repository import DraftVariableSaverFactory
@@ -71,7 +76,8 @@ from core.workflow.system_variable import SystemVariable
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from models import Account, Conversation, EndUser, Message, MessageFile
-from models.enums import CreatorUserRole
+from models.enums import CreatorUserRole, MessageStatus
+from models.execution_extra_content import HumanInputContent
from models.workflow import Workflow
logger = logging.getLogger(__name__)
@@ -128,6 +134,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
)
self._task_state = WorkflowTaskState()
+ self._seed_task_state_from_message(message)
self._message_cycle_manager = MessageCycleManager(
application_generate_entity=application_generate_entity, task_state=self._task_state
)
@@ -135,6 +142,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
self._application_generate_entity = application_generate_entity
self._workflow_id = workflow.id
self._workflow_features_dict = workflow.features_dict
+ self._workflow_tenant_id = workflow.tenant_id
self._conversation_id = conversation.id
self._conversation_mode = conversation.mode
self._message_id = message.id
@@ -144,8 +152,13 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
self._workflow_run_id: str = ""
self._draft_var_saver_factory = draft_var_saver_factory
self._graph_runtime_state: GraphRuntimeState | None = None
+ self._message_saved_on_pause = False
self._seed_graph_runtime_state_from_queue_manager()
+ def _seed_task_state_from_message(self, message: Message) -> None:
+ if message.status == MessageStatus.PAUSED and message.answer:
+ self._task_state.answer = message.answer
+
def process(self) -> Union[ChatbotAppBlockingResponse, Generator[ChatbotAppStreamResponse, None, None]]:
"""
Process generate task pipeline.
@@ -308,6 +321,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
task_id=self._application_generate_entity.task_id,
workflow_run_id=run_id,
workflow_id=self._workflow_id,
+ reason=event.reason,
)
yield workflow_start_resp
@@ -525,6 +539,35 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
)
yield workflow_finish_resp
+
+ def _handle_workflow_paused_event(
+ self,
+ event: QueueWorkflowPausedEvent,
+ **kwargs,
+ ) -> Generator[StreamResponse, None, None]:
+ """Handle workflow paused events."""
+ validated_state = self._ensure_graph_runtime_initialized()
+ responses = self._workflow_response_converter.workflow_pause_to_stream_response(
+ event=event,
+ task_id=self._application_generate_entity.task_id,
+ graph_runtime_state=validated_state,
+ )
+ for reason in event.reasons:
+ if isinstance(reason, HumanInputRequired):
+ self._persist_human_input_extra_content(form_id=reason.form_id, node_id=reason.node_id)
+ yield from responses
+ resolved_state: GraphRuntimeState | None = None
+ try:
+ resolved_state = self._ensure_graph_runtime_initialized()
+ except ValueError:
+ resolved_state = None
+
+ with self._database_session() as session:
+ self._save_message(session=session, graph_runtime_state=resolved_state)
+ message = self._get_message(session=session)
+ if message is not None:
+ message.status = MessageStatus.PAUSED
+ self._message_saved_on_pause = True
self._base_task_pipeline.queue_manager.publish(QueueAdvancedChatMessageEndEvent(), PublishFrom.TASK_PIPELINE)
def _handle_workflow_failed_event(
@@ -614,9 +657,10 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
reason=QueueMessageReplaceEvent.MessageReplaceReason.OUTPUT_MODERATION,
)
- # Save message
- with self._database_session() as session:
- self._save_message(session=session, graph_runtime_state=resolved_state)
+ # Save message unless it has already been persisted on pause.
+ if not self._message_saved_on_pause:
+ with self._database_session() as session:
+ self._save_message(session=session, graph_runtime_state=resolved_state)
yield self._message_end_to_stream_response()
@@ -642,6 +686,65 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
"""Handle message replace events."""
yield self._message_cycle_manager.message_replace_to_stream_response(answer=event.text, reason=event.reason)
+ def _handle_human_input_form_filled_event(
+ self, event: QueueHumanInputFormFilledEvent, **kwargs
+ ) -> Generator[StreamResponse, None, None]:
+ """Handle human input form filled events."""
+ self._persist_human_input_extra_content(node_id=event.node_id)
+ yield self._workflow_response_converter.human_input_form_filled_to_stream_response(
+ event=event, task_id=self._application_generate_entity.task_id
+ )
+
+ def _handle_human_input_form_timeout_event(
+ self, event: QueueHumanInputFormTimeoutEvent, **kwargs
+ ) -> Generator[StreamResponse, None, None]:
+ """Handle human input form timeout events."""
+ yield self._workflow_response_converter.human_input_form_timeout_to_stream_response(
+ event=event, task_id=self._application_generate_entity.task_id
+ )
+
+ def _persist_human_input_extra_content(self, *, node_id: str | None = None, form_id: str | None = None) -> None:
+ if not self._workflow_run_id or not self._message_id:
+ return
+
+ if form_id is None:
+ if node_id is None:
+ return
+ form_id = self._load_human_input_form_id(node_id=node_id)
+ if form_id is None:
+ logger.warning(
+ "HumanInput form not found for workflow run %s node %s",
+ self._workflow_run_id,
+ node_id,
+ )
+ return
+
+ with self._database_session() as session:
+ exists_stmt = select(HumanInputContent).where(
+ HumanInputContent.workflow_run_id == self._workflow_run_id,
+ HumanInputContent.message_id == self._message_id,
+ HumanInputContent.form_id == form_id,
+ )
+ if session.scalar(exists_stmt) is not None:
+ return
+
+ content = HumanInputContent(
+ workflow_run_id=self._workflow_run_id,
+ message_id=self._message_id,
+ form_id=form_id,
+ )
+ session.add(content)
+
+ def _load_human_input_form_id(self, *, node_id: str) -> str | None:
+ form_repository = HumanInputFormRepositoryImpl(
+ session_factory=db.engine,
+ tenant_id=self._workflow_tenant_id,
+ )
+ form = form_repository.get_form(self._workflow_run_id, node_id)
+ if form is None:
+ return None
+ return form.id
+
def _handle_agent_log_event(self, event: QueueAgentLogEvent, **kwargs) -> Generator[StreamResponse, None, None]:
"""Handle agent log events."""
yield self._workflow_response_converter.handle_agent_log(
@@ -659,6 +762,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
QueueWorkflowStartedEvent: self._handle_workflow_started_event,
QueueWorkflowSucceededEvent: self._handle_workflow_succeeded_event,
QueueWorkflowPartialSuccessEvent: self._handle_workflow_partial_success_event,
+ QueueWorkflowPausedEvent: self._handle_workflow_paused_event,
QueueWorkflowFailedEvent: self._handle_workflow_failed_event,
# Node events
QueueNodeRetryEvent: self._handle_node_retry_event,
@@ -680,6 +784,8 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
QueueMessageReplaceEvent: self._handle_message_replace_event,
QueueAdvancedChatMessageEndEvent: self._handle_advanced_chat_message_end_event,
QueueAgentLogEvent: self._handle_agent_log_event,
+ QueueHumanInputFormFilledEvent: self._handle_human_input_form_filled_event,
+ QueueHumanInputFormTimeoutEvent: self._handle_human_input_form_timeout_event,
}
def _dispatch_event(
@@ -747,6 +853,9 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
case QueueWorkflowFailedEvent():
yield from self._handle_workflow_failed_event(event, trace_manager=trace_manager)
break
+ case QueueWorkflowPausedEvent():
+ yield from self._handle_workflow_paused_event(event)
+ break
case QueueStopEvent():
yield from self._handle_stop_event(event, graph_runtime_state=None, trace_manager=trace_manager)
@@ -772,6 +881,11 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
def _save_message(self, *, session: Session, graph_runtime_state: GraphRuntimeState | None = None):
message = self._get_message(session=session)
+ if message is None:
+ return
+
+ if message.status == MessageStatus.PAUSED:
+ message.status = MessageStatus.NORMAL
# If there are assistant files, remove markdown image links from answer
answer_text = self._task_state.answer
diff --git a/api/core/app/apps/common/workflow_response_converter.py b/api/core/app/apps/common/workflow_response_converter.py
index 38ecec5d30..6d329063f8 100644
--- a/api/core/app/apps/common/workflow_response_converter.py
+++ b/api/core/app/apps/common/workflow_response_converter.py
@@ -5,9 +5,14 @@ from dataclasses import dataclass
from datetime import datetime
from typing import Any, NewType, Union
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom, WorkflowAppGenerateEntity
from core.app.entities.queue_entities import (
QueueAgentLogEvent,
+ QueueHumanInputFormFilledEvent,
+ QueueHumanInputFormTimeoutEvent,
QueueIterationCompletedEvent,
QueueIterationNextEvent,
QueueIterationStartEvent,
@@ -19,9 +24,13 @@ from core.app.entities.queue_entities import (
QueueNodeRetryEvent,
QueueNodeStartedEvent,
QueueNodeSucceededEvent,
+ QueueWorkflowPausedEvent,
)
from core.app.entities.task_entities import (
AgentLogStreamResponse,
+ HumanInputFormFilledResponse,
+ HumanInputFormTimeoutResponse,
+ HumanInputRequiredResponse,
IterationNodeCompletedStreamResponse,
IterationNodeNextStreamResponse,
IterationNodeStartStreamResponse,
@@ -31,7 +40,9 @@ from core.app.entities.task_entities import (
NodeFinishStreamResponse,
NodeRetryStreamResponse,
NodeStartStreamResponse,
+ StreamResponse,
WorkflowFinishStreamResponse,
+ WorkflowPauseStreamResponse,
WorkflowStartStreamResponse,
)
from core.file import FILE_MODEL_IDENTITY, File
@@ -40,6 +51,8 @@ from core.tools.entities.tool_entities import ToolProviderType
from core.tools.tool_manager import ToolManager
from core.trigger.trigger_manager import TriggerManager
from core.variables.segments import ArrayFileSegment, FileSegment, Segment
+from core.workflow.entities.pause_reason import HumanInputRequired
+from core.workflow.entities.workflow_start_reason import WorkflowStartReason
from core.workflow.enums import (
NodeType,
SystemVariableKey,
@@ -51,8 +64,11 @@ from core.workflow.runtime import GraphRuntimeState
from core.workflow.system_variable import SystemVariable
from core.workflow.workflow_entry import WorkflowEntry
from core.workflow.workflow_type_encoder import WorkflowRuntimeTypeConverter
+from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from models import Account, EndUser
+from models.human_input import HumanInputForm
+from models.workflow import WorkflowRun
from services.variable_truncator import BaseTruncator, DummyVariableTruncator, VariableTruncator
NodeExecutionId = NewType("NodeExecutionId", str)
@@ -191,6 +207,7 @@ class WorkflowResponseConverter:
task_id: str,
workflow_run_id: str,
workflow_id: str,
+ reason: WorkflowStartReason,
) -> WorkflowStartStreamResponse:
run_id = self._ensure_workflow_run_id(workflow_run_id)
started_at = naive_utc_now()
@@ -204,6 +221,7 @@ class WorkflowResponseConverter:
workflow_id=workflow_id,
inputs=self._workflow_inputs,
created_at=int(started_at.timestamp()),
+ reason=reason,
),
)
@@ -264,6 +282,160 @@ class WorkflowResponseConverter:
),
)
+ def workflow_pause_to_stream_response(
+ self,
+ *,
+ event: QueueWorkflowPausedEvent,
+ task_id: str,
+ graph_runtime_state: GraphRuntimeState,
+ ) -> list[StreamResponse]:
+ run_id = self._ensure_workflow_run_id()
+ started_at = self._workflow_started_at
+ if started_at is None:
+ raise ValueError(
+ "workflow_pause_to_stream_response called before workflow_start_to_stream_response",
+ )
+ paused_at = naive_utc_now()
+ elapsed_time = (paused_at - started_at).total_seconds()
+ encoded_outputs = self._encode_outputs(event.outputs) or {}
+ if self._application_generate_entity.invoke_from == InvokeFrom.SERVICE_API:
+ encoded_outputs = {}
+ pause_reasons = [reason.model_dump(mode="json") for reason in event.reasons]
+ human_input_form_ids = [reason.form_id for reason in event.reasons if isinstance(reason, HumanInputRequired)]
+ expiration_times_by_form_id: dict[str, datetime] = {}
+ if human_input_form_ids:
+ stmt = select(HumanInputForm.id, HumanInputForm.expiration_time).where(
+ HumanInputForm.id.in_(human_input_form_ids)
+ )
+ with Session(bind=db.engine) as session:
+ for form_id, expiration_time in session.execute(stmt):
+ expiration_times_by_form_id[str(form_id)] = expiration_time
+
+ responses: list[StreamResponse] = []
+
+ for reason in event.reasons:
+ if isinstance(reason, HumanInputRequired):
+ expiration_time = expiration_times_by_form_id.get(reason.form_id)
+ if expiration_time is None:
+ raise ValueError(f"HumanInputForm not found for pause reason, form_id={reason.form_id}")
+ responses.append(
+ HumanInputRequiredResponse(
+ task_id=task_id,
+ workflow_run_id=run_id,
+ data=HumanInputRequiredResponse.Data(
+ form_id=reason.form_id,
+ node_id=reason.node_id,
+ node_title=reason.node_title,
+ form_content=reason.form_content,
+ inputs=reason.inputs,
+ actions=reason.actions,
+ display_in_ui=reason.display_in_ui,
+ form_token=reason.form_token,
+ resolved_default_values=reason.resolved_default_values,
+ expiration_time=int(expiration_time.timestamp()),
+ ),
+ )
+ )
+
+ responses.append(
+ WorkflowPauseStreamResponse(
+ task_id=task_id,
+ workflow_run_id=run_id,
+ data=WorkflowPauseStreamResponse.Data(
+ workflow_run_id=run_id,
+ paused_nodes=list(event.paused_nodes),
+ outputs=encoded_outputs,
+ reasons=pause_reasons,
+ status=WorkflowExecutionStatus.PAUSED.value,
+ created_at=int(started_at.timestamp()),
+ elapsed_time=elapsed_time,
+ total_tokens=graph_runtime_state.total_tokens,
+ total_steps=graph_runtime_state.node_run_steps,
+ ),
+ )
+ )
+
+ return responses
+
+ def human_input_form_filled_to_stream_response(
+ self, *, event: QueueHumanInputFormFilledEvent, task_id: str
+ ) -> HumanInputFormFilledResponse:
+ run_id = self._ensure_workflow_run_id()
+ return HumanInputFormFilledResponse(
+ task_id=task_id,
+ workflow_run_id=run_id,
+ data=HumanInputFormFilledResponse.Data(
+ node_id=event.node_id,
+ node_title=event.node_title,
+ rendered_content=event.rendered_content,
+ action_id=event.action_id,
+ action_text=event.action_text,
+ ),
+ )
+
+ def human_input_form_timeout_to_stream_response(
+ self, *, event: QueueHumanInputFormTimeoutEvent, task_id: str
+ ) -> HumanInputFormTimeoutResponse:
+ run_id = self._ensure_workflow_run_id()
+ return HumanInputFormTimeoutResponse(
+ task_id=task_id,
+ workflow_run_id=run_id,
+ data=HumanInputFormTimeoutResponse.Data(
+ node_id=event.node_id,
+ node_title=event.node_title,
+ expiration_time=int(event.expiration_time.timestamp()),
+ ),
+ )
+
+ @classmethod
+ def workflow_run_result_to_finish_response(
+ cls,
+ *,
+ task_id: str,
+ workflow_run: WorkflowRun,
+ creator_user: Account | EndUser,
+ ) -> WorkflowFinishStreamResponse:
+ run_id = workflow_run.id
+ elapsed_time = workflow_run.elapsed_time
+
+ encoded_outputs = workflow_run.outputs_dict
+ finished_at = workflow_run.finished_at
+ assert finished_at is not None
+
+ created_by: Mapping[str, object]
+ user = creator_user
+ if isinstance(user, Account):
+ created_by = {
+ "id": user.id,
+ "name": user.name,
+ "email": user.email,
+ }
+ else:
+ created_by = {
+ "id": user.id,
+ "user": user.session_id,
+ }
+
+ return WorkflowFinishStreamResponse(
+ task_id=task_id,
+ workflow_run_id=run_id,
+ data=WorkflowFinishStreamResponse.Data(
+ id=run_id,
+ workflow_id=workflow_run.workflow_id,
+ status=workflow_run.status.value,
+ outputs=encoded_outputs,
+ error=workflow_run.error,
+ elapsed_time=elapsed_time,
+ total_tokens=workflow_run.total_tokens,
+ total_steps=workflow_run.total_steps,
+ created_by=created_by,
+ created_at=int(workflow_run.created_at.timestamp()),
+ finished_at=int(finished_at.timestamp()),
+ files=cls.fetch_files_from_node_outputs(encoded_outputs),
+ exceptions_count=workflow_run.exceptions_count,
+ ),
+ )
+
def workflow_node_start_to_stream_response(
self,
*,
@@ -592,7 +764,8 @@ class WorkflowResponseConverter:
),
)
- def fetch_files_from_node_outputs(self, outputs_dict: Mapping[str, Any] | None) -> Sequence[Mapping[str, Any]]:
+ @classmethod
+ def fetch_files_from_node_outputs(cls, outputs_dict: Mapping[str, Any] | None) -> Sequence[Mapping[str, Any]]:
"""
Fetch files from node outputs
:param outputs_dict: node outputs dict
@@ -601,7 +774,7 @@ class WorkflowResponseConverter:
if not outputs_dict:
return []
- files = [self._fetch_files_from_variable_value(output_value) for output_value in outputs_dict.values()]
+ files = [cls._fetch_files_from_variable_value(output_value) for output_value in outputs_dict.values()]
# Remove None
files = [file for file in files if file]
# Flatten list
diff --git a/api/core/app/apps/message_based_app_generator.py b/api/core/app/apps/message_based_app_generator.py
index 57617d8863..4e9a191dae 100644
--- a/api/core/app/apps/message_based_app_generator.py
+++ b/api/core/app/apps/message_based_app_generator.py
@@ -1,6 +1,6 @@
import json
import logging
-from collections.abc import Generator
+from collections.abc import Callable, Generator, Mapping
from typing import Union, cast
from sqlalchemy import select
@@ -10,12 +10,14 @@ from core.app.app_config.entities import EasyUIBasedAppConfig, EasyUIBasedAppMod
from core.app.apps.base_app_generator import BaseAppGenerator
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.apps.exc import GenerateTaskStoppedError
+from core.app.apps.streaming_utils import stream_topic_events
from core.app.entities.app_invoke_entities import (
AdvancedChatAppGenerateEntity,
AgentChatAppGenerateEntity,
AppGenerateEntity,
ChatAppGenerateEntity,
CompletionAppGenerateEntity,
+ ConversationAppGenerateEntity,
InvokeFrom,
)
from core.app.entities.task_entities import (
@@ -27,6 +29,8 @@ from core.app.entities.task_entities import (
from core.app.task_pipeline.easy_ui_based_generate_task_pipeline import EasyUIBasedGenerateTaskPipeline
from core.prompt.utils.prompt_template_parser import PromptTemplateParser
from extensions.ext_database import db
+from extensions.ext_redis import get_pubsub_broadcast_channel
+from libs.broadcast_channel.channel import Topic
from libs.datetime_utils import naive_utc_now
from models import Account
from models.enums import CreatorUserRole
@@ -156,6 +160,7 @@ class MessageBasedAppGenerator(BaseAppGenerator):
query = application_generate_entity.query or "New conversation"
conversation_name = (query[:20] + "…") if len(query) > 20 else query
+ created_new_conversation = conversation is None
try:
if not conversation:
conversation = Conversation(
@@ -232,6 +237,10 @@ class MessageBasedAppGenerator(BaseAppGenerator):
db.session.add_all(message_files)
db.session.commit()
+
+ if isinstance(application_generate_entity, ConversationAppGenerateEntity):
+ application_generate_entity.conversation_id = conversation.id
+ application_generate_entity.is_new_conversation = created_new_conversation
return conversation, message
except Exception:
db.session.rollback()
@@ -284,3 +293,29 @@ class MessageBasedAppGenerator(BaseAppGenerator):
raise MessageNotExistsError("Message not exists")
return message
+
+ @staticmethod
+ def _make_channel_key(app_mode: AppMode, workflow_run_id: str):
+ return f"channel:{app_mode}:{workflow_run_id}"
+
+ @classmethod
+ def get_response_topic(cls, app_mode: AppMode, workflow_run_id: str) -> Topic:
+ key = cls._make_channel_key(app_mode, workflow_run_id)
+ channel = get_pubsub_broadcast_channel()
+ topic = channel.topic(key)
+ return topic
+
+ @classmethod
+ def retrieve_events(
+ cls,
+ app_mode: AppMode,
+ workflow_run_id: str,
+ idle_timeout=300,
+ on_subscribe: Callable[[], None] | None = None,
+ ) -> Generator[Mapping | str, None, None]:
+ topic = cls.get_response_topic(app_mode, workflow_run_id)
+ return stream_topic_events(
+ topic=topic,
+ idle_timeout=idle_timeout,
+ on_subscribe=on_subscribe,
+ )
diff --git a/api/core/app/apps/message_generator.py b/api/core/app/apps/message_generator.py
new file mode 100644
index 0000000000..68631bb230
--- /dev/null
+++ b/api/core/app/apps/message_generator.py
@@ -0,0 +1,36 @@
+from collections.abc import Callable, Generator, Mapping
+
+from core.app.apps.streaming_utils import stream_topic_events
+from extensions.ext_redis import get_pubsub_broadcast_channel
+from libs.broadcast_channel.channel import Topic
+from models.model import AppMode
+
+
+class MessageGenerator:
+ @staticmethod
+ def _make_channel_key(app_mode: AppMode, workflow_run_id: str):
+ return f"channel:{app_mode}:{str(workflow_run_id)}"
+
+ @classmethod
+ def get_response_topic(cls, app_mode: AppMode, workflow_run_id: str) -> Topic:
+ key = cls._make_channel_key(app_mode, workflow_run_id)
+ channel = get_pubsub_broadcast_channel()
+ topic = channel.topic(key)
+ return topic
+
+ @classmethod
+ def retrieve_events(
+ cls,
+ app_mode: AppMode,
+ workflow_run_id: str,
+ idle_timeout=300,
+ ping_interval: float = 10.0,
+ on_subscribe: Callable[[], None] | None = None,
+ ) -> Generator[Mapping | str, None, None]:
+ topic = cls.get_response_topic(app_mode, workflow_run_id)
+ return stream_topic_events(
+ topic=topic,
+ idle_timeout=idle_timeout,
+ ping_interval=ping_interval,
+ on_subscribe=on_subscribe,
+ )
diff --git a/api/core/app/apps/streaming_utils.py b/api/core/app/apps/streaming_utils.py
new file mode 100644
index 0000000000..57d4b537a4
--- /dev/null
+++ b/api/core/app/apps/streaming_utils.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+import json
+import time
+from collections.abc import Callable, Generator, Iterable, Mapping
+from typing import Any
+
+from core.app.entities.task_entities import StreamEvent
+from libs.broadcast_channel.channel import Topic
+from libs.broadcast_channel.exc import SubscriptionClosedError
+
+
+def stream_topic_events(
+ *,
+ topic: Topic,
+ idle_timeout: float,
+ ping_interval: float | None = None,
+ on_subscribe: Callable[[], None] | None = None,
+ terminal_events: Iterable[str | StreamEvent] | None = None,
+) -> Generator[Mapping[str, Any] | str, None, None]:
+ # send a PING event immediately to prevent the connection staying in pending state for a long time.
+ #
+ # This simplify the debugging process as the DevTools in Chrome does not
+ # provide complete curl command for pending connections.
+ yield StreamEvent.PING.value
+
+ terminal_values = _normalize_terminal_events(terminal_events)
+ last_msg_time = time.time()
+ last_ping_time = last_msg_time
+ with topic.subscribe() as sub:
+ # on_subscribe fires only after the Redis subscription is active.
+ # This is used to gate task start and reduce pub/sub race for the first event.
+ if on_subscribe is not None:
+ on_subscribe()
+ while True:
+ try:
+ msg = sub.receive(timeout=0.1)
+ except SubscriptionClosedError:
+ return
+ if msg is None:
+ current_time = time.time()
+ if current_time - last_msg_time > idle_timeout:
+ return
+ if ping_interval is not None and current_time - last_ping_time >= ping_interval:
+ yield StreamEvent.PING.value
+ last_ping_time = current_time
+ continue
+
+ last_msg_time = time.time()
+ last_ping_time = last_msg_time
+ event = json.loads(msg)
+ yield event
+ if not isinstance(event, dict):
+ continue
+
+ event_type = event.get("event")
+ if event_type in terminal_values:
+ return
+
+
+def _normalize_terminal_events(terminal_events: Iterable[str | StreamEvent] | None) -> set[str]:
+ if not terminal_events:
+ return {StreamEvent.WORKFLOW_FINISHED.value, StreamEvent.WORKFLOW_PAUSED.value}
+ values: set[str] = set()
+ for item in terminal_events:
+ if isinstance(item, StreamEvent):
+ values.add(item.value)
+ else:
+ values.add(str(item))
+ return values
diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py
index ee205ed153..dc5852d552 100644
--- a/api/core/app/apps/workflow/app_generator.py
+++ b/api/core/app/apps/workflow/app_generator.py
@@ -25,6 +25,7 @@ from core.app.apps.workflow.generate_response_converter import WorkflowAppGenera
from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline
from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
from core.app.entities.task_entities import WorkflowAppBlockingResponse, WorkflowAppStreamResponse
+from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, PauseStatePersistenceLayer
from core.db.session_factory import session_factory
from core.helper.trace_id_helper import extract_external_trace_id_from_args
from core.model_runtime.errors.invoke import InvokeAuthorizationError
@@ -34,12 +35,15 @@ from core.workflow.graph_engine.layers.base import GraphEngineLayer
from core.workflow.repositories.draft_variable_repository import DraftVariableSaverFactory
from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository
from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository
+from core.workflow.runtime import GraphRuntimeState
from core.workflow.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader
from extensions.ext_database import db
from factories import file_factory
from libs.flask_utils import preserve_flask_contexts
-from models import Account, App, EndUser, Workflow, WorkflowNodeExecutionTriggeredFrom
+from models.account import Account
from models.enums import WorkflowRunTriggeredFrom
+from models.model import App, EndUser
+from models.workflow import Workflow, WorkflowNodeExecutionTriggeredFrom
from services.workflow_draft_variable_service import DraftVarLoader, WorkflowDraftVariableService
if TYPE_CHECKING:
@@ -66,9 +70,11 @@ class WorkflowAppGenerator(BaseAppGenerator):
invoke_from: InvokeFrom,
streaming: Literal[True],
call_depth: int,
+ workflow_run_id: str | uuid.UUID | None = None,
triggered_from: WorkflowRunTriggeredFrom | None = None,
root_node_id: str | None = None,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
+ pause_state_config: PauseStateLayerConfig | None = None,
) -> Generator[Mapping[str, Any] | str, None, None]: ...
@overload
@@ -82,9 +88,11 @@ class WorkflowAppGenerator(BaseAppGenerator):
invoke_from: InvokeFrom,
streaming: Literal[False],
call_depth: int,
+ workflow_run_id: str | uuid.UUID | None = None,
triggered_from: WorkflowRunTriggeredFrom | None = None,
root_node_id: str | None = None,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
+ pause_state_config: PauseStateLayerConfig | None = None,
) -> Mapping[str, Any]: ...
@overload
@@ -98,9 +106,11 @@ class WorkflowAppGenerator(BaseAppGenerator):
invoke_from: InvokeFrom,
streaming: bool,
call_depth: int,
+ workflow_run_id: str | uuid.UUID | None = None,
triggered_from: WorkflowRunTriggeredFrom | None = None,
root_node_id: str | None = None,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
+ pause_state_config: PauseStateLayerConfig | None = None,
) -> Union[Mapping[str, Any], Generator[Mapping[str, Any] | str, None, None]]: ...
def generate(
@@ -113,9 +123,11 @@ class WorkflowAppGenerator(BaseAppGenerator):
invoke_from: InvokeFrom,
streaming: bool = True,
call_depth: int = 0,
+ workflow_run_id: str | uuid.UUID | None = None,
triggered_from: WorkflowRunTriggeredFrom | None = None,
root_node_id: str | None = None,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
+ pause_state_config: PauseStateLayerConfig | None = None,
) -> Union[Mapping[str, Any], Generator[Mapping[str, Any] | str, None, None]]:
files: Sequence[Mapping[str, Any]] = args.get("files") or []
@@ -150,7 +162,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
extras = {
**extract_external_trace_id_from_args(args),
}
- workflow_run_id = str(uuid.uuid4())
+ workflow_run_id = str(workflow_run_id or uuid.uuid4())
# FIXME (Yeuoly): we need to remove the SKIP_PREPARE_USER_INPUTS_KEY from the args
# trigger shouldn't prepare user inputs
if self._should_prepare_user_inputs(args):
@@ -216,13 +228,40 @@ class WorkflowAppGenerator(BaseAppGenerator):
streaming=streaming,
root_node_id=root_node_id,
graph_engine_layers=graph_engine_layers,
+ pause_state_config=pause_state_config,
)
- def resume(self, *, workflow_run_id: str) -> None:
+ def resume(
+ self,
+ *,
+ app_model: App,
+ workflow: Workflow,
+ user: Union[Account, EndUser],
+ application_generate_entity: WorkflowAppGenerateEntity,
+ graph_runtime_state: GraphRuntimeState,
+ workflow_execution_repository: WorkflowExecutionRepository,
+ workflow_node_execution_repository: WorkflowNodeExecutionRepository,
+ graph_engine_layers: Sequence[GraphEngineLayer] = (),
+ pause_state_config: PauseStateLayerConfig | None = None,
+ variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER,
+ ) -> Union[Mapping[str, Any], Generator[str | Mapping[str, Any], None, None]]:
"""
- @TBD
+ Resume a paused workflow execution using the persisted runtime state.
"""
- pass
+ return self._generate(
+ app_model=app_model,
+ workflow=workflow,
+ user=user,
+ application_generate_entity=application_generate_entity,
+ invoke_from=application_generate_entity.invoke_from,
+ workflow_execution_repository=workflow_execution_repository,
+ workflow_node_execution_repository=workflow_node_execution_repository,
+ streaming=application_generate_entity.stream,
+ variable_loader=variable_loader,
+ graph_engine_layers=graph_engine_layers,
+ graph_runtime_state=graph_runtime_state,
+ pause_state_config=pause_state_config,
+ )
def _generate(
self,
@@ -238,6 +277,8 @@ class WorkflowAppGenerator(BaseAppGenerator):
variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER,
root_node_id: str | None = None,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
+ graph_runtime_state: GraphRuntimeState | None = None,
+ pause_state_config: PauseStateLayerConfig | None = None,
) -> Union[Mapping[str, Any], Generator[str | Mapping[str, Any], None, None]]:
"""
Generate App response.
@@ -251,6 +292,8 @@ class WorkflowAppGenerator(BaseAppGenerator):
:param workflow_node_execution_repository: repository for workflow node execution
:param streaming: is stream
"""
+ graph_layers: list[GraphEngineLayer] = list(graph_engine_layers)
+
# init queue manager
queue_manager = WorkflowAppQueueManager(
task_id=application_generate_entity.task_id,
@@ -259,6 +302,15 @@ class WorkflowAppGenerator(BaseAppGenerator):
app_mode=app_model.mode,
)
+ if pause_state_config is not None:
+ graph_layers.append(
+ PauseStatePersistenceLayer(
+ session_factory=pause_state_config.session_factory,
+ generate_entity=application_generate_entity,
+ state_owner_user_id=pause_state_config.state_owner_user_id,
+ )
+ )
+
# new thread with request context and contextvars
context = contextvars.copy_context()
@@ -276,7 +328,8 @@ class WorkflowAppGenerator(BaseAppGenerator):
"root_node_id": root_node_id,
"workflow_execution_repository": workflow_execution_repository,
"workflow_node_execution_repository": workflow_node_execution_repository,
- "graph_engine_layers": graph_engine_layers,
+ "graph_engine_layers": tuple(graph_layers),
+ "graph_runtime_state": graph_runtime_state,
},
)
@@ -378,6 +431,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
workflow_node_execution_repository=workflow_node_execution_repository,
streaming=streaming,
variable_loader=var_loader,
+ pause_state_config=None,
)
def single_loop_generate(
@@ -459,6 +513,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
workflow_node_execution_repository=workflow_node_execution_repository,
streaming=streaming,
variable_loader=var_loader,
+ pause_state_config=None,
)
def _generate_worker(
@@ -472,6 +527,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
workflow_node_execution_repository: WorkflowNodeExecutionRepository,
root_node_id: str | None = None,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
+ graph_runtime_state: GraphRuntimeState | None = None,
) -> None:
"""
Generate worker in a new thread.
@@ -517,6 +573,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
workflow_node_execution_repository=workflow_node_execution_repository,
root_node_id=root_node_id,
graph_engine_layers=graph_engine_layers,
+ graph_runtime_state=graph_runtime_state,
)
try:
diff --git a/api/core/app/apps/workflow/app_runner.py b/api/core/app/apps/workflow/app_runner.py
index 0ee3c177f2..a43f7879d6 100644
--- a/api/core/app/apps/workflow/app_runner.py
+++ b/api/core/app/apps/workflow/app_runner.py
@@ -42,6 +42,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
workflow_execution_repository: WorkflowExecutionRepository,
workflow_node_execution_repository: WorkflowNodeExecutionRepository,
graph_engine_layers: Sequence[GraphEngineLayer] = (),
+ graph_runtime_state: GraphRuntimeState | None = None,
):
super().__init__(
queue_manager=queue_manager,
@@ -55,6 +56,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
self._root_node_id = root_node_id
self._workflow_execution_repository = workflow_execution_repository
self._workflow_node_execution_repository = workflow_node_execution_repository
+ self._resume_graph_runtime_state = graph_runtime_state
@trace_span(WorkflowAppRunnerHandler)
def run(self):
@@ -63,23 +65,28 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
"""
app_config = self.application_generate_entity.app_config
app_config = cast(WorkflowAppConfig, app_config)
-
- system_inputs = SystemVariable(
- files=self.application_generate_entity.files,
- user_id=self._sys_user_id,
- app_id=app_config.app_id,
- timestamp=int(naive_utc_now().timestamp()),
- workflow_id=app_config.workflow_id,
- workflow_execution_id=self.application_generate_entity.workflow_execution_id,
- )
-
invoke_from = self.application_generate_entity.invoke_from
# if only single iteration or single loop run is requested
if self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run:
invoke_from = InvokeFrom.DEBUGGER
user_from = self._resolve_user_from(invoke_from)
- if self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run:
+ resume_state = self._resume_graph_runtime_state
+
+ if resume_state is not None:
+ graph_runtime_state = resume_state
+ variable_pool = graph_runtime_state.variable_pool
+ graph = self._init_graph(
+ graph_config=self._workflow.graph_dict,
+ graph_runtime_state=graph_runtime_state,
+ workflow_id=self._workflow.id,
+ tenant_id=self._workflow.tenant_id,
+ user_id=self.application_generate_entity.user_id,
+ user_from=user_from,
+ invoke_from=invoke_from,
+ root_node_id=self._root_node_id,
+ )
+ elif self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run:
graph, variable_pool, graph_runtime_state = self._prepare_single_node_execution(
workflow=self._workflow,
single_iteration_run=self.application_generate_entity.single_iteration_run,
@@ -89,7 +96,14 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
inputs = self.application_generate_entity.inputs
# Create a variable pool.
-
+ system_inputs = SystemVariable(
+ files=self.application_generate_entity.files,
+ user_id=self._sys_user_id,
+ app_id=app_config.app_id,
+ timestamp=int(naive_utc_now().timestamp()),
+ workflow_id=app_config.workflow_id,
+ workflow_execution_id=self.application_generate_entity.workflow_execution_id,
+ )
variable_pool = VariablePool(
system_variables=system_inputs,
user_inputs=inputs,
@@ -98,8 +112,6 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
)
graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
-
- # init graph
graph = self._init_graph(
graph_config=self._workflow.graph_dict,
graph_runtime_state=graph_runtime_state,
diff --git a/api/core/app/apps/workflow/errors.py b/api/core/app/apps/workflow/errors.py
new file mode 100644
index 0000000000..16cd864209
--- /dev/null
+++ b/api/core/app/apps/workflow/errors.py
@@ -0,0 +1,7 @@
+from libs.exception import BaseHTTPException
+
+
+class WorkflowPausedInBlockingModeError(BaseHTTPException):
+ error_code = "workflow_paused_in_blocking_mode"
+ description = "Workflow execution paused for human input; blocking response mode is not supported."
+ code = 400
diff --git a/api/core/app/apps/workflow/generate_task_pipeline.py b/api/core/app/apps/workflow/generate_task_pipeline.py
index 842ad545ad..0a567a4315 100644
--- a/api/core/app/apps/workflow/generate_task_pipeline.py
+++ b/api/core/app/apps/workflow/generate_task_pipeline.py
@@ -16,6 +16,8 @@ from core.app.entities.queue_entities import (
MessageQueueMessage,
QueueAgentLogEvent,
QueueErrorEvent,
+ QueueHumanInputFormFilledEvent,
+ QueueHumanInputFormTimeoutEvent,
QueueIterationCompletedEvent,
QueueIterationNextEvent,
QueueIterationStartEvent,
@@ -32,6 +34,7 @@ from core.app.entities.queue_entities import (
QueueTextChunkEvent,
QueueWorkflowFailedEvent,
QueueWorkflowPartialSuccessEvent,
+ QueueWorkflowPausedEvent,
QueueWorkflowStartedEvent,
QueueWorkflowSucceededEvent,
WorkflowQueueMessage,
@@ -46,11 +49,13 @@ from core.app.entities.task_entities import (
WorkflowAppBlockingResponse,
WorkflowAppStreamResponse,
WorkflowFinishStreamResponse,
+ WorkflowPauseStreamResponse,
WorkflowStartStreamResponse,
)
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk
from core.ops.ops_trace_manager import TraceQueueManager
+from core.workflow.entities.workflow_start_reason import WorkflowStartReason
from core.workflow.enums import WorkflowExecutionStatus
from core.workflow.repositories.draft_variable_repository import DraftVariableSaverFactory
from core.workflow.runtime import GraphRuntimeState
@@ -132,6 +137,25 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
for stream_response in generator:
if isinstance(stream_response, ErrorStreamResponse):
raise stream_response.err
+ elif isinstance(stream_response, WorkflowPauseStreamResponse):
+ response = WorkflowAppBlockingResponse(
+ task_id=self._application_generate_entity.task_id,
+ workflow_run_id=stream_response.data.workflow_run_id,
+ data=WorkflowAppBlockingResponse.Data(
+ id=stream_response.data.workflow_run_id,
+ workflow_id=self._workflow.id,
+ status=stream_response.data.status,
+ outputs=stream_response.data.outputs or {},
+ error=None,
+ elapsed_time=stream_response.data.elapsed_time,
+ total_tokens=stream_response.data.total_tokens,
+ total_steps=stream_response.data.total_steps,
+ created_at=stream_response.data.created_at,
+ finished_at=None,
+ ),
+ )
+
+ return response
elif isinstance(stream_response, WorkflowFinishStreamResponse):
response = WorkflowAppBlockingResponse(
task_id=self._application_generate_entity.task_id,
@@ -146,7 +170,7 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
total_tokens=stream_response.data.total_tokens,
total_steps=stream_response.data.total_steps,
created_at=int(stream_response.data.created_at),
- finished_at=int(stream_response.data.finished_at),
+ finished_at=int(stream_response.data.finished_at) if stream_response.data.finished_at else None,
),
)
@@ -259,13 +283,15 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
run_id = self._extract_workflow_run_id(runtime_state)
self._workflow_execution_id = run_id
- with self._database_session() as session:
- self._save_workflow_app_log(session=session, workflow_run_id=self._workflow_execution_id)
+ if event.reason == WorkflowStartReason.INITIAL:
+ with self._database_session() as session:
+ self._save_workflow_app_log(session=session, workflow_run_id=self._workflow_execution_id)
start_resp = self._workflow_response_converter.workflow_start_to_stream_response(
task_id=self._application_generate_entity.task_id,
workflow_run_id=run_id,
workflow_id=self._workflow.id,
+ reason=event.reason,
)
yield start_resp
@@ -440,6 +466,21 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
)
yield workflow_finish_resp
+ def _handle_workflow_paused_event(
+ self,
+ event: QueueWorkflowPausedEvent,
+ **kwargs,
+ ) -> Generator[StreamResponse, None, None]:
+ """Handle workflow paused events."""
+ self._ensure_workflow_initialized()
+ validated_state = self._ensure_graph_runtime_initialized()
+ responses = self._workflow_response_converter.workflow_pause_to_stream_response(
+ event=event,
+ task_id=self._application_generate_entity.task_id,
+ graph_runtime_state=validated_state,
+ )
+ yield from responses
+
def _handle_workflow_failed_and_stop_events(
self,
event: Union[QueueWorkflowFailedEvent, QueueStopEvent],
@@ -495,6 +536,22 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
task_id=self._application_generate_entity.task_id, event=event
)
+ def _handle_human_input_form_filled_event(
+ self, event: QueueHumanInputFormFilledEvent, **kwargs
+ ) -> Generator[StreamResponse, None, None]:
+ """Handle human input form filled events."""
+ yield self._workflow_response_converter.human_input_form_filled_to_stream_response(
+ event=event, task_id=self._application_generate_entity.task_id
+ )
+
+ def _handle_human_input_form_timeout_event(
+ self, event: QueueHumanInputFormTimeoutEvent, **kwargs
+ ) -> Generator[StreamResponse, None, None]:
+ """Handle human input form timeout events."""
+ yield self._workflow_response_converter.human_input_form_timeout_to_stream_response(
+ event=event, task_id=self._application_generate_entity.task_id
+ )
+
def _get_event_handlers(self) -> dict[type, Callable]:
"""Get mapping of event types to their handlers using fluent pattern."""
return {
@@ -506,6 +563,7 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
QueueWorkflowStartedEvent: self._handle_workflow_started_event,
QueueWorkflowSucceededEvent: self._handle_workflow_succeeded_event,
QueueWorkflowPartialSuccessEvent: self._handle_workflow_partial_success_event,
+ QueueWorkflowPausedEvent: self._handle_workflow_paused_event,
# Node events
QueueNodeRetryEvent: self._handle_node_retry_event,
QueueNodeStartedEvent: self._handle_node_started_event,
@@ -520,6 +578,8 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
QueueLoopCompletedEvent: self._handle_loop_completed_event,
# Agent events
QueueAgentLogEvent: self._handle_agent_log_event,
+ QueueHumanInputFormFilledEvent: self._handle_human_input_form_filled_event,
+ QueueHumanInputFormTimeoutEvent: self._handle_human_input_form_timeout_event,
}
def _dispatch_event(
@@ -602,6 +662,9 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
case QueueWorkflowFailedEvent():
yield from self._handle_workflow_failed_and_stop_events(event)
break
+ case QueueWorkflowPausedEvent():
+ yield from self._handle_workflow_paused_event(event)
+ break
case QueueStopEvent():
yield from self._handle_workflow_failed_and_stop_events(event)
diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py
index 13b7865f55..c9d7464c17 100644
--- a/api/core/app/apps/workflow_app_runner.py
+++ b/api/core/app/apps/workflow_app_runner.py
@@ -1,3 +1,4 @@
+import logging
import time
from collections.abc import Mapping, Sequence
from typing import Any, cast
@@ -7,6 +8,8 @@ from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.entities.queue_entities import (
AppQueueEvent,
QueueAgentLogEvent,
+ QueueHumanInputFormFilledEvent,
+ QueueHumanInputFormTimeoutEvent,
QueueIterationCompletedEvent,
QueueIterationNextEvent,
QueueIterationStartEvent,
@@ -22,22 +25,27 @@ from core.app.entities.queue_entities import (
QueueTextChunkEvent,
QueueWorkflowFailedEvent,
QueueWorkflowPartialSuccessEvent,
+ QueueWorkflowPausedEvent,
QueueWorkflowStartedEvent,
QueueWorkflowSucceededEvent,
)
from core.app.workflow.node_factory import DifyNodeFactory
from core.workflow.entities import GraphInitParams
+from core.workflow.entities.pause_reason import HumanInputRequired
from core.workflow.graph import Graph
from core.workflow.graph_engine.layers.base import GraphEngineLayer
from core.workflow.graph_events import (
GraphEngineEvent,
GraphRunFailedEvent,
GraphRunPartialSucceededEvent,
+ GraphRunPausedEvent,
GraphRunStartedEvent,
GraphRunSucceededEvent,
NodeRunAgentLogEvent,
NodeRunExceptionEvent,
NodeRunFailedEvent,
+ NodeRunHumanInputFormFilledEvent,
+ NodeRunHumanInputFormTimeoutEvent,
NodeRunIterationFailedEvent,
NodeRunIterationNextEvent,
NodeRunIterationStartedEvent,
@@ -61,6 +69,9 @@ from core.workflow.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader,
from core.workflow.workflow_entry import WorkflowEntry
from models.enums import UserFrom
from models.workflow import Workflow
+from tasks.mail_human_input_delivery_task import dispatch_human_input_email_task
+
+logger = logging.getLogger(__name__)
class WorkflowBasedAppRunner:
@@ -327,7 +338,7 @@ class WorkflowBasedAppRunner:
:param event: event
"""
if isinstance(event, GraphRunStartedEvent):
- self._publish_event(QueueWorkflowStartedEvent())
+ self._publish_event(QueueWorkflowStartedEvent(reason=event.reason))
elif isinstance(event, GraphRunSucceededEvent):
self._publish_event(QueueWorkflowSucceededEvent(outputs=event.outputs))
elif isinstance(event, GraphRunPartialSucceededEvent):
@@ -338,6 +349,38 @@ class WorkflowBasedAppRunner:
self._publish_event(QueueWorkflowFailedEvent(error=event.error, exceptions_count=event.exceptions_count))
elif isinstance(event, GraphRunAbortedEvent):
self._publish_event(QueueWorkflowFailedEvent(error=event.reason or "Unknown error", exceptions_count=0))
+ elif isinstance(event, GraphRunPausedEvent):
+ runtime_state = workflow_entry.graph_engine.graph_runtime_state
+ paused_nodes = runtime_state.get_paused_nodes()
+ self._enqueue_human_input_notifications(event.reasons)
+ self._publish_event(
+ QueueWorkflowPausedEvent(
+ reasons=event.reasons,
+ outputs=event.outputs,
+ paused_nodes=paused_nodes,
+ )
+ )
+ elif isinstance(event, NodeRunHumanInputFormFilledEvent):
+ self._publish_event(
+ QueueHumanInputFormFilledEvent(
+ node_execution_id=event.id,
+ node_id=event.node_id,
+ node_type=event.node_type,
+ node_title=event.node_title,
+ rendered_content=event.rendered_content,
+ action_id=event.action_id,
+ action_text=event.action_text,
+ )
+ )
+ elif isinstance(event, NodeRunHumanInputFormTimeoutEvent):
+ self._publish_event(
+ QueueHumanInputFormTimeoutEvent(
+ node_id=event.node_id,
+ node_type=event.node_type,
+ node_title=event.node_title,
+ expiration_time=event.expiration_time,
+ )
+ )
elif isinstance(event, NodeRunRetryEvent):
node_run_result = event.node_run_result
inputs = node_run_result.inputs
@@ -544,5 +587,19 @@ class WorkflowBasedAppRunner:
)
)
+ def _enqueue_human_input_notifications(self, reasons: Sequence[object]) -> None:
+ for reason in reasons:
+ if not isinstance(reason, HumanInputRequired):
+ continue
+ if not reason.form_id:
+ continue
+ try:
+ dispatch_human_input_email_task.apply_async(
+ kwargs={"form_id": reason.form_id, "node_title": reason.node_title},
+ queue="mail",
+ )
+ except Exception: # pragma: no cover - defensive logging
+ logger.exception("Failed to enqueue human input email task for form %s", reason.form_id)
+
def _publish_event(self, event: AppQueueEvent):
self._queue_manager.publish(event, PublishFrom.APPLICATION_MANAGER)
diff --git a/api/core/app/entities/app_invoke_entities.py b/api/core/app/entities/app_invoke_entities.py
index 5bc453420d..0e68e554c8 100644
--- a/api/core/app/entities/app_invoke_entities.py
+++ b/api/core/app/entities/app_invoke_entities.py
@@ -132,7 +132,7 @@ class AppGenerateEntity(BaseModel):
extras: dict[str, Any] = Field(default_factory=dict)
# tracing instance
- trace_manager: Optional["TraceQueueManager"] = None
+ trace_manager: Optional["TraceQueueManager"] = Field(default=None, exclude=True, repr=False)
class EasyUIBasedAppGenerateEntity(AppGenerateEntity):
@@ -156,6 +156,7 @@ class ConversationAppGenerateEntity(AppGenerateEntity):
"""
conversation_id: str | None = None
+ is_new_conversation: bool = False
parent_message_id: str | None = Field(
default=None,
description=(
diff --git a/api/core/app/entities/queue_entities.py b/api/core/app/entities/queue_entities.py
index 77d6bf03b4..5b2fa29b56 100644
--- a/api/core/app/entities/queue_entities.py
+++ b/api/core/app/entities/queue_entities.py
@@ -8,6 +8,8 @@ from pydantic import BaseModel, ConfigDict, Field
from core.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk
from core.rag.entities.citation_metadata import RetrievalSourceMetadata
from core.workflow.entities import AgentNodeStrategyInit
+from core.workflow.entities.pause_reason import PauseReason
+from core.workflow.entities.workflow_start_reason import WorkflowStartReason
from core.workflow.enums import WorkflowNodeExecutionMetadataKey
from core.workflow.nodes import NodeType
@@ -46,6 +48,9 @@ class QueueEvent(StrEnum):
PING = "ping"
STOP = "stop"
RETRY = "retry"
+ PAUSE = "pause"
+ HUMAN_INPUT_FORM_FILLED = "human_input_form_filled"
+ HUMAN_INPUT_FORM_TIMEOUT = "human_input_form_timeout"
class AppQueueEvent(BaseModel):
@@ -261,6 +266,8 @@ class QueueWorkflowStartedEvent(AppQueueEvent):
"""QueueWorkflowStartedEvent entity."""
event: QueueEvent = QueueEvent.WORKFLOW_STARTED
+ # Always present; mirrors GraphRunStartedEvent.reason for downstream consumers.
+ reason: WorkflowStartReason = WorkflowStartReason.INITIAL
class QueueWorkflowSucceededEvent(AppQueueEvent):
@@ -484,6 +491,35 @@ class QueueStopEvent(AppQueueEvent):
return reason_mapping.get(self.stopped_by, "Stopped by unknown reason.")
+class QueueHumanInputFormFilledEvent(AppQueueEvent):
+ """
+ QueueHumanInputFormFilledEvent entity
+ """
+
+ event: QueueEvent = QueueEvent.HUMAN_INPUT_FORM_FILLED
+
+ node_execution_id: str
+ node_id: str
+ node_type: NodeType
+ node_title: str
+ rendered_content: str
+ action_id: str
+ action_text: str
+
+
+class QueueHumanInputFormTimeoutEvent(AppQueueEvent):
+ """
+ QueueHumanInputFormTimeoutEvent entity
+ """
+
+ event: QueueEvent = QueueEvent.HUMAN_INPUT_FORM_TIMEOUT
+
+ node_id: str
+ node_type: NodeType
+ node_title: str
+ expiration_time: datetime
+
+
class QueueMessage(BaseModel):
"""
QueueMessage abstract entity
@@ -509,3 +545,14 @@ class WorkflowQueueMessage(QueueMessage):
"""
pass
+
+
+class QueueWorkflowPausedEvent(AppQueueEvent):
+ """
+ QueueWorkflowPausedEvent entity
+ """
+
+ event: QueueEvent = QueueEvent.PAUSE
+ reasons: Sequence[PauseReason] = Field(default_factory=list)
+ outputs: Mapping[str, object] = Field(default_factory=dict)
+ paused_nodes: Sequence[str] = Field(default_factory=list)
diff --git a/api/core/app/entities/task_entities.py b/api/core/app/entities/task_entities.py
index 79a5e657b3..3f38904d2f 100644
--- a/api/core/app/entities/task_entities.py
+++ b/api/core/app/entities/task_entities.py
@@ -7,7 +7,9 @@ from pydantic import BaseModel, ConfigDict, Field
from core.model_runtime.entities.llm_entities import LLMResult, LLMUsage
from core.rag.entities.citation_metadata import RetrievalSourceMetadata
from core.workflow.entities import AgentNodeStrategyInit
+from core.workflow.entities.workflow_start_reason import WorkflowStartReason
from core.workflow.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
+from core.workflow.nodes.human_input.entities import FormInput, UserAction
class AnnotationReplyAccount(BaseModel):
@@ -69,6 +71,7 @@ class StreamEvent(StrEnum):
AGENT_THOUGHT = "agent_thought"
AGENT_MESSAGE = "agent_message"
WORKFLOW_STARTED = "workflow_started"
+ WORKFLOW_PAUSED = "workflow_paused"
WORKFLOW_FINISHED = "workflow_finished"
NODE_STARTED = "node_started"
NODE_FINISHED = "node_finished"
@@ -82,6 +85,9 @@ class StreamEvent(StrEnum):
TEXT_CHUNK = "text_chunk"
TEXT_REPLACE = "text_replace"
AGENT_LOG = "agent_log"
+ HUMAN_INPUT_REQUIRED = "human_input_required"
+ HUMAN_INPUT_FORM_FILLED = "human_input_form_filled"
+ HUMAN_INPUT_FORM_TIMEOUT = "human_input_form_timeout"
class StreamResponse(BaseModel):
@@ -205,6 +211,8 @@ class WorkflowStartStreamResponse(StreamResponse):
workflow_id: str
inputs: Mapping[str, Any]
created_at: int
+ # Always present; mirrors QueueWorkflowStartedEvent.reason for SSE clients.
+ reason: WorkflowStartReason = WorkflowStartReason.INITIAL
event: StreamEvent = StreamEvent.WORKFLOW_STARTED
workflow_run_id: str
@@ -231,7 +239,7 @@ class WorkflowFinishStreamResponse(StreamResponse):
total_steps: int
created_by: Mapping[str, object] = Field(default_factory=dict)
created_at: int
- finished_at: int
+ finished_at: int | None
exceptions_count: int | None = 0
files: Sequence[Mapping[str, Any]] | None = []
@@ -240,6 +248,85 @@ class WorkflowFinishStreamResponse(StreamResponse):
data: Data
+class WorkflowPauseStreamResponse(StreamResponse):
+ """
+ WorkflowPauseStreamResponse entity
+ """
+
+ class Data(BaseModel):
+ """
+ Data entity
+ """
+
+ workflow_run_id: str
+ paused_nodes: Sequence[str] = Field(default_factory=list)
+ outputs: Mapping[str, Any] = Field(default_factory=dict)
+ reasons: Sequence[Mapping[str, Any]] = Field(default_factory=list)
+ status: str
+ created_at: int
+ elapsed_time: float
+ total_tokens: int
+ total_steps: int
+
+ event: StreamEvent = StreamEvent.WORKFLOW_PAUSED
+ workflow_run_id: str
+ data: Data
+
+
+class HumanInputRequiredResponse(StreamResponse):
+ class Data(BaseModel):
+ """
+ Data entity
+ """
+
+ form_id: str
+ node_id: str
+ node_title: str
+ form_content: str
+ inputs: Sequence[FormInput] = Field(default_factory=list)
+ actions: Sequence[UserAction] = Field(default_factory=list)
+ display_in_ui: bool = False
+ form_token: str | None = None
+ resolved_default_values: Mapping[str, Any] = Field(default_factory=dict)
+ expiration_time: int = Field(..., description="Unix timestamp in seconds")
+
+ event: StreamEvent = StreamEvent.HUMAN_INPUT_REQUIRED
+ workflow_run_id: str
+ data: Data
+
+
+class HumanInputFormFilledResponse(StreamResponse):
+ class Data(BaseModel):
+ """
+ Data entity
+ """
+
+ node_id: str
+ node_title: str
+ rendered_content: str
+ action_id: str
+ action_text: str
+
+ event: StreamEvent = StreamEvent.HUMAN_INPUT_FORM_FILLED
+ workflow_run_id: str
+ data: Data
+
+
+class HumanInputFormTimeoutResponse(StreamResponse):
+ class Data(BaseModel):
+ """
+ Data entity
+ """
+
+ node_id: str
+ node_title: str
+ expiration_time: int
+
+ event: StreamEvent = StreamEvent.HUMAN_INPUT_FORM_TIMEOUT
+ workflow_run_id: str
+ data: Data
+
+
class NodeStartStreamResponse(StreamResponse):
"""
NodeStartStreamResponse entity
@@ -726,7 +813,7 @@ class WorkflowAppBlockingResponse(AppBlockingResponse):
total_tokens: int
total_steps: int
created_at: int
- finished_at: int
+ finished_at: int | None
workflow_run_id: str
data: Data
diff --git a/api/core/app/features/rate_limiting/rate_limit.py b/api/core/app/features/rate_limiting/rate_limit.py
index 565905be0d..2ca1275a8a 100644
--- a/api/core/app/features/rate_limiting/rate_limit.py
+++ b/api/core/app/features/rate_limiting/rate_limit.py
@@ -1,3 +1,4 @@
+import contextlib
import logging
import time
import uuid
@@ -103,6 +104,14 @@ class RateLimit:
)
+@contextlib.contextmanager
+def rate_limit_context(rate_limit: RateLimit, request_id: str | None):
+ request_id = rate_limit.enter(request_id)
+ yield
+ if request_id is not None:
+ rate_limit.exit(request_id)
+
+
class RateLimitGenerator:
def __init__(self, rate_limit: RateLimit, generator: Generator[str, None, None], request_id: str):
self.rate_limit = rate_limit
diff --git a/api/core/app/layers/pause_state_persist_layer.py b/api/core/app/layers/pause_state_persist_layer.py
index bf76ae8178..1c267091a4 100644
--- a/api/core/app/layers/pause_state_persist_layer.py
+++ b/api/core/app/layers/pause_state_persist_layer.py
@@ -1,3 +1,4 @@
+from dataclasses import dataclass
from typing import Annotated, Literal, Self, TypeAlias
from pydantic import BaseModel, Field
@@ -52,6 +53,14 @@ class WorkflowResumptionContext(BaseModel):
return self.generate_entity.entity
+@dataclass(frozen=True)
+class PauseStateLayerConfig:
+ """Configuration container for instantiating pause persistence layers."""
+
+ session_factory: Engine | sessionmaker[Session]
+ state_owner_user_id: str
+
+
class PauseStatePersistenceLayer(GraphEngineLayer):
def __init__(
self,
diff --git a/api/core/app/task_pipeline/message_cycle_manager.py b/api/core/app/task_pipeline/message_cycle_manager.py
index 2d4ee08daf..d682083f34 100644
--- a/api/core/app/task_pipeline/message_cycle_manager.py
+++ b/api/core/app/task_pipeline/message_cycle_manager.py
@@ -82,10 +82,11 @@ class MessageCycleManager:
if isinstance(self._application_generate_entity, CompletionAppGenerateEntity):
return None
- is_first_message = self._application_generate_entity.conversation_id is None
+ is_first_message = self._application_generate_entity.is_new_conversation
extras = self._application_generate_entity.extras
auto_generate_conversation_name = extras.get("auto_generate_conversation_name", True)
+ thread: Thread | None = None
if auto_generate_conversation_name and is_first_message:
# start generate thread
# time.sleep not block other logic
@@ -101,9 +102,10 @@ class MessageCycleManager:
thread.daemon = True
thread.start()
- return thread
+ if is_first_message:
+ self._application_generate_entity.is_new_conversation = False
- return None
+ return thread
def _generate_conversation_name_worker(self, flask_app: Flask, conversation_id: str, query: str):
with flask_app.app_context():
diff --git a/api/core/entities/execution_extra_content.py b/api/core/entities/execution_extra_content.py
new file mode 100644
index 0000000000..46006f4381
--- /dev/null
+++ b/api/core/entities/execution_extra_content.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from typing import Any, TypeAlias
+
+from pydantic import BaseModel, ConfigDict, Field
+
+from core.workflow.nodes.human_input.entities import FormInput, UserAction
+from models.execution_extra_content import ExecutionContentType
+
+
+class HumanInputFormDefinition(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ form_id: str
+ node_id: str
+ node_title: str
+ form_content: str
+ inputs: Sequence[FormInput] = Field(default_factory=list)
+ actions: Sequence[UserAction] = Field(default_factory=list)
+ display_in_ui: bool = False
+ form_token: str | None = None
+ resolved_default_values: Mapping[str, Any] = Field(default_factory=dict)
+ expiration_time: int
+
+
+class HumanInputFormSubmissionData(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ node_id: str
+ node_title: str
+ rendered_content: str
+ action_id: str
+ action_text: str
+
+
+class HumanInputContent(BaseModel):
+ model_config = ConfigDict(frozen=True)
+
+ workflow_run_id: str
+ submitted: bool
+ form_definition: HumanInputFormDefinition | None = None
+ form_submission_data: HumanInputFormSubmissionData | None = None
+ type: ExecutionContentType = Field(default=ExecutionContentType.HUMAN_INPUT)
+
+
+ExecutionExtraContentDomainModel: TypeAlias = HumanInputContent
+
+__all__ = [
+ "ExecutionExtraContentDomainModel",
+ "HumanInputContent",
+ "HumanInputFormDefinition",
+ "HumanInputFormSubmissionData",
+]
diff --git a/api/core/entities/provider_configuration.py b/api/core/entities/provider_configuration.py
index e8d41b9387..8a26b2e91b 100644
--- a/api/core/entities/provider_configuration.py
+++ b/api/core/entities/provider_configuration.py
@@ -28,8 +28,8 @@ from core.model_runtime.entities.provider_entities import (
)
from core.model_runtime.model_providers.__base.ai_model import AIModel
from core.model_runtime.model_providers.model_provider_factory import ModelProviderFactory
-from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
+from models.engine import db
from models.provider import (
LoadBalancingModelConfig,
Provider,
diff --git a/api/core/ops/ops_trace_manager.py b/api/core/ops/ops_trace_manager.py
index 84f5bf5512..549e428f88 100644
--- a/api/core/ops/ops_trace_manager.py
+++ b/api/core/ops/ops_trace_manager.py
@@ -15,10 +15,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from core.helper.encrypter import batch_decrypt_token, encrypt_token, obfuscated_token
-from core.ops.entities.config_entity import (
- OPS_FILE_PATH,
- TracingProviderEnum,
-)
+from core.ops.entities.config_entity import OPS_FILE_PATH, TracingProviderEnum
from core.ops.entities.trace_entity import (
DatasetRetrievalTraceInfo,
GenerateNameTraceInfo,
@@ -31,8 +28,8 @@ from core.ops.entities.trace_entity import (
WorkflowTraceInfo,
)
from core.ops.utils import get_message_data
-from extensions.ext_database import db
from extensions.ext_storage import storage
+from models.engine import db
from models.model import App, AppModelConfig, Conversation, Message, MessageFile, TraceAppConfig
from models.workflow import WorkflowAppLog
from tasks.ops_trace_task import process_trace_tasks
@@ -469,6 +466,8 @@ class TraceTask:
@classmethod
def _get_workflow_run_repo(cls):
+ from repositories.factory import DifyAPIRepositoryFactory
+
if cls._workflow_run_repo is None:
with cls._repo_lock:
if cls._workflow_run_repo is None:
diff --git a/api/core/ops/utils.py b/api/core/ops/utils.py
index 631e3b77b2..a5196d66c0 100644
--- a/api/core/ops/utils.py
+++ b/api/core/ops/utils.py
@@ -5,7 +5,7 @@ from urllib.parse import urlparse
from sqlalchemy import select
-from extensions.ext_database import db
+from models.engine import db
from models.model import Message
diff --git a/api/core/plugin/backwards_invocation/app.py b/api/core/plugin/backwards_invocation/app.py
index 32e8ef385c..ca7b6506f3 100644
--- a/api/core/plugin/backwards_invocation/app.py
+++ b/api/core/plugin/backwards_invocation/app.py
@@ -1,3 +1,4 @@
+import uuid
from collections.abc import Generator, Mapping
from typing import Union
@@ -112,6 +113,7 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
"conversation_id": conversation_id,
},
invoke_from=InvokeFrom.SERVICE_API,
+ workflow_run_id=str(uuid.uuid4()),
streaming=stream,
)
elif app.mode == AppMode.AGENT_CHAT:
diff --git a/api/core/repositories/__init__.py b/api/core/repositories/__init__.py
index d83823d7b9..6f2826f634 100644
--- a/api/core/repositories/__init__.py
+++ b/api/core/repositories/__init__.py
@@ -1,19 +1,18 @@
-"""
-Repository implementations for data access.
+"""Repository implementations for data access."""
-This package contains concrete implementations of the repository interfaces
-defined in the core.workflow.repository package.
-"""
+from __future__ import annotations
-from core.repositories.celery_workflow_execution_repository import CeleryWorkflowExecutionRepository
-from core.repositories.celery_workflow_node_execution_repository import CeleryWorkflowNodeExecutionRepository
-from core.repositories.factory import DifyCoreRepositoryFactory, RepositoryImportError
-from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository
+from .celery_workflow_execution_repository import CeleryWorkflowExecutionRepository
+from .celery_workflow_node_execution_repository import CeleryWorkflowNodeExecutionRepository
+from .factory import DifyCoreRepositoryFactory, RepositoryImportError
+from .sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository
+from .sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository
__all__ = [
"CeleryWorkflowExecutionRepository",
"CeleryWorkflowNodeExecutionRepository",
"DifyCoreRepositoryFactory",
"RepositoryImportError",
+ "SQLAlchemyWorkflowExecutionRepository",
"SQLAlchemyWorkflowNodeExecutionRepository",
]
diff --git a/api/core/repositories/human_input_repository.py b/api/core/repositories/human_input_repository.py
new file mode 100644
index 0000000000..0e04c56e0e
--- /dev/null
+++ b/api/core/repositories/human_input_repository.py
@@ -0,0 +1,553 @@
+import dataclasses
+import json
+from collections.abc import Mapping, Sequence
+from datetime import datetime
+from typing import Any
+
+from sqlalchemy import Engine, select
+from sqlalchemy.orm import Session, selectinload, sessionmaker
+
+from core.workflow.nodes.human_input.entities import (
+ DeliveryChannelConfig,
+ EmailDeliveryMethod,
+ EmailRecipients,
+ ExternalRecipient,
+ FormDefinition,
+ HumanInputNodeData,
+ MemberRecipient,
+ WebAppDeliveryMethod,
+)
+from core.workflow.nodes.human_input.enums import (
+ DeliveryMethodType,
+ HumanInputFormKind,
+ HumanInputFormStatus,
+)
+from core.workflow.repositories.human_input_form_repository import (
+ FormCreateParams,
+ FormNotFoundError,
+ HumanInputFormEntity,
+ HumanInputFormRecipientEntity,
+)
+from libs.datetime_utils import naive_utc_now
+from libs.uuid_utils import uuidv7
+from models.account import Account, TenantAccountJoin
+from models.human_input import (
+ BackstageRecipientPayload,
+ ConsoleDeliveryPayload,
+ ConsoleRecipientPayload,
+ EmailExternalRecipientPayload,
+ EmailMemberRecipientPayload,
+ HumanInputDelivery,
+ HumanInputForm,
+ HumanInputFormRecipient,
+ RecipientType,
+ StandaloneWebAppRecipientPayload,
+)
+
+
+@dataclasses.dataclass(frozen=True)
+class _DeliveryAndRecipients:
+ delivery: HumanInputDelivery
+ recipients: Sequence[HumanInputFormRecipient]
+
+
+@dataclasses.dataclass(frozen=True)
+class _WorkspaceMemberInfo:
+ user_id: str
+ email: str
+
+
+class _HumanInputFormRecipientEntityImpl(HumanInputFormRecipientEntity):
+ def __init__(self, recipient_model: HumanInputFormRecipient):
+ self._recipient_model = recipient_model
+
+ @property
+ def id(self) -> str:
+ return self._recipient_model.id
+
+ @property
+ def token(self) -> str:
+ if self._recipient_model.access_token is None:
+ raise AssertionError(f"access_token should not be None for recipient {self._recipient_model.id}")
+ return self._recipient_model.access_token
+
+
+class _HumanInputFormEntityImpl(HumanInputFormEntity):
+ def __init__(self, form_model: HumanInputForm, recipient_models: Sequence[HumanInputFormRecipient]):
+ self._form_model = form_model
+ self._recipients = [_HumanInputFormRecipientEntityImpl(recipient) for recipient in recipient_models]
+ self._web_app_recipient = next(
+ (
+ recipient
+ for recipient in recipient_models
+ if recipient.recipient_type == RecipientType.STANDALONE_WEB_APP
+ ),
+ None,
+ )
+ self._console_recipient = next(
+ (recipient for recipient in recipient_models if recipient.recipient_type == RecipientType.CONSOLE),
+ None,
+ )
+ self._submitted_data: Mapping[str, Any] | None = (
+ json.loads(form_model.submitted_data) if form_model.submitted_data is not None else None
+ )
+
+ @property
+ def id(self) -> str:
+ return self._form_model.id
+
+ @property
+ def web_app_token(self):
+ if self._console_recipient is not None:
+ return self._console_recipient.access_token
+ if self._web_app_recipient is None:
+ return None
+ return self._web_app_recipient.access_token
+
+ @property
+ def recipients(self) -> list[HumanInputFormRecipientEntity]:
+ return list(self._recipients)
+
+ @property
+ def rendered_content(self) -> str:
+ return self._form_model.rendered_content
+
+ @property
+ def selected_action_id(self) -> str | None:
+ return self._form_model.selected_action_id
+
+ @property
+ def submitted_data(self) -> Mapping[str, Any] | None:
+ return self._submitted_data
+
+ @property
+ def submitted(self) -> bool:
+ return self._form_model.submitted_at is not None
+
+ @property
+ def status(self) -> HumanInputFormStatus:
+ return self._form_model.status
+
+ @property
+ def expiration_time(self) -> datetime:
+ return self._form_model.expiration_time
+
+
+@dataclasses.dataclass(frozen=True)
+class HumanInputFormRecord:
+ form_id: str
+ workflow_run_id: str | None
+ node_id: str
+ tenant_id: str
+ app_id: str
+ form_kind: HumanInputFormKind
+ definition: FormDefinition
+ rendered_content: str
+ created_at: datetime
+ expiration_time: datetime
+ status: HumanInputFormStatus
+ selected_action_id: str | None
+ submitted_data: Mapping[str, Any] | None
+ submitted_at: datetime | None
+ submission_user_id: str | None
+ submission_end_user_id: str | None
+ completed_by_recipient_id: str | None
+ recipient_id: str | None
+ recipient_type: RecipientType | None
+ access_token: str | None
+
+ @property
+ def submitted(self) -> bool:
+ return self.submitted_at is not None
+
+ @classmethod
+ def from_models(
+ cls, form_model: HumanInputForm, recipient_model: HumanInputFormRecipient | None
+ ) -> "HumanInputFormRecord":
+ definition_payload = json.loads(form_model.form_definition)
+ if "expiration_time" not in definition_payload:
+ definition_payload["expiration_time"] = form_model.expiration_time
+ return cls(
+ form_id=form_model.id,
+ workflow_run_id=form_model.workflow_run_id,
+ node_id=form_model.node_id,
+ tenant_id=form_model.tenant_id,
+ app_id=form_model.app_id,
+ form_kind=form_model.form_kind,
+ definition=FormDefinition.model_validate(definition_payload),
+ rendered_content=form_model.rendered_content,
+ created_at=form_model.created_at,
+ expiration_time=form_model.expiration_time,
+ status=form_model.status,
+ selected_action_id=form_model.selected_action_id,
+ submitted_data=json.loads(form_model.submitted_data) if form_model.submitted_data else None,
+ submitted_at=form_model.submitted_at,
+ submission_user_id=form_model.submission_user_id,
+ submission_end_user_id=form_model.submission_end_user_id,
+ completed_by_recipient_id=form_model.completed_by_recipient_id,
+ recipient_id=recipient_model.id if recipient_model else None,
+ recipient_type=recipient_model.recipient_type if recipient_model else None,
+ access_token=recipient_model.access_token if recipient_model else None,
+ )
+
+
+class _InvalidTimeoutStatusError(ValueError):
+ pass
+
+
+class HumanInputFormRepositoryImpl:
+ def __init__(
+ self,
+ session_factory: sessionmaker | Engine,
+ tenant_id: str,
+ ):
+ if isinstance(session_factory, Engine):
+ session_factory = sessionmaker(bind=session_factory)
+ self._session_factory = session_factory
+ self._tenant_id = tenant_id
+
+ def _delivery_method_to_model(
+ self,
+ session: Session,
+ form_id: str,
+ delivery_method: DeliveryChannelConfig,
+ ) -> _DeliveryAndRecipients:
+ delivery_id = str(uuidv7())
+ delivery_model = HumanInputDelivery(
+ id=delivery_id,
+ form_id=form_id,
+ delivery_method_type=delivery_method.type,
+ delivery_config_id=delivery_method.id,
+ channel_payload=delivery_method.model_dump_json(),
+ )
+ recipients: list[HumanInputFormRecipient] = []
+ if isinstance(delivery_method, WebAppDeliveryMethod):
+ recipient_model = HumanInputFormRecipient(
+ form_id=form_id,
+ delivery_id=delivery_id,
+ recipient_type=RecipientType.STANDALONE_WEB_APP,
+ recipient_payload=StandaloneWebAppRecipientPayload().model_dump_json(),
+ )
+ recipients.append(recipient_model)
+ elif isinstance(delivery_method, EmailDeliveryMethod):
+ email_recipients_config = delivery_method.config.recipients
+ recipients.extend(
+ self._build_email_recipients(
+ session=session,
+ form_id=form_id,
+ delivery_id=delivery_id,
+ recipients_config=email_recipients_config,
+ )
+ )
+
+ return _DeliveryAndRecipients(delivery=delivery_model, recipients=recipients)
+
+ def _build_email_recipients(
+ self,
+ session: Session,
+ form_id: str,
+ delivery_id: str,
+ recipients_config: EmailRecipients,
+ ) -> list[HumanInputFormRecipient]:
+ member_user_ids = [
+ recipient.user_id for recipient in recipients_config.items if isinstance(recipient, MemberRecipient)
+ ]
+ external_emails = [
+ recipient.email for recipient in recipients_config.items if isinstance(recipient, ExternalRecipient)
+ ]
+ if recipients_config.whole_workspace:
+ members = self._query_all_workspace_members(session=session)
+ else:
+ members = self._query_workspace_members_by_ids(session=session, restrict_to_user_ids=member_user_ids)
+
+ return self._create_email_recipients_from_resolved(
+ form_id=form_id,
+ delivery_id=delivery_id,
+ members=members,
+ external_emails=external_emails,
+ )
+
+ @staticmethod
+ def _create_email_recipients_from_resolved(
+ *,
+ form_id: str,
+ delivery_id: str,
+ members: Sequence[_WorkspaceMemberInfo],
+ external_emails: Sequence[str],
+ ) -> list[HumanInputFormRecipient]:
+ recipient_models: list[HumanInputFormRecipient] = []
+ seen_emails: set[str] = set()
+
+ for member in members:
+ if not member.email:
+ continue
+ if member.email in seen_emails:
+ continue
+ seen_emails.add(member.email)
+ payload = EmailMemberRecipientPayload(user_id=member.user_id, email=member.email)
+ recipient_models.append(
+ HumanInputFormRecipient.new(
+ form_id=form_id,
+ delivery_id=delivery_id,
+ payload=payload,
+ )
+ )
+
+ for email in external_emails:
+ if not email:
+ continue
+ if email in seen_emails:
+ continue
+ seen_emails.add(email)
+ recipient_models.append(
+ HumanInputFormRecipient.new(
+ form_id=form_id,
+ delivery_id=delivery_id,
+ payload=EmailExternalRecipientPayload(email=email),
+ )
+ )
+
+ return recipient_models
+
+ def _query_all_workspace_members(
+ self,
+ session: Session,
+ ) -> list[_WorkspaceMemberInfo]:
+ stmt = (
+ select(Account.id, Account.email)
+ .join(TenantAccountJoin, TenantAccountJoin.account_id == Account.id)
+ .where(TenantAccountJoin.tenant_id == self._tenant_id)
+ )
+ rows = session.execute(stmt).all()
+ return [_WorkspaceMemberInfo(user_id=account_id, email=email) for account_id, email in rows]
+
+ def _query_workspace_members_by_ids(
+ self,
+ session: Session,
+ restrict_to_user_ids: Sequence[str],
+ ) -> list[_WorkspaceMemberInfo]:
+ unique_ids = {user_id for user_id in restrict_to_user_ids if user_id}
+ if not unique_ids:
+ return []
+
+ stmt = (
+ select(Account.id, Account.email)
+ .join(TenantAccountJoin, TenantAccountJoin.account_id == Account.id)
+ .where(TenantAccountJoin.tenant_id == self._tenant_id)
+ )
+ stmt = stmt.where(Account.id.in_(unique_ids))
+
+ rows = session.execute(stmt).all()
+ return [_WorkspaceMemberInfo(user_id=account_id, email=email) for account_id, email in rows]
+
+ def create_form(self, params: FormCreateParams) -> HumanInputFormEntity:
+ form_config: HumanInputNodeData = params.form_config
+
+ with self._session_factory(expire_on_commit=False) as session, session.begin():
+ # Generate unique form ID
+ form_id = str(uuidv7())
+ start_time = naive_utc_now()
+ node_expiration = form_config.expiration_time(start_time)
+ form_definition = FormDefinition(
+ form_content=form_config.form_content,
+ inputs=form_config.inputs,
+ user_actions=form_config.user_actions,
+ rendered_content=params.rendered_content,
+ expiration_time=node_expiration,
+ default_values=dict(params.resolved_default_values),
+ display_in_ui=params.display_in_ui,
+ node_title=form_config.title,
+ )
+ form_model = HumanInputForm(
+ id=form_id,
+ tenant_id=self._tenant_id,
+ app_id=params.app_id,
+ workflow_run_id=params.workflow_execution_id,
+ form_kind=params.form_kind,
+ node_id=params.node_id,
+ form_definition=form_definition.model_dump_json(),
+ rendered_content=params.rendered_content,
+ expiration_time=node_expiration,
+ created_at=start_time,
+ )
+ session.add(form_model)
+ recipient_models: list[HumanInputFormRecipient] = []
+ for delivery in params.delivery_methods:
+ delivery_and_recipients = self._delivery_method_to_model(
+ session=session,
+ form_id=form_id,
+ delivery_method=delivery,
+ )
+ session.add(delivery_and_recipients.delivery)
+ session.add_all(delivery_and_recipients.recipients)
+ recipient_models.extend(delivery_and_recipients.recipients)
+ if params.console_recipient_required and not any(
+ recipient.recipient_type == RecipientType.CONSOLE for recipient in recipient_models
+ ):
+ console_delivery_id = str(uuidv7())
+ console_delivery = HumanInputDelivery(
+ id=console_delivery_id,
+ form_id=form_id,
+ delivery_method_type=DeliveryMethodType.WEBAPP,
+ delivery_config_id=None,
+ channel_payload=ConsoleDeliveryPayload().model_dump_json(),
+ )
+ console_recipient = HumanInputFormRecipient(
+ form_id=form_id,
+ delivery_id=console_delivery_id,
+ recipient_type=RecipientType.CONSOLE,
+ recipient_payload=ConsoleRecipientPayload(
+ account_id=params.console_creator_account_id,
+ ).model_dump_json(),
+ )
+ session.add(console_delivery)
+ session.add(console_recipient)
+ recipient_models.append(console_recipient)
+ if params.backstage_recipient_required and not any(
+ recipient.recipient_type == RecipientType.BACKSTAGE for recipient in recipient_models
+ ):
+ backstage_delivery_id = str(uuidv7())
+ backstage_delivery = HumanInputDelivery(
+ id=backstage_delivery_id,
+ form_id=form_id,
+ delivery_method_type=DeliveryMethodType.WEBAPP,
+ delivery_config_id=None,
+ channel_payload=ConsoleDeliveryPayload().model_dump_json(),
+ )
+ backstage_recipient = HumanInputFormRecipient(
+ form_id=form_id,
+ delivery_id=backstage_delivery_id,
+ recipient_type=RecipientType.BACKSTAGE,
+ recipient_payload=BackstageRecipientPayload(
+ account_id=params.console_creator_account_id,
+ ).model_dump_json(),
+ )
+ session.add(backstage_delivery)
+ session.add(backstage_recipient)
+ recipient_models.append(backstage_recipient)
+ session.flush()
+
+ return _HumanInputFormEntityImpl(form_model=form_model, recipient_models=recipient_models)
+
+ def get_form(self, workflow_execution_id: str, node_id: str) -> HumanInputFormEntity | None:
+ form_query = select(HumanInputForm).where(
+ HumanInputForm.workflow_run_id == workflow_execution_id,
+ HumanInputForm.node_id == node_id,
+ HumanInputForm.tenant_id == self._tenant_id,
+ )
+ with self._session_factory(expire_on_commit=False) as session:
+ form_model: HumanInputForm | None = session.scalars(form_query).first()
+ if form_model is None:
+ return None
+
+ recipient_query = select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id == form_model.id)
+ recipient_models = session.scalars(recipient_query).all()
+ return _HumanInputFormEntityImpl(form_model=form_model, recipient_models=recipient_models)
+
+
+class HumanInputFormSubmissionRepository:
+ """Repository for fetching and submitting human input forms."""
+
+ def __init__(self, session_factory: sessionmaker | Engine):
+ if isinstance(session_factory, Engine):
+ session_factory = sessionmaker(bind=session_factory)
+ self._session_factory = session_factory
+
+ def get_by_token(self, form_token: str) -> HumanInputFormRecord | None:
+ query = (
+ select(HumanInputFormRecipient)
+ .options(selectinload(HumanInputFormRecipient.form))
+ .where(HumanInputFormRecipient.access_token == form_token)
+ )
+ with self._session_factory(expire_on_commit=False) as session:
+ recipient_model = session.scalars(query).first()
+ if recipient_model is None or recipient_model.form is None:
+ return None
+ return HumanInputFormRecord.from_models(recipient_model.form, recipient_model)
+
+ def get_by_form_id_and_recipient_type(
+ self,
+ form_id: str,
+ recipient_type: RecipientType,
+ ) -> HumanInputFormRecord | None:
+ query = (
+ select(HumanInputFormRecipient)
+ .options(selectinload(HumanInputFormRecipient.form))
+ .where(
+ HumanInputFormRecipient.form_id == form_id,
+ HumanInputFormRecipient.recipient_type == recipient_type,
+ )
+ )
+ with self._session_factory(expire_on_commit=False) as session:
+ recipient_model = session.scalars(query).first()
+ if recipient_model is None or recipient_model.form is None:
+ return None
+ return HumanInputFormRecord.from_models(recipient_model.form, recipient_model)
+
+ def mark_submitted(
+ self,
+ *,
+ form_id: str,
+ recipient_id: str | None,
+ selected_action_id: str,
+ form_data: Mapping[str, Any],
+ submission_user_id: str | None,
+ submission_end_user_id: str | None,
+ ) -> HumanInputFormRecord:
+ with self._session_factory(expire_on_commit=False) as session, session.begin():
+ form_model = session.get(HumanInputForm, form_id)
+ if form_model is None:
+ raise FormNotFoundError(f"form not found, id={form_id}")
+
+ recipient_model = session.get(HumanInputFormRecipient, recipient_id) if recipient_id else None
+
+ form_model.selected_action_id = selected_action_id
+ form_model.submitted_data = json.dumps(form_data)
+ form_model.submitted_at = naive_utc_now()
+ form_model.status = HumanInputFormStatus.SUBMITTED
+ form_model.submission_user_id = submission_user_id
+ form_model.submission_end_user_id = submission_end_user_id
+ form_model.completed_by_recipient_id = recipient_id
+
+ session.add(form_model)
+ session.flush()
+ session.refresh(form_model)
+ if recipient_model is not None:
+ session.refresh(recipient_model)
+
+ return HumanInputFormRecord.from_models(form_model, recipient_model)
+
+ def mark_timeout(
+ self,
+ *,
+ form_id: str,
+ timeout_status: HumanInputFormStatus,
+ reason: str | None = None,
+ ) -> HumanInputFormRecord:
+ with self._session_factory(expire_on_commit=False) as session, session.begin():
+ form_model = session.get(HumanInputForm, form_id)
+ if form_model is None:
+ raise FormNotFoundError(f"form not found, id={form_id}")
+
+ if timeout_status not in {HumanInputFormStatus.TIMEOUT, HumanInputFormStatus.EXPIRED}:
+ raise _InvalidTimeoutStatusError(f"invalid timeout status: {timeout_status}")
+
+ # already handled or submitted
+ if form_model.status in {HumanInputFormStatus.TIMEOUT, HumanInputFormStatus.EXPIRED}:
+ return HumanInputFormRecord.from_models(form_model, None)
+
+ if form_model.submitted_at is not None or form_model.status == HumanInputFormStatus.SUBMITTED:
+ raise FormNotFoundError(f"form already submitted, id={form_id}")
+
+ form_model.status = timeout_status
+ form_model.selected_action_id = None
+ form_model.submitted_data = None
+ form_model.submission_user_id = None
+ form_model.submission_end_user_id = None
+ form_model.completed_by_recipient_id = None
+ # Reason is recorded in status/error downstream; not stored on form.
+ session.add(form_model)
+ session.flush()
+ session.refresh(form_model)
+
+ return HumanInputFormRecord.from_models(form_model, None)
diff --git a/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py b/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py
index 4436773d25..324dd059d1 100644
--- a/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py
+++ b/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py
@@ -488,6 +488,7 @@ class SQLAlchemyWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository)
WorkflowNodeExecutionModel.workflow_run_id == workflow_run_id,
WorkflowNodeExecutionModel.tenant_id == self._tenant_id,
WorkflowNodeExecutionModel.triggered_from == triggered_from,
+ WorkflowNodeExecutionModel.status != WorkflowNodeExecutionStatus.PAUSED,
)
if self._app_id:
diff --git a/api/core/tools/errors.py b/api/core/tools/errors.py
index e4afe24426..4c3efd6ff9 100644
--- a/api/core/tools/errors.py
+++ b/api/core/tools/errors.py
@@ -1,4 +1,5 @@
from core.tools.entities.tool_entities import ToolInvokeMeta
+from libs.exception import BaseHTTPException
class ToolProviderNotFoundError(ValueError):
@@ -37,6 +38,12 @@ class ToolCredentialPolicyViolationError(ValueError):
pass
+class WorkflowToolHumanInputNotSupportedError(BaseHTTPException):
+ error_code = "workflow_tool_human_input_not_supported"
+ description = "Workflow with Human Input nodes cannot be published as a workflow tool."
+ code = 400
+
+
class ToolEngineInvokeError(Exception):
meta: ToolInvokeMeta
diff --git a/api/core/tools/utils/workflow_configuration_sync.py b/api/core/tools/utils/workflow_configuration_sync.py
index 188da0c32d..8588ccc718 100644
--- a/api/core/tools/utils/workflow_configuration_sync.py
+++ b/api/core/tools/utils/workflow_configuration_sync.py
@@ -3,6 +3,8 @@ from typing import Any
from core.app.app_config.entities import VariableEntity
from core.tools.entities.tool_entities import WorkflowToolParameterConfiguration
+from core.tools.errors import WorkflowToolHumanInputNotSupportedError
+from core.workflow.enums import NodeType
from core.workflow.nodes.base.entities import OutputVariableEntity
@@ -50,6 +52,13 @@ class WorkflowToolConfigurationUtils:
return [outputs_by_variable[variable] for variable in variable_order]
+ @classmethod
+ def ensure_no_human_input_nodes(cls, graph: Mapping[str, Any]) -> None:
+ nodes = graph.get("nodes", [])
+ for node in nodes:
+ if node.get("data", {}).get("type") == NodeType.HUMAN_INPUT:
+ raise WorkflowToolHumanInputNotSupportedError()
+
@classmethod
def check_is_synced(
cls, variables: list[VariableEntity], tool_configurations: list[WorkflowToolParameterConfiguration]
diff --git a/api/core/workflow/entities/__init__.py b/api/core/workflow/entities/__init__.py
index be70e467a0..e73c38c1d3 100644
--- a/api/core/workflow/entities/__init__.py
+++ b/api/core/workflow/entities/__init__.py
@@ -2,10 +2,12 @@ from .agent import AgentNodeStrategyInit
from .graph_init_params import GraphInitParams
from .workflow_execution import WorkflowExecution
from .workflow_node_execution import WorkflowNodeExecution
+from .workflow_start_reason import WorkflowStartReason
__all__ = [
"AgentNodeStrategyInit",
"GraphInitParams",
"WorkflowExecution",
"WorkflowNodeExecution",
+ "WorkflowStartReason",
]
diff --git a/api/core/workflow/entities/graph_init_params.py b/api/core/workflow/entities/graph_init_params.py
index 7bf25b9f43..ff224a28d1 100644
--- a/api/core/workflow/entities/graph_init_params.py
+++ b/api/core/workflow/entities/graph_init_params.py
@@ -5,6 +5,16 @@ from pydantic import BaseModel, Field
class GraphInitParams(BaseModel):
+ """GraphInitParams encapsulates the configurations and contextual information
+ that remain constant throughout a single execution of the graph engine.
+
+ A single execution is defined as follows: as long as the execution has not reached
+ its conclusion, it is considered one execution. For instance, if a workflow is suspended
+ and later resumed, it is still regarded as a single execution, not two.
+
+ For the state diagram of workflow execution, refer to `WorkflowExecutionStatus`.
+ """
+
# init params
tenant_id: str = Field(..., description="tenant / workspace id")
app_id: str = Field(..., description="app id")
diff --git a/api/core/workflow/entities/pause_reason.py b/api/core/workflow/entities/pause_reason.py
index c6655b7eab..147f56e8be 100644
--- a/api/core/workflow/entities/pause_reason.py
+++ b/api/core/workflow/entities/pause_reason.py
@@ -1,8 +1,11 @@
+from collections.abc import Mapping
from enum import StrEnum, auto
-from typing import Annotated, Literal, TypeAlias
+from typing import Annotated, Any, Literal, TypeAlias
from pydantic import BaseModel, Field
+from core.workflow.nodes.human_input.entities import FormInput, UserAction
+
class PauseReasonType(StrEnum):
HUMAN_INPUT_REQUIRED = auto()
@@ -11,10 +14,31 @@ class PauseReasonType(StrEnum):
class HumanInputRequired(BaseModel):
TYPE: Literal[PauseReasonType.HUMAN_INPUT_REQUIRED] = PauseReasonType.HUMAN_INPUT_REQUIRED
-
form_id: str
- # The identifier of the human input node causing the pause.
+ form_content: str
+ inputs: list[FormInput] = Field(default_factory=list)
+ actions: list[UserAction] = Field(default_factory=list)
+ display_in_ui: bool = False
node_id: str
+ node_title: str
+
+ # The `resolved_default_values` stores the resolved values of variable defaults. It's a mapping from
+ # `output_variable_name` to their resolved values.
+ #
+ # For example, The form contains a input with output variable name `name` and placeholder type `VARIABLE`, its
+ # selector is ["start", "name"]. While the HumanInputNode is executed, the correspond value of variable
+ # `start.name` in variable pool is `John`. Thus, the resolved value of the output variable `name` is `John`. The
+ # `resolved_default_values` is `{"name": "John"}`.
+ #
+ # Only form inputs with default value type `VARIABLE` will be resolved and stored in `resolved_default_values`.
+ resolved_default_values: Mapping[str, Any] = Field(default_factory=dict)
+
+ # The `form_token` is the token used to submit the form via UI surfaces. It corresponds to
+ # `HumanInputFormRecipient.access_token`.
+ #
+ # This field is `None` if webapp delivery is not set and not
+ # in orchestrating mode.
+ form_token: str | None = None
class SchedulingPause(BaseModel):
diff --git a/api/core/workflow/entities/workflow_start_reason.py b/api/core/workflow/entities/workflow_start_reason.py
new file mode 100644
index 0000000000..df0f75383b
--- /dev/null
+++ b/api/core/workflow/entities/workflow_start_reason.py
@@ -0,0 +1,8 @@
+from enum import StrEnum
+
+
+class WorkflowStartReason(StrEnum):
+ """Reason for workflow start events across graph/queue/SSE layers."""
+
+ INITIAL = "initial" # First start of a workflow run.
+ RESUMPTION = "resumption" # Start triggered after resuming a paused run.
diff --git a/api/core/workflow/graph_engine/_engine_utils.py b/api/core/workflow/graph_engine/_engine_utils.py
new file mode 100644
index 0000000000..28898268fe
--- /dev/null
+++ b/api/core/workflow/graph_engine/_engine_utils.py
@@ -0,0 +1,15 @@
+import time
+
+
+def get_timestamp() -> float:
+ """Retrieve a timestamp as a float point numer representing the number of seconds
+ since the Unix epoch.
+
+ This function is primarily used to measure the execution time of the workflow engine.
+ Since workflow execution may be paused and resumed on a different machine,
+ `time.perf_counter` cannot be used as it is inconsistent across machines.
+
+ To address this, the function uses the wall clock as the time source.
+ However, it assumes that the clocks of all servers are properly synchronized.
+ """
+ return round(time.time())
diff --git a/api/core/workflow/graph_engine/config.py b/api/core/workflow/graph_engine/config.py
index 10dbbd7535..d56a69cee0 100644
--- a/api/core/workflow/graph_engine/config.py
+++ b/api/core/workflow/graph_engine/config.py
@@ -2,12 +2,14 @@
GraphEngine configuration models.
"""
-from pydantic import BaseModel
+from pydantic import BaseModel, ConfigDict
class GraphEngineConfig(BaseModel):
"""Configuration for GraphEngine worker pool scaling."""
+ model_config = ConfigDict(frozen=True)
+
min_workers: int = 1
max_workers: int = 5
scale_up_threshold: int = 3
diff --git a/api/core/workflow/graph_engine/event_management/event_handlers.py b/api/core/workflow/graph_engine/event_management/event_handlers.py
index 5b0f56e59d..98a0702e1c 100644
--- a/api/core/workflow/graph_engine/event_management/event_handlers.py
+++ b/api/core/workflow/graph_engine/event_management/event_handlers.py
@@ -192,9 +192,13 @@ class EventHandler:
self._event_collector.collect(edge_event)
# Enqueue ready nodes
- for node_id in ready_nodes:
- self._state_manager.enqueue_node(node_id)
- self._state_manager.start_execution(node_id)
+ if self._graph_execution.is_paused:
+ for node_id in ready_nodes:
+ self._graph_runtime_state.register_deferred_node(node_id)
+ else:
+ for node_id in ready_nodes:
+ self._state_manager.enqueue_node(node_id)
+ self._state_manager.start_execution(node_id)
# Update execution tracking
self._state_manager.finish_execution(event.node_id)
diff --git a/api/core/workflow/graph_engine/graph_engine.py b/api/core/workflow/graph_engine/graph_engine.py
index 0b359a2392..ac9e00e29e 100644
--- a/api/core/workflow/graph_engine/graph_engine.py
+++ b/api/core/workflow/graph_engine/graph_engine.py
@@ -14,6 +14,7 @@ from collections.abc import Generator
from typing import TYPE_CHECKING, cast, final
from core.workflow.context import capture_current_context
+from core.workflow.entities.workflow_start_reason import WorkflowStartReason
from core.workflow.enums import NodeExecutionType
from core.workflow.graph import Graph
from core.workflow.graph_events import (
@@ -56,6 +57,9 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
+_DEFAULT_CONFIG = GraphEngineConfig()
+
+
@final
class GraphEngine:
"""
@@ -71,7 +75,7 @@ class GraphEngine:
graph: Graph,
graph_runtime_state: GraphRuntimeState,
command_channel: CommandChannel,
- config: GraphEngineConfig,
+ config: GraphEngineConfig = _DEFAULT_CONFIG,
) -> None:
"""Initialize the graph engine with all subsystems and dependencies."""
# stop event
@@ -235,7 +239,9 @@ class GraphEngine:
self._graph_execution.paused = False
self._graph_execution.pause_reasons = []
- start_event = GraphRunStartedEvent()
+ start_event = GraphRunStartedEvent(
+ reason=WorkflowStartReason.RESUMPTION if is_resume else WorkflowStartReason.INITIAL,
+ )
self._event_manager.notify_layers(start_event)
yield start_event
@@ -304,15 +310,17 @@ class GraphEngine:
for layer in self._layers:
try:
layer.on_graph_start()
- except Exception as e:
- logger.warning("Layer %s failed on_graph_start: %s", layer.__class__.__name__, e)
+ except Exception:
+ logger.exception("Layer %s failed on_graph_start", layer.__class__.__name__)
def _start_execution(self, *, resume: bool = False) -> None:
"""Start execution subsystems."""
self._stop_event.clear()
paused_nodes: list[str] = []
+ deferred_nodes: list[str] = []
if resume:
paused_nodes = self._graph_runtime_state.consume_paused_nodes()
+ deferred_nodes = self._graph_runtime_state.consume_deferred_nodes()
# Start worker pool (it calculates initial workers internally)
self._worker_pool.start()
@@ -328,7 +336,11 @@ class GraphEngine:
self._state_manager.enqueue_node(root_node.id)
self._state_manager.start_execution(root_node.id)
else:
- for node_id in paused_nodes:
+ seen_nodes: set[str] = set()
+ for node_id in paused_nodes + deferred_nodes:
+ if node_id in seen_nodes:
+ continue
+ seen_nodes.add(node_id)
self._state_manager.enqueue_node(node_id)
self._state_manager.start_execution(node_id)
@@ -346,8 +358,8 @@ class GraphEngine:
for layer in self._layers:
try:
layer.on_graph_end(self._graph_execution.error)
- except Exception as e:
- logger.warning("Layer %s failed on_graph_end: %s", layer.__class__.__name__, e)
+ except Exception:
+ logger.exception("Layer %s failed on_graph_end", layer.__class__.__name__)
# Public property accessors for attributes that need external access
@property
diff --git a/api/core/workflow/graph_engine/graph_state_manager.py b/api/core/workflow/graph_engine/graph_state_manager.py
index 22a3a826fc..d9773645c3 100644
--- a/api/core/workflow/graph_engine/graph_state_manager.py
+++ b/api/core/workflow/graph_engine/graph_state_manager.py
@@ -224,6 +224,8 @@ class GraphStateManager:
Returns:
Number of executing nodes
"""
+ # This count is a best-effort snapshot and can change concurrently.
+ # Only use it for pause-drain checks where scheduling is already frozen.
with self._lock:
return len(self._executing_nodes)
diff --git a/api/core/workflow/graph_engine/orchestration/dispatcher.py b/api/core/workflow/graph_engine/orchestration/dispatcher.py
index 27439a2412..d40d15c545 100644
--- a/api/core/workflow/graph_engine/orchestration/dispatcher.py
+++ b/api/core/workflow/graph_engine/orchestration/dispatcher.py
@@ -83,12 +83,12 @@ class Dispatcher:
"""Main dispatcher loop."""
try:
self._process_commands()
+ paused = False
while not self._stop_event.is_set():
- if (
- self._execution_coordinator.aborted
- or self._execution_coordinator.paused
- or self._execution_coordinator.execution_complete
- ):
+ if self._execution_coordinator.aborted or self._execution_coordinator.execution_complete:
+ break
+ if self._execution_coordinator.paused:
+ paused = True
break
self._execution_coordinator.check_scaling()
@@ -101,13 +101,10 @@ class Dispatcher:
time.sleep(0.1)
self._process_commands()
- while True:
- try:
- event = self._event_queue.get(block=False)
- self._event_handler.dispatch(event)
- self._event_queue.task_done()
- except queue.Empty:
- break
+ if paused:
+ self._drain_events_until_idle()
+ else:
+ self._drain_event_queue()
except Exception as e:
logger.exception("Dispatcher error")
@@ -122,3 +119,24 @@ class Dispatcher:
def _process_commands(self, event: GraphNodeEventBase | None = None):
if event is None or isinstance(event, self._COMMAND_TRIGGER_EVENTS):
self._execution_coordinator.process_commands()
+
+ def _drain_event_queue(self) -> None:
+ while True:
+ try:
+ event = self._event_queue.get(block=False)
+ self._event_handler.dispatch(event)
+ self._event_queue.task_done()
+ except queue.Empty:
+ break
+
+ def _drain_events_until_idle(self) -> None:
+ while not self._stop_event.is_set():
+ try:
+ event = self._event_queue.get(timeout=0.1)
+ self._event_handler.dispatch(event)
+ self._event_queue.task_done()
+ self._process_commands(event)
+ except queue.Empty:
+ if not self._execution_coordinator.has_executing_nodes():
+ break
+ self._drain_event_queue()
diff --git a/api/core/workflow/graph_engine/orchestration/execution_coordinator.py b/api/core/workflow/graph_engine/orchestration/execution_coordinator.py
index e8e8f9f16c..0f8550eb12 100644
--- a/api/core/workflow/graph_engine/orchestration/execution_coordinator.py
+++ b/api/core/workflow/graph_engine/orchestration/execution_coordinator.py
@@ -94,3 +94,11 @@ class ExecutionCoordinator:
self._worker_pool.stop()
self._state_manager.clear_executing()
+
+ def has_executing_nodes(self) -> bool:
+ """Return True if any nodes are currently marked as executing."""
+ # This check is only safe once execution has already paused.
+ # Before pause, executing state can change concurrently, which makes the result unreliable.
+ if not self._graph_execution.is_paused:
+ raise AssertionError("has_executing_nodes should only be called after execution is paused")
+ return self._state_manager.get_executing_count() > 0
diff --git a/api/core/workflow/graph_events/__init__.py b/api/core/workflow/graph_events/__init__.py
index 2b6ee4ec1c..56ea642092 100644
--- a/api/core/workflow/graph_events/__init__.py
+++ b/api/core/workflow/graph_events/__init__.py
@@ -38,6 +38,8 @@ from .loop import (
from .node import (
NodeRunExceptionEvent,
NodeRunFailedEvent,
+ NodeRunHumanInputFormFilledEvent,
+ NodeRunHumanInputFormTimeoutEvent,
NodeRunPauseRequestedEvent,
NodeRunRetrieverResourceEvent,
NodeRunRetryEvent,
@@ -60,6 +62,8 @@ __all__ = [
"NodeRunAgentLogEvent",
"NodeRunExceptionEvent",
"NodeRunFailedEvent",
+ "NodeRunHumanInputFormFilledEvent",
+ "NodeRunHumanInputFormTimeoutEvent",
"NodeRunIterationFailedEvent",
"NodeRunIterationNextEvent",
"NodeRunIterationStartedEvent",
diff --git a/api/core/workflow/graph_events/graph.py b/api/core/workflow/graph_events/graph.py
index 5d10a76c15..f46526bcab 100644
--- a/api/core/workflow/graph_events/graph.py
+++ b/api/core/workflow/graph_events/graph.py
@@ -1,11 +1,16 @@
from pydantic import Field
from core.workflow.entities.pause_reason import PauseReason
+from core.workflow.entities.workflow_start_reason import WorkflowStartReason
from core.workflow.graph_events import BaseGraphEvent
class GraphRunStartedEvent(BaseGraphEvent):
- pass
+ # Reason is emitted for workflow start events and is always set.
+ reason: WorkflowStartReason = Field(
+ default=WorkflowStartReason.INITIAL,
+ description="reason for workflow start",
+ )
class GraphRunSucceededEvent(BaseGraphEvent):
diff --git a/api/core/workflow/graph_events/human_input.py b/api/core/workflow/graph_events/human_input.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/core/workflow/graph_events/node.py b/api/core/workflow/graph_events/node.py
index 4d0108e77b..975d72ad1f 100644
--- a/api/core/workflow/graph_events/node.py
+++ b/api/core/workflow/graph_events/node.py
@@ -54,6 +54,22 @@ class NodeRunRetryEvent(NodeRunStartedEvent):
retry_index: int = Field(..., description="which retry attempt is about to be performed")
+class NodeRunHumanInputFormFilledEvent(GraphNodeEventBase):
+ """Emitted when a HumanInput form is submitted and before the node finishes."""
+
+ node_title: str = Field(..., description="HumanInput node title")
+ rendered_content: str = Field(..., description="Markdown content rendered with user inputs.")
+ action_id: str = Field(..., description="User action identifier chosen in the form.")
+ action_text: str = Field(..., description="Display text of the chosen action button.")
+
+
+class NodeRunHumanInputFormTimeoutEvent(GraphNodeEventBase):
+ """Emitted when a HumanInput form times out."""
+
+ node_title: str = Field(..., description="HumanInput node title")
+ expiration_time: datetime = Field(..., description="Form expiration time")
+
+
class NodeRunPauseRequestedEvent(GraphNodeEventBase):
reason: PauseReason = Field(..., description="pause reason")
diff --git a/api/core/workflow/node_events/__init__.py b/api/core/workflow/node_events/__init__.py
index f14a594c85..a9bef8f9a2 100644
--- a/api/core/workflow/node_events/__init__.py
+++ b/api/core/workflow/node_events/__init__.py
@@ -13,6 +13,8 @@ from .loop import (
LoopSucceededEvent,
)
from .node import (
+ HumanInputFormFilledEvent,
+ HumanInputFormTimeoutEvent,
ModelInvokeCompletedEvent,
PauseRequestedEvent,
RunRetrieverResourceEvent,
@@ -23,6 +25,8 @@ from .node import (
__all__ = [
"AgentLogEvent",
+ "HumanInputFormFilledEvent",
+ "HumanInputFormTimeoutEvent",
"IterationFailedEvent",
"IterationNextEvent",
"IterationStartedEvent",
diff --git a/api/core/workflow/node_events/node.py b/api/core/workflow/node_events/node.py
index e4fa52f444..9c76b7d7c2 100644
--- a/api/core/workflow/node_events/node.py
+++ b/api/core/workflow/node_events/node.py
@@ -47,3 +47,19 @@ class StreamCompletedEvent(NodeEventBase):
class PauseRequestedEvent(NodeEventBase):
reason: PauseReason = Field(..., description="pause reason")
+
+
+class HumanInputFormFilledEvent(NodeEventBase):
+ """Event emitted when a human input form is submitted."""
+
+ node_title: str
+ rendered_content: str
+ action_id: str
+ action_text: str
+
+
+class HumanInputFormTimeoutEvent(NodeEventBase):
+ """Event emitted when a human input form times out."""
+
+ node_title: str
+ expiration_time: datetime
diff --git a/api/core/workflow/nodes/base/node.py b/api/core/workflow/nodes/base/node.py
index 63e0260341..2b773b537c 100644
--- a/api/core/workflow/nodes/base/node.py
+++ b/api/core/workflow/nodes/base/node.py
@@ -18,6 +18,8 @@ from core.workflow.graph_events import (
GraphNodeEventBase,
NodeRunAgentLogEvent,
NodeRunFailedEvent,
+ NodeRunHumanInputFormFilledEvent,
+ NodeRunHumanInputFormTimeoutEvent,
NodeRunIterationFailedEvent,
NodeRunIterationNextEvent,
NodeRunIterationStartedEvent,
@@ -34,6 +36,8 @@ from core.workflow.graph_events import (
)
from core.workflow.node_events import (
AgentLogEvent,
+ HumanInputFormFilledEvent,
+ HumanInputFormTimeoutEvent,
IterationFailedEvent,
IterationNextEvent,
IterationStartedEvent,
@@ -61,6 +65,15 @@ logger = logging.getLogger(__name__)
class Node(Generic[NodeDataT]):
+ """BaseNode serves as the foundational class for all node implementations.
+
+ Nodes are allowed to maintain transient states (e.g., `LLMNode` uses the `_file_output`
+ attribute to track files generated by the LLM). However, these states are not persisted
+ when the workflow is suspended or resumed. If a node needs its state to be preserved
+ across workflow suspension and resumption, it should include the relevant state data
+ in its output.
+ """
+
node_type: ClassVar[NodeType]
execution_type: NodeExecutionType = NodeExecutionType.EXECUTABLE
_node_data_type: ClassVar[type[BaseNodeData]] = BaseNodeData
@@ -251,10 +264,33 @@ class Node(Generic[NodeDataT]):
return self._node_execution_id
def ensure_execution_id(self) -> str:
- if not self._node_execution_id:
- self._node_execution_id = str(uuid4())
+ if self._node_execution_id:
+ return self._node_execution_id
+
+ resumed_execution_id = self._restore_execution_id_from_runtime_state()
+ if resumed_execution_id:
+ self._node_execution_id = resumed_execution_id
+ return self._node_execution_id
+
+ self._node_execution_id = str(uuid4())
return self._node_execution_id
+ def _restore_execution_id_from_runtime_state(self) -> str | None:
+ graph_execution = self.graph_runtime_state.graph_execution
+ try:
+ node_executions = graph_execution.node_executions
+ except AttributeError:
+ return None
+ if not isinstance(node_executions, dict):
+ return None
+ node_execution = node_executions.get(self._node_id)
+ if node_execution is None:
+ return None
+ execution_id = node_execution.execution_id
+ if not execution_id:
+ return None
+ return str(execution_id)
+
def _hydrate_node_data(self, data: Mapping[str, Any]) -> NodeDataT:
return cast(NodeDataT, self._node_data_type.model_validate(data))
@@ -620,6 +656,28 @@ class Node(Generic[NodeDataT]):
metadata=event.metadata,
)
+ @_dispatch.register
+ def _(self, event: HumanInputFormFilledEvent):
+ return NodeRunHumanInputFormFilledEvent(
+ id=self.execution_id,
+ node_id=self._node_id,
+ node_type=self.node_type,
+ node_title=event.node_title,
+ rendered_content=event.rendered_content,
+ action_id=event.action_id,
+ action_text=event.action_text,
+ )
+
+ @_dispatch.register
+ def _(self, event: HumanInputFormTimeoutEvent):
+ return NodeRunHumanInputFormTimeoutEvent(
+ id=self.execution_id,
+ node_id=self._node_id,
+ node_type=self.node_type,
+ node_title=event.node_title,
+ expiration_time=event.expiration_time,
+ )
+
@_dispatch.register
def _(self, event: LoopStartedEvent) -> NodeRunLoopStartedEvent:
return NodeRunLoopStartedEvent(
diff --git a/api/core/workflow/nodes/human_input/__init__.py b/api/core/workflow/nodes/human_input/__init__.py
index 379440557c..1789604577 100644
--- a/api/core/workflow/nodes/human_input/__init__.py
+++ b/api/core/workflow/nodes/human_input/__init__.py
@@ -1,3 +1,3 @@
-from .human_input_node import HumanInputNode
-
-__all__ = ["HumanInputNode"]
+"""
+Human Input node implementation.
+"""
diff --git a/api/core/workflow/nodes/human_input/entities.py b/api/core/workflow/nodes/human_input/entities.py
index 02913d93c3..72d4fc675b 100644
--- a/api/core/workflow/nodes/human_input/entities.py
+++ b/api/core/workflow/nodes/human_input/entities.py
@@ -1,10 +1,350 @@
-from pydantic import Field
+"""
+Human Input node entities.
+"""
+import re
+import uuid
+from collections.abc import Mapping, Sequence
+from datetime import datetime, timedelta
+from typing import Annotated, Any, ClassVar, Literal, Self
+
+from pydantic import BaseModel, Field, field_validator, model_validator
+
+from core.variables.consts import SELECTORS_LENGTH
from core.workflow.nodes.base import BaseNodeData
+from core.workflow.nodes.base.variable_template_parser import VariableTemplateParser
+from core.workflow.runtime import VariablePool
+
+from .enums import ButtonStyle, DeliveryMethodType, EmailRecipientType, FormInputType, PlaceholderType, TimeoutUnit
+
+_OUTPUT_VARIABLE_PATTERN = re.compile(r"\{\{#\$output\.(?P[a-zA-Z_][a-zA-Z0-9_]{0,29})#\}\}")
+
+
+class _WebAppDeliveryConfig(BaseModel):
+ """Configuration for webapp delivery method."""
+
+ pass # Empty for webapp delivery
+
+
+class MemberRecipient(BaseModel):
+ """Member recipient for email delivery."""
+
+ type: Literal[EmailRecipientType.MEMBER] = EmailRecipientType.MEMBER
+ user_id: str
+
+
+class ExternalRecipient(BaseModel):
+ """External recipient for email delivery."""
+
+ type: Literal[EmailRecipientType.EXTERNAL] = EmailRecipientType.EXTERNAL
+ email: str
+
+
+EmailRecipient = Annotated[MemberRecipient | ExternalRecipient, Field(discriminator="type")]
+
+
+class EmailRecipients(BaseModel):
+ """Email recipients configuration."""
+
+ # When true, recipients are the union of all workspace members and external items.
+ # Member items are ignored because they are already covered by the workspace scope.
+ # De-duplication is applied by email, with member recipients taking precedence.
+ whole_workspace: bool = False
+ items: list[EmailRecipient] = Field(default_factory=list)
+
+
+class EmailDeliveryConfig(BaseModel):
+ """Configuration for email delivery method."""
+
+ URL_PLACEHOLDER: ClassVar[str] = "{{#url#}}"
+
+ recipients: EmailRecipients
+
+ # the subject of email
+ subject: str
+
+ # Body is the content of email.It may contain the speical placeholder `{{#url#}}`, which
+ # represent the url to submit the form.
+ #
+ # It may also reference the output variable of the previous node with the syntax
+ # `{{#.#}}`.
+ body: str
+ debug_mode: bool = False
+
+ def with_debug_recipient(self, user_id: str) -> "EmailDeliveryConfig":
+ if not user_id:
+ debug_recipients = EmailRecipients(whole_workspace=False, items=[])
+ return self.model_copy(update={"recipients": debug_recipients})
+ debug_recipients = EmailRecipients(whole_workspace=False, items=[MemberRecipient(user_id=user_id)])
+ return self.model_copy(update={"recipients": debug_recipients})
+
+ @classmethod
+ def replace_url_placeholder(cls, body: str, url: str | None) -> str:
+ """Replace the url placeholder with provided value."""
+ return body.replace(cls.URL_PLACEHOLDER, url or "")
+
+ @classmethod
+ def render_body_template(
+ cls,
+ *,
+ body: str,
+ url: str | None,
+ variable_pool: VariablePool | None = None,
+ ) -> str:
+ """Render email body by replacing placeholders with runtime values."""
+ templated_body = cls.replace_url_placeholder(body, url)
+ if variable_pool is None:
+ return templated_body
+ return variable_pool.convert_template(templated_body).text
+
+
+class _DeliveryMethodBase(BaseModel):
+ """Base delivery method configuration."""
+
+ enabled: bool = True
+ id: uuid.UUID = Field(default_factory=uuid.uuid4)
+
+ def extract_variable_selectors(self) -> Sequence[Sequence[str]]:
+ return ()
+
+
+class WebAppDeliveryMethod(_DeliveryMethodBase):
+ """Webapp delivery method configuration."""
+
+ type: Literal[DeliveryMethodType.WEBAPP] = DeliveryMethodType.WEBAPP
+ # The config field is not used currently.
+ config: _WebAppDeliveryConfig = Field(default_factory=_WebAppDeliveryConfig)
+
+
+class EmailDeliveryMethod(_DeliveryMethodBase):
+ """Email delivery method configuration."""
+
+ type: Literal[DeliveryMethodType.EMAIL] = DeliveryMethodType.EMAIL
+ config: EmailDeliveryConfig
+
+ def extract_variable_selectors(self) -> Sequence[Sequence[str]]:
+ variable_template_parser = VariableTemplateParser(template=self.config.body)
+ selectors: list[Sequence[str]] = []
+ for variable_selector in variable_template_parser.extract_variable_selectors():
+ value_selector = list(variable_selector.value_selector)
+ if len(value_selector) < SELECTORS_LENGTH:
+ continue
+ selectors.append(value_selector[:SELECTORS_LENGTH])
+ return selectors
+
+
+DeliveryChannelConfig = Annotated[WebAppDeliveryMethod | EmailDeliveryMethod, Field(discriminator="type")]
+
+
+def apply_debug_email_recipient(
+ method: DeliveryChannelConfig,
+ *,
+ enabled: bool,
+ user_id: str,
+) -> DeliveryChannelConfig:
+ if not enabled:
+ return method
+ if not isinstance(method, EmailDeliveryMethod):
+ return method
+ if not method.config.debug_mode:
+ return method
+ debug_config = method.config.with_debug_recipient(user_id or "")
+ return method.model_copy(update={"config": debug_config})
+
+
+class FormInputDefault(BaseModel):
+ """Default configuration for form inputs."""
+
+ # NOTE: Ideally, a discriminated union would be used to model
+ # FormInputDefault. However, the UI requires preserving the previous
+ # value when switching between `VARIABLE` and `CONSTANT` types. This
+ # necessitates retaining all fields, making a discriminated union unsuitable.
+
+ type: PlaceholderType
+
+ # The selector of default variable, used when `type` is `VARIABLE`.
+ selector: Sequence[str] = Field(default_factory=tuple) #
+
+ # The value of the default, used when `type` is `CONSTANT`.
+ # TODO: How should we express JSON values?
+ value: str = ""
+
+ @model_validator(mode="after")
+ def _validate_selector(self) -> Self:
+ if self.type == PlaceholderType.CONSTANT:
+ return self
+ if len(self.selector) < SELECTORS_LENGTH:
+ raise ValueError(f"the length of selector should be at least {SELECTORS_LENGTH}, selector={self.selector}")
+ return self
+
+
+class FormInput(BaseModel):
+ """Form input definition."""
+
+ type: FormInputType
+ output_variable_name: str
+ default: FormInputDefault | None = None
+
+
+_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
+
+
+class UserAction(BaseModel):
+ """User action configuration."""
+
+ # id is the identifier for this action.
+ # It also serves as the identifiers of output handle.
+ #
+ # The id must be a valid identifier (satisfy the _IDENTIFIER_PATTERN above.)
+ id: str = Field(max_length=20)
+ title: str = Field(max_length=20)
+ button_style: ButtonStyle = ButtonStyle.DEFAULT
+
+ @field_validator("id")
+ @classmethod
+ def _validate_id(cls, value: str) -> str:
+ if not _IDENTIFIER_PATTERN.match(value):
+ raise ValueError(
+ f"'{value}' is not a valid identifier. It must start with a letter or underscore, "
+ f"and contain only letters, numbers, or underscores."
+ )
+ return value
class HumanInputNodeData(BaseNodeData):
- """Configuration schema for the HumanInput node."""
+ """Human Input node data."""
- required_variables: list[str] = Field(default_factory=list)
- pause_reason: str | None = Field(default=None)
+ delivery_methods: list[DeliveryChannelConfig] = Field(default_factory=list)
+ form_content: str = ""
+ inputs: list[FormInput] = Field(default_factory=list)
+ user_actions: list[UserAction] = Field(default_factory=list)
+ timeout: int = 36
+ timeout_unit: TimeoutUnit = TimeoutUnit.HOUR
+
+ @field_validator("inputs")
+ @classmethod
+ def _validate_inputs(cls, inputs: list[FormInput]) -> list[FormInput]:
+ seen_names: set[str] = set()
+ for form_input in inputs:
+ name = form_input.output_variable_name
+ if name in seen_names:
+ raise ValueError(f"duplicated output_variable_name '{name}' in inputs")
+ seen_names.add(name)
+ return inputs
+
+ @field_validator("user_actions")
+ @classmethod
+ def _validate_user_actions(cls, user_actions: list[UserAction]) -> list[UserAction]:
+ seen_ids: set[str] = set()
+ for action in user_actions:
+ action_id = action.id
+ if action_id in seen_ids:
+ raise ValueError(f"duplicated user action id '{action_id}'")
+ seen_ids.add(action_id)
+ return user_actions
+
+ def is_webapp_enabled(self) -> bool:
+ for dm in self.delivery_methods:
+ if not dm.enabled:
+ continue
+ if dm.type == DeliveryMethodType.WEBAPP:
+ return True
+ return False
+
+ def expiration_time(self, start_time: datetime) -> datetime:
+ if self.timeout_unit == TimeoutUnit.HOUR:
+ return start_time + timedelta(hours=self.timeout)
+ elif self.timeout_unit == TimeoutUnit.DAY:
+ return start_time + timedelta(days=self.timeout)
+ else:
+ raise AssertionError("unknown timeout unit.")
+
+ def outputs_field_names(self) -> Sequence[str]:
+ field_names = []
+ for match in _OUTPUT_VARIABLE_PATTERN.finditer(self.form_content):
+ field_names.append(match.group("field_name"))
+ return field_names
+
+ def extract_variable_selector_to_variable_mapping(self, node_id: str) -> Mapping[str, Sequence[str]]:
+ variable_mappings: dict[str, Sequence[str]] = {}
+
+ def _add_variable_selectors(selectors: Sequence[Sequence[str]]) -> None:
+ for selector in selectors:
+ if len(selector) < SELECTORS_LENGTH:
+ continue
+ qualified_variable_mapping_key = f"{node_id}.#{'.'.join(selector[:SELECTORS_LENGTH])}#"
+ variable_mappings[qualified_variable_mapping_key] = list(selector[:SELECTORS_LENGTH])
+
+ form_template_parser = VariableTemplateParser(template=self.form_content)
+ _add_variable_selectors(
+ [selector.value_selector for selector in form_template_parser.extract_variable_selectors()]
+ )
+ for delivery_method in self.delivery_methods:
+ if not delivery_method.enabled:
+ continue
+ _add_variable_selectors(delivery_method.extract_variable_selectors())
+
+ for input in self.inputs:
+ default_value = input.default
+ if default_value is None:
+ continue
+ if default_value.type == PlaceholderType.CONSTANT:
+ continue
+ default_value_key = ".".join(default_value.selector)
+ qualified_variable_mapping_key = f"{node_id}.#{default_value_key}#"
+ variable_mappings[qualified_variable_mapping_key] = default_value.selector
+
+ return variable_mappings
+
+ def find_action_text(self, action_id: str) -> str:
+ """
+ Resolve action display text by id.
+ """
+ for action in self.user_actions:
+ if action.id == action_id:
+ return action.title
+ return action_id
+
+
+class FormDefinition(BaseModel):
+ form_content: str
+ inputs: list[FormInput] = Field(default_factory=list)
+ user_actions: list[UserAction] = Field(default_factory=list)
+ rendered_content: str
+ expiration_time: datetime
+
+ # this is used to store the resolved default values
+ default_values: dict[str, Any] = Field(default_factory=dict)
+
+ # node_title records the title of the HumanInput node.
+ node_title: str | None = None
+
+ # display_in_ui controls whether the form should be displayed in UI surfaces.
+ display_in_ui: bool | None = None
+
+
+class HumanInputSubmissionValidationError(ValueError):
+ pass
+
+
+def validate_human_input_submission(
+ *,
+ inputs: Sequence[FormInput],
+ user_actions: Sequence[UserAction],
+ selected_action_id: str,
+ form_data: Mapping[str, Any],
+) -> None:
+ available_actions = {action.id for action in user_actions}
+ if selected_action_id not in available_actions:
+ raise HumanInputSubmissionValidationError(f"Invalid action: {selected_action_id}")
+
+ provided_inputs = set(form_data.keys())
+ missing_inputs = [
+ form_input.output_variable_name
+ for form_input in inputs
+ if form_input.output_variable_name not in provided_inputs
+ ]
+
+ if missing_inputs:
+ missing_list = ", ".join(missing_inputs)
+ raise HumanInputSubmissionValidationError(f"Missing required inputs: {missing_list}")
diff --git a/api/core/workflow/nodes/human_input/enums.py b/api/core/workflow/nodes/human_input/enums.py
new file mode 100644
index 0000000000..da85728828
--- /dev/null
+++ b/api/core/workflow/nodes/human_input/enums.py
@@ -0,0 +1,72 @@
+import enum
+
+
+class HumanInputFormStatus(enum.StrEnum):
+ """Status of a human input form."""
+
+ # Awaiting submission from any recipient. Forms stay in this state until
+ # submitted or a timeout rule applies.
+ WAITING = enum.auto()
+ # Global timeout reached. The workflow run is stopped and will not resume.
+ # This is distinct from node-level timeout.
+ EXPIRED = enum.auto()
+ # Submitted by a recipient; form data is available and execution resumes
+ # along the selected action edge.
+ SUBMITTED = enum.auto()
+ # Node-level timeout reached. The human input node should emit a timeout
+ # event and the workflow should resume along the timeout edge.
+ TIMEOUT = enum.auto()
+
+
+class HumanInputFormKind(enum.StrEnum):
+ """Kind of a human input form."""
+
+ RUNTIME = enum.auto() # Form created during workflow execution.
+ DELIVERY_TEST = enum.auto() # Form created for delivery tests.
+
+
+class DeliveryMethodType(enum.StrEnum):
+ """Delivery method types for human input forms."""
+
+ # WEBAPP controls whether the form is delivered to the web app. It not only controls
+ # the standalone web app, but also controls the installed apps in the console.
+ WEBAPP = enum.auto()
+
+ EMAIL = enum.auto()
+
+
+class ButtonStyle(enum.StrEnum):
+ """Button styles for user actions."""
+
+ PRIMARY = enum.auto()
+ DEFAULT = enum.auto()
+ ACCENT = enum.auto()
+ GHOST = enum.auto()
+
+
+class TimeoutUnit(enum.StrEnum):
+ """Timeout unit for form expiration."""
+
+ HOUR = enum.auto()
+ DAY = enum.auto()
+
+
+class FormInputType(enum.StrEnum):
+ """Form input types."""
+
+ TEXT_INPUT = enum.auto()
+ PARAGRAPH = enum.auto()
+
+
+class PlaceholderType(enum.StrEnum):
+ """Default value types for form inputs."""
+
+ VARIABLE = enum.auto()
+ CONSTANT = enum.auto()
+
+
+class EmailRecipientType(enum.StrEnum):
+ """Email recipient types."""
+
+ MEMBER = enum.auto()
+ EXTERNAL = enum.auto()
diff --git a/api/core/workflow/nodes/human_input/human_input_node.py b/api/core/workflow/nodes/human_input/human_input_node.py
index 6c8bf36fab..1d7522ea25 100644
--- a/api/core/workflow/nodes/human_input/human_input_node.py
+++ b/api/core/workflow/nodes/human_input/human_input_node.py
@@ -1,12 +1,42 @@
-from collections.abc import Mapping
-from typing import Any
+import json
+import logging
+from collections.abc import Generator, Mapping, Sequence
+from typing import TYPE_CHECKING, Any
+from core.app.entities.app_invoke_entities import InvokeFrom
+from core.repositories.human_input_repository import HumanInputFormRepositoryImpl
from core.workflow.entities.pause_reason import HumanInputRequired
from core.workflow.enums import NodeExecutionType, NodeType, WorkflowNodeExecutionStatus
-from core.workflow.node_events import NodeRunResult, PauseRequestedEvent
+from core.workflow.node_events import (
+ HumanInputFormFilledEvent,
+ HumanInputFormTimeoutEvent,
+ NodeRunResult,
+ PauseRequestedEvent,
+)
+from core.workflow.node_events.base import NodeEventBase
+from core.workflow.node_events.node import StreamCompletedEvent
from core.workflow.nodes.base.node import Node
+from core.workflow.repositories.human_input_form_repository import (
+ FormCreateParams,
+ HumanInputFormEntity,
+ HumanInputFormRepository,
+)
+from core.workflow.workflow_type_encoder import WorkflowRuntimeTypeConverter
+from extensions.ext_database import db
+from libs.datetime_utils import naive_utc_now
-from .entities import HumanInputNodeData
+from .entities import DeliveryChannelConfig, HumanInputNodeData, apply_debug_email_recipient
+from .enums import DeliveryMethodType, HumanInputFormStatus, PlaceholderType
+
+if TYPE_CHECKING:
+ from core.workflow.entities.graph_init_params import GraphInitParams
+ from core.workflow.runtime.graph_runtime_state import GraphRuntimeState
+
+
+_SELECTED_BRANCH_KEY = "selected_branch"
+
+
+logger = logging.getLogger(__name__)
class HumanInputNode(Node[HumanInputNodeData]):
@@ -17,7 +47,7 @@ class HumanInputNode(Node[HumanInputNodeData]):
"edge_source_handle",
"edgeSourceHandle",
"source_handle",
- "selected_branch",
+ _SELECTED_BRANCH_KEY,
"selectedBranch",
"branch",
"branch_id",
@@ -25,43 +55,37 @@ class HumanInputNode(Node[HumanInputNodeData]):
"handle",
)
+ _node_data: HumanInputNodeData
+ _form_repository: HumanInputFormRepository
+ _OUTPUT_FIELD_ACTION_ID = "__action_id"
+ _OUTPUT_FIELD_RENDERED_CONTENT = "__rendered_content"
+ _TIMEOUT_HANDLE = _TIMEOUT_ACTION_ID = "__timeout"
+
+ def __init__(
+ self,
+ id: str,
+ config: Mapping[str, Any],
+ graph_init_params: "GraphInitParams",
+ graph_runtime_state: "GraphRuntimeState",
+ form_repository: HumanInputFormRepository | None = None,
+ ) -> None:
+ super().__init__(
+ id=id,
+ config=config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=graph_runtime_state,
+ )
+ if form_repository is None:
+ form_repository = HumanInputFormRepositoryImpl(
+ session_factory=db.engine,
+ tenant_id=self.tenant_id,
+ )
+ self._form_repository = form_repository
+
@classmethod
def version(cls) -> str:
return "1"
- def _run(self): # type: ignore[override]
- if self._is_completion_ready():
- branch_handle = self._resolve_branch_selection()
- return NodeRunResult(
- status=WorkflowNodeExecutionStatus.SUCCEEDED,
- outputs={},
- edge_source_handle=branch_handle or "source",
- )
-
- return self._pause_generator()
-
- def _pause_generator(self):
- # TODO(QuantumGhost): yield a real form id.
- yield PauseRequestedEvent(reason=HumanInputRequired(form_id="test_form_id", node_id=self.id))
-
- def _is_completion_ready(self) -> bool:
- """Determine whether all required inputs are satisfied."""
-
- if not self.node_data.required_variables:
- return False
-
- variable_pool = self.graph_runtime_state.variable_pool
-
- for selector_str in self.node_data.required_variables:
- parts = selector_str.split(".")
- if len(parts) != 2:
- return False
- segment = variable_pool.get(parts)
- if segment is None:
- return False
-
- return True
-
def _resolve_branch_selection(self) -> str | None:
"""Determine the branch handle selected by human input if available."""
@@ -108,3 +132,224 @@ class HumanInputNode(Node[HumanInputNodeData]):
return candidate
return None
+
+ @property
+ def _workflow_execution_id(self) -> str:
+ workflow_exec_id = self.graph_runtime_state.variable_pool.system_variables.workflow_execution_id
+ assert workflow_exec_id is not None
+ return workflow_exec_id
+
+ def _form_to_pause_event(self, form_entity: HumanInputFormEntity):
+ required_event = self._human_input_required_event(form_entity)
+ pause_requested_event = PauseRequestedEvent(reason=required_event)
+ return pause_requested_event
+
+ def resolve_default_values(self) -> Mapping[str, Any]:
+ variable_pool = self.graph_runtime_state.variable_pool
+ resolved_defaults: dict[str, Any] = {}
+ for input in self._node_data.inputs:
+ if (default_value := input.default) is None:
+ continue
+ if default_value.type == PlaceholderType.CONSTANT:
+ continue
+ resolved_value = variable_pool.get(default_value.selector)
+ if resolved_value is None:
+ # TODO: How should we handle this?
+ continue
+ resolved_defaults[input.output_variable_name] = (
+ WorkflowRuntimeTypeConverter().value_to_json_encodable_recursive(resolved_value.value)
+ )
+
+ return resolved_defaults
+
+ def _should_require_console_recipient(self) -> bool:
+ if self.invoke_from == InvokeFrom.DEBUGGER:
+ return True
+ if self.invoke_from == InvokeFrom.EXPLORE:
+ return self._node_data.is_webapp_enabled()
+ return False
+
+ def _display_in_ui(self) -> bool:
+ if self.invoke_from == InvokeFrom.DEBUGGER:
+ return True
+ return self._node_data.is_webapp_enabled()
+
+ def _effective_delivery_methods(self) -> Sequence[DeliveryChannelConfig]:
+ enabled_methods = [method for method in self._node_data.delivery_methods if method.enabled]
+ if self.invoke_from in {InvokeFrom.DEBUGGER, InvokeFrom.EXPLORE}:
+ enabled_methods = [method for method in enabled_methods if method.type != DeliveryMethodType.WEBAPP]
+ return [
+ apply_debug_email_recipient(
+ method,
+ enabled=self.invoke_from == InvokeFrom.DEBUGGER,
+ user_id=self.user_id or "",
+ )
+ for method in enabled_methods
+ ]
+
+ def _human_input_required_event(self, form_entity: HumanInputFormEntity) -> HumanInputRequired:
+ node_data = self._node_data
+ resolved_default_values = self.resolve_default_values()
+ display_in_ui = self._display_in_ui()
+ form_token = form_entity.web_app_token
+ if display_in_ui and form_token is None:
+ raise AssertionError("Form token should be available for UI execution.")
+ return HumanInputRequired(
+ form_id=form_entity.id,
+ form_content=form_entity.rendered_content,
+ inputs=node_data.inputs,
+ actions=node_data.user_actions,
+ display_in_ui=display_in_ui,
+ node_id=self.id,
+ node_title=node_data.title,
+ form_token=form_token,
+ resolved_default_values=resolved_default_values,
+ )
+
+ def _run(self) -> Generator[NodeEventBase, None, None]:
+ """
+ Execute the human input node.
+
+ This method will:
+ 1. Generate a unique form ID
+ 2. Create form content with variable substitution
+ 3. Create form in database
+ 4. Send form via configured delivery methods
+ 5. Suspend workflow execution
+ 6. Wait for form submission to resume
+ """
+ repo = self._form_repository
+ form = repo.get_form(self._workflow_execution_id, self.id)
+ if form is None:
+ display_in_ui = self._display_in_ui()
+ params = FormCreateParams(
+ app_id=self.app_id,
+ workflow_execution_id=self._workflow_execution_id,
+ node_id=self.id,
+ form_config=self._node_data,
+ rendered_content=self.render_form_content_before_submission(),
+ delivery_methods=self._effective_delivery_methods(),
+ display_in_ui=display_in_ui,
+ resolved_default_values=self.resolve_default_values(),
+ console_recipient_required=self._should_require_console_recipient(),
+ console_creator_account_id=(
+ self.user_id if self.invoke_from in {InvokeFrom.DEBUGGER, InvokeFrom.EXPLORE} else None
+ ),
+ backstage_recipient_required=True,
+ )
+ form_entity = self._form_repository.create_form(params)
+ # Create human input required event
+
+ logger.info(
+ "Human Input node suspended workflow for form. workflow_run_id=%s, node_id=%s, form_id=%s",
+ self.graph_runtime_state.variable_pool.system_variables.workflow_execution_id,
+ self.id,
+ form_entity.id,
+ )
+ yield self._form_to_pause_event(form_entity)
+ return
+
+ if (
+ form.status in {HumanInputFormStatus.TIMEOUT, HumanInputFormStatus.EXPIRED}
+ or form.expiration_time <= naive_utc_now()
+ ):
+ yield HumanInputFormTimeoutEvent(
+ node_title=self._node_data.title,
+ expiration_time=form.expiration_time,
+ )
+ yield StreamCompletedEvent(
+ node_run_result=NodeRunResult(
+ status=WorkflowNodeExecutionStatus.SUCCEEDED,
+ outputs={self._OUTPUT_FIELD_ACTION_ID: ""},
+ edge_source_handle=self._TIMEOUT_HANDLE,
+ )
+ )
+ return
+
+ if not form.submitted:
+ yield self._form_to_pause_event(form)
+ return
+
+ selected_action_id = form.selected_action_id
+ if selected_action_id is None:
+ raise AssertionError(f"selected_action_id should not be None when form submitted, form_id={form.id}")
+ submitted_data = form.submitted_data or {}
+ outputs: dict[str, Any] = dict(submitted_data)
+ outputs[self._OUTPUT_FIELD_ACTION_ID] = selected_action_id
+ rendered_content = self.render_form_content_with_outputs(
+ form.rendered_content,
+ outputs,
+ self._node_data.outputs_field_names(),
+ )
+ outputs[self._OUTPUT_FIELD_RENDERED_CONTENT] = rendered_content
+
+ action_text = self._node_data.find_action_text(selected_action_id)
+
+ yield HumanInputFormFilledEvent(
+ node_title=self._node_data.title,
+ rendered_content=rendered_content,
+ action_id=selected_action_id,
+ action_text=action_text,
+ )
+
+ yield StreamCompletedEvent(
+ node_run_result=NodeRunResult(
+ status=WorkflowNodeExecutionStatus.SUCCEEDED,
+ outputs=outputs,
+ edge_source_handle=selected_action_id,
+ )
+ )
+
+ def render_form_content_before_submission(self) -> str:
+ """
+ Process form content by substituting variables.
+
+ This method should:
+ 1. Parse the form_content markdown
+ 2. Substitute {{#node_name.var_name#}} with actual values
+ 3. Keep {{#$output.field_name#}} placeholders for form inputs
+ """
+ rendered_form_content = self.graph_runtime_state.variable_pool.convert_template(
+ self._node_data.form_content,
+ )
+ return rendered_form_content.markdown
+
+ @staticmethod
+ def render_form_content_with_outputs(
+ form_content: str,
+ outputs: Mapping[str, Any],
+ field_names: Sequence[str],
+ ) -> str:
+ """
+ Replace {{#$output.xxx#}} placeholders with submitted values.
+ """
+ rendered_content = form_content
+ for field_name in field_names:
+ placeholder = "{{#$output." + field_name + "#}}"
+ value = outputs.get(field_name)
+ if value is None:
+ replacement = ""
+ elif isinstance(value, (dict, list)):
+ replacement = json.dumps(value, ensure_ascii=False)
+ else:
+ replacement = str(value)
+ rendered_content = rendered_content.replace(placeholder, replacement)
+ return rendered_content
+
+ @classmethod
+ def _extract_variable_selector_to_variable_mapping(
+ cls,
+ *,
+ graph_config: Mapping[str, Any],
+ node_id: str,
+ node_data: Mapping[str, Any],
+ ) -> Mapping[str, Sequence[str]]:
+ """
+ Extract variable selectors referenced in form content and input default values.
+
+ This method should parse:
+ 1. Variables referenced in form_content ({{#node_name.var_name#}})
+ 2. Variables referenced in input default values
+ """
+ validated_node_data = HumanInputNodeData.model_validate(node_data)
+ return validated_node_data.extract_variable_selector_to_variable_mapping(node_id)
diff --git a/api/core/workflow/repositories/human_input_form_repository.py b/api/core/workflow/repositories/human_input_form_repository.py
new file mode 100644
index 0000000000..efde59c6fd
--- /dev/null
+++ b/api/core/workflow/repositories/human_input_form_repository.py
@@ -0,0 +1,152 @@
+import abc
+import dataclasses
+from collections.abc import Mapping, Sequence
+from datetime import datetime
+from typing import Any, Protocol
+
+from core.workflow.nodes.human_input.entities import DeliveryChannelConfig, HumanInputNodeData
+from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
+
+
+class HumanInputError(Exception):
+ pass
+
+
+class FormNotFoundError(HumanInputError):
+ pass
+
+
+@dataclasses.dataclass
+class FormCreateParams:
+ # app_id is the identifier for the app that the form belongs to.
+ # It is a string with uuid format.
+ app_id: str
+ # None when creating a delivery test form; set for runtime forms.
+ workflow_execution_id: str | None
+
+ # node_id is the identifier for a specific
+ # node in the graph.
+ #
+ # TODO: for node inside loop / iteration, this would
+ # cause problems, as a single node may be executed multiple times.
+ node_id: str
+
+ form_config: HumanInputNodeData
+ rendered_content: str
+ # Delivery methods already filtered by runtime context (invoke_from).
+ delivery_methods: Sequence[DeliveryChannelConfig]
+ # UI display flag computed by runtime context.
+ display_in_ui: bool
+
+ # resolved_default_values saves the values for defaults with
+ # type = VARIABLE.
+ #
+ # For type = CONSTANT, the value is not stored inside `resolved_default_values`
+ resolved_default_values: Mapping[str, Any]
+ form_kind: HumanInputFormKind = HumanInputFormKind.RUNTIME
+
+ # Force creating a console-only recipient for submission in Console.
+ console_recipient_required: bool = False
+ console_creator_account_id: str | None = None
+ # Force creating a backstage recipient for submission in Console.
+ backstage_recipient_required: bool = False
+
+
+class HumanInputFormEntity(abc.ABC):
+ @property
+ @abc.abstractmethod
+ def id(self) -> str:
+ """id returns the identifer of the form."""
+ pass
+
+ @property
+ @abc.abstractmethod
+ def web_app_token(self) -> str | None:
+ """web_app_token returns the token for submission inside webapp.
+
+ For console/debug execution, this may point to the console submission token
+ if the form is configured to require console delivery.
+ """
+
+ # TODO: what if the users are allowed to add multiple
+ # webapp delivery?
+ pass
+
+ @property
+ @abc.abstractmethod
+ def recipients(self) -> list["HumanInputFormRecipientEntity"]: ...
+
+ @property
+ @abc.abstractmethod
+ def rendered_content(self) -> str:
+ """Rendered markdown content associated with the form."""
+ ...
+
+ @property
+ @abc.abstractmethod
+ def selected_action_id(self) -> str | None:
+ """Identifier of the selected user action if the form has been submitted."""
+ ...
+
+ @property
+ @abc.abstractmethod
+ def submitted_data(self) -> Mapping[str, Any] | None:
+ """Submitted form data if available."""
+ ...
+
+ @property
+ @abc.abstractmethod
+ def submitted(self) -> bool:
+ """Whether the form has been submitted."""
+ ...
+
+ @property
+ @abc.abstractmethod
+ def status(self) -> HumanInputFormStatus:
+ """Current status of the form."""
+ ...
+
+ @property
+ @abc.abstractmethod
+ def expiration_time(self) -> datetime:
+ """When the form expires."""
+ ...
+
+
+class HumanInputFormRecipientEntity(abc.ABC):
+ @property
+ @abc.abstractmethod
+ def id(self) -> str:
+ """id returns the identifer of this recipient."""
+ ...
+
+ @property
+ @abc.abstractmethod
+ def token(self) -> str:
+ """token returns a random string used to submit form"""
+ ...
+
+
+class HumanInputFormRepository(Protocol):
+ """
+ Repository interface for HumanInputForm.
+
+ This interface defines the contract for accessing and manipulating
+ HumanInputForm data, regardless of the underlying storage mechanism.
+
+ Note: Domain-specific concepts like multi-tenancy (tenant_id), application context (app_id),
+ and other implementation details should be handled at the implementation level, not in
+ the core interface. This keeps the core domain model clean and independent of specific
+ application domains or deployment scenarios.
+ """
+
+ def get_form(self, workflow_execution_id: str, node_id: str) -> HumanInputFormEntity | None:
+ """Get the form created for a given human input node in a workflow execution. Returns
+ `None` if the form has not been created yet."""
+ ...
+
+ def create_form(self, params: FormCreateParams) -> HumanInputFormEntity:
+ """
+ Create a human input form from form definition.
+ """
+ ...
diff --git a/api/core/workflow/runtime/graph_runtime_state.py b/api/core/workflow/runtime/graph_runtime_state.py
index 401cecc162..f79230217c 100644
--- a/api/core/workflow/runtime/graph_runtime_state.py
+++ b/api/core/workflow/runtime/graph_runtime_state.py
@@ -6,14 +6,18 @@ import threading
from collections.abc import Mapping, Sequence
from copy import deepcopy
from dataclasses import dataclass
-from typing import Any, Protocol
+from typing import TYPE_CHECKING, Any, Protocol
+from pydantic import BaseModel, Field
from pydantic.json import pydantic_encoder
from core.model_runtime.entities.llm_entities import LLMUsage
-from core.workflow.entities.pause_reason import PauseReason
+from core.workflow.enums import NodeState
from core.workflow.runtime.variable_pool import VariablePool
+if TYPE_CHECKING:
+ from core.workflow.entities.pause_reason import PauseReason
+
class ReadyQueueProtocol(Protocol):
"""Structural interface required from ready queue implementations."""
@@ -60,7 +64,7 @@ class GraphExecutionProtocol(Protocol):
aborted: bool
error: Exception | None
exceptions_count: int
- pause_reasons: list[PauseReason]
+ pause_reasons: Sequence[PauseReason]
def start(self) -> None:
"""Transition execution into the running state."""
@@ -103,14 +107,33 @@ class ResponseStreamCoordinatorProtocol(Protocol):
...
+class NodeProtocol(Protocol):
+ """Structural interface for graph nodes."""
+
+ id: str
+ state: NodeState
+
+
+class EdgeProtocol(Protocol):
+ id: str
+ state: NodeState
+
+
class GraphProtocol(Protocol):
"""Structural interface required from graph instances attached to the runtime state."""
- nodes: Mapping[str, object]
- edges: Mapping[str, object]
- root_node: object
+ nodes: Mapping[str, NodeProtocol]
+ edges: Mapping[str, EdgeProtocol]
+ root_node: NodeProtocol
- def get_outgoing_edges(self, node_id: str) -> Sequence[object]: ...
+ def get_outgoing_edges(self, node_id: str) -> Sequence[EdgeProtocol]: ...
+
+
+class _GraphStateSnapshot(BaseModel):
+ """Serializable graph state snapshot for node/edge states."""
+
+ nodes: dict[str, NodeState] = Field(default_factory=dict)
+ edges: dict[str, NodeState] = Field(default_factory=dict)
@dataclass(slots=True)
@@ -128,10 +151,20 @@ class _GraphRuntimeStateSnapshot:
graph_execution_dump: str | None
response_coordinator_dump: str | None
paused_nodes: tuple[str, ...]
+ deferred_nodes: tuple[str, ...]
+ graph_node_states: dict[str, NodeState]
+ graph_edge_states: dict[str, NodeState]
class GraphRuntimeState:
- """Mutable runtime state shared across graph execution components."""
+ """Mutable runtime state shared across graph execution components.
+
+ `GraphRuntimeState` encapsulates the runtime state of workflow execution,
+ including scheduling details, variable values, and timing information.
+
+ Values that are initialized prior to workflow execution and remain constant
+ throughout the execution should be part of `GraphInitParams` instead.
+ """
def __init__(
self,
@@ -169,6 +202,16 @@ class GraphRuntimeState:
self._pending_response_coordinator_dump: str | None = None
self._pending_graph_execution_workflow_id: str | None = None
self._paused_nodes: set[str] = set()
+ self._deferred_nodes: set[str] = set()
+
+ # Node and edges states needed to be restored into
+ # graph object.
+ #
+ # These two fields are non-None only when resuming from a snapshot.
+ # Once the graph is attached, these two fields will be set to None.
+ self._pending_graph_node_states: dict[str, NodeState] | None = None
+ self._pending_graph_edge_states: dict[str, NodeState] | None = None
+
self.stop_event: threading.Event = threading.Event()
if graph is not None:
@@ -190,6 +233,7 @@ class GraphRuntimeState:
if self._pending_response_coordinator_dump is not None and self._response_coordinator is not None:
self._response_coordinator.loads(self._pending_response_coordinator_dump)
self._pending_response_coordinator_dump = None
+ self._apply_pending_graph_state()
def configure(self, *, graph: GraphProtocol | None = None) -> None:
"""Ensure core collaborators are initialized with the provided context."""
@@ -311,8 +355,13 @@ class GraphRuntimeState:
"ready_queue": self.ready_queue.dumps(),
"graph_execution": self.graph_execution.dumps(),
"paused_nodes": list(self._paused_nodes),
+ "deferred_nodes": list(self._deferred_nodes),
}
+ graph_state = self._snapshot_graph_state()
+ if graph_state is not None:
+ snapshot["graph_state"] = graph_state
+
if self._response_coordinator is not None and self._graph is not None:
snapshot["response_coordinator"] = self._response_coordinator.dumps()
@@ -346,6 +395,11 @@ class GraphRuntimeState:
self._paused_nodes.add(node_id)
+ def get_paused_nodes(self) -> list[str]:
+ """Retrieve the list of paused nodes without mutating internal state."""
+
+ return list(self._paused_nodes)
+
def consume_paused_nodes(self) -> list[str]:
"""Retrieve and clear the list of paused nodes awaiting resume."""
@@ -353,6 +407,23 @@ class GraphRuntimeState:
self._paused_nodes.clear()
return nodes
+ def register_deferred_node(self, node_id: str) -> None:
+ """Record a node that became ready during pause and should resume later."""
+
+ self._deferred_nodes.add(node_id)
+
+ def get_deferred_nodes(self) -> list[str]:
+ """Retrieve deferred nodes without mutating internal state."""
+
+ return list(self._deferred_nodes)
+
+ def consume_deferred_nodes(self) -> list[str]:
+ """Retrieve and clear deferred nodes awaiting resume."""
+
+ nodes = list(self._deferred_nodes)
+ self._deferred_nodes.clear()
+ return nodes
+
# ------------------------------------------------------------------
# Builders
# ------------------------------------------------------------------
@@ -414,6 +485,10 @@ class GraphRuntimeState:
graph_execution_payload = payload.get("graph_execution")
response_payload = payload.get("response_coordinator")
paused_nodes_payload = payload.get("paused_nodes", [])
+ deferred_nodes_payload = payload.get("deferred_nodes", [])
+ graph_state_payload = payload.get("graph_state", {}) or {}
+ graph_node_states = _coerce_graph_state_map(graph_state_payload, "nodes")
+ graph_edge_states = _coerce_graph_state_map(graph_state_payload, "edges")
return _GraphRuntimeStateSnapshot(
start_at=start_at,
@@ -427,6 +502,9 @@ class GraphRuntimeState:
graph_execution_dump=graph_execution_payload,
response_coordinator_dump=response_payload,
paused_nodes=tuple(map(str, paused_nodes_payload)),
+ deferred_nodes=tuple(map(str, deferred_nodes_payload)),
+ graph_node_states=graph_node_states,
+ graph_edge_states=graph_edge_states,
)
def _apply_snapshot(self, snapshot: _GraphRuntimeStateSnapshot) -> None:
@@ -442,6 +520,10 @@ class GraphRuntimeState:
self._restore_graph_execution(snapshot.graph_execution_dump)
self._restore_response_coordinator(snapshot.response_coordinator_dump)
self._paused_nodes = set(snapshot.paused_nodes)
+ self._deferred_nodes = set(snapshot.deferred_nodes)
+ self._pending_graph_node_states = snapshot.graph_node_states or None
+ self._pending_graph_edge_states = snapshot.graph_edge_states or None
+ self._apply_pending_graph_state()
def _restore_ready_queue(self, payload: str | None) -> None:
if payload is not None:
@@ -478,3 +560,68 @@ class GraphRuntimeState:
self._pending_response_coordinator_dump = payload
self._response_coordinator = None
+
+ def _snapshot_graph_state(self) -> _GraphStateSnapshot:
+ graph = self._graph
+ if graph is None:
+ if self._pending_graph_node_states is None and self._pending_graph_edge_states is None:
+ return _GraphStateSnapshot()
+ return _GraphStateSnapshot(
+ nodes=self._pending_graph_node_states or {},
+ edges=self._pending_graph_edge_states or {},
+ )
+
+ nodes = graph.nodes
+ edges = graph.edges
+ if not isinstance(nodes, Mapping) or not isinstance(edges, Mapping):
+ return _GraphStateSnapshot()
+
+ node_states = {}
+ for node_id, node in nodes.items():
+ if not isinstance(node_id, str):
+ continue
+ node_states[node_id] = node.state
+
+ edge_states = {}
+ for edge_id, edge in edges.items():
+ if not isinstance(edge_id, str):
+ continue
+ edge_states[edge_id] = edge.state
+
+ return _GraphStateSnapshot(nodes=node_states, edges=edge_states)
+
+ def _apply_pending_graph_state(self) -> None:
+ if self._graph is None:
+ return
+ if self._pending_graph_node_states:
+ for node_id, state in self._pending_graph_node_states.items():
+ node = self._graph.nodes.get(node_id)
+ if node is None:
+ continue
+ node.state = state
+ if self._pending_graph_edge_states:
+ for edge_id, state in self._pending_graph_edge_states.items():
+ edge = self._graph.edges.get(edge_id)
+ if edge is None:
+ continue
+ edge.state = state
+
+ self._pending_graph_node_states = None
+ self._pending_graph_edge_states = None
+
+
+def _coerce_graph_state_map(payload: Any, key: str) -> dict[str, NodeState]:
+ if not isinstance(payload, Mapping):
+ return {}
+ raw_map = payload.get(key, {})
+ if not isinstance(raw_map, Mapping):
+ return {}
+ result: dict[str, NodeState] = {}
+ for node_id, raw_state in raw_map.items():
+ if not isinstance(node_id, str):
+ continue
+ try:
+ result[node_id] = NodeState(str(raw_state))
+ except ValueError:
+ continue
+ return result
diff --git a/api/core/workflow/workflow_type_encoder.py b/api/core/workflow/workflow_type_encoder.py
index 5456043ccd..f1f549e1f8 100644
--- a/api/core/workflow/workflow_type_encoder.py
+++ b/api/core/workflow/workflow_type_encoder.py
@@ -15,12 +15,14 @@ class WorkflowRuntimeTypeConverter:
def to_json_encodable(self, value: None) -> None: ...
def to_json_encodable(self, value: Mapping[str, Any] | None) -> Mapping[str, Any] | None:
- result = self._to_json_encodable_recursive(value)
+ """Convert runtime values to JSON-serializable structures."""
+
+ result = self.value_to_json_encodable_recursive(value)
if isinstance(result, Mapping) or result is None:
return result
return {}
- def _to_json_encodable_recursive(self, value: Any):
+ def value_to_json_encodable_recursive(self, value: Any):
if value is None:
return value
if isinstance(value, (bool, int, str, float)):
@@ -29,7 +31,7 @@ class WorkflowRuntimeTypeConverter:
# Convert Decimal to float for JSON serialization
return float(value)
if isinstance(value, Segment):
- return self._to_json_encodable_recursive(value.value)
+ return self.value_to_json_encodable_recursive(value.value)
if isinstance(value, File):
return value.to_dict()
if isinstance(value, BaseModel):
@@ -37,11 +39,11 @@ class WorkflowRuntimeTypeConverter:
if isinstance(value, dict):
res = {}
for k, v in value.items():
- res[k] = self._to_json_encodable_recursive(v)
+ res[k] = self.value_to_json_encodable_recursive(v)
return res
if isinstance(value, list):
res_list = []
for item in value:
- res_list.append(self._to_json_encodable_recursive(item))
+ res_list.append(self.value_to_json_encodable_recursive(item))
return res_list
return value
diff --git a/api/docker/entrypoint.sh b/api/docker/entrypoint.sh
index c0279f893b..03e6cbda68 100755
--- a/api/docker/entrypoint.sh
+++ b/api/docker/entrypoint.sh
@@ -35,10 +35,10 @@ if [[ "${MODE}" == "worker" ]]; then
if [[ -z "${CELERY_QUEUES}" ]]; then
if [[ "${EDITION}" == "CLOUD" ]]; then
# Cloud edition: separate queues for dataset and trigger tasks
- DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
+ DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention,workflow_based_app_execution"
else
# Community edition (SELF_HOSTED): dataset, pipeline and workflow have separate queues
- DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention"
+ DEFAULT_QUEUES="dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention,workflow_based_app_execution"
fi
else
DEFAULT_QUEUES="${CELERY_QUEUES}"
@@ -102,7 +102,7 @@ elif [[ "${MODE}" == "job" ]]; then
fi
echo "Running Flask job command: flask $*"
-
+
# Temporarily disable exit on error to capture exit code
set +e
flask "$@"
diff --git a/api/extensions/ext_celery.py b/api/extensions/ext_celery.py
index af983f6d87..aa9723f375 100644
--- a/api/extensions/ext_celery.py
+++ b/api/extensions/ext_celery.py
@@ -151,6 +151,12 @@ def init_app(app: DifyApp) -> Celery:
"task": "schedule.queue_monitor_task.queue_monitor_task",
"schedule": timedelta(minutes=dify_config.QUEUE_MONITOR_INTERVAL or 30),
}
+ if dify_config.ENABLE_HUMAN_INPUT_TIMEOUT_TASK:
+ imports.append("tasks.human_input_timeout_tasks")
+ beat_schedule["human_input_form_timeout"] = {
+ "task": "human_input_form_timeout.check_and_resume",
+ "schedule": timedelta(minutes=dify_config.HUMAN_INPUT_TIMEOUT_TASK_INTERVAL),
+ }
if dify_config.ENABLE_CHECK_UPGRADABLE_PLUGIN_TASK and dify_config.MARKETPLACE_ENABLED:
imports.append("schedule.check_upgradable_plugin_task")
imports.append("tasks.process_tenant_plugin_autoupgrade_check_task")
diff --git a/api/extensions/ext_redis.py b/api/extensions/ext_redis.py
index 5e75bc36b0..0797a3cb98 100644
--- a/api/extensions/ext_redis.py
+++ b/api/extensions/ext_redis.py
@@ -8,12 +8,16 @@ from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, Union
import redis
from redis import RedisError
from redis.cache import CacheConfig
+from redis.client import PubSub
from redis.cluster import ClusterNode, RedisCluster
from redis.connection import Connection, SSLConnection
from redis.sentinel import Sentinel
from configs import dify_config
from dify_app import DifyApp
+from libs.broadcast_channel.channel import BroadcastChannel as BroadcastChannelProtocol
+from libs.broadcast_channel.redis.channel import BroadcastChannel as RedisBroadcastChannel
+from libs.broadcast_channel.redis.sharded_channel import ShardedRedisBroadcastChannel
if TYPE_CHECKING:
from redis.lock import Lock
@@ -106,6 +110,7 @@ class RedisClientWrapper:
def zremrangebyscore(self, name: str | bytes, min: float | str, max: float | str) -> Any: ...
def zcard(self, name: str | bytes) -> Any: ...
def getdel(self, name: str | bytes) -> Any: ...
+ def pubsub(self) -> PubSub: ...
def __getattr__(self, item: str) -> Any:
if self._client is None:
@@ -114,6 +119,7 @@ class RedisClientWrapper:
redis_client: RedisClientWrapper = RedisClientWrapper()
+pubsub_redis_client: RedisClientWrapper = RedisClientWrapper()
def _get_ssl_configuration() -> tuple[type[Union[Connection, SSLConnection]], dict[str, Any]]:
@@ -226,6 +232,12 @@ def _create_standalone_client(redis_params: dict[str, Any]) -> Union[redis.Redis
return client
+def _create_pubsub_client(pubsub_url: str, use_clusters: bool) -> Union[redis.Redis, RedisCluster]:
+ if use_clusters:
+ return RedisCluster.from_url(pubsub_url)
+ return redis.Redis.from_url(pubsub_url)
+
+
def init_app(app: DifyApp):
"""Initialize Redis client and attach it to the app."""
global redis_client
@@ -244,6 +256,24 @@ def init_app(app: DifyApp):
redis_client.initialize(client)
app.extensions["redis"] = redis_client
+ pubsub_client = client
+ if dify_config.normalized_pubsub_redis_url:
+ pubsub_client = _create_pubsub_client(
+ dify_config.normalized_pubsub_redis_url, dify_config.PUBSUB_REDIS_USE_CLUSTERS
+ )
+ pubsub_redis_client.initialize(pubsub_client)
+
+
+def get_pubsub_redis_client() -> RedisClientWrapper:
+ return pubsub_redis_client
+
+
+def get_pubsub_broadcast_channel() -> BroadcastChannelProtocol:
+ redis_conn = get_pubsub_redis_client()
+ if dify_config.PUBSUB_REDIS_CHANNEL_TYPE == "sharded":
+ return ShardedRedisBroadcastChannel(redis_conn) # pyright: ignore[reportArgumentType]
+ return RedisBroadcastChannel(redis_conn) # pyright: ignore[reportArgumentType]
+
P = ParamSpec("P")
R = TypeVar("R")
diff --git a/api/extensions/logstore/repositories/logstore_api_workflow_node_execution_repository.py b/api/extensions/logstore/repositories/logstore_api_workflow_node_execution_repository.py
index f67723630b..817c8b0448 100644
--- a/api/extensions/logstore/repositories/logstore_api_workflow_node_execution_repository.py
+++ b/api/extensions/logstore/repositories/logstore_api_workflow_node_execution_repository.py
@@ -13,6 +13,7 @@ from typing import Any
from sqlalchemy.orm import sessionmaker
+from core.workflow.enums import WorkflowNodeExecutionStatus
from extensions.logstore.aliyun_logstore import AliyunLogStore
from extensions.logstore.repositories import safe_float, safe_int
from extensions.logstore.sql_escape import escape_identifier, escape_logstore_query_value
@@ -207,8 +208,10 @@ class LogstoreAPIWorkflowNodeExecutionRepository(DifyAPIWorkflowNodeExecutionRep
reverse=True,
)
- if deduplicated_results:
- return _dict_to_workflow_node_execution_model(deduplicated_results[0])
+ for row in deduplicated_results:
+ model = _dict_to_workflow_node_execution_model(row)
+ if model.status != WorkflowNodeExecutionStatus.PAUSED:
+ return model
return None
@@ -309,6 +312,8 @@ class LogstoreAPIWorkflowNodeExecutionRepository(DifyAPIWorkflowNodeExecutionRep
if model and model.id: # Ensure model is valid
models.append(model)
+ models = [model for model in models if model.status != WorkflowNodeExecutionStatus.PAUSED]
+
# Sort by index DESC for trace visualization
models.sort(key=lambda x: x.index, reverse=True)
diff --git a/api/fields/conversation_fields.py b/api/fields/conversation_fields.py
index d8ae0ad8b8..cda46f2339 100644
--- a/api/fields/conversation_fields.py
+++ b/api/fields/conversation_fields.py
@@ -192,6 +192,7 @@ class StatusCount(ResponseModel):
success: int
failed: int
partial_success: int
+ paused: int
class ModelConfig(ResponseModel):
diff --git a/api/fields/message_fields.py b/api/fields/message_fields.py
index e6c3b42f93..77b26a7423 100644
--- a/api/fields/message_fields.py
+++ b/api/fields/message_fields.py
@@ -6,6 +6,7 @@ from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field, field_validator
+from core.entities.execution_extra_content import ExecutionExtraContentDomainModel
from core.file import File
from fields.conversation_fields import AgentThought, JSONValue, MessageFile
@@ -61,6 +62,7 @@ class MessageListItem(ResponseModel):
message_files: list[MessageFile]
status: str
error: str | None = None
+ extra_contents: list[ExecutionExtraContentDomainModel]
@field_validator("inputs", mode="before")
@classmethod
diff --git a/api/libs/broadcast_channel/redis/_subscription.py b/api/libs/broadcast_channel/redis/_subscription.py
index 7d4b8e63ca..fa2be421a1 100644
--- a/api/libs/broadcast_channel/redis/_subscription.py
+++ b/api/libs/broadcast_channel/redis/_subscription.py
@@ -162,7 +162,7 @@ class RedisSubscriptionBase(Subscription):
self._start_if_needed()
return iter(self._message_iterator())
- def receive(self, timeout: float | None = None) -> bytes | None:
+ def receive(self, timeout: float | None = 0.1) -> bytes | None:
"""Receive the next message from the subscription."""
if self._closed.is_set():
raise SubscriptionClosedError(f"The Redis {self._get_subscription_type()} subscription is closed")
diff --git a/api/libs/broadcast_channel/redis/sharded_channel.py b/api/libs/broadcast_channel/redis/sharded_channel.py
index d190c51bbc..9e8ab90e8e 100644
--- a/api/libs/broadcast_channel/redis/sharded_channel.py
+++ b/api/libs/broadcast_channel/redis/sharded_channel.py
@@ -61,7 +61,14 @@ class _RedisShardedSubscription(RedisSubscriptionBase):
def _get_message(self) -> dict | None:
assert self._pubsub is not None
- return self._pubsub.get_sharded_message(ignore_subscribe_messages=True, timeout=0.1) # type: ignore[attr-defined]
+ # NOTE(QuantumGhost): this is an issue in
+ # upstream code. If Sharded PubSub is used with Cluster, the
+ # `ClusterPubSub.get_sharded_message` will return `None` regardless of
+ # message['type'].
+ #
+ # Since we have already filtered at the caller's site, we can safely set
+ # `ignore_subscribe_messages=False`.
+ return self._pubsub.get_sharded_message(ignore_subscribe_messages=False, timeout=0.1) # type: ignore[attr-defined]
def _get_message_type(self) -> str:
return "smessage"
diff --git a/api/libs/email_template_renderer.py b/api/libs/email_template_renderer.py
new file mode 100644
index 0000000000..98ea30ab46
--- /dev/null
+++ b/api/libs/email_template_renderer.py
@@ -0,0 +1,49 @@
+"""
+Email template rendering helpers with configurable safety modes.
+"""
+
+import time
+from collections.abc import Mapping
+from typing import Any
+
+from flask import render_template_string
+from jinja2.runtime import Context
+from jinja2.sandbox import ImmutableSandboxedEnvironment
+
+from configs import dify_config
+from configs.feature import TemplateMode
+
+
+class SandboxedEnvironment(ImmutableSandboxedEnvironment):
+ """Sandboxed environment with execution timeout."""
+
+ def __init__(self, timeout: int, *args: Any, **kwargs: Any):
+ self._deadline = time.time() + timeout if timeout else None
+ super().__init__(*args, **kwargs)
+
+ def call(self, context: Context, obj: Any, *args: Any, **kwargs: Any) -> Any:
+ if self._deadline is not None and time.time() > self._deadline:
+ raise TimeoutError("Template rendering timeout")
+ return super().call(context, obj, *args, **kwargs)
+
+
+def render_email_template(template: str, substitutions: Mapping[str, str]) -> str:
+ """
+ Render email template content according to the configured template mode.
+
+ In unsafe mode, Jinja expressions are evaluated directly.
+ In sandbox mode, a sandboxed environment with timeout is used.
+ In disabled mode, the template is returned without rendering.
+ """
+ mode = dify_config.MAIL_TEMPLATING_MODE
+ timeout = dify_config.MAIL_TEMPLATING_TIMEOUT
+
+ if mode == TemplateMode.UNSAFE:
+ return render_template_string(template, **substitutions)
+ if mode == TemplateMode.SANDBOX:
+ env = SandboxedEnvironment(timeout=timeout)
+ tmpl = env.from_string(template)
+ return tmpl.render(substitutions)
+ if mode == TemplateMode.DISABLED:
+ return template
+ raise ValueError(f"Unsupported mail templating mode: {mode}")
diff --git a/api/libs/flask_utils.py b/api/libs/flask_utils.py
index beade7eb25..e45c8fe319 100644
--- a/api/libs/flask_utils.py
+++ b/api/libs/flask_utils.py
@@ -1,12 +1,15 @@
import contextvars
from collections.abc import Iterator
from contextlib import contextmanager
-from typing import TypeVar
+from typing import TYPE_CHECKING, TypeVar
from flask import Flask, g
T = TypeVar("T")
+if TYPE_CHECKING:
+ from models import Account, EndUser
+
@contextmanager
def preserve_flask_contexts(
@@ -64,3 +67,7 @@ def preserve_flask_contexts(
finally:
# Any cleanup can be added here if needed
pass
+
+
+def set_login_user(user: "Account | EndUser"):
+ g._login_user = user
diff --git a/api/libs/helper.py b/api/libs/helper.py
index 07c4823727..fb577b9c99 100644
--- a/api/libs/helper.py
+++ b/api/libs/helper.py
@@ -7,10 +7,10 @@ import struct
import subprocess
import time
import uuid
-from collections.abc import Generator, Mapping
+from collections.abc import Callable, Generator, Mapping
from datetime import datetime
from hashlib import sha256
-from typing import TYPE_CHECKING, Annotated, Any, Optional, Union, cast
+from typing import TYPE_CHECKING, Annotated, Any, Optional, Protocol, Union, cast
from uuid import UUID
from zoneinfo import available_timezones
@@ -126,6 +126,13 @@ class TimestampField(fields.Raw):
return int(value.timestamp())
+class OptionalTimestampField(fields.Raw):
+ def format(self, value) -> int | None:
+ if value is None:
+ return None
+ return int(value.timestamp())
+
+
def email(email):
# Define a regex pattern for email addresses
pattern = r"^[\w\.!#$%&'*+\-/=?^_`{|}~]+@([\w-]+\.)+[\w-]{2,}$"
@@ -237,6 +244,26 @@ def convert_datetime_to_date(field, target_timezone: str = ":tz"):
def generate_string(n):
+ """
+ Generates a cryptographically secure random string of the specified length.
+
+ This function uses a cryptographically secure pseudorandom number generator (CSPRNG)
+ to create a string composed of ASCII letters (both uppercase and lowercase) and digits.
+
+ Each character in the generated string provides approximately 5.95 bits of entropy
+ (log2(62)). To ensure a minimum of 128 bits of entropy for security purposes, the
+ length of the string (`n`) should be at least 22 characters.
+
+ Args:
+ n (int): The length of the random string to generate. For secure usage,
+ `n` should be 22 or greater.
+
+ Returns:
+ str: A random string of length `n` composed of ASCII letters and digits.
+
+ Note:
+ This function is suitable for generating credentials or other secure tokens.
+ """
letters_digits = string.ascii_letters + string.digits
result = ""
for _ in range(n):
@@ -405,11 +432,35 @@ class TokenManager:
return f"{token_type}:account:{account_id}"
+class _RateLimiterRedisClient(Protocol):
+ def zadd(self, name: str | bytes, mapping: dict[str | bytes | int | float, float | int | str | bytes]) -> int: ...
+
+ def zremrangebyscore(self, name: str | bytes, min: str | float, max: str | float) -> int: ...
+
+ def zcard(self, name: str | bytes) -> int: ...
+
+ def expire(self, name: str | bytes, time: int) -> bool: ...
+
+
+def _default_rate_limit_member_factory() -> str:
+ current_time = int(time.time())
+ return f"{current_time}:{secrets.token_urlsafe(nbytes=8)}"
+
+
class RateLimiter:
- def __init__(self, prefix: str, max_attempts: int, time_window: int):
+ def __init__(
+ self,
+ prefix: str,
+ max_attempts: int,
+ time_window: int,
+ member_factory: Callable[[], str] = _default_rate_limit_member_factory,
+ redis_client: _RateLimiterRedisClient = redis_client,
+ ):
self.prefix = prefix
self.max_attempts = max_attempts
self.time_window = time_window
+ self._member_factory = member_factory
+ self._redis_client = redis_client
def _get_key(self, email: str) -> str:
return f"{self.prefix}:{email}"
@@ -419,8 +470,8 @@ class RateLimiter:
current_time = int(time.time())
window_start_time = current_time - self.time_window
- redis_client.zremrangebyscore(key, "-inf", window_start_time)
- attempts = redis_client.zcard(key)
+ self._redis_client.zremrangebyscore(key, "-inf", window_start_time)
+ attempts = self._redis_client.zcard(key)
if attempts and int(attempts) >= self.max_attempts:
return True
@@ -428,7 +479,8 @@ class RateLimiter:
def increment_rate_limit(self, email: str):
key = self._get_key(email)
+ member = self._member_factory()
current_time = int(time.time())
- redis_client.zadd(key, {current_time: current_time})
- redis_client.expire(key, self.time_window * 2)
+ self._redis_client.zadd(key, {member: current_time})
+ self._redis_client.expire(key, self.time_window * 2)
diff --git a/api/migrations/versions/2026_01_29_1415-e8c3b3c46151_add_human_input_related_db_models.py b/api/migrations/versions/2026_01_29_1415-e8c3b3c46151_add_human_input_related_db_models.py
new file mode 100644
index 0000000000..a1546ef940
--- /dev/null
+++ b/api/migrations/versions/2026_01_29_1415-e8c3b3c46151_add_human_input_related_db_models.py
@@ -0,0 +1,99 @@
+"""Add human input related db models
+
+Revision ID: e8c3b3c46151
+Revises: 788d3099ae3a
+Create Date: 2026-01-29 14:15:23.081903
+
+"""
+
+from alembic import op
+import models as models
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "e8c3b3c46151"
+down_revision = "788d3099ae3a"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ "execution_extra_contents",
+ sa.Column("id", models.types.StringUUID(), nullable=False),
+ sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+ sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+
+ sa.Column("type", sa.String(length=30), nullable=False),
+ sa.Column("workflow_run_id", models.types.StringUUID(), nullable=False),
+ sa.Column("message_id", models.types.StringUUID(), nullable=True),
+ sa.Column("form_id", models.types.StringUUID(), nullable=True),
+ sa.PrimaryKeyConstraint("id", name=op.f("execution_extra_contents_pkey")),
+ )
+ with op.batch_alter_table("execution_extra_contents", schema=None) as batch_op:
+ batch_op.create_index(batch_op.f("execution_extra_contents_message_id_idx"), ["message_id"], unique=False)
+ batch_op.create_index(
+ batch_op.f("execution_extra_contents_workflow_run_id_idx"), ["workflow_run_id"], unique=False
+ )
+
+ op.create_table(
+ "human_input_form_deliveries",
+ sa.Column("id", models.types.StringUUID(), nullable=False),
+ sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+ sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+
+ sa.Column("form_id", models.types.StringUUID(), nullable=False),
+ sa.Column("delivery_method_type", sa.String(length=20), nullable=False),
+ sa.Column("delivery_config_id", models.types.StringUUID(), nullable=True),
+ sa.Column("channel_payload", sa.Text(), nullable=False),
+ sa.PrimaryKeyConstraint("id", name=op.f("human_input_form_deliveries_pkey")),
+ )
+
+ op.create_table(
+ "human_input_form_recipients",
+ sa.Column("id", models.types.StringUUID(), nullable=False),
+ sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+ sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+
+ sa.Column("form_id", models.types.StringUUID(), nullable=False),
+ sa.Column("delivery_id", models.types.StringUUID(), nullable=False),
+ sa.Column("recipient_type", sa.String(length=20), nullable=False),
+ sa.Column("recipient_payload", sa.Text(), nullable=False),
+ sa.Column("access_token", sa.VARCHAR(length=32), nullable=False),
+ sa.PrimaryKeyConstraint("id", name=op.f("human_input_form_recipients_pkey")),
+ )
+ with op.batch_alter_table('human_input_form_recipients', schema=None) as batch_op:
+ batch_op.create_unique_constraint(batch_op.f('human_input_form_recipients_access_token_key'), ['access_token'])
+
+ op.create_table(
+ "human_input_forms",
+ sa.Column("id", models.types.StringUUID(), nullable=False),
+ sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+ sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
+
+ sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
+ sa.Column("app_id", models.types.StringUUID(), nullable=False),
+ sa.Column("workflow_run_id", models.types.StringUUID(), nullable=True),
+ sa.Column("form_kind", sa.String(length=20), nullable=False),
+ sa.Column("node_id", sa.String(length=60), nullable=False),
+ sa.Column("form_definition", sa.Text(), nullable=False),
+ sa.Column("rendered_content", sa.Text(), nullable=False),
+ sa.Column("status", sa.String(length=20), nullable=False),
+ sa.Column("expiration_time", sa.DateTime(), nullable=False),
+ sa.Column("selected_action_id", sa.String(length=200), nullable=True),
+ sa.Column("submitted_data", sa.Text(), nullable=True),
+ sa.Column("submitted_at", sa.DateTime(), nullable=True),
+ sa.Column("submission_user_id", models.types.StringUUID(), nullable=True),
+ sa.Column("submission_end_user_id", models.types.StringUUID(), nullable=True),
+ sa.Column("completed_by_recipient_id", models.types.StringUUID(), nullable=True),
+
+ sa.PrimaryKeyConstraint("id", name=op.f("human_input_forms_pkey")),
+ )
+
+
+def downgrade():
+ op.drop_table("human_input_forms")
+ op.drop_table("human_input_form_recipients")
+ op.drop_table("human_input_form_deliveries")
+ op.drop_table("execution_extra_contents")
diff --git a/api/models/__init__.py b/api/models/__init__.py
index 74b33130ef..1d5d604ba7 100644
--- a/api/models/__init__.py
+++ b/api/models/__init__.py
@@ -34,6 +34,8 @@ from .enums import (
WorkflowRunTriggeredFrom,
WorkflowTriggerStatus,
)
+from .execution_extra_content import ExecutionExtraContent, HumanInputContent
+from .human_input import HumanInputForm
from .model import (
AccountTrialAppRecord,
ApiRequest,
@@ -155,9 +157,12 @@ __all__ = [
"DocumentSegment",
"Embedding",
"EndUser",
+ "ExecutionExtraContent",
"ExporleBanner",
"ExternalKnowledgeApis",
"ExternalKnowledgeBindings",
+ "HumanInputContent",
+ "HumanInputForm",
"IconType",
"InstalledApp",
"InvitationCode",
diff --git a/api/models/base.py b/api/models/base.py
index c8a5e20f25..aa93d31199 100644
--- a/api/models/base.py
+++ b/api/models/base.py
@@ -41,7 +41,7 @@ class DefaultFieldsMixin:
)
updated_at: Mapped[datetime] = mapped_column(
- __name_pos=DateTime,
+ DateTime,
nullable=False,
default=naive_utc_now,
server_default=func.current_timestamp(),
diff --git a/api/models/enums.py b/api/models/enums.py
index 8cd3d4cf2a..2bc61120ce 100644
--- a/api/models/enums.py
+++ b/api/models/enums.py
@@ -36,6 +36,7 @@ class MessageStatus(StrEnum):
"""
NORMAL = "normal"
+ PAUSED = "paused"
ERROR = "error"
diff --git a/api/models/execution_extra_content.py b/api/models/execution_extra_content.py
new file mode 100644
index 0000000000..d0bd34efec
--- /dev/null
+++ b/api/models/execution_extra_content.py
@@ -0,0 +1,78 @@
+from enum import StrEnum, auto
+from typing import TYPE_CHECKING
+
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from .base import Base, DefaultFieldsMixin
+from .types import EnumText, StringUUID
+
+if TYPE_CHECKING:
+ from .human_input import HumanInputForm
+
+
+class ExecutionContentType(StrEnum):
+ HUMAN_INPUT = auto()
+
+
+class ExecutionExtraContent(DefaultFieldsMixin, Base):
+ """ExecutionExtraContent stores extra contents produced during workflow / chatflow execution."""
+
+ # The `ExecutionExtraContent` uses single table inheritance to model different
+ # kinds of contents produced during message generation.
+ #
+ # See: https://docs.sqlalchemy.org/en/20/orm/inheritance.html#single-table-inheritance
+
+ __tablename__ = "execution_extra_contents"
+ __mapper_args__ = {
+ "polymorphic_abstract": True,
+ "polymorphic_on": "type",
+ "with_polymorphic": "*",
+ }
+ # type records the type of the content. It serves as the `discriminator` for the
+ # single table inheritance.
+ type: Mapped[ExecutionContentType] = mapped_column(
+ EnumText(ExecutionContentType, length=30),
+ nullable=False,
+ )
+
+ # `workflow_run_id` records the workflow execution which generates this content, correspond to
+ # `WorkflowRun.id`.
+ workflow_run_id: Mapped[str] = mapped_column(StringUUID, nullable=False, index=True)
+
+ # `message_id` records the messages generated by the execution associated with this `ExecutionExtraContent`.
+ # It references to `Message.id`.
+ #
+ # For workflow execution, this field is `None`.
+ #
+ # For chatflow execution, `message_id`` is not None, and the following condition holds:
+ #
+ # The message referenced by `message_id` has `message.workflow_run_id == execution_extra_content.workflow_run_id`
+ #
+ message_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, index=True)
+
+
+class HumanInputContent(ExecutionExtraContent):
+ """HumanInputContent is a concrete class that represents human input content.
+ It should only be initialized with the `new` class method."""
+
+ __mapper_args__ = {
+ "polymorphic_identity": ExecutionContentType.HUMAN_INPUT,
+ }
+
+ # A relation to HumanInputForm table.
+ #
+ # While the form_id column is nullable in database (due to the nature of single table inheritance),
+ # the form_id field should not be null for a given `HumanInputContent` instance.
+ form_id: Mapped[str] = mapped_column(StringUUID, nullable=True)
+
+ @classmethod
+ def new(cls, form_id: str, message_id: str | None) -> "HumanInputContent":
+ return cls(form_id=form_id, message_id=message_id)
+
+ form: Mapped["HumanInputForm"] = relationship(
+ "HumanInputForm",
+ foreign_keys=[form_id],
+ uselist=False,
+ lazy="raise",
+ primaryjoin="foreign(HumanInputContent.form_id) == HumanInputForm.id",
+ )
diff --git a/api/models/human_input.py b/api/models/human_input.py
new file mode 100644
index 0000000000..5208461de1
--- /dev/null
+++ b/api/models/human_input.py
@@ -0,0 +1,237 @@
+from datetime import datetime
+from enum import StrEnum
+from typing import Annotated, Literal, Self, final
+
+import sqlalchemy as sa
+from pydantic import BaseModel, Field
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from core.workflow.nodes.human_input.enums import (
+ DeliveryMethodType,
+ HumanInputFormKind,
+ HumanInputFormStatus,
+)
+from libs.helper import generate_string
+
+from .base import Base, DefaultFieldsMixin
+from .types import EnumText, StringUUID
+
+_token_length = 22
+# A 32-character string can store a base64-encoded value with 192 bits of entropy
+# or a base62-encoded value with over 180 bits of entropy, providing sufficient
+# uniqueness for most use cases.
+_token_field_length = 32
+_email_field_length = 330
+
+
+def _generate_token() -> str:
+ return generate_string(_token_length)
+
+
+class HumanInputForm(DefaultFieldsMixin, Base):
+ __tablename__ = "human_input_forms"
+
+ tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
+ app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
+ workflow_run_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
+ form_kind: Mapped[HumanInputFormKind] = mapped_column(
+ EnumText(HumanInputFormKind),
+ nullable=False,
+ default=HumanInputFormKind.RUNTIME,
+ )
+
+ # The human input node the current form corresponds to.
+ node_id: Mapped[str] = mapped_column(sa.String(60), nullable=False)
+ form_definition: Mapped[str] = mapped_column(sa.Text, nullable=False)
+ rendered_content: Mapped[str] = mapped_column(sa.Text, nullable=False)
+ status: Mapped[HumanInputFormStatus] = mapped_column(
+ EnumText(HumanInputFormStatus),
+ nullable=False,
+ default=HumanInputFormStatus.WAITING,
+ )
+
+ expiration_time: Mapped[datetime] = mapped_column(
+ sa.DateTime,
+ nullable=False,
+ )
+
+ # Submission-related fields (nullable until a submission happens).
+ selected_action_id: Mapped[str | None] = mapped_column(sa.String(200), nullable=True)
+ submitted_data: Mapped[str | None] = mapped_column(sa.Text, nullable=True)
+ submitted_at: Mapped[datetime | None] = mapped_column(sa.DateTime, nullable=True)
+ submission_user_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
+ submission_end_user_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
+
+ completed_by_recipient_id: Mapped[str | None] = mapped_column(
+ StringUUID,
+ nullable=True,
+ )
+
+ deliveries: Mapped[list["HumanInputDelivery"]] = relationship(
+ "HumanInputDelivery",
+ primaryjoin="HumanInputForm.id == foreign(HumanInputDelivery.form_id)",
+ uselist=True,
+ back_populates="form",
+ lazy="raise",
+ )
+ completed_by_recipient: Mapped["HumanInputFormRecipient | None"] = relationship(
+ "HumanInputFormRecipient",
+ primaryjoin="HumanInputForm.completed_by_recipient_id == foreign(HumanInputFormRecipient.id)",
+ lazy="raise",
+ viewonly=True,
+ )
+
+
+class HumanInputDelivery(DefaultFieldsMixin, Base):
+ __tablename__ = "human_input_form_deliveries"
+
+ form_id: Mapped[str] = mapped_column(
+ StringUUID,
+ nullable=False,
+ )
+ delivery_method_type: Mapped[DeliveryMethodType] = mapped_column(
+ EnumText(DeliveryMethodType),
+ nullable=False,
+ )
+ delivery_config_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
+ channel_payload: Mapped[str] = mapped_column(sa.Text, nullable=False)
+
+ form: Mapped[HumanInputForm] = relationship(
+ "HumanInputForm",
+ uselist=False,
+ foreign_keys=[form_id],
+ primaryjoin="HumanInputDelivery.form_id == HumanInputForm.id",
+ back_populates="deliveries",
+ lazy="raise",
+ )
+
+ recipients: Mapped[list["HumanInputFormRecipient"]] = relationship(
+ "HumanInputFormRecipient",
+ primaryjoin="HumanInputDelivery.id == foreign(HumanInputFormRecipient.delivery_id)",
+ uselist=True,
+ back_populates="delivery",
+ # Require explicit preloading
+ lazy="raise",
+ )
+
+
+class RecipientType(StrEnum):
+ # EMAIL_MEMBER member means that the
+ EMAIL_MEMBER = "email_member"
+ EMAIL_EXTERNAL = "email_external"
+ # STANDALONE_WEB_APP is used by the standalone web app.
+ #
+ # It's not used while running workflows / chatflows containing HumanInput
+ # node inside console.
+ STANDALONE_WEB_APP = "standalone_web_app"
+ # CONSOLE is used while running workflows / chatflows containing HumanInput
+ # node inside console. (E.G. running installed apps or debugging workflows / chatflows)
+ CONSOLE = "console"
+ # BACKSTAGE is used for backstage input inside console.
+ BACKSTAGE = "backstage"
+
+
+@final
+class EmailMemberRecipientPayload(BaseModel):
+ TYPE: Literal[RecipientType.EMAIL_MEMBER] = RecipientType.EMAIL_MEMBER
+ user_id: str
+
+ # The `email` field here is only used for mail sending.
+ email: str
+
+
+@final
+class EmailExternalRecipientPayload(BaseModel):
+ TYPE: Literal[RecipientType.EMAIL_EXTERNAL] = RecipientType.EMAIL_EXTERNAL
+ email: str
+
+
+@final
+class StandaloneWebAppRecipientPayload(BaseModel):
+ TYPE: Literal[RecipientType.STANDALONE_WEB_APP] = RecipientType.STANDALONE_WEB_APP
+
+
+@final
+class ConsoleRecipientPayload(BaseModel):
+ TYPE: Literal[RecipientType.CONSOLE] = RecipientType.CONSOLE
+ account_id: str | None = None
+
+
+@final
+class BackstageRecipientPayload(BaseModel):
+ TYPE: Literal[RecipientType.BACKSTAGE] = RecipientType.BACKSTAGE
+ account_id: str | None = None
+
+
+@final
+class ConsoleDeliveryPayload(BaseModel):
+ type: Literal["console"] = "console"
+ internal: bool = True
+
+
+RecipientPayload = Annotated[
+ EmailMemberRecipientPayload
+ | EmailExternalRecipientPayload
+ | StandaloneWebAppRecipientPayload
+ | ConsoleRecipientPayload
+ | BackstageRecipientPayload,
+ Field(discriminator="TYPE"),
+]
+
+
+class HumanInputFormRecipient(DefaultFieldsMixin, Base):
+ __tablename__ = "human_input_form_recipients"
+
+ form_id: Mapped[str] = mapped_column(
+ StringUUID,
+ nullable=False,
+ )
+ delivery_id: Mapped[str] = mapped_column(
+ StringUUID,
+ nullable=False,
+ )
+ recipient_type: Mapped["RecipientType"] = mapped_column(EnumText(RecipientType), nullable=False)
+ recipient_payload: Mapped[str] = mapped_column(sa.Text, nullable=False)
+
+ # Token primarily used for authenticated resume links (email, etc.).
+ access_token: Mapped[str | None] = mapped_column(
+ sa.VARCHAR(_token_field_length),
+ nullable=False,
+ default=_generate_token,
+ unique=True,
+ )
+
+ delivery: Mapped[HumanInputDelivery] = relationship(
+ "HumanInputDelivery",
+ uselist=False,
+ foreign_keys=[delivery_id],
+ back_populates="recipients",
+ primaryjoin="HumanInputFormRecipient.delivery_id == HumanInputDelivery.id",
+ # Require explicit preloading
+ lazy="raise",
+ )
+
+ form: Mapped[HumanInputForm] = relationship(
+ "HumanInputForm",
+ uselist=False,
+ foreign_keys=[form_id],
+ primaryjoin="HumanInputFormRecipient.form_id == HumanInputForm.id",
+ # Require explicit preloading
+ lazy="raise",
+ )
+
+ @classmethod
+ def new(
+ cls,
+ form_id: str,
+ delivery_id: str,
+ payload: RecipientPayload,
+ ) -> Self:
+ recipient_model = cls(
+ form_id=form_id,
+ delivery_id=delivery_id,
+ recipient_type=payload.TYPE,
+ recipient_payload=payload.model_dump_json(),
+ access_token=_generate_token(),
+ )
+ return recipient_model
diff --git a/api/models/model.py b/api/models/model.py
index c1c6e04ce9..c12362f359 100644
--- a/api/models/model.py
+++ b/api/models/model.py
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
import re
import uuid
-from collections.abc import Mapping
+from collections.abc import Mapping, Sequence
from datetime import datetime
from decimal import Decimal
from enum import StrEnum, auto
@@ -943,6 +943,7 @@ class Conversation(Base):
WorkflowExecutionStatus.FAILED: 0,
WorkflowExecutionStatus.STOPPED: 0,
WorkflowExecutionStatus.PARTIAL_SUCCEEDED: 0,
+ WorkflowExecutionStatus.PAUSED: 0,
}
for message in messages:
@@ -963,6 +964,7 @@ class Conversation(Base):
"success": status_counts[WorkflowExecutionStatus.SUCCEEDED],
"failed": status_counts[WorkflowExecutionStatus.FAILED],
"partial_success": status_counts[WorkflowExecutionStatus.PARTIAL_SUCCEEDED],
+ "paused": status_counts[WorkflowExecutionStatus.PAUSED],
}
@property
@@ -1345,6 +1347,14 @@ class Message(Base):
db.session.commit()
return result
+ # TODO(QuantumGhost): dirty hacks, fix this later.
+ def set_extra_contents(self, contents: Sequence[dict[str, Any]]) -> None:
+ self._extra_contents = list(contents)
+
+ @property
+ def extra_contents(self) -> list[dict[str, Any]]:
+ return getattr(self, "_extra_contents", [])
+
@property
def workflow_run(self):
if self.workflow_run_id:
diff --git a/api/models/workflow.py b/api/models/workflow.py
index df83228c2a..94e0881bd1 100644
--- a/api/models/workflow.py
+++ b/api/models/workflow.py
@@ -20,6 +20,7 @@ from sqlalchemy import (
select,
)
from sqlalchemy.orm import Mapped, declared_attr, mapped_column
+from typing_extensions import deprecated
from core.file.constants import maybe_file_object
from core.file.models import File
@@ -30,7 +31,7 @@ from core.workflow.constants import (
SYSTEM_VARIABLE_NODE_ID,
)
from core.workflow.entities.pause_reason import HumanInputRequired, PauseReason, PauseReasonType, SchedulingPause
-from core.workflow.enums import NodeType
+from core.workflow.enums import NodeType, WorkflowExecutionStatus
from extensions.ext_storage import Storage
from factories.variable_factory import TypeMismatchError, build_segment_with_type
from libs.datetime_utils import naive_utc_now
@@ -405,6 +406,11 @@ class Workflow(Base): # bug
return helper.generate_text_hash(json.dumps(entity, sort_keys=True))
@property
+ @deprecated(
+ "This property is not accurate for determining if a workflow is published as a tool."
+ "It only checks if there's a WorkflowToolProvider for the app, "
+ "not if this specific workflow version is the one being used by the tool."
+ )
def tool_published(self) -> bool:
"""
DEPRECATED: This property is not accurate for determining if a workflow is published as a tool.
@@ -607,13 +613,16 @@ class WorkflowRun(Base):
version: Mapped[str] = mapped_column(String(255))
graph: Mapped[str | None] = mapped_column(LongText)
inputs: Mapped[str | None] = mapped_column(LongText)
- status: Mapped[str] = mapped_column(String(255)) # running, succeeded, failed, stopped, partial-succeeded
+ status: Mapped[WorkflowExecutionStatus] = mapped_column(
+ EnumText(WorkflowExecutionStatus, length=255),
+ nullable=False,
+ )
outputs: Mapped[str | None] = mapped_column(LongText, default="{}")
error: Mapped[str | None] = mapped_column(LongText)
elapsed_time: Mapped[float] = mapped_column(sa.Float, nullable=False, server_default=sa.text("0"))
total_tokens: Mapped[int] = mapped_column(sa.BigInteger, server_default=sa.text("0"))
total_steps: Mapped[int] = mapped_column(sa.Integer, server_default=sa.text("0"), nullable=True)
- created_by_role: Mapped[str] = mapped_column(String(255)) # account, end_user
+ created_by_role: Mapped[CreatorUserRole] = mapped_column(EnumText(CreatorUserRole, length=255)) # account, end_user
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp())
finished_at: Mapped[datetime | None] = mapped_column(DateTime)
@@ -629,11 +638,13 @@ class WorkflowRun(Base):
)
@property
+ @deprecated("This method is retained for historical reasons; avoid using it if possible.")
def created_by_account(self):
created_by_role = CreatorUserRole(self.created_by_role)
return db.session.get(Account, self.created_by) if created_by_role == CreatorUserRole.ACCOUNT else None
@property
+ @deprecated("This method is retained for historical reasons; avoid using it if possible.")
def created_by_end_user(self):
from .model import EndUser
@@ -653,6 +664,7 @@ class WorkflowRun(Base):
return json.loads(self.outputs) if self.outputs else {}
@property
+ @deprecated("This method is retained for historical reasons; avoid using it if possible.")
def message(self):
from .model import Message
@@ -661,6 +673,7 @@ class WorkflowRun(Base):
)
@property
+ @deprecated("This method is retained for historical reasons; avoid using it if possible.")
def workflow(self):
return db.session.query(Workflow).where(Workflow.id == self.workflow_id).first()
@@ -1861,7 +1874,12 @@ class WorkflowPauseReason(DefaultFieldsMixin, Base):
def to_entity(self) -> PauseReason:
if self.type_ == PauseReasonType.HUMAN_INPUT_REQUIRED:
- return HumanInputRequired(form_id=self.form_id, node_id=self.node_id)
+ return HumanInputRequired(
+ form_id=self.form_id,
+ form_content="",
+ node_id=self.node_id,
+ node_title="",
+ )
elif self.type_ == PauseReasonType.SCHEDULED_PAUSE:
return SchedulingPause(message=self.message)
else:
diff --git a/api/repositories/api_workflow_node_execution_repository.py b/api/repositories/api_workflow_node_execution_repository.py
index 5b3f635301..6446eb0d6e 100644
--- a/api/repositories/api_workflow_node_execution_repository.py
+++ b/api/repositories/api_workflow_node_execution_repository.py
@@ -10,6 +10,7 @@ tenant_id, app_id, triggered_from, etc., which are not part of the core domain m
"""
from collections.abc import Sequence
+from dataclasses import dataclass
from datetime import datetime
from typing import Protocol
@@ -19,6 +20,27 @@ from core.workflow.repositories.workflow_node_execution_repository import Workfl
from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionOffload
+@dataclass(frozen=True)
+class WorkflowNodeExecutionSnapshot:
+ """
+ Minimal snapshot of workflow node execution for stream recovery.
+
+ Only includes fields required by snapshot events.
+ """
+
+ execution_id: str # Unique execution identifier (node_execution_id or row id).
+ node_id: str # Workflow graph node id.
+ node_type: str # Workflow graph node type (e.g. "human-input").
+ title: str # Human-friendly node title.
+ index: int # Execution order index within the workflow run.
+ status: str # Execution status (running/succeeded/failed/paused).
+ elapsed_time: float # Execution elapsed time in seconds.
+ created_at: datetime # Execution created timestamp.
+ finished_at: datetime | None # Execution finished timestamp.
+ iteration_id: str | None = None # Iteration id from execution metadata, if any.
+ loop_id: str | None = None # Loop id from execution metadata, if any.
+
+
class DifyAPIWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository, Protocol):
"""
Protocol for service-layer operations on WorkflowNodeExecutionModel.
@@ -79,6 +101,8 @@ class DifyAPIWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository, Pr
Args:
tenant_id: The tenant identifier
app_id: The application identifier
+ workflow_id: The workflow identifier
+ triggered_from: The workflow trigger source
workflow_run_id: The workflow run identifier
Returns:
@@ -86,6 +110,27 @@ class DifyAPIWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository, Pr
"""
...
+ def get_execution_snapshots_by_workflow_run(
+ self,
+ tenant_id: str,
+ app_id: str,
+ workflow_id: str,
+ triggered_from: str,
+ workflow_run_id: str,
+ ) -> Sequence[WorkflowNodeExecutionSnapshot]:
+ """
+ Get minimal snapshots for node executions in a workflow run.
+
+ Args:
+ tenant_id: The tenant identifier
+ app_id: The application identifier
+ workflow_run_id: The workflow run identifier
+
+ Returns:
+ A sequence of WorkflowNodeExecutionSnapshot ordered by creation time
+ """
+ ...
+
def get_execution_by_id(
self,
execution_id: str,
diff --git a/api/repositories/api_workflow_run_repository.py b/api/repositories/api_workflow_run_repository.py
index 1d3954571f..17e01a6e18 100644
--- a/api/repositories/api_workflow_run_repository.py
+++ b/api/repositories/api_workflow_run_repository.py
@@ -432,6 +432,13 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol):
# while creating pause.
...
+ def get_workflow_pause(self, workflow_run_id: str) -> WorkflowPauseEntity | None:
+ """Retrieve the current pause for a workflow execution.
+
+ If there is no current pause, this method would return `None`.
+ """
+ ...
+
def resume_workflow_pause(
self,
workflow_run_id: str,
@@ -627,3 +634,19 @@ class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol):
[{"date": "2024-01-01", "interactions": 2.5}, ...]
"""
...
+
+ def get_workflow_run_by_id_and_tenant_id(self, tenant_id: str, run_id: str) -> WorkflowRun | None:
+ """
+ Get a specific workflow run by its id and the associated tenant id.
+
+ This function does not apply application isolation. It should only be used when
+ the application identifier is not available.
+
+ Args:
+ tenant_id: Tenant identifier for multi-tenant isolation
+ run_id: Workflow run identifier
+
+ Returns:
+ WorkflowRun object if found, None otherwise
+ """
+ ...
diff --git a/api/repositories/entities/workflow_pause.py b/api/repositories/entities/workflow_pause.py
index b970f39816..a3c4039aaa 100644
--- a/api/repositories/entities/workflow_pause.py
+++ b/api/repositories/entities/workflow_pause.py
@@ -63,6 +63,12 @@ class WorkflowPauseEntity(ABC):
"""
pass
+ @property
+ @abstractmethod
+ def paused_at(self) -> datetime:
+ """`paused_at` returns the creation time of the pause."""
+ pass
+
@abstractmethod
def get_pause_reasons(self) -> Sequence[PauseReason]:
"""
@@ -70,7 +76,5 @@ class WorkflowPauseEntity(ABC):
Returns a sequence of `PauseReason` objects describing the specific nodes and
reasons for which the workflow execution was paused.
- This information is related to, but distinct from, the `PauseReason` type
- defined in `api/core/workflow/entities/pause_reason.py`.
"""
...
diff --git a/api/repositories/execution_extra_content_repository.py b/api/repositories/execution_extra_content_repository.py
new file mode 100644
index 0000000000..72b5443d2c
--- /dev/null
+++ b/api/repositories/execution_extra_content_repository.py
@@ -0,0 +1,13 @@
+from __future__ import annotations
+
+from collections.abc import Sequence
+from typing import Protocol
+
+from core.entities.execution_extra_content import ExecutionExtraContentDomainModel
+
+
+class ExecutionExtraContentRepository(Protocol):
+ def get_by_message_ids(self, message_ids: Sequence[str]) -> list[list[ExecutionExtraContentDomainModel]]: ...
+
+
+__all__ = ["ExecutionExtraContentRepository"]
diff --git a/api/repositories/sqlalchemy_api_workflow_node_execution_repository.py b/api/repositories/sqlalchemy_api_workflow_node_execution_repository.py
index b19cc73bd1..6c696b6478 100644
--- a/api/repositories/sqlalchemy_api_workflow_node_execution_repository.py
+++ b/api/repositories/sqlalchemy_api_workflow_node_execution_repository.py
@@ -5,6 +5,7 @@ This module provides a concrete implementation of the service repository protoco
using SQLAlchemy 2.0 style queries for WorkflowNodeExecutionModel operations.
"""
+import json
from collections.abc import Sequence
from datetime import datetime
from typing import cast
@@ -13,11 +14,12 @@ from sqlalchemy import asc, delete, desc, func, select
from sqlalchemy.engine import CursorResult
from sqlalchemy.orm import Session, sessionmaker
-from models.workflow import (
- WorkflowNodeExecutionModel,
- WorkflowNodeExecutionOffload,
+from core.workflow.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
+from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionOffload
+from repositories.api_workflow_node_execution_repository import (
+ DifyAPIWorkflowNodeExecutionRepository,
+ WorkflowNodeExecutionSnapshot,
)
-from repositories.api_workflow_node_execution_repository import DifyAPIWorkflowNodeExecutionRepository
class DifyAPISQLAlchemyWorkflowNodeExecutionRepository(DifyAPIWorkflowNodeExecutionRepository):
@@ -79,6 +81,7 @@ class DifyAPISQLAlchemyWorkflowNodeExecutionRepository(DifyAPIWorkflowNodeExecut
WorkflowNodeExecutionModel.app_id == app_id,
WorkflowNodeExecutionModel.workflow_id == workflow_id,
WorkflowNodeExecutionModel.node_id == node_id,
+ WorkflowNodeExecutionModel.status != WorkflowNodeExecutionStatus.PAUSED,
)
.order_by(desc(WorkflowNodeExecutionModel.created_at))
.limit(1)
@@ -117,6 +120,80 @@ class DifyAPISQLAlchemyWorkflowNodeExecutionRepository(DifyAPIWorkflowNodeExecut
with self._session_maker() as session:
return session.execute(stmt).scalars().all()
+ def get_execution_snapshots_by_workflow_run(
+ self,
+ tenant_id: str,
+ app_id: str,
+ workflow_id: str,
+ triggered_from: str,
+ workflow_run_id: str,
+ ) -> Sequence[WorkflowNodeExecutionSnapshot]:
+ stmt = (
+ select(
+ WorkflowNodeExecutionModel.id,
+ WorkflowNodeExecutionModel.node_execution_id,
+ WorkflowNodeExecutionModel.node_id,
+ WorkflowNodeExecutionModel.node_type,
+ WorkflowNodeExecutionModel.title,
+ WorkflowNodeExecutionModel.index,
+ WorkflowNodeExecutionModel.status,
+ WorkflowNodeExecutionModel.elapsed_time,
+ WorkflowNodeExecutionModel.created_at,
+ WorkflowNodeExecutionModel.finished_at,
+ WorkflowNodeExecutionModel.execution_metadata,
+ )
+ .where(
+ WorkflowNodeExecutionModel.tenant_id == tenant_id,
+ WorkflowNodeExecutionModel.app_id == app_id,
+ WorkflowNodeExecutionModel.workflow_id == workflow_id,
+ WorkflowNodeExecutionModel.triggered_from == triggered_from,
+ WorkflowNodeExecutionModel.workflow_run_id == workflow_run_id,
+ )
+ .order_by(
+ asc(WorkflowNodeExecutionModel.created_at),
+ asc(WorkflowNodeExecutionModel.index),
+ )
+ )
+
+ with self._session_maker() as session:
+ rows = session.execute(stmt).all()
+
+ return [self._row_to_snapshot(row) for row in rows]
+
+ @staticmethod
+ def _row_to_snapshot(row: object) -> WorkflowNodeExecutionSnapshot:
+ metadata: dict[str, object] = {}
+ execution_metadata = getattr(row, "execution_metadata", None)
+ if execution_metadata:
+ try:
+ metadata = json.loads(execution_metadata)
+ except json.JSONDecodeError:
+ metadata = {}
+ iteration_id = metadata.get(WorkflowNodeExecutionMetadataKey.ITERATION_ID.value)
+ loop_id = metadata.get(WorkflowNodeExecutionMetadataKey.LOOP_ID.value)
+ execution_id = getattr(row, "node_execution_id", None) or row.id
+ elapsed_time = getattr(row, "elapsed_time", None)
+ created_at = row.created_at
+ finished_at = getattr(row, "finished_at", None)
+ if elapsed_time is None:
+ if finished_at is not None and created_at is not None:
+ elapsed_time = (finished_at - created_at).total_seconds()
+ else:
+ elapsed_time = 0.0
+ return WorkflowNodeExecutionSnapshot(
+ execution_id=str(execution_id),
+ node_id=row.node_id,
+ node_type=row.node_type,
+ title=row.title,
+ index=row.index,
+ status=row.status,
+ elapsed_time=float(elapsed_time),
+ created_at=created_at,
+ finished_at=finished_at,
+ iteration_id=str(iteration_id) if iteration_id else None,
+ loop_id=str(loop_id) if loop_id else None,
+ )
+
def get_execution_by_id(
self,
execution_id: str,
diff --git a/api/repositories/sqlalchemy_api_workflow_run_repository.py b/api/repositories/sqlalchemy_api_workflow_run_repository.py
index d5214be042..00cb979e17 100644
--- a/api/repositories/sqlalchemy_api_workflow_run_repository.py
+++ b/api/repositories/sqlalchemy_api_workflow_run_repository.py
@@ -19,6 +19,7 @@ Implementation Notes:
- Maintains data consistency with proper transaction handling
"""
+import json
import logging
import uuid
from collections.abc import Callable, Sequence
@@ -27,12 +28,14 @@ from decimal import Decimal
from typing import Any, cast
import sqlalchemy as sa
+from pydantic import ValidationError
from sqlalchemy import and_, delete, func, null, or_, select
from sqlalchemy.engine import CursorResult
from sqlalchemy.orm import Session, selectinload, sessionmaker
-from core.workflow.entities.pause_reason import HumanInputRequired, PauseReason, SchedulingPause
+from core.workflow.entities.pause_reason import HumanInputRequired, PauseReason, PauseReasonType, SchedulingPause
from core.workflow.enums import WorkflowExecutionStatus, WorkflowType
+from core.workflow.nodes.human_input.entities import FormDefinition
from extensions.ext_storage import storage
from libs.datetime_utils import naive_utc_now
from libs.helper import convert_datetime_to_date
@@ -40,6 +43,7 @@ from libs.infinite_scroll_pagination import InfiniteScrollPagination
from libs.time_parser import get_time_threshold
from libs.uuid_utils import uuidv7
from models.enums import WorkflowRunTriggeredFrom
+from models.human_input import HumanInputForm, HumanInputFormRecipient, RecipientType
from models.workflow import WorkflowAppLog, WorkflowArchiveLog, WorkflowPause, WorkflowPauseReason, WorkflowRun
from repositories.api_workflow_run_repository import APIWorkflowRunRepository
from repositories.entities.workflow_pause import WorkflowPauseEntity
@@ -57,6 +61,67 @@ class _WorkflowRunError(Exception):
pass
+def _select_recipient_token(
+ recipients: Sequence[HumanInputFormRecipient],
+ recipient_type: RecipientType,
+) -> str | None:
+ for recipient in recipients:
+ if recipient.recipient_type == recipient_type and recipient.access_token:
+ return recipient.access_token
+ return None
+
+
+def _build_human_input_required_reason(
+ reason_model: WorkflowPauseReason,
+ form_model: HumanInputForm | None,
+ recipients: Sequence[HumanInputFormRecipient],
+) -> HumanInputRequired:
+ form_content = ""
+ inputs = []
+ actions = []
+ display_in_ui = False
+ resolved_default_values: dict[str, Any] = {}
+ node_title = "Human Input"
+ form_id = reason_model.form_id
+ node_id = reason_model.node_id
+ if form_model is not None:
+ form_id = form_model.id
+ node_id = form_model.node_id or node_id
+ try:
+ definition_payload = json.loads(form_model.form_definition)
+ if "expiration_time" not in definition_payload:
+ definition_payload["expiration_time"] = form_model.expiration_time
+ definition = FormDefinition.model_validate(definition_payload)
+ except ValidationError:
+ definition = None
+
+ if definition is not None:
+ form_content = definition.form_content
+ inputs = list(definition.inputs)
+ actions = list(definition.user_actions)
+ display_in_ui = bool(definition.display_in_ui)
+ resolved_default_values = dict(definition.default_values)
+ node_title = definition.node_title or node_title
+
+ form_token = (
+ _select_recipient_token(recipients, RecipientType.BACKSTAGE)
+ or _select_recipient_token(recipients, RecipientType.CONSOLE)
+ or _select_recipient_token(recipients, RecipientType.STANDALONE_WEB_APP)
+ )
+
+ return HumanInputRequired(
+ form_id=form_id,
+ form_content=form_content,
+ inputs=inputs,
+ actions=actions,
+ display_in_ui=display_in_ui,
+ node_id=node_id,
+ node_title=node_title,
+ form_token=form_token,
+ resolved_default_values=resolved_default_values,
+ )
+
+
class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
"""
SQLAlchemy implementation of APIWorkflowRunRepository.
@@ -676,9 +741,11 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
raise ValueError(f"WorkflowRun not found: {workflow_run_id}")
# Check if workflow is in RUNNING status
- if workflow_run.status != WorkflowExecutionStatus.RUNNING:
+ # TODO(QuantumGhost): It seems that the persistence of `WorkflowRun.status`
+ # happens before the execution of GraphLayer
+ if workflow_run.status not in {WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.PAUSED}:
raise _WorkflowRunError(
- f"Only WorkflowRun with RUNNING status can be paused, "
+ f"Only WorkflowRun with RUNNING or PAUSED status can be paused, "
f"workflow_run_id={workflow_run_id}, current_status={workflow_run.status}"
)
#
@@ -729,13 +796,48 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
logger.info("Created workflow pause %s for workflow run %s", pause_model.id, workflow_run_id)
- return _PrivateWorkflowPauseEntity(pause_model=pause_model, reason_models=pause_reason_models)
+ return _PrivateWorkflowPauseEntity(
+ pause_model=pause_model,
+ reason_models=pause_reason_models,
+ pause_reasons=pause_reasons,
+ )
def _get_reasons_by_pause_id(self, session: Session, pause_id: str):
reason_stmt = select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id == pause_id)
pause_reason_models = session.scalars(reason_stmt).all()
return pause_reason_models
+ def _hydrate_pause_reasons(
+ self,
+ session: Session,
+ pause_reason_models: Sequence[WorkflowPauseReason],
+ ) -> list[PauseReason]:
+ form_ids = [
+ reason.form_id
+ for reason in pause_reason_models
+ if reason.type_ == PauseReasonType.HUMAN_INPUT_REQUIRED and reason.form_id
+ ]
+ form_models: dict[str, HumanInputForm] = {}
+ recipient_models_by_form: dict[str, list[HumanInputFormRecipient]] = {}
+ if form_ids:
+ form_stmt = select(HumanInputForm).where(HumanInputForm.id.in_(form_ids))
+ for form in session.scalars(form_stmt).all():
+ form_models[form.id] = form
+
+ recipient_stmt = select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id.in_(form_ids))
+ for recipient in session.scalars(recipient_stmt).all():
+ recipient_models_by_form.setdefault(recipient.form_id, []).append(recipient)
+
+ pause_reasons: list[PauseReason] = []
+ for reason in pause_reason_models:
+ if reason.type_ == PauseReasonType.HUMAN_INPUT_REQUIRED:
+ form_model = form_models.get(reason.form_id)
+ recipients = recipient_models_by_form.get(reason.form_id, [])
+ pause_reasons.append(_build_human_input_required_reason(reason, form_model, recipients))
+ else:
+ pause_reasons.append(reason.to_entity())
+ return pause_reasons
+
def get_workflow_pause(
self,
workflow_run_id: str,
@@ -767,14 +869,12 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
if pause_model is None:
return None
pause_reason_models = self._get_reasons_by_pause_id(session, pause_model.id)
-
- human_input_form: list[Any] = []
- # TODO(QuantumGhost): query human_input_forms model and rebuild PauseReason
+ pause_reasons = self._hydrate_pause_reasons(session, pause_reason_models)
return _PrivateWorkflowPauseEntity(
pause_model=pause_model,
reason_models=pause_reason_models,
- human_input_form=human_input_form,
+ pause_reasons=pause_reasons,
)
def resume_workflow_pause(
@@ -828,10 +928,10 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
raise _WorkflowRunError(f"Cannot resume an already resumed pause, pause_id={pause_model.id}")
pause_reasons = self._get_reasons_by_pause_id(session, pause_model.id)
+ hydrated_pause_reasons = self._hydrate_pause_reasons(session, pause_reasons)
# Mark as resumed
pause_model.resumed_at = naive_utc_now()
- workflow_run.pause_id = None # type: ignore
workflow_run.status = WorkflowExecutionStatus.RUNNING
session.add(pause_model)
@@ -839,7 +939,11 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
logger.info("Resumed workflow pause %s for workflow run %s", pause_model.id, workflow_run_id)
- return _PrivateWorkflowPauseEntity(pause_model=pause_model, reason_models=pause_reasons)
+ return _PrivateWorkflowPauseEntity(
+ pause_model=pause_model,
+ reason_models=pause_reasons,
+ pause_reasons=hydrated_pause_reasons,
+ )
def delete_workflow_pause(
self,
@@ -1165,6 +1269,15 @@ GROUP BY
return cast(list[AverageInteractionStats], response_data)
+ def get_workflow_run_by_id_and_tenant_id(self, tenant_id: str, run_id: str) -> WorkflowRun | None:
+ """Get a specific workflow run by its id and the associated tenant id."""
+ with self._session_maker() as session:
+ stmt = select(WorkflowRun).where(
+ WorkflowRun.tenant_id == tenant_id,
+ WorkflowRun.id == run_id,
+ )
+ return session.scalar(stmt)
+
class _PrivateWorkflowPauseEntity(WorkflowPauseEntity):
"""
@@ -1179,10 +1292,12 @@ class _PrivateWorkflowPauseEntity(WorkflowPauseEntity):
*,
pause_model: WorkflowPause,
reason_models: Sequence[WorkflowPauseReason],
+ pause_reasons: Sequence[PauseReason] | None = None,
human_input_form: Sequence = (),
) -> None:
self._pause_model = pause_model
self._reason_models = reason_models
+ self._pause_reasons = pause_reasons
self._cached_state: bytes | None = None
self._human_input_form = human_input_form
@@ -1219,4 +1334,10 @@ class _PrivateWorkflowPauseEntity(WorkflowPauseEntity):
return self._pause_model.resumed_at
def get_pause_reasons(self) -> Sequence[PauseReason]:
+ if self._pause_reasons is not None:
+ return list(self._pause_reasons)
return [reason.to_entity() for reason in self._reason_models]
+
+ @property
+ def paused_at(self) -> datetime:
+ return self._pause_model.created_at
diff --git a/api/repositories/sqlalchemy_execution_extra_content_repository.py b/api/repositories/sqlalchemy_execution_extra_content_repository.py
new file mode 100644
index 0000000000..5a2c0ea46f
--- /dev/null
+++ b/api/repositories/sqlalchemy_execution_extra_content_repository.py
@@ -0,0 +1,200 @@
+from __future__ import annotations
+
+import json
+import logging
+import re
+from collections import defaultdict
+from collections.abc import Sequence
+from typing import Any
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session, selectinload, sessionmaker
+
+from core.entities.execution_extra_content import (
+ ExecutionExtraContentDomainModel,
+ HumanInputFormDefinition,
+ HumanInputFormSubmissionData,
+)
+from core.entities.execution_extra_content import (
+ HumanInputContent as HumanInputContentDomainModel,
+)
+from core.workflow.nodes.human_input.entities import FormDefinition
+from core.workflow.nodes.human_input.enums import HumanInputFormStatus
+from core.workflow.nodes.human_input.human_input_node import HumanInputNode
+from models.execution_extra_content import (
+ ExecutionExtraContent as ExecutionExtraContentModel,
+)
+from models.execution_extra_content import (
+ HumanInputContent as HumanInputContentModel,
+)
+from models.human_input import HumanInputFormRecipient, RecipientType
+from repositories.execution_extra_content_repository import ExecutionExtraContentRepository
+
+logger = logging.getLogger(__name__)
+
+_OUTPUT_VARIABLE_PATTERN = re.compile(r"\{\{#\$output\.(?P[a-zA-Z_][a-zA-Z0-9_]{0,29})#\}\}")
+
+
+def _extract_output_field_names(form_content: str) -> list[str]:
+ if not form_content:
+ return []
+ return [match.group("field_name") for match in _OUTPUT_VARIABLE_PATTERN.finditer(form_content)]
+
+
+class SQLAlchemyExecutionExtraContentRepository(ExecutionExtraContentRepository):
+ def __init__(self, session_maker: sessionmaker[Session]):
+ self._session_maker = session_maker
+
+ def get_by_message_ids(self, message_ids: Sequence[str]) -> list[list[ExecutionExtraContentDomainModel]]:
+ if not message_ids:
+ return []
+
+ grouped_contents: dict[str, list[ExecutionExtraContentDomainModel]] = {
+ message_id: [] for message_id in message_ids
+ }
+
+ stmt = (
+ select(ExecutionExtraContentModel)
+ .where(ExecutionExtraContentModel.message_id.in_(message_ids))
+ .options(selectinload(HumanInputContentModel.form))
+ .order_by(ExecutionExtraContentModel.created_at.asc())
+ )
+
+ with self._session_maker() as session:
+ results = session.scalars(stmt).all()
+
+ form_ids = {
+ content.form_id
+ for content in results
+ if isinstance(content, HumanInputContentModel) and content.form_id is not None
+ }
+ recipients_by_form_id: dict[str, list[HumanInputFormRecipient]] = defaultdict(list)
+ if form_ids:
+ recipient_stmt = select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id.in_(form_ids))
+ recipients = session.scalars(recipient_stmt).all()
+ for recipient in recipients:
+ recipients_by_form_id[recipient.form_id].append(recipient)
+ else:
+ recipients_by_form_id = {}
+
+ for content in results:
+ message_id = content.message_id
+ if not message_id or message_id not in grouped_contents:
+ continue
+
+ domain_model = self._map_model_to_domain(content, recipients_by_form_id)
+ if domain_model is None:
+ continue
+
+ grouped_contents[message_id].append(domain_model)
+
+ return [grouped_contents[message_id] for message_id in message_ids]
+
+ def _map_model_to_domain(
+ self,
+ model: ExecutionExtraContentModel,
+ recipients_by_form_id: dict[str, list[HumanInputFormRecipient]],
+ ) -> ExecutionExtraContentDomainModel | None:
+ if isinstance(model, HumanInputContentModel):
+ return self._map_human_input_content(model, recipients_by_form_id)
+
+ logger.debug("Unsupported execution extra content type encountered: %s", model.type)
+ return None
+
+ def _map_human_input_content(
+ self,
+ model: HumanInputContentModel,
+ recipients_by_form_id: dict[str, list[HumanInputFormRecipient]],
+ ) -> HumanInputContentDomainModel | None:
+ form = model.form
+ if form is None:
+ logger.warning("HumanInputContent(id=%s) has no associated form loaded", model.id)
+ return None
+
+ try:
+ definition_payload = json.loads(form.form_definition)
+ if "expiration_time" not in definition_payload:
+ definition_payload["expiration_time"] = form.expiration_time
+ form_definition = FormDefinition.model_validate(definition_payload)
+ except ValueError:
+ logger.warning("Failed to load form definition for HumanInputContent(id=%s)", model.id)
+ return None
+ node_title = form_definition.node_title or form.node_id
+ display_in_ui = bool(form_definition.display_in_ui)
+
+ submitted = form.submitted_at is not None or form.status == HumanInputFormStatus.SUBMITTED
+ if not submitted:
+ form_token = self._resolve_form_token(recipients_by_form_id.get(form.id, []))
+ return HumanInputContentDomainModel(
+ workflow_run_id=model.workflow_run_id,
+ submitted=False,
+ form_definition=HumanInputFormDefinition(
+ form_id=form.id,
+ node_id=form.node_id,
+ node_title=node_title,
+ form_content=form.rendered_content,
+ inputs=form_definition.inputs,
+ actions=form_definition.user_actions,
+ display_in_ui=display_in_ui,
+ form_token=form_token,
+ resolved_default_values=form_definition.default_values,
+ expiration_time=int(form.expiration_time.timestamp()),
+ ),
+ )
+
+ selected_action_id = form.selected_action_id
+ if not selected_action_id:
+ logger.warning("HumanInputContent(id=%s) form has no selected action", model.id)
+ return None
+
+ action_text = next(
+ (action.title for action in form_definition.user_actions if action.id == selected_action_id),
+ selected_action_id,
+ )
+
+ submitted_data: dict[str, Any] = {}
+ if form.submitted_data:
+ try:
+ submitted_data = json.loads(form.submitted_data)
+ except ValueError:
+ logger.warning("Failed to load submitted data for HumanInputContent(id=%s)", model.id)
+ return None
+
+ rendered_content = HumanInputNode.render_form_content_with_outputs(
+ form.rendered_content,
+ submitted_data,
+ _extract_output_field_names(form_definition.form_content),
+ )
+
+ return HumanInputContentDomainModel(
+ workflow_run_id=model.workflow_run_id,
+ submitted=True,
+ form_submission_data=HumanInputFormSubmissionData(
+ node_id=form.node_id,
+ node_title=node_title,
+ rendered_content=rendered_content,
+ action_id=selected_action_id,
+ action_text=action_text,
+ ),
+ )
+
+ @staticmethod
+ def _resolve_form_token(recipients: Sequence[HumanInputFormRecipient]) -> str | None:
+ console_recipient = next(
+ (recipient for recipient in recipients if recipient.recipient_type == RecipientType.CONSOLE),
+ None,
+ )
+ if console_recipient and console_recipient.access_token:
+ return console_recipient.access_token
+
+ web_app_recipient = next(
+ (recipient for recipient in recipients if recipient.recipient_type == RecipientType.STANDALONE_WEB_APP),
+ None,
+ )
+ if web_app_recipient and web_app_recipient.access_token:
+ return web_app_recipient.access_token
+
+ return None
+
+
+__all__ = ["SQLAlchemyExecutionExtraContentRepository"]
diff --git a/api/repositories/sqlalchemy_workflow_trigger_log_repository.py b/api/repositories/sqlalchemy_workflow_trigger_log_repository.py
index f3dc4cd60b..1f6740b066 100644
--- a/api/repositories/sqlalchemy_workflow_trigger_log_repository.py
+++ b/api/repositories/sqlalchemy_workflow_trigger_log_repository.py
@@ -92,6 +92,16 @@ class SQLAlchemyWorkflowTriggerLogRepository(WorkflowTriggerLogRepository):
return list(self.session.scalars(query).all())
+ def get_by_workflow_run_id(self, workflow_run_id: str) -> WorkflowTriggerLog | None:
+ """Get the trigger log associated with a workflow run."""
+ query = (
+ select(WorkflowTriggerLog)
+ .where(WorkflowTriggerLog.workflow_run_id == workflow_run_id)
+ .order_by(WorkflowTriggerLog.created_at.desc())
+ .limit(1)
+ )
+ return self.session.scalar(query)
+
def delete_by_run_ids(self, run_ids: Sequence[str]) -> int:
"""
Delete trigger logs associated with the given workflow run ids.
diff --git a/api/repositories/workflow_trigger_log_repository.py b/api/repositories/workflow_trigger_log_repository.py
index b0009e398d..7f9e6b7b68 100644
--- a/api/repositories/workflow_trigger_log_repository.py
+++ b/api/repositories/workflow_trigger_log_repository.py
@@ -110,6 +110,18 @@ class WorkflowTriggerLogRepository(Protocol):
"""
...
+ def get_by_workflow_run_id(self, workflow_run_id: str) -> WorkflowTriggerLog | None:
+ """
+ Retrieve a trigger log associated with a specific workflow run.
+
+ Args:
+ workflow_run_id: Identifier of the workflow run
+
+ Returns:
+ The matching WorkflowTriggerLog if present, None otherwise
+ """
+ ...
+
def delete_by_run_ids(self, run_ids: Sequence[str]) -> int:
"""
Delete trigger logs for workflow run IDs.
diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py
index 0f42c99246..9400362605 100644
--- a/api/services/app_dsl_service.py
+++ b/api/services/app_dsl_service.py
@@ -44,7 +44,7 @@ IMPORT_INFO_REDIS_KEY_PREFIX = "app_import_info:"
CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "app_check_dependencies:"
IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes
DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB
-CURRENT_DSL_VERSION = "0.5.0"
+CURRENT_DSL_VERSION = "0.6.0"
class ImportMode(StrEnum):
diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py
index ce85f2e914..a3de046d99 100644
--- a/api/services/app_generate_service.py
+++ b/api/services/app_generate_service.py
@@ -1,7 +1,9 @@
from __future__ import annotations
+import logging
+import threading
import uuid
-from collections.abc import Generator, Mapping
+from collections.abc import Callable, Generator, Mapping
from typing import TYPE_CHECKING, Any, Union
from configs import dify_config
@@ -9,22 +11,61 @@ from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
from core.app.apps.agent_chat.app_generator import AgentChatAppGenerator
from core.app.apps.chat.app_generator import ChatAppGenerator
from core.app.apps.completion.app_generator import CompletionAppGenerator
+from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
from core.app.apps.workflow.app_generator import WorkflowAppGenerator
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.features.rate_limiting import RateLimit
+from core.app.features.rate_limiting.rate_limit import rate_limit_context
from enums.quota_type import QuotaType, unlimited
from extensions.otel import AppGenerateHandler, trace_span
from models.model import Account, App, AppMode, EndUser
-from models.workflow import Workflow
+from models.workflow import Workflow, WorkflowRun
from services.errors.app import QuotaExceededError, WorkflowIdFormatError, WorkflowNotFoundError
from services.errors.llm import InvokeRateLimitError
from services.workflow_service import WorkflowService
+from tasks.app_generate.workflow_execute_task import AppExecutionParams, workflow_based_app_execution_task
+
+logger = logging.getLogger(__name__)
+
+SSE_TASK_START_FALLBACK_MS = 200
if TYPE_CHECKING:
from controllers.console.app.workflow import LoopNodeRunPayload
class AppGenerateService:
+ @staticmethod
+ def _build_streaming_task_on_subscribe(start_task: Callable[[], None]) -> Callable[[], None]:
+ started = False
+ lock = threading.Lock()
+
+ def _try_start() -> bool:
+ nonlocal started
+ with lock:
+ if started:
+ return True
+ try:
+ start_task()
+ except Exception:
+ logger.exception("Failed to enqueue streaming task")
+ return False
+ started = True
+ return True
+
+ # XXX(QuantumGhost): dirty hacks to avoid a race between publisher and SSE subscriber.
+ # The Celery task may publish the first event before the API side actually subscribes,
+ # causing an "at most once" drop with Redis Pub/Sub. We start the task on subscribe,
+ # but also use a short fallback timer so the task still runs if the client never consumes.
+ timer = threading.Timer(SSE_TASK_START_FALLBACK_MS / 1000.0, _try_start)
+ timer.daemon = True
+ timer.start()
+
+ def _on_subscribe() -> None:
+ if _try_start():
+ timer.cancel()
+
+ return _on_subscribe
+
@classmethod
@trace_span(AppGenerateHandler)
def generate(
@@ -88,15 +129,29 @@ class AppGenerateService:
elif app_model.mode == AppMode.ADVANCED_CHAT:
workflow_id = args.get("workflow_id")
workflow = cls._get_workflow(app_model, invoke_from, workflow_id)
+ with rate_limit_context(rate_limit, request_id):
+ payload = AppExecutionParams.new(
+ app_model=app_model,
+ workflow=workflow,
+ user=user,
+ args=args,
+ invoke_from=invoke_from,
+ streaming=streaming,
+ call_depth=0,
+ )
+ payload_json = payload.model_dump_json()
+
+ def on_subscribe():
+ workflow_based_app_execution_task.delay(payload_json)
+
+ on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe)
+ generator = AdvancedChatAppGenerator()
return rate_limit.generate(
- AdvancedChatAppGenerator.convert_to_event_stream(
- AdvancedChatAppGenerator().generate(
- app_model=app_model,
- workflow=workflow,
- user=user,
- args=args,
- invoke_from=invoke_from,
- streaming=streaming,
+ generator.convert_to_event_stream(
+ generator.retrieve_events(
+ AppMode.ADVANCED_CHAT,
+ payload.workflow_run_id,
+ on_subscribe=on_subscribe,
),
),
request_id=request_id,
@@ -104,6 +159,36 @@ class AppGenerateService:
elif app_model.mode == AppMode.WORKFLOW:
workflow_id = args.get("workflow_id")
workflow = cls._get_workflow(app_model, invoke_from, workflow_id)
+ if streaming:
+ with rate_limit_context(rate_limit, request_id):
+ payload = AppExecutionParams.new(
+ app_model=app_model,
+ workflow=workflow,
+ user=user,
+ args=args,
+ invoke_from=invoke_from,
+ streaming=True,
+ call_depth=0,
+ root_node_id=root_node_id,
+ workflow_run_id=str(uuid.uuid4()),
+ )
+ payload_json = payload.model_dump_json()
+
+ def on_subscribe():
+ workflow_based_app_execution_task.delay(payload_json)
+
+ on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe)
+ return rate_limit.generate(
+ WorkflowAppGenerator.convert_to_event_stream(
+ MessageBasedAppGenerator.retrieve_events(
+ AppMode.WORKFLOW,
+ payload.workflow_run_id,
+ on_subscribe=on_subscribe,
+ ),
+ ),
+ request_id,
+ )
+
return rate_limit.generate(
WorkflowAppGenerator.convert_to_event_stream(
WorkflowAppGenerator().generate(
@@ -112,7 +197,7 @@ class AppGenerateService:
user=user,
args=args,
invoke_from=invoke_from,
- streaming=streaming,
+ streaming=False,
root_node_id=root_node_id,
call_depth=0,
),
@@ -248,3 +333,19 @@ class AppGenerateService:
raise ValueError("Workflow not published")
return workflow
+
+ @classmethod
+ def get_response_generator(
+ cls,
+ app_model: App,
+ workflow_run: WorkflowRun,
+ ):
+ if workflow_run.status.is_ended():
+ # TODO(QuantumGhost): handled the ended scenario.
+ pass
+
+ generator = AdvancedChatAppGenerator()
+
+ return generator.convert_to_event_stream(
+ generator.retrieve_events(AppMode(app_model.mode), workflow_run.id),
+ )
diff --git a/api/services/audio_service.py b/api/services/audio_service.py
index 41ee9c88aa..a95361cebd 100644
--- a/api/services/audio_service.py
+++ b/api/services/audio_service.py
@@ -136,7 +136,7 @@ class AudioService:
message = db.session.query(Message).where(Message.id == message_id).first()
if message is None:
return None
- if message.answer == "" and message.status == MessageStatus.NORMAL:
+ if message.answer == "" and message.status in {MessageStatus.NORMAL, MessageStatus.PAUSED}:
return None
else:
diff --git a/api/services/feature_service.py b/api/services/feature_service.py
index d94ae49d91..fda3a15144 100644
--- a/api/services/feature_service.py
+++ b/api/services/feature_service.py
@@ -138,6 +138,8 @@ class FeatureModel(BaseModel):
is_allow_transfer_workspace: bool = True
trigger_event: Quota = Quota(usage=0, limit=3000, reset_date=0)
api_rate_limit: Quota = Quota(usage=0, limit=5000, reset_date=0)
+ # Controls whether email delivery is allowed for HumanInput nodes.
+ human_input_email_delivery_enabled: bool = False
# pydantic configs
model_config = ConfigDict(protected_namespaces=())
knowledge_pipeline: KnowledgePipeline = KnowledgePipeline()
@@ -191,6 +193,11 @@ class FeatureService:
features.knowledge_pipeline.publish_enabled = True
cls._fulfill_params_from_workspace_info(features, tenant_id)
+ features.human_input_email_delivery_enabled = cls._resolve_human_input_email_delivery_enabled(
+ features=features,
+ tenant_id=tenant_id,
+ )
+
return features
@classmethod
@@ -203,6 +210,17 @@ class FeatureService:
knowledge_rate_limit.subscription_plan = limit_info.get("subscription_plan", CloudPlan.SANDBOX)
return knowledge_rate_limit
+ @classmethod
+ def _resolve_human_input_email_delivery_enabled(cls, *, features: FeatureModel, tenant_id: str | None) -> bool:
+ if dify_config.ENTERPRISE_ENABLED or not dify_config.BILLING_ENABLED:
+ return True
+ if not tenant_id:
+ return False
+ return features.billing.enabled and features.billing.subscription.plan in (
+ CloudPlan.PROFESSIONAL,
+ CloudPlan.TEAM,
+ )
+
@classmethod
def get_system_features(cls, is_authenticated: bool = False) -> SystemFeatureModel:
system_features = SystemFeatureModel()
diff --git a/api/services/human_input_delivery_test_service.py b/api/services/human_input_delivery_test_service.py
new file mode 100644
index 0000000000..ff37ff098f
--- /dev/null
+++ b/api/services/human_input_delivery_test_service.py
@@ -0,0 +1,249 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from enum import StrEnum
+from typing import Protocol
+
+from sqlalchemy import Engine, select
+from sqlalchemy.orm import sessionmaker
+
+from configs import dify_config
+from core.workflow.nodes.human_input.entities import (
+ DeliveryChannelConfig,
+ EmailDeliveryConfig,
+ EmailDeliveryMethod,
+ ExternalRecipient,
+ MemberRecipient,
+)
+from core.workflow.runtime import VariablePool
+from extensions.ext_database import db
+from extensions.ext_mail import mail
+from libs.email_template_renderer import render_email_template
+from models import Account, TenantAccountJoin
+from services.feature_service import FeatureService
+
+
+class DeliveryTestStatus(StrEnum):
+ OK = "ok"
+ FAILED = "failed"
+
+
+@dataclass(frozen=True)
+class DeliveryTestEmailRecipient:
+ email: str
+ form_token: str
+
+
+@dataclass(frozen=True)
+class DeliveryTestContext:
+ tenant_id: str
+ app_id: str
+ node_id: str
+ node_title: str | None
+ rendered_content: str
+ template_vars: dict[str, str] = field(default_factory=dict)
+ recipients: list[DeliveryTestEmailRecipient] = field(default_factory=list)
+ variable_pool: VariablePool | None = None
+
+
+@dataclass(frozen=True)
+class DeliveryTestResult:
+ status: DeliveryTestStatus
+ delivered_to: list[str] = field(default_factory=list)
+ warnings: list[str] = field(default_factory=list)
+
+
+class DeliveryTestError(Exception):
+ pass
+
+
+class DeliveryTestUnsupportedError(DeliveryTestError):
+ pass
+
+
+def _build_form_link(token: str | None) -> str | None:
+ if not token:
+ return None
+ base_url = dify_config.APP_WEB_URL
+ if not base_url:
+ return None
+ return f"{base_url.rstrip('/')}/form/{token}"
+
+
+class DeliveryTestHandler(Protocol):
+ def supports(self, method: DeliveryChannelConfig) -> bool: ...
+
+ def send_test(
+ self,
+ *,
+ context: DeliveryTestContext,
+ method: DeliveryChannelConfig,
+ ) -> DeliveryTestResult: ...
+
+
+class DeliveryTestRegistry:
+ def __init__(self, handlers: list[DeliveryTestHandler] | None = None) -> None:
+ self._handlers = list(handlers or [])
+
+ def register(self, handler: DeliveryTestHandler) -> None:
+ self._handlers.append(handler)
+
+ def dispatch(
+ self,
+ *,
+ context: DeliveryTestContext,
+ method: DeliveryChannelConfig,
+ ) -> DeliveryTestResult:
+ for handler in self._handlers:
+ if handler.supports(method):
+ return handler.send_test(context=context, method=method)
+ raise DeliveryTestUnsupportedError("Delivery method does not support test send.")
+
+ @classmethod
+ def default(cls) -> DeliveryTestRegistry:
+ return cls([EmailDeliveryTestHandler()])
+
+
+class HumanInputDeliveryTestService:
+ def __init__(self, registry: DeliveryTestRegistry | None = None) -> None:
+ self._registry = registry or DeliveryTestRegistry.default()
+
+ def send_test(
+ self,
+ *,
+ context: DeliveryTestContext,
+ method: DeliveryChannelConfig,
+ ) -> DeliveryTestResult:
+ return self._registry.dispatch(context=context, method=method)
+
+
+class EmailDeliveryTestHandler:
+ def __init__(self, session_factory: sessionmaker | Engine | None = None) -> None:
+ if session_factory is None:
+ session_factory = sessionmaker(bind=db.engine)
+ elif isinstance(session_factory, Engine):
+ session_factory = sessionmaker(bind=session_factory)
+ self._session_factory = session_factory
+
+ def supports(self, method: DeliveryChannelConfig) -> bool:
+ return isinstance(method, EmailDeliveryMethod)
+
+ def send_test(
+ self,
+ *,
+ context: DeliveryTestContext,
+ method: DeliveryChannelConfig,
+ ) -> DeliveryTestResult:
+ if not isinstance(method, EmailDeliveryMethod):
+ raise DeliveryTestUnsupportedError("Delivery method does not support test send.")
+ features = FeatureService.get_features(context.tenant_id)
+ if not features.human_input_email_delivery_enabled:
+ raise DeliveryTestError("Email delivery is not available for current plan.")
+ if not mail.is_inited():
+ raise DeliveryTestError("Mail client is not initialized.")
+
+ recipients = self._resolve_recipients(
+ tenant_id=context.tenant_id,
+ method=method,
+ )
+ if not recipients:
+ raise DeliveryTestError("No recipients configured for delivery method.")
+
+ delivered: list[str] = []
+ for recipient_email in recipients:
+ substitutions = self._build_substitutions(
+ context=context,
+ recipient_email=recipient_email,
+ )
+ subject = render_email_template(method.config.subject, substitutions)
+ templated_body = EmailDeliveryConfig.render_body_template(
+ body=method.config.body,
+ url=substitutions.get("form_link"),
+ variable_pool=context.variable_pool,
+ )
+ body = render_email_template(templated_body, substitutions)
+
+ mail.send(
+ to=recipient_email,
+ subject=subject,
+ html=body,
+ )
+ delivered.append(recipient_email)
+
+ return DeliveryTestResult(status=DeliveryTestStatus.OK, delivered_to=delivered)
+
+ def _resolve_recipients(self, *, tenant_id: str, method: EmailDeliveryMethod) -> list[str]:
+ recipients = method.config.recipients
+ emails: list[str] = []
+ member_user_ids: list[str] = []
+ for recipient in recipients.items:
+ if isinstance(recipient, MemberRecipient):
+ member_user_ids.append(recipient.user_id)
+ elif isinstance(recipient, ExternalRecipient):
+ if recipient.email:
+ emails.append(recipient.email)
+
+ if recipients.whole_workspace:
+ member_user_ids = []
+ member_emails = self._query_workspace_member_emails(tenant_id=tenant_id, user_ids=None)
+ emails.extend(member_emails.values())
+ elif member_user_ids:
+ member_emails = self._query_workspace_member_emails(tenant_id=tenant_id, user_ids=member_user_ids)
+ for user_id in member_user_ids:
+ email = member_emails.get(user_id)
+ if email:
+ emails.append(email)
+
+ return list(dict.fromkeys([email for email in emails if email]))
+
+ def _query_workspace_member_emails(
+ self,
+ *,
+ tenant_id: str,
+ user_ids: list[str] | None,
+ ) -> dict[str, str]:
+ if user_ids is None:
+ unique_ids = None
+ else:
+ unique_ids = {user_id for user_id in user_ids if user_id}
+ if not unique_ids:
+ return {}
+
+ stmt = (
+ select(Account.id, Account.email)
+ .join(TenantAccountJoin, TenantAccountJoin.account_id == Account.id)
+ .where(TenantAccountJoin.tenant_id == tenant_id)
+ )
+ if unique_ids is not None:
+ stmt = stmt.where(Account.id.in_(unique_ids))
+
+ with self._session_factory() as session:
+ rows = session.execute(stmt).all()
+ return dict(rows)
+
+ @staticmethod
+ def _build_substitutions(
+ *,
+ context: DeliveryTestContext,
+ recipient_email: str,
+ ) -> dict[str, str]:
+ raw_values: dict[str, str | None] = {
+ "form_id": "",
+ "node_title": context.node_title,
+ "workflow_run_id": "",
+ "form_token": "",
+ "form_link": "",
+ "form_content": context.rendered_content,
+ "recipient_email": recipient_email,
+ }
+ substitutions = {key: value or "" for key, value in raw_values.items()}
+ if context.template_vars:
+ substitutions.update({key: value for key, value in context.template_vars.items() if value is not None})
+ token = next(
+ (recipient.form_token for recipient in context.recipients if recipient.email == recipient_email),
+ None,
+ )
+ if token:
+ substitutions["form_token"] = token
+ substitutions["form_link"] = _build_form_link(token) or ""
+ return substitutions
diff --git a/api/services/human_input_service.py b/api/services/human_input_service.py
new file mode 100644
index 0000000000..d50325e5e5
--- /dev/null
+++ b/api/services/human_input_service.py
@@ -0,0 +1,250 @@
+import logging
+from collections.abc import Mapping
+from datetime import datetime, timedelta
+from typing import Any
+
+from sqlalchemy import Engine, select
+from sqlalchemy.orm import Session, sessionmaker
+
+from configs import dify_config
+from core.repositories.human_input_repository import (
+ HumanInputFormRecord,
+ HumanInputFormSubmissionRepository,
+)
+from core.workflow.nodes.human_input.entities import (
+ FormDefinition,
+ HumanInputSubmissionValidationError,
+ validate_human_input_submission,
+)
+from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
+from libs.datetime_utils import ensure_naive_utc, naive_utc_now
+from libs.exception import BaseHTTPException
+from models.human_input import RecipientType
+from models.model import App, AppMode
+from repositories.factory import DifyAPIRepositoryFactory
+from tasks.app_generate.workflow_execute_task import WORKFLOW_BASED_APP_EXECUTION_QUEUE, resume_app_execution
+
+
+class Form:
+ def __init__(self, record: HumanInputFormRecord):
+ self._record = record
+
+ def get_definition(self) -> FormDefinition:
+ return self._record.definition
+
+ @property
+ def submitted(self) -> bool:
+ return self._record.submitted
+
+ @property
+ def id(self) -> str:
+ return self._record.form_id
+
+ @property
+ def workflow_run_id(self) -> str | None:
+ """Workflow run id for runtime forms; None for delivery tests."""
+ return self._record.workflow_run_id
+
+ @property
+ def tenant_id(self) -> str:
+ return self._record.tenant_id
+
+ @property
+ def app_id(self) -> str:
+ return self._record.app_id
+
+ @property
+ def recipient_id(self) -> str | None:
+ return self._record.recipient_id
+
+ @property
+ def recipient_type(self) -> RecipientType | None:
+ return self._record.recipient_type
+
+ @property
+ def status(self) -> HumanInputFormStatus:
+ return self._record.status
+
+ @property
+ def form_kind(self) -> HumanInputFormKind:
+ return self._record.form_kind
+
+ @property
+ def created_at(self) -> "datetime":
+ return self._record.created_at
+
+ @property
+ def expiration_time(self) -> "datetime":
+ return self._record.expiration_time
+
+
+class HumanInputError(Exception):
+ pass
+
+
+class FormSubmittedError(HumanInputError, BaseHTTPException):
+ error_code = "human_input_form_submitted"
+ description = "This form has already been submitted by another user, form_id={form_id}"
+ code = 412
+
+ def __init__(self, form_id: str):
+ template = self.description or "This form has already been submitted by another user, form_id={form_id}"
+ description = template.format(form_id=form_id)
+ super().__init__(description=description)
+
+
+class FormNotFoundError(HumanInputError, BaseHTTPException):
+ error_code = "human_input_form_not_found"
+ code = 404
+
+
+class InvalidFormDataError(HumanInputError, BaseHTTPException):
+ error_code = "invalid_form_data"
+ code = 400
+
+ def __init__(self, description: str):
+ super().__init__(description=description)
+
+
+class WebAppDeliveryNotEnabledError(HumanInputError, BaseException):
+ pass
+
+
+class FormExpiredError(HumanInputError, BaseHTTPException):
+ error_code = "human_input_form_expired"
+ code = 412
+
+ def __init__(self, form_id: str):
+ super().__init__(description=f"This form has expired, form_id={form_id}")
+
+
+logger = logging.getLogger(__name__)
+
+
+class HumanInputService:
+ def __init__(
+ self,
+ session_factory: sessionmaker[Session] | Engine,
+ form_repository: HumanInputFormSubmissionRepository | None = None,
+ ):
+ if isinstance(session_factory, Engine):
+ session_factory = sessionmaker(bind=session_factory)
+ self._session_factory = session_factory
+ self._form_repository = form_repository or HumanInputFormSubmissionRepository(session_factory)
+
+ def get_form_by_token(self, form_token: str) -> Form | None:
+ record = self._form_repository.get_by_token(form_token)
+ if record is None:
+ return None
+ return Form(record)
+
+ def get_form_definition_by_token(self, recipient_type: RecipientType, form_token: str) -> Form | None:
+ form = self.get_form_by_token(form_token)
+ if form is None or form.recipient_type != recipient_type:
+ return None
+ self._ensure_not_submitted(form)
+ return form
+
+ def get_form_definition_by_token_for_console(self, form_token: str) -> Form | None:
+ form = self.get_form_by_token(form_token)
+ if form is None or form.recipient_type not in {RecipientType.CONSOLE, RecipientType.BACKSTAGE}:
+ return None
+ self._ensure_not_submitted(form)
+ return form
+
+ def submit_form_by_token(
+ self,
+ recipient_type: RecipientType,
+ form_token: str,
+ selected_action_id: str,
+ form_data: Mapping[str, Any],
+ submission_end_user_id: str | None = None,
+ submission_user_id: str | None = None,
+ ):
+ form = self.get_form_by_token(form_token)
+ if form is None or form.recipient_type != recipient_type:
+ raise WebAppDeliveryNotEnabledError()
+
+ self.ensure_form_active(form)
+ self._validate_submission(form=form, selected_action_id=selected_action_id, form_data=form_data)
+
+ result = self._form_repository.mark_submitted(
+ form_id=form.id,
+ recipient_id=form.recipient_id,
+ selected_action_id=selected_action_id,
+ form_data=form_data,
+ submission_user_id=submission_user_id,
+ submission_end_user_id=submission_end_user_id,
+ )
+
+ if result.form_kind != HumanInputFormKind.RUNTIME:
+ return
+ if result.workflow_run_id is None:
+ return
+ self.enqueue_resume(result.workflow_run_id)
+
+ def ensure_form_active(self, form: Form) -> None:
+ if form.submitted:
+ raise FormSubmittedError(form.id)
+ if form.status in {HumanInputFormStatus.TIMEOUT, HumanInputFormStatus.EXPIRED}:
+ raise FormExpiredError(form.id)
+ now = naive_utc_now()
+ if ensure_naive_utc(form.expiration_time) <= now:
+ raise FormExpiredError(form.id)
+ if self._is_globally_expired(form, now=now):
+ raise FormExpiredError(form.id)
+
+ def _ensure_not_submitted(self, form: Form) -> None:
+ if form.submitted:
+ raise FormSubmittedError(form.id)
+
+ def _validate_submission(self, form: Form, selected_action_id: str, form_data: Mapping[str, Any]) -> None:
+ definition = form.get_definition()
+ try:
+ validate_human_input_submission(
+ inputs=definition.inputs,
+ user_actions=definition.user_actions,
+ selected_action_id=selected_action_id,
+ form_data=form_data,
+ )
+ except HumanInputSubmissionValidationError as exc:
+ raise InvalidFormDataError(str(exc)) from exc
+
+ def enqueue_resume(self, workflow_run_id: str) -> None:
+ workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(self._session_factory)
+ workflow_run = workflow_run_repo.get_workflow_run_by_id_without_tenant(workflow_run_id)
+
+ if workflow_run is None:
+ raise AssertionError(f"WorkflowRun not found, id={workflow_run_id}")
+ with self._session_factory(expire_on_commit=False) as session:
+ app_query = select(App).where(App.id == workflow_run.app_id)
+ app = session.execute(app_query).scalar_one_or_none()
+ if app is None:
+ logger.error(
+ "App not found for WorkflowRun, workflow_run_id=%s, app_id=%s", workflow_run_id, workflow_run.app_id
+ )
+ return
+
+ if app.mode in {AppMode.WORKFLOW, AppMode.ADVANCED_CHAT}:
+ payload = {"workflow_run_id": workflow_run_id}
+ try:
+ resume_app_execution.apply_async(
+ kwargs={"payload": payload},
+ queue=WORKFLOW_BASED_APP_EXECUTION_QUEUE,
+ )
+ except Exception: # pragma: no cover
+ logger.exception("Failed to enqueue resume task for workflow run %s", workflow_run_id)
+ return
+
+ logger.warning("App mode %s does not support resume for workflow run %s", app.mode, workflow_run_id)
+
+ def _is_globally_expired(self, form: Form, *, now: datetime | None = None) -> bool:
+ global_timeout_seconds = dify_config.HITL_GLOBAL_TIMEOUT_SECONDS
+ if global_timeout_seconds <= 0:
+ return False
+ if form.workflow_run_id is None:
+ return False
+ current = now or naive_utc_now()
+ created_at = ensure_naive_utc(form.created_at)
+ global_deadline = created_at + timedelta(seconds=global_timeout_seconds)
+ return global_deadline <= current
diff --git a/api/services/message_service.py b/api/services/message_service.py
index a53ca8b22d..ce699e79d4 100644
--- a/api/services/message_service.py
+++ b/api/services/message_service.py
@@ -1,6 +1,9 @@
import json
+from collections.abc import Sequence
from typing import Union
+from sqlalchemy.orm import sessionmaker
+
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
from core.app.entities.app_invoke_entities import InvokeFrom
from core.llm_generator.llm_generator import LLMGenerator
@@ -14,6 +17,10 @@ from extensions.ext_database import db
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from models import Account
from models.model import App, AppMode, AppModelConfig, EndUser, Message, MessageFeedback
+from repositories.execution_extra_content_repository import ExecutionExtraContentRepository
+from repositories.sqlalchemy_execution_extra_content_repository import (
+ SQLAlchemyExecutionExtraContentRepository,
+)
from services.conversation_service import ConversationService
from services.errors.message import (
FirstMessageNotExistsError,
@@ -24,6 +31,23 @@ from services.errors.message import (
from services.workflow_service import WorkflowService
+def _create_execution_extra_content_repository() -> ExecutionExtraContentRepository:
+ session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
+ return SQLAlchemyExecutionExtraContentRepository(session_maker=session_maker)
+
+
+def attach_message_extra_contents(messages: Sequence[Message]) -> None:
+ if not messages:
+ return
+
+ repository = _create_execution_extra_content_repository()
+ extra_contents_lists = repository.get_by_message_ids([message.id for message in messages])
+
+ for index, message in enumerate(messages):
+ contents = extra_contents_lists[index] if index < len(extra_contents_lists) else []
+ message.set_extra_contents([content.model_dump(mode="json", exclude_none=True) for content in contents])
+
+
class MessageService:
@classmethod
def pagination_by_first_id(
@@ -85,6 +109,8 @@ class MessageService:
if order == "asc":
history_messages = list(reversed(history_messages))
+ attach_message_extra_contents(history_messages)
+
return InfiniteScrollPagination(data=history_messages, limit=limit, has_more=has_more)
@classmethod
diff --git a/api/services/tools/workflow_tools_manage_service.py b/api/services/tools/workflow_tools_manage_service.py
index ab5d5480df..0ae40199ab 100644
--- a/api/services/tools/workflow_tools_manage_service.py
+++ b/api/services/tools/workflow_tools_manage_service.py
@@ -67,6 +67,8 @@ class WorkflowToolManageService:
if workflow is None:
raise ValueError(f"Workflow not found for app {workflow_app_id}")
+ WorkflowToolConfigurationUtils.ensure_no_human_input_nodes(workflow.graph_dict)
+
workflow_tool_provider = WorkflowToolProvider(
tenant_id=tenant_id,
user_id=user_id,
@@ -158,6 +160,8 @@ class WorkflowToolManageService:
if workflow is None:
raise ValueError(f"Workflow not found for app {workflow_tool_provider.app_id}")
+ WorkflowToolConfigurationUtils.ensure_no_human_input_nodes(workflow.graph_dict)
+
workflow_tool_provider.name = name
workflow_tool_provider.label = label
workflow_tool_provider.icon = json.dumps(icon)
diff --git a/api/services/workflow/entities.py b/api/services/workflow/entities.py
index 70ec8d6e2a..2af0d1fd90 100644
--- a/api/services/workflow/entities.py
+++ b/api/services/workflow/entities.py
@@ -98,6 +98,12 @@ class WorkflowTaskData(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
+class WorkflowResumeTaskData(BaseModel):
+ """Payload for workflow resumption tasks."""
+
+ workflow_run_id: str
+
+
class AsyncTriggerExecutionResult(BaseModel):
"""Result from async trigger-based workflow execution"""
diff --git a/api/services/workflow_event_snapshot_service.py b/api/services/workflow_event_snapshot_service.py
new file mode 100644
index 0000000000..dd4651f130
--- /dev/null
+++ b/api/services/workflow_event_snapshot_service.py
@@ -0,0 +1,460 @@
+from __future__ import annotations
+
+import json
+import logging
+import queue
+import threading
+import time
+from collections.abc import Generator, Mapping, Sequence
+from dataclasses import dataclass
+from typing import Any
+
+from sqlalchemy import desc, select
+from sqlalchemy.orm import Session, sessionmaker
+
+from core.app.apps.message_generator import MessageGenerator
+from core.app.entities.task_entities import (
+ MessageReplaceStreamResponse,
+ NodeFinishStreamResponse,
+ NodeStartStreamResponse,
+ StreamEvent,
+ WorkflowPauseStreamResponse,
+ WorkflowStartStreamResponse,
+)
+from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext
+from core.workflow.entities import WorkflowStartReason
+from core.workflow.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
+from core.workflow.runtime import GraphRuntimeState
+from core.workflow.workflow_type_encoder import WorkflowRuntimeTypeConverter
+from models.model import AppMode, Message
+from models.workflow import WorkflowNodeExecutionTriggeredFrom, WorkflowRun
+from repositories.api_workflow_node_execution_repository import WorkflowNodeExecutionSnapshot
+from repositories.entities.workflow_pause import WorkflowPauseEntity
+from repositories.factory import DifyAPIRepositoryFactory
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class MessageContext:
+ conversation_id: str
+ message_id: str
+ created_at: int
+ answer: str | None = None
+
+
+@dataclass
+class BufferState:
+ queue: queue.Queue[Mapping[str, Any]]
+ stop_event: threading.Event
+ done_event: threading.Event
+ task_id_ready: threading.Event
+ task_id_hint: str | None = None
+
+
+def build_workflow_event_stream(
+ *,
+ app_mode: AppMode,
+ workflow_run: WorkflowRun,
+ tenant_id: str,
+ app_id: str,
+ session_maker: sessionmaker[Session],
+ idle_timeout: float = 300,
+ ping_interval: float = 10.0,
+) -> Generator[Mapping[str, Any] | str, None, None]:
+ topic = MessageGenerator.get_response_topic(app_mode, workflow_run.id)
+ workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker)
+ node_execution_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(session_maker)
+ message_context = (
+ _get_message_context(session_maker, workflow_run.id) if app_mode == AppMode.ADVANCED_CHAT else None
+ )
+
+ pause_entity: WorkflowPauseEntity | None = None
+ if workflow_run.status == WorkflowExecutionStatus.PAUSED:
+ try:
+ pause_entity = workflow_run_repo.get_workflow_pause(workflow_run.id)
+ except Exception:
+ logger.exception("Failed to load workflow pause for run %s", workflow_run.id)
+ pause_entity = None
+
+ resumption_context = _load_resumption_context(pause_entity)
+ node_snapshots = node_execution_repo.get_execution_snapshots_by_workflow_run(
+ tenant_id=tenant_id,
+ app_id=app_id,
+ workflow_id=workflow_run.workflow_id,
+ # NOTE(QuantumGhost): for events resumption, we only care about
+ # the execution records from `WORKFLOW_RUN`.
+ #
+ # Ideally filtering with `workflow_run_id` is enough. However,
+ # due to the index of `WorkflowNodeExecution` table, we have to
+ # add a filter condition of `triggered_from` to
+ # ensure that we can utilize the index.
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
+ workflow_run_id=workflow_run.id,
+ )
+
+ def _generate() -> Generator[Mapping[str, Any] | str, None, None]:
+ # send a PING event immediately to prevent the connection staying in pending state for a long time.
+ #
+ # This simplify the debugging process as the DevTools in Chrome does not
+ # provide complete curl command for pending connections.
+ yield StreamEvent.PING.value
+
+ last_msg_time = time.time()
+ last_ping_time = last_msg_time
+
+ with topic.subscribe() as sub:
+ buffer_state = _start_buffering(sub)
+ try:
+ task_id = _resolve_task_id(resumption_context, buffer_state, workflow_run.id)
+
+ snapshot_events = _build_snapshot_events(
+ workflow_run=workflow_run,
+ node_snapshots=node_snapshots,
+ task_id=task_id,
+ message_context=message_context,
+ pause_entity=pause_entity,
+ resumption_context=resumption_context,
+ )
+
+ for event in snapshot_events:
+ last_msg_time = time.time()
+ last_ping_time = last_msg_time
+ yield event
+ if _is_terminal_event(event, include_paused=True):
+ return
+
+ while True:
+ if buffer_state.done_event.is_set() and buffer_state.queue.empty():
+ return
+
+ try:
+ event = buffer_state.queue.get(timeout=0.1)
+ except queue.Empty:
+ current_time = time.time()
+ if current_time - last_msg_time > idle_timeout:
+ logger.debug(
+ "No workflow events received for %s seconds, keeping stream open",
+ idle_timeout,
+ )
+ last_msg_time = current_time
+ if current_time - last_ping_time >= ping_interval:
+ yield StreamEvent.PING.value
+ last_ping_time = current_time
+ continue
+
+ last_msg_time = time.time()
+ last_ping_time = last_msg_time
+ yield event
+ if _is_terminal_event(event, include_paused=True):
+ return
+ finally:
+ buffer_state.stop_event.set()
+
+ return _generate()
+
+
+def _get_message_context(session_maker: sessionmaker[Session], workflow_run_id: str) -> MessageContext | None:
+ with session_maker() as session:
+ stmt = select(Message).where(Message.workflow_run_id == workflow_run_id).order_by(desc(Message.created_at))
+ message = session.scalar(stmt)
+ if message is None:
+ return None
+ created_at = int(message.created_at.timestamp()) if message.created_at else 0
+ return MessageContext(
+ conversation_id=message.conversation_id,
+ message_id=message.id,
+ created_at=created_at,
+ answer=message.answer,
+ )
+
+
+def _load_resumption_context(pause_entity: WorkflowPauseEntity | None) -> WorkflowResumptionContext | None:
+ if pause_entity is None:
+ return None
+ try:
+ raw_state = pause_entity.get_state().decode()
+ return WorkflowResumptionContext.loads(raw_state)
+ except Exception:
+ logger.exception("Failed to load resumption context")
+ return None
+
+
+def _resolve_task_id(
+ resumption_context: WorkflowResumptionContext | None,
+ buffer_state: BufferState | None,
+ workflow_run_id: str,
+ wait_timeout: float = 0.2,
+) -> str:
+ if resumption_context is not None:
+ generate_entity = resumption_context.get_generate_entity()
+ if generate_entity.task_id:
+ return generate_entity.task_id
+ if buffer_state is None:
+ return workflow_run_id
+ if buffer_state.task_id_hint is None:
+ buffer_state.task_id_ready.wait(timeout=wait_timeout)
+ if buffer_state.task_id_hint:
+ return buffer_state.task_id_hint
+ return workflow_run_id
+
+
+def _build_snapshot_events(
+ *,
+ workflow_run: WorkflowRun,
+ node_snapshots: Sequence[WorkflowNodeExecutionSnapshot],
+ task_id: str,
+ message_context: MessageContext | None,
+ pause_entity: WorkflowPauseEntity | None,
+ resumption_context: WorkflowResumptionContext | None,
+) -> list[Mapping[str, Any]]:
+ events: list[Mapping[str, Any]] = []
+
+ workflow_started = _build_workflow_started_event(
+ workflow_run=workflow_run,
+ task_id=task_id,
+ )
+ _apply_message_context(workflow_started, message_context)
+ events.append(workflow_started)
+
+ if message_context is not None and message_context.answer is not None:
+ message_replace = _build_message_replace_event(task_id=task_id, answer=message_context.answer)
+ _apply_message_context(message_replace, message_context)
+ events.append(message_replace)
+
+ for snapshot in node_snapshots:
+ node_started = _build_node_started_event(
+ workflow_run_id=workflow_run.id,
+ task_id=task_id,
+ snapshot=snapshot,
+ )
+ _apply_message_context(node_started, message_context)
+ events.append(node_started)
+
+ if snapshot.status != WorkflowNodeExecutionStatus.RUNNING.value:
+ node_finished = _build_node_finished_event(
+ workflow_run_id=workflow_run.id,
+ task_id=task_id,
+ snapshot=snapshot,
+ )
+ _apply_message_context(node_finished, message_context)
+ events.append(node_finished)
+
+ if workflow_run.status == WorkflowExecutionStatus.PAUSED and pause_entity is not None:
+ pause_event = _build_pause_event(
+ workflow_run=workflow_run,
+ workflow_run_id=workflow_run.id,
+ task_id=task_id,
+ pause_entity=pause_entity,
+ resumption_context=resumption_context,
+ )
+ if pause_event is not None:
+ _apply_message_context(pause_event, message_context)
+ events.append(pause_event)
+
+ return events
+
+
+def _build_workflow_started_event(
+ *,
+ workflow_run: WorkflowRun,
+ task_id: str,
+) -> dict[str, Any]:
+ response = WorkflowStartStreamResponse(
+ task_id=task_id,
+ workflow_run_id=workflow_run.id,
+ data=WorkflowStartStreamResponse.Data(
+ id=workflow_run.id,
+ workflow_id=workflow_run.workflow_id,
+ inputs=workflow_run.inputs_dict or {},
+ created_at=int(workflow_run.created_at.timestamp()),
+ reason=WorkflowStartReason.INITIAL,
+ ),
+ )
+ payload = response.model_dump(mode="json")
+ payload["event"] = response.event.value
+ return payload
+
+
+def _build_message_replace_event(*, task_id: str, answer: str) -> dict[str, Any]:
+ response = MessageReplaceStreamResponse(
+ task_id=task_id,
+ answer=answer,
+ reason="",
+ )
+ payload = response.model_dump(mode="json")
+ payload["event"] = response.event.value
+ return payload
+
+
+def _build_node_started_event(
+ *,
+ workflow_run_id: str,
+ task_id: str,
+ snapshot: WorkflowNodeExecutionSnapshot,
+) -> dict[str, Any]:
+ created_at = int(snapshot.created_at.timestamp()) if snapshot.created_at else 0
+ response = NodeStartStreamResponse(
+ task_id=task_id,
+ workflow_run_id=workflow_run_id,
+ data=NodeStartStreamResponse.Data(
+ id=snapshot.execution_id,
+ node_id=snapshot.node_id,
+ node_type=snapshot.node_type,
+ title=snapshot.title,
+ index=snapshot.index,
+ predecessor_node_id=None,
+ inputs=None,
+ created_at=created_at,
+ extras={},
+ iteration_id=snapshot.iteration_id,
+ loop_id=snapshot.loop_id,
+ ),
+ )
+ return response.to_ignore_detail_dict()
+
+
+def _build_node_finished_event(
+ *,
+ workflow_run_id: str,
+ task_id: str,
+ snapshot: WorkflowNodeExecutionSnapshot,
+) -> dict[str, Any]:
+ created_at = int(snapshot.created_at.timestamp()) if snapshot.created_at else 0
+ finished_at = int(snapshot.finished_at.timestamp()) if snapshot.finished_at else created_at
+ response = NodeFinishStreamResponse(
+ task_id=task_id,
+ workflow_run_id=workflow_run_id,
+ data=NodeFinishStreamResponse.Data(
+ id=snapshot.execution_id,
+ node_id=snapshot.node_id,
+ node_type=snapshot.node_type,
+ title=snapshot.title,
+ index=snapshot.index,
+ predecessor_node_id=None,
+ inputs=None,
+ process_data=None,
+ outputs=None,
+ status=snapshot.status,
+ error=None,
+ elapsed_time=snapshot.elapsed_time,
+ execution_metadata=None,
+ created_at=created_at,
+ finished_at=finished_at,
+ files=[],
+ iteration_id=snapshot.iteration_id,
+ loop_id=snapshot.loop_id,
+ ),
+ )
+ return response.to_ignore_detail_dict()
+
+
+def _build_pause_event(
+ *,
+ workflow_run: WorkflowRun,
+ workflow_run_id: str,
+ task_id: str,
+ pause_entity: WorkflowPauseEntity,
+ resumption_context: WorkflowResumptionContext | None,
+) -> dict[str, Any] | None:
+ paused_nodes: list[str] = []
+ outputs: dict[str, Any] = {}
+ if resumption_context is not None:
+ state = GraphRuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
+ paused_nodes = state.get_paused_nodes()
+ outputs = dict(WorkflowRuntimeTypeConverter().to_json_encodable(state.outputs or {}))
+
+ reasons = [reason.model_dump(mode="json") for reason in pause_entity.get_pause_reasons()]
+ response = WorkflowPauseStreamResponse(
+ task_id=task_id,
+ workflow_run_id=workflow_run_id,
+ data=WorkflowPauseStreamResponse.Data(
+ workflow_run_id=workflow_run_id,
+ paused_nodes=paused_nodes,
+ outputs=outputs,
+ reasons=reasons,
+ status=workflow_run.status.value,
+ created_at=int(workflow_run.created_at.timestamp()),
+ elapsed_time=float(workflow_run.elapsed_time or 0.0),
+ total_tokens=int(workflow_run.total_tokens or 0),
+ total_steps=int(workflow_run.total_steps or 0),
+ ),
+ )
+ payload = response.model_dump(mode="json")
+ payload["event"] = response.event.value
+ return payload
+
+
+def _apply_message_context(payload: dict[str, Any], message_context: MessageContext | None) -> None:
+ if message_context is None:
+ return
+ payload["conversation_id"] = message_context.conversation_id
+ payload["message_id"] = message_context.message_id
+ payload["created_at"] = message_context.created_at
+
+
+def _start_buffering(subscription) -> BufferState:
+ buffer_state = BufferState(
+ queue=queue.Queue(maxsize=2048),
+ stop_event=threading.Event(),
+ done_event=threading.Event(),
+ task_id_ready=threading.Event(),
+ )
+
+ def _worker() -> None:
+ dropped_count = 0
+ try:
+ while not buffer_state.stop_event.is_set():
+ msg = subscription.receive(timeout=0.1)
+ if msg is None:
+ continue
+ event = _parse_event_message(msg)
+ if event is None:
+ continue
+ task_id = event.get("task_id")
+ if task_id and buffer_state.task_id_hint is None:
+ buffer_state.task_id_hint = str(task_id)
+ buffer_state.task_id_ready.set()
+ try:
+ buffer_state.queue.put_nowait(event)
+ except queue.Full:
+ dropped_count += 1
+ try:
+ buffer_state.queue.get_nowait()
+ except queue.Empty:
+ pass
+ try:
+ buffer_state.queue.put_nowait(event)
+ except queue.Full:
+ continue
+ logger.warning("Dropped buffered workflow event, total_dropped=%s", dropped_count)
+ except Exception:
+ logger.exception("Failed while buffering workflow events")
+ finally:
+ buffer_state.done_event.set()
+
+ thread = threading.Thread(target=_worker, name=f"workflow-event-buffer-{id(subscription)}", daemon=True)
+ thread.start()
+ return buffer_state
+
+
+def _parse_event_message(message: bytes) -> Mapping[str, Any] | None:
+ try:
+ event = json.loads(message)
+ except json.JSONDecodeError:
+ logger.warning("Failed to decode workflow event payload")
+ return None
+ if not isinstance(event, dict):
+ return None
+ return event
+
+
+def _is_terminal_event(event: Mapping[str, Any] | str, include_paused=False) -> bool:
+ if not isinstance(event, Mapping):
+ return False
+ event_type = event.get("event")
+ if event_type == StreamEvent.WORKFLOW_FINISHED.value:
+ return True
+ if include_paused:
+ return event_type == StreamEvent.WORKFLOW_PAUSED.value
+ return False
diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py
index 6404136994..4e1e515de5 100644
--- a/api/services/workflow_service.py
+++ b/api/services/workflow_service.py
@@ -1,4 +1,5 @@
import json
+import logging
import time
import uuid
from collections.abc import Callable, Generator, Mapping, Sequence
@@ -11,21 +12,34 @@ from configs import dify_config
from core.app.app_config.entities import VariableEntityType
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
+from core.app.entities.app_invoke_entities import InvokeFrom
from core.file import File
from core.repositories import DifyCoreRepositoryFactory
+from core.repositories.human_input_repository import HumanInputFormRepositoryImpl
from core.variables import VariableBase
from core.variables.variables import Variable
-from core.workflow.entities import WorkflowNodeExecution
+from core.workflow.entities import GraphInitParams, WorkflowNodeExecution
+from core.workflow.entities.pause_reason import HumanInputRequired
from core.workflow.enums import ErrorStrategy, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
from core.workflow.errors import WorkflowNodeRunFailedError
from core.workflow.graph_events import GraphNodeEventBase, NodeRunFailedEvent, NodeRunSucceededEvent
from core.workflow.node_events import NodeRunResult
from core.workflow.nodes import NodeType
from core.workflow.nodes.base.node import Node
+from core.workflow.nodes.human_input.entities import (
+ DeliveryChannelConfig,
+ HumanInputNodeData,
+ apply_debug_email_recipient,
+ validate_human_input_submission,
+)
+from core.workflow.nodes.human_input.enums import HumanInputFormKind
+from core.workflow.nodes.human_input.human_input_node import HumanInputNode
from core.workflow.nodes.node_mapping import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING
from core.workflow.nodes.start.entities import StartNodeData
-from core.workflow.runtime import VariablePool
+from core.workflow.repositories.human_input_form_repository import FormCreateParams
+from core.workflow.runtime import GraphRuntimeState, VariablePool
from core.workflow.system_variable import SystemVariable
+from core.workflow.variable_loader import load_into_variable_pool
from core.workflow.workflow_entry import WorkflowEntry
from enums.cloud_plan import CloudPlan
from events.app_event import app_draft_workflow_was_synced, app_published_workflow_was_updated
@@ -34,6 +48,8 @@ from extensions.ext_storage import storage
from factories.file_factory import build_from_mapping, build_from_mappings
from libs.datetime_utils import naive_utc_now
from models import Account
+from models.enums import UserFrom
+from models.human_input import HumanInputFormRecipient, RecipientType
from models.model import App, AppMode
from models.tools import WorkflowToolProvider
from models.workflow import Workflow, WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom, WorkflowType
@@ -44,6 +60,13 @@ from services.errors.app import IsDraftWorkflowError, TriggerNodeLimitExceededEr
from services.workflow.workflow_converter import WorkflowConverter
from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
+from .human_input_delivery_test_service import (
+ DeliveryTestContext,
+ DeliveryTestEmailRecipient,
+ DeliveryTestError,
+ DeliveryTestUnsupportedError,
+ HumanInputDeliveryTestService,
+)
from .workflow_draft_variable_service import DraftVariableSaver, DraftVarLoader, WorkflowDraftVariableService
@@ -744,6 +767,344 @@ class WorkflowService:
return workflow_node_execution
+ def get_human_input_form_preview(
+ self,
+ *,
+ app_model: App,
+ account: Account,
+ node_id: str,
+ inputs: Mapping[str, Any] | None = None,
+ ) -> Mapping[str, Any]:
+ """
+ Build a human input form preview for a draft workflow.
+
+ Args:
+ app_model: Target application model.
+ account: Current account.
+ node_id: Human input node ID.
+ inputs: Values used to fill missing upstream variables referenced in form_content.
+ """
+ draft_workflow = self.get_draft_workflow(app_model=app_model)
+ if not draft_workflow:
+ raise ValueError("Workflow not initialized")
+
+ node_config = draft_workflow.get_node_config_by_id(node_id)
+ node_type = Workflow.get_node_type_from_node_config(node_config)
+ if node_type is not NodeType.HUMAN_INPUT:
+ raise ValueError("Node type must be human-input.")
+
+ # inputs: values used to fill missing upstream variables referenced in form_content.
+ variable_pool = self._build_human_input_variable_pool(
+ app_model=app_model,
+ workflow=draft_workflow,
+ node_config=node_config,
+ manual_inputs=inputs or {},
+ )
+ node = self._build_human_input_node(
+ workflow=draft_workflow,
+ account=account,
+ node_config=node_config,
+ variable_pool=variable_pool,
+ )
+
+ rendered_content = node.render_form_content_before_submission()
+ resolved_default_values = node.resolve_default_values()
+ node_data = node.node_data
+ human_input_required = HumanInputRequired(
+ form_id=node_id,
+ form_content=rendered_content,
+ inputs=node_data.inputs,
+ actions=node_data.user_actions,
+ node_id=node_id,
+ node_title=node.title,
+ resolved_default_values=resolved_default_values,
+ form_token=None,
+ )
+ return human_input_required.model_dump(mode="json")
+
+ def submit_human_input_form_preview(
+ self,
+ *,
+ app_model: App,
+ account: Account,
+ node_id: str,
+ form_inputs: Mapping[str, Any],
+ inputs: Mapping[str, Any] | None = None,
+ action: str,
+ ) -> Mapping[str, Any]:
+ """
+ Submit a human input form preview for a draft workflow.
+
+ Args:
+ app_model: Target application model.
+ account: Current account.
+ node_id: Human input node ID.
+ form_inputs: Values the user provides for the form's own fields.
+ inputs: Values used to fill missing upstream variables referenced in form_content.
+ action: Selected action ID.
+ """
+ draft_workflow = self.get_draft_workflow(app_model=app_model)
+ if not draft_workflow:
+ raise ValueError("Workflow not initialized")
+
+ node_config = draft_workflow.get_node_config_by_id(node_id)
+ node_type = Workflow.get_node_type_from_node_config(node_config)
+ if node_type is not NodeType.HUMAN_INPUT:
+ raise ValueError("Node type must be human-input.")
+
+ # inputs: values used to fill missing upstream variables referenced in form_content.
+ # form_inputs: values the user provides for the form's own fields.
+ variable_pool = self._build_human_input_variable_pool(
+ app_model=app_model,
+ workflow=draft_workflow,
+ node_config=node_config,
+ manual_inputs=inputs or {},
+ )
+ node = self._build_human_input_node(
+ workflow=draft_workflow,
+ account=account,
+ node_config=node_config,
+ variable_pool=variable_pool,
+ )
+ node_data = node.node_data
+
+ validate_human_input_submission(
+ inputs=node_data.inputs,
+ user_actions=node_data.user_actions,
+ selected_action_id=action,
+ form_data=form_inputs,
+ )
+
+ rendered_content = node.render_form_content_before_submission()
+ outputs: dict[str, Any] = dict(form_inputs)
+ outputs["__action_id"] = action
+ outputs["__rendered_content"] = node.render_form_content_with_outputs(
+ rendered_content, outputs, node_data.outputs_field_names()
+ )
+
+ enclosing_node_type_and_id = draft_workflow.get_enclosing_node_type_and_id(node_config)
+ enclosing_node_id = enclosing_node_type_and_id[1] if enclosing_node_type_and_id else None
+ with Session(bind=db.engine) as session, session.begin():
+ draft_var_saver = DraftVariableSaver(
+ session=session,
+ app_id=app_model.id,
+ node_id=node_id,
+ node_type=NodeType.HUMAN_INPUT,
+ node_execution_id=str(uuid.uuid4()),
+ user=account,
+ enclosing_node_id=enclosing_node_id,
+ )
+ draft_var_saver.save(outputs=outputs, process_data={})
+ session.commit()
+
+ return outputs
+
+ def test_human_input_delivery(
+ self,
+ *,
+ app_model: App,
+ account: Account,
+ node_id: str,
+ delivery_method_id: str,
+ inputs: Mapping[str, Any] | None = None,
+ ) -> None:
+ draft_workflow = self.get_draft_workflow(app_model=app_model)
+ if not draft_workflow:
+ raise ValueError("Workflow not initialized")
+
+ node_config = draft_workflow.get_node_config_by_id(node_id)
+ node_type = Workflow.get_node_type_from_node_config(node_config)
+ if node_type is not NodeType.HUMAN_INPUT:
+ raise ValueError("Node type must be human-input.")
+
+ node_data = HumanInputNodeData.model_validate(node_config.get("data", {}))
+ delivery_method = self._resolve_human_input_delivery_method(
+ node_data=node_data,
+ delivery_method_id=delivery_method_id,
+ )
+ if delivery_method is None:
+ raise ValueError("Delivery method not found.")
+ delivery_method = apply_debug_email_recipient(
+ delivery_method,
+ enabled=True,
+ user_id=account.id or "",
+ )
+
+ variable_pool = self._build_human_input_variable_pool(
+ app_model=app_model,
+ workflow=draft_workflow,
+ node_config=node_config,
+ manual_inputs=inputs or {},
+ )
+ node = self._build_human_input_node(
+ workflow=draft_workflow,
+ account=account,
+ node_config=node_config,
+ variable_pool=variable_pool,
+ )
+ rendered_content = node.render_form_content_before_submission()
+ resolved_default_values = node.resolve_default_values()
+ form_id, recipients = self._create_human_input_delivery_test_form(
+ app_model=app_model,
+ node_id=node_id,
+ node_data=node_data,
+ delivery_method=delivery_method,
+ rendered_content=rendered_content,
+ resolved_default_values=resolved_default_values,
+ )
+ test_service = HumanInputDeliveryTestService()
+ context = DeliveryTestContext(
+ tenant_id=app_model.tenant_id,
+ app_id=app_model.id,
+ node_id=node_id,
+ node_title=node_data.title,
+ rendered_content=rendered_content,
+ template_vars={"form_id": form_id},
+ recipients=recipients,
+ variable_pool=variable_pool,
+ )
+ try:
+ test_service.send_test(context=context, method=delivery_method)
+ except DeliveryTestUnsupportedError as exc:
+ raise ValueError("Delivery method does not support test send.") from exc
+ except DeliveryTestError as exc:
+ raise ValueError(str(exc)) from exc
+
+ @staticmethod
+ def _resolve_human_input_delivery_method(
+ *,
+ node_data: HumanInputNodeData,
+ delivery_method_id: str,
+ ) -> DeliveryChannelConfig | None:
+ for method in node_data.delivery_methods:
+ if str(method.id) == delivery_method_id:
+ return method
+ return None
+
+ def _create_human_input_delivery_test_form(
+ self,
+ *,
+ app_model: App,
+ node_id: str,
+ node_data: HumanInputNodeData,
+ delivery_method: DeliveryChannelConfig,
+ rendered_content: str,
+ resolved_default_values: Mapping[str, Any],
+ ) -> tuple[str, list[DeliveryTestEmailRecipient]]:
+ repo = HumanInputFormRepositoryImpl(session_factory=db.engine, tenant_id=app_model.tenant_id)
+ params = FormCreateParams(
+ app_id=app_model.id,
+ workflow_execution_id=None,
+ node_id=node_id,
+ form_config=node_data,
+ rendered_content=rendered_content,
+ delivery_methods=[delivery_method],
+ display_in_ui=False,
+ resolved_default_values=resolved_default_values,
+ form_kind=HumanInputFormKind.DELIVERY_TEST,
+ )
+ form_entity = repo.create_form(params)
+ return form_entity.id, self._load_email_recipients(form_entity.id)
+
+ @staticmethod
+ def _load_email_recipients(form_id: str) -> list[DeliveryTestEmailRecipient]:
+ logger = logging.getLogger(__name__)
+
+ with Session(bind=db.engine) as session:
+ recipients = session.scalars(
+ select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id == form_id)
+ ).all()
+ recipients_data: list[DeliveryTestEmailRecipient] = []
+ for recipient in recipients:
+ if recipient.recipient_type not in {RecipientType.EMAIL_MEMBER, RecipientType.EMAIL_EXTERNAL}:
+ continue
+ if not recipient.access_token:
+ continue
+ try:
+ payload = json.loads(recipient.recipient_payload)
+ except Exception:
+ logger.exception("Failed to parse human input recipient payload for delivery test.")
+ continue
+ email = payload.get("email")
+ if email:
+ recipients_data.append(DeliveryTestEmailRecipient(email=email, form_token=recipient.access_token))
+ return recipients_data
+
+ def _build_human_input_node(
+ self,
+ *,
+ workflow: Workflow,
+ account: Account,
+ node_config: Mapping[str, Any],
+ variable_pool: VariablePool,
+ ) -> HumanInputNode:
+ graph_init_params = GraphInitParams(
+ tenant_id=workflow.tenant_id,
+ app_id=workflow.app_id,
+ workflow_id=workflow.id,
+ graph_config=workflow.graph_dict,
+ user_id=account.id,
+ user_from=UserFrom.ACCOUNT.value,
+ invoke_from=InvokeFrom.DEBUGGER.value,
+ call_depth=0,
+ )
+ graph_runtime_state = GraphRuntimeState(
+ variable_pool=variable_pool,
+ start_at=time.perf_counter(),
+ )
+ node = HumanInputNode(
+ id=node_config.get("id", str(uuid.uuid4())),
+ config=node_config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=graph_runtime_state,
+ )
+ return node
+
+ def _build_human_input_variable_pool(
+ self,
+ *,
+ app_model: App,
+ workflow: Workflow,
+ node_config: Mapping[str, Any],
+ manual_inputs: Mapping[str, Any],
+ ) -> VariablePool:
+ with Session(bind=db.engine, expire_on_commit=False) as session, session.begin():
+ draft_var_srv = WorkflowDraftVariableService(session)
+ draft_var_srv.prefill_conversation_variable_default_values(workflow)
+
+ variable_pool = VariablePool(
+ system_variables=SystemVariable.default(),
+ user_inputs={},
+ environment_variables=workflow.environment_variables,
+ conversation_variables=[],
+ )
+
+ variable_loader = DraftVarLoader(
+ engine=db.engine,
+ app_id=app_model.id,
+ tenant_id=app_model.tenant_id,
+ )
+ variable_mapping = HumanInputNode.extract_variable_selector_to_variable_mapping(
+ graph_config=workflow.graph_dict,
+ config=node_config,
+ )
+ normalized_user_inputs: dict[str, Any] = dict(manual_inputs)
+
+ load_into_variable_pool(
+ variable_loader=variable_loader,
+ variable_pool=variable_pool,
+ variable_mapping=variable_mapping,
+ user_inputs=normalized_user_inputs,
+ )
+ WorkflowEntry.mapping_user_inputs_to_variable_pool(
+ variable_mapping=variable_mapping,
+ user_inputs=normalized_user_inputs,
+ variable_pool=variable_pool,
+ tenant_id=app_model.tenant_id,
+ )
+
+ return variable_pool
+
def run_free_workflow_node(
self, node_data: dict, tenant_id: str, user_id: str, node_id: str, user_inputs: dict[str, Any]
) -> WorkflowNodeExecution:
@@ -945,6 +1306,13 @@ class WorkflowService:
if any(nt.is_trigger_node for nt in node_types):
raise ValueError("Start node and trigger nodes cannot coexist in the same workflow")
+ for node in node_configs:
+ node_data = node.get("data", {})
+ node_type = node_data.get("type")
+
+ if node_type == NodeType.HUMAN_INPUT:
+ self._validate_human_input_node_data(node_data)
+
def validate_features_structure(self, app_model: App, features: dict):
if app_model.mode == AppMode.ADVANCED_CHAT:
return AdvancedChatAppConfigManager.config_validate(
@@ -957,6 +1325,23 @@ class WorkflowService:
else:
raise ValueError(f"Invalid app mode: {app_model.mode}")
+ def _validate_human_input_node_data(self, node_data: dict) -> None:
+ """
+ Validate HumanInput node data format.
+
+ Args:
+ node_data: The node data dictionary
+
+ Raises:
+ ValueError: If the node data format is invalid
+ """
+ from core.workflow.nodes.human_input.entities import HumanInputNodeData
+
+ try:
+ HumanInputNodeData.model_validate(node_data)
+ except Exception as e:
+ raise ValueError(f"Invalid HumanInput node data: {str(e)}")
+
def update_workflow(
self, *, session: Session, workflow_id: str, tenant_id: str, account_id: str, data: dict
) -> Workflow | None:
diff --git a/api/tasks/app_generate/__init__.py b/api/tasks/app_generate/__init__.py
new file mode 100644
index 0000000000..4aa02ef39f
--- /dev/null
+++ b/api/tasks/app_generate/__init__.py
@@ -0,0 +1,3 @@
+from .workflow_execute_task import AppExecutionParams, resume_app_execution, workflow_based_app_execution_task
+
+__all__ = ["AppExecutionParams", "resume_app_execution", "workflow_based_app_execution_task"]
diff --git a/api/tasks/app_generate/workflow_execute_task.py b/api/tasks/app_generate/workflow_execute_task.py
new file mode 100644
index 0000000000..e58d334f41
--- /dev/null
+++ b/api/tasks/app_generate/workflow_execute_task.py
@@ -0,0 +1,491 @@
+import contextlib
+import logging
+import uuid
+from collections.abc import Generator, Mapping
+from enum import StrEnum
+from typing import Annotated, Any, TypeAlias, Union
+
+from celery import shared_task
+from flask import current_app, json
+from pydantic import BaseModel, Discriminator, Field, Tag
+from sqlalchemy import Engine, select
+from sqlalchemy.orm import Session, sessionmaker
+
+from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
+from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
+from core.app.apps.workflow.app_generator import WorkflowAppGenerator
+from core.app.entities.app_invoke_entities import (
+ AdvancedChatAppGenerateEntity,
+ InvokeFrom,
+ WorkflowAppGenerateEntity,
+)
+from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, WorkflowResumptionContext
+from core.repositories import DifyCoreRepositoryFactory
+from core.workflow.runtime import GraphRuntimeState
+from extensions.ext_database import db
+from libs.flask_utils import set_login_user
+from models.account import Account
+from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
+from models.model import App, AppMode, Conversation, EndUser, Message
+from models.workflow import Workflow, WorkflowNodeExecutionTriggeredFrom, WorkflowRun
+from repositories.factory import DifyAPIRepositoryFactory
+
+logger = logging.getLogger(__name__)
+
+WORKFLOW_BASED_APP_EXECUTION_QUEUE = "workflow_based_app_execution"
+
+
+class _UserType(StrEnum):
+ ACCOUNT = "account"
+ END_USER = "end_user"
+
+
+class _Account(BaseModel):
+ TYPE: _UserType = _UserType.ACCOUNT
+
+ user_id: str
+
+
+class _EndUser(BaseModel):
+ TYPE: _UserType = _UserType.END_USER
+ end_user_id: str
+
+
+def _get_user_type_descriminator(value: Any):
+ if isinstance(value, (_Account, _EndUser)):
+ return value.TYPE
+ elif isinstance(value, dict):
+ user_type_str = value.get("TYPE")
+ if user_type_str is None:
+ return None
+ try:
+ user_type = _UserType(user_type_str)
+ except ValueError:
+ return None
+ return user_type
+ else:
+ # return None if the discriminator value isn't found
+ return None
+
+
+User: TypeAlias = Annotated[
+ (Annotated[_Account, Tag(_UserType.ACCOUNT)] | Annotated[_EndUser, Tag(_UserType.END_USER)]),
+ Discriminator(_get_user_type_descriminator),
+]
+
+
+class AppExecutionParams(BaseModel):
+ app_id: str
+ workflow_id: str
+ tenant_id: str
+ app_mode: AppMode = AppMode.ADVANCED_CHAT
+ user: User
+ args: Mapping[str, Any]
+
+ invoke_from: InvokeFrom
+ streaming: bool = True
+ call_depth: int = 0
+ root_node_id: str | None = None
+ workflow_run_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
+
+ @classmethod
+ def new(
+ cls,
+ app_model: App,
+ workflow: Workflow,
+ user: Union[Account, EndUser],
+ args: Mapping[str, Any],
+ invoke_from: InvokeFrom,
+ streaming: bool = True,
+ call_depth: int = 0,
+ root_node_id: str | None = None,
+ workflow_run_id: str | None = None,
+ ):
+ user_params: _Account | _EndUser
+ if isinstance(user, Account):
+ user_params = _Account(user_id=user.id)
+ elif isinstance(user, EndUser):
+ user_params = _EndUser(end_user_id=user.id)
+ else:
+ raise AssertionError("this statement should be unreachable.")
+ return cls(
+ app_id=app_model.id,
+ workflow_id=workflow.id,
+ tenant_id=app_model.tenant_id,
+ app_mode=AppMode.value_of(app_model.mode),
+ user=user_params,
+ args=args,
+ invoke_from=invoke_from,
+ streaming=streaming,
+ call_depth=call_depth,
+ root_node_id=root_node_id,
+ workflow_run_id=workflow_run_id or str(uuid.uuid4()),
+ )
+
+
+class _AppRunner:
+ def __init__(self, session_factory: sessionmaker | Engine, exec_params: AppExecutionParams):
+ if isinstance(session_factory, Engine):
+ session_factory = sessionmaker(bind=session_factory)
+ self._session_factory = session_factory
+ self._exec_params = exec_params
+
+ @contextlib.contextmanager
+ def _session(self):
+ with self._session_factory(expire_on_commit=False) as session, session.begin():
+ yield session
+
+ @contextlib.contextmanager
+ def _setup_flask_context(self, user: Account | EndUser):
+ flask_app = current_app._get_current_object() # type: ignore
+ with flask_app.app_context():
+ set_login_user(user)
+ yield
+
+ def run(self):
+ exec_params = self._exec_params
+ with self._session() as session:
+ workflow = session.get(Workflow, exec_params.workflow_id)
+ if workflow is None:
+ logger.warning("Workflow %s not found for execution", exec_params.workflow_id)
+ return None
+ app = session.get(App, workflow.app_id)
+ if app is None:
+ logger.warning("App %s not found for workflow %s", workflow.app_id, exec_params.workflow_id)
+ return None
+
+ pause_config = PauseStateLayerConfig(
+ session_factory=self._session_factory,
+ state_owner_user_id=workflow.created_by,
+ )
+
+ user = self._resolve_user()
+
+ with self._setup_flask_context(user):
+ response = self._run_app(
+ app=app,
+ workflow=workflow,
+ user=user,
+ pause_state_config=pause_config,
+ )
+ if not exec_params.streaming:
+ return response
+
+ assert isinstance(response, Generator)
+ _publish_streaming_response(response, exec_params.workflow_run_id, exec_params.app_mode)
+
+ def _run_app(
+ self,
+ *,
+ app: App,
+ workflow: Workflow,
+ user: Account | EndUser,
+ pause_state_config: PauseStateLayerConfig,
+ ):
+ exec_params = self._exec_params
+ if exec_params.app_mode == AppMode.ADVANCED_CHAT:
+ return AdvancedChatAppGenerator().generate(
+ app_model=app,
+ workflow=workflow,
+ user=user,
+ args=exec_params.args,
+ invoke_from=exec_params.invoke_from,
+ streaming=exec_params.streaming,
+ workflow_run_id=exec_params.workflow_run_id,
+ pause_state_config=pause_state_config,
+ )
+ if exec_params.app_mode == AppMode.WORKFLOW:
+ return WorkflowAppGenerator().generate(
+ app_model=app,
+ workflow=workflow,
+ user=user,
+ args=exec_params.args,
+ invoke_from=exec_params.invoke_from,
+ streaming=exec_params.streaming,
+ call_depth=exec_params.call_depth,
+ root_node_id=exec_params.root_node_id,
+ workflow_run_id=exec_params.workflow_run_id,
+ pause_state_config=pause_state_config,
+ )
+
+ logger.error("Unsupported app mode for execution: %s", exec_params.app_mode)
+ return None
+
+ def _resolve_user(self) -> Account | EndUser:
+ user_params = self._exec_params.user
+
+ if isinstance(user_params, _EndUser):
+ with self._session() as session:
+ return session.get(EndUser, user_params.end_user_id)
+ elif not isinstance(user_params, _Account):
+ raise AssertionError(f"user should only be _Account or _EndUser, got {type(user_params)}")
+
+ with self._session() as session:
+ user: Account = session.get(Account, user_params.user_id)
+ user.set_tenant_id(self._exec_params.tenant_id)
+
+ return user
+
+
+def _resolve_user_for_run(session: Session, workflow_run: WorkflowRun) -> Account | EndUser | None:
+ role = CreatorUserRole(workflow_run.created_by_role)
+ if role == CreatorUserRole.ACCOUNT:
+ user = session.get(Account, workflow_run.created_by)
+ if user:
+ user.set_tenant_id(workflow_run.tenant_id)
+ return user
+
+ return session.get(EndUser, workflow_run.created_by)
+
+
+def _publish_streaming_response(
+ response_stream: Generator[str | Mapping[str, Any], None, None], workflow_run_id: str, app_mode: AppMode
+) -> None:
+ topic = MessageBasedAppGenerator.get_response_topic(app_mode, workflow_run_id)
+ for event in response_stream:
+ try:
+ payload = json.dumps(event)
+ except TypeError:
+ logger.exception("error while encoding event")
+ continue
+
+ topic.publish(payload.encode())
+
+
+@shared_task(queue=WORKFLOW_BASED_APP_EXECUTION_QUEUE)
+def workflow_based_app_execution_task(
+ payload: str,
+) -> Generator[Mapping[str, Any] | str, None, None] | Mapping[str, Any] | None:
+ exec_params = AppExecutionParams.model_validate_json(payload)
+
+ logger.info("workflow_based_app_execution_task run with params: %s", exec_params)
+
+ runner = _AppRunner(db.engine, exec_params=exec_params)
+ return runner.run()
+
+
+def _resume_app_execution(payload: dict[str, Any]) -> None:
+ workflow_run_id = payload["workflow_run_id"]
+
+ session_factory = sessionmaker(bind=db.engine, expire_on_commit=False)
+ workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker=session_factory)
+
+ pause_entity = workflow_run_repo.get_workflow_pause(workflow_run_id)
+ if pause_entity is None:
+ logger.warning("No pause entity found for workflow run %s", workflow_run_id)
+ return
+
+ try:
+ resumption_context = WorkflowResumptionContext.loads(pause_entity.get_state().decode())
+ except Exception:
+ logger.exception("Failed to load resumption context for workflow run %s", workflow_run_id)
+ return
+
+ generate_entity = resumption_context.get_generate_entity()
+
+ graph_runtime_state = GraphRuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
+
+ conversation = None
+ message = None
+ with Session(db.engine, expire_on_commit=False) as session:
+ workflow_run = session.get(WorkflowRun, workflow_run_id)
+ if workflow_run is None:
+ logger.warning("Workflow run %s not found during resume", workflow_run_id)
+ return
+
+ workflow = session.get(Workflow, workflow_run.workflow_id)
+ if workflow is None:
+ logger.warning("Workflow %s not found during resume", workflow_run.workflow_id)
+ return
+
+ app_model = session.get(App, workflow_run.app_id)
+ if app_model is None:
+ logger.warning("App %s not found during resume", workflow_run.app_id)
+ return
+
+ user = _resolve_user_for_run(session, workflow_run)
+ if user is None:
+ logger.warning("User %s not found for workflow run %s", workflow_run.created_by, workflow_run_id)
+ return
+
+ if isinstance(generate_entity, AdvancedChatAppGenerateEntity):
+ if generate_entity.conversation_id is None:
+ logger.warning("Conversation id missing in resumption context for workflow run %s", workflow_run_id)
+ return
+
+ conversation = session.get(Conversation, generate_entity.conversation_id)
+ if conversation is None:
+ logger.warning(
+ "Conversation %s not found for workflow run %s", generate_entity.conversation_id, workflow_run_id
+ )
+ return
+
+ message = session.scalar(
+ select(Message).where(Message.workflow_run_id == workflow_run_id).order_by(Message.created_at.desc())
+ )
+ if message is None:
+ logger.warning("Message not found for workflow run %s", workflow_run_id)
+ return
+
+ if not isinstance(generate_entity, (AdvancedChatAppGenerateEntity, WorkflowAppGenerateEntity)):
+ logger.error(
+ "Unsupported resumption entity for workflow run %s (found %s)",
+ workflow_run_id,
+ type(generate_entity),
+ )
+ return
+
+ workflow_run_repo.resume_workflow_pause(workflow_run_id, pause_entity)
+
+ pause_config = PauseStateLayerConfig(
+ session_factory=session_factory,
+ state_owner_user_id=workflow.created_by,
+ )
+
+ if isinstance(generate_entity, AdvancedChatAppGenerateEntity):
+ assert conversation is not None
+ assert message is not None
+ _resume_advanced_chat(
+ app_model=app_model,
+ workflow=workflow,
+ user=user,
+ conversation=conversation,
+ message=message,
+ generate_entity=generate_entity,
+ graph_runtime_state=graph_runtime_state,
+ session_factory=session_factory,
+ pause_state_config=pause_config,
+ workflow_run_id=workflow_run_id,
+ workflow_run=workflow_run,
+ )
+ elif isinstance(generate_entity, WorkflowAppGenerateEntity):
+ _resume_workflow(
+ app_model=app_model,
+ workflow=workflow,
+ user=user,
+ generate_entity=generate_entity,
+ graph_runtime_state=graph_runtime_state,
+ session_factory=session_factory,
+ pause_state_config=pause_config,
+ workflow_run_id=workflow_run_id,
+ workflow_run=workflow_run,
+ workflow_run_repo=workflow_run_repo,
+ pause_entity=pause_entity,
+ )
+
+
+def _resume_advanced_chat(
+ *,
+ app_model: App,
+ workflow: Workflow,
+ user: Account | EndUser,
+ conversation: Conversation,
+ message: Message,
+ generate_entity: AdvancedChatAppGenerateEntity,
+ graph_runtime_state: GraphRuntimeState,
+ session_factory: sessionmaker,
+ pause_state_config: PauseStateLayerConfig,
+ workflow_run_id: str,
+ workflow_run: WorkflowRun,
+) -> None:
+ try:
+ triggered_from = WorkflowRunTriggeredFrom(workflow_run.triggered_from)
+ except ValueError:
+ triggered_from = WorkflowRunTriggeredFrom.APP_RUN
+
+ workflow_execution_repository = DifyCoreRepositoryFactory.create_workflow_execution_repository(
+ session_factory=session_factory,
+ user=user,
+ app_id=app_model.id,
+ triggered_from=triggered_from,
+ )
+ workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
+ session_factory=session_factory,
+ user=user,
+ app_id=app_model.id,
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
+ )
+
+ generator = AdvancedChatAppGenerator()
+
+ try:
+ response = generator.resume(
+ app_model=app_model,
+ workflow=workflow,
+ user=user,
+ conversation=conversation,
+ message=message,
+ application_generate_entity=generate_entity,
+ workflow_execution_repository=workflow_execution_repository,
+ workflow_node_execution_repository=workflow_node_execution_repository,
+ graph_runtime_state=graph_runtime_state,
+ pause_state_config=pause_state_config,
+ )
+ except Exception:
+ logger.exception("Failed to resume chatflow execution for workflow run %s", workflow_run_id)
+ raise
+
+ if generate_entity.stream:
+ assert isinstance(response, Generator)
+ _publish_streaming_response(response, workflow_run_id, AppMode.ADVANCED_CHAT)
+
+
+def _resume_workflow(
+ *,
+ app_model: App,
+ workflow: Workflow,
+ user: Account | EndUser,
+ generate_entity: WorkflowAppGenerateEntity,
+ graph_runtime_state: GraphRuntimeState,
+ session_factory: sessionmaker,
+ pause_state_config: PauseStateLayerConfig,
+ workflow_run_id: str,
+ workflow_run: WorkflowRun,
+ workflow_run_repo,
+ pause_entity,
+) -> None:
+ try:
+ triggered_from = WorkflowRunTriggeredFrom(workflow_run.triggered_from)
+ except ValueError:
+ triggered_from = WorkflowRunTriggeredFrom.APP_RUN
+
+ workflow_execution_repository = DifyCoreRepositoryFactory.create_workflow_execution_repository(
+ session_factory=session_factory,
+ user=user,
+ app_id=app_model.id,
+ triggered_from=triggered_from,
+ )
+ workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
+ session_factory=session_factory,
+ user=user,
+ app_id=app_model.id,
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
+ )
+
+ generator = WorkflowAppGenerator()
+
+ try:
+ response = generator.resume(
+ app_model=app_model,
+ workflow=workflow,
+ user=user,
+ application_generate_entity=generate_entity,
+ graph_runtime_state=graph_runtime_state,
+ workflow_execution_repository=workflow_execution_repository,
+ workflow_node_execution_repository=workflow_node_execution_repository,
+ pause_state_config=pause_state_config,
+ )
+ except Exception:
+ logger.exception("Failed to resume workflow execution for workflow run %s", workflow_run_id)
+ raise
+
+ if generate_entity.stream:
+ assert isinstance(response, Generator)
+ _publish_streaming_response(response, workflow_run_id, AppMode.WORKFLOW)
+
+ workflow_run_repo.delete_workflow_pause(pause_entity)
+
+
+@shared_task(queue=WORKFLOW_BASED_APP_EXECUTION_QUEUE, name="resume_app_execution")
+def resume_app_execution(payload: dict[str, Any]) -> None:
+ _resume_app_execution(payload)
diff --git a/api/tasks/async_workflow_tasks.py b/api/tasks/async_workflow_tasks.py
index b51884148e..cc96542d4b 100644
--- a/api/tasks/async_workflow_tasks.py
+++ b/api/tasks/async_workflow_tasks.py
@@ -5,32 +5,42 @@ These tasks handle workflow execution for different subscription tiers
with appropriate retry policies and error handling.
"""
+import logging
from datetime import UTC, datetime
from typing import Any
from celery import shared_task
from sqlalchemy import select
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY, WorkflowAppGenerator
-from core.app.entities.app_invoke_entities import InvokeFrom
+from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
+from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, WorkflowResumptionContext
+from core.app.layers.timeslice_layer import TimeSliceLayer
from core.app.layers.trigger_post_layer import TriggerPostLayer
from core.db.session_factory import session_factory
+from core.repositories import DifyCoreRepositoryFactory
+from core.workflow.runtime import GraphRuntimeState
+from extensions.ext_database import db
from models.account import Account
-from models.enums import CreatorUserRole, WorkflowTriggerStatus
+from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom, WorkflowTriggerStatus
from models.model import App, EndUser, Tenant
from models.trigger import WorkflowTriggerLog
-from models.workflow import Workflow
+from models.workflow import Workflow, WorkflowNodeExecutionTriggeredFrom, WorkflowRun
+from repositories.factory import DifyAPIRepositoryFactory
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
from services.errors.app import WorkflowNotFoundError
from services.workflow.entities import (
TriggerData,
+ WorkflowResumeTaskData,
WorkflowTaskData,
)
from tasks.workflow_cfs_scheduler.cfs_scheduler import AsyncWorkflowCFSPlanEntity, AsyncWorkflowCFSPlanScheduler
from tasks.workflow_cfs_scheduler.entities import AsyncWorkflowQueue, AsyncWorkflowSystemStrategy
+logger = logging.getLogger(__name__)
+
@shared_task(queue=AsyncWorkflowQueue.PROFESSIONAL_QUEUE)
def execute_workflow_professional(task_data_dict: dict[str, Any]):
@@ -141,6 +151,11 @@ def _execute_workflow_common(
if trigger_data.workflow_id:
args["workflow_id"] = str(trigger_data.workflow_id)
+ pause_config = PauseStateLayerConfig(
+ session_factory=session_factory.get_session_maker(),
+ state_owner_user_id=workflow.created_by,
+ )
+
# Execute the workflow with the trigger type
generator.generate(
app_model=app_model,
@@ -156,6 +171,7 @@ def _execute_workflow_common(
# TODO: Re-enable TimeSliceLayer after the HITL release.
TriggerPostLayer(cfs_plan_scheduler_entity, start_time, trigger_log.id),
],
+ pause_state_config=pause_config,
)
except Exception as e:
@@ -173,21 +189,153 @@ def _execute_workflow_common(
session.commit()
-def _get_user(session: Session, trigger_log: WorkflowTriggerLog) -> Account | EndUser:
+@shared_task(name="resume_workflow_execution")
+def resume_workflow_execution(task_data_dict: dict[str, Any]) -> None:
+ """Resume a paused workflow run via Celery."""
+ task_data = WorkflowResumeTaskData.model_validate(task_data_dict)
+ session_factory = sessionmaker(bind=db.engine, expire_on_commit=False)
+ workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_factory)
+
+ pause_entity = workflow_run_repo.get_workflow_pause(task_data.workflow_run_id)
+ if pause_entity is None:
+ logger.warning("No pause state for workflow run %s", task_data.workflow_run_id)
+ return
+ workflow_run = workflow_run_repo.get_workflow_run_by_id_without_tenant(pause_entity.workflow_execution_id)
+ if workflow_run is None:
+ logger.warning("Workflow run not found for pause entity: pause_entity_id=%s", pause_entity.id)
+ return
+
+ try:
+ resumption_context = WorkflowResumptionContext.loads(pause_entity.get_state().decode())
+ except Exception as exc:
+ logger.exception("Failed to load resumption context for workflow run %s", task_data.workflow_run_id)
+ raise exc
+
+ generate_entity = resumption_context.get_generate_entity()
+ if not isinstance(generate_entity, WorkflowAppGenerateEntity):
+ logger.error(
+ "Unsupported resumption entity for workflow run %s: %s",
+ task_data.workflow_run_id,
+ type(generate_entity),
+ )
+ return
+
+ graph_runtime_state = GraphRuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
+
+ with session_factory() as session:
+ workflow = session.scalar(select(Workflow).where(Workflow.id == workflow_run.workflow_id))
+ if workflow is None:
+ raise WorkflowNotFoundError(
+ "Workflow not found: workflow_run_id=%s, workflow_id=%s", workflow_run.id, workflow_run.workflow_id
+ )
+ user = _get_user(session, workflow_run)
+ app_model = session.scalar(select(App).where(App.id == workflow_run.app_id))
+ if app_model is None:
+ raise _AppNotFoundError(
+ "App not found: app_id=%s, workflow_run_id=%s", workflow_run.app_id, workflow_run.id
+ )
+
+ workflow_execution_repository = DifyCoreRepositoryFactory.create_workflow_execution_repository(
+ session_factory=session_factory,
+ user=user,
+ app_id=generate_entity.app_config.app_id,
+ triggered_from=WorkflowRunTriggeredFrom(workflow_run.triggered_from),
+ )
+ workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
+ session_factory=session_factory,
+ user=user,
+ app_id=generate_entity.app_config.app_id,
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
+ )
+
+ pause_config = PauseStateLayerConfig(
+ session_factory=session_factory,
+ state_owner_user_id=workflow.created_by,
+ )
+
+ generator = WorkflowAppGenerator()
+ start_time = datetime.now(UTC)
+ graph_engine_layers = []
+ trigger_log = _query_trigger_log_info(session_factory, task_data.workflow_run_id)
+
+ if trigger_log:
+ cfs_plan_scheduler_entity = AsyncWorkflowCFSPlanEntity(
+ queue=AsyncWorkflowQueue(trigger_log.queue_name),
+ schedule_strategy=AsyncWorkflowSystemStrategy,
+ granularity=dify_config.ASYNC_WORKFLOW_SCHEDULER_GRANULARITY,
+ )
+ cfs_plan_scheduler = AsyncWorkflowCFSPlanScheduler(plan=cfs_plan_scheduler_entity)
+
+ graph_engine_layers.extend(
+ [
+ TimeSliceLayer(cfs_plan_scheduler),
+ TriggerPostLayer(cfs_plan_scheduler_entity, start_time, trigger_log.id),
+ ]
+ )
+
+ workflow_run_repo.resume_workflow_pause(task_data.workflow_run_id, pause_entity)
+
+ generator.resume(
+ app_model=app_model,
+ workflow=workflow,
+ user=user,
+ application_generate_entity=generate_entity,
+ graph_runtime_state=graph_runtime_state,
+ workflow_execution_repository=workflow_execution_repository,
+ workflow_node_execution_repository=workflow_node_execution_repository,
+ graph_engine_layers=graph_engine_layers,
+ pause_state_config=pause_config,
+ )
+ workflow_run_repo.delete_workflow_pause(pause_entity)
+
+
+def _get_user(session: Session, workflow_run: WorkflowRun | WorkflowTriggerLog) -> Account | EndUser:
"""Compose user from trigger log"""
- tenant = session.scalar(select(Tenant).where(Tenant.id == trigger_log.tenant_id))
+ tenant = session.scalar(select(Tenant).where(Tenant.id == workflow_run.tenant_id))
if not tenant:
- raise ValueError(f"Tenant not found: {trigger_log.tenant_id}")
+ raise _TenantNotFoundError(
+ "Tenant not found for WorkflowRun: tenant_id=%s, workflow_run_id=%s",
+ workflow_run.tenant_id,
+ workflow_run.id,
+ )
# Get user from trigger log
- if trigger_log.created_by_role == CreatorUserRole.ACCOUNT:
- user = session.scalar(select(Account).where(Account.id == trigger_log.created_by))
+ if workflow_run.created_by_role == CreatorUserRole.ACCOUNT:
+ user = session.scalar(select(Account).where(Account.id == workflow_run.created_by))
if user:
user.current_tenant = tenant
else: # CreatorUserRole.END_USER
- user = session.scalar(select(EndUser).where(EndUser.id == trigger_log.created_by))
+ user = session.scalar(select(EndUser).where(EndUser.id == workflow_run.created_by))
if not user:
- raise ValueError(f"User not found: {trigger_log.created_by} (role: {trigger_log.created_by_role})")
+ raise _UserNotFoundError(
+ "User not found: user_id=%s, created_by_role=%s, workflow_run_id=%s",
+ workflow_run.created_by,
+ workflow_run.created_by_role,
+ workflow_run.id,
+ )
return user
+
+
+def _query_trigger_log_info(session_factory: sessionmaker[Session], workflow_run_id) -> WorkflowTriggerLog | None:
+ with session_factory() as session, session.begin():
+ trigger_log_repo = SQLAlchemyWorkflowTriggerLogRepository(session)
+ trigger_log = trigger_log_repo.get_by_workflow_run_id(workflow_run_id)
+ if not trigger_log:
+ logger.debug("Trigger log not found for workflow_run: workflow_run_id=%s", workflow_run_id)
+ return None
+
+ return trigger_log
+
+
+class _TenantNotFoundError(Exception):
+ pass
+
+
+class _UserNotFoundError(Exception):
+ pass
+
+
+class _AppNotFoundError(Exception):
+ pass
diff --git a/api/tasks/human_input_timeout_tasks.py b/api/tasks/human_input_timeout_tasks.py
new file mode 100644
index 0000000000..0c40877309
--- /dev/null
+++ b/api/tasks/human_input_timeout_tasks.py
@@ -0,0 +1,113 @@
+import logging
+from datetime import timedelta
+
+from celery import shared_task
+from sqlalchemy import or_, select
+from sqlalchemy.orm import sessionmaker
+
+from configs import dify_config
+from core.repositories.human_input_repository import HumanInputFormSubmissionRepository
+from core.workflow.enums import WorkflowExecutionStatus
+from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
+from extensions.ext_database import db
+from extensions.ext_storage import storage
+from libs.datetime_utils import ensure_naive_utc, naive_utc_now
+from models.human_input import HumanInputForm
+from models.workflow import WorkflowPause, WorkflowRun
+from services.human_input_service import HumanInputService
+
+logger = logging.getLogger(__name__)
+
+
+def _is_global_timeout(form_model: HumanInputForm, global_timeout_seconds: int, *, now) -> bool:
+ if global_timeout_seconds <= 0:
+ return False
+ if form_model.workflow_run_id is None:
+ return False
+ created_at = ensure_naive_utc(form_model.created_at)
+ global_deadline = created_at + timedelta(seconds=global_timeout_seconds)
+ return global_deadline <= now
+
+
+def _handle_global_timeout(*, form_id: str, workflow_run_id: str, node_id: str, session_factory: sessionmaker) -> None:
+ now = naive_utc_now()
+ with session_factory() as session, session.begin():
+ workflow_run = session.get(WorkflowRun, workflow_run_id)
+ if workflow_run is not None:
+ workflow_run.status = WorkflowExecutionStatus.STOPPED
+ workflow_run.error = f"Human input global timeout at node {node_id}"
+ workflow_run.finished_at = now
+ session.add(workflow_run)
+
+ pause_model = session.scalar(select(WorkflowPause).where(WorkflowPause.workflow_run_id == workflow_run_id))
+ if pause_model is not None:
+ try:
+ storage.delete(pause_model.state_object_key)
+ except Exception:
+ logger.exception(
+ "Failed to delete pause state object for workflow_run_id=%s, pause_id=%s",
+ workflow_run_id,
+ pause_model.id,
+ )
+ pause_model.resumed_at = now
+ session.add(pause_model)
+
+
+@shared_task(name="human_input_form_timeout.check_and_resume", queue="schedule_executor")
+def check_and_handle_human_input_timeouts(limit: int = 100) -> None:
+ """Scan for expired human input forms and resume or end workflows."""
+
+ session_factory = sessionmaker(bind=db.engine, expire_on_commit=False)
+ form_repo = HumanInputFormSubmissionRepository(session_factory)
+ service = HumanInputService(session_factory, form_repository=form_repo)
+ now = naive_utc_now()
+ global_timeout_seconds = dify_config.HITL_GLOBAL_TIMEOUT_SECONDS
+
+ with session_factory() as session:
+ global_deadline = now - timedelta(seconds=global_timeout_seconds) if global_timeout_seconds > 0 else None
+ timeout_filter = HumanInputForm.expiration_time <= now
+ if global_deadline is not None:
+ timeout_filter = or_(timeout_filter, HumanInputForm.created_at <= global_deadline)
+ stmt = (
+ select(HumanInputForm)
+ .where(
+ HumanInputForm.status == HumanInputFormStatus.WAITING,
+ timeout_filter,
+ )
+ .order_by(HumanInputForm.id.asc())
+ .limit(limit)
+ )
+ expired_forms = session.scalars(stmt).all()
+
+ for form_model in expired_forms:
+ try:
+ if form_model.form_kind == HumanInputFormKind.DELIVERY_TEST:
+ form_repo.mark_timeout(
+ form_id=form_model.id,
+ timeout_status=HumanInputFormStatus.TIMEOUT,
+ reason="delivery_test_timeout",
+ )
+ continue
+
+ is_global = _is_global_timeout(form_model, global_timeout_seconds, now=now)
+ record = form_repo.mark_timeout(
+ form_id=form_model.id,
+ timeout_status=HumanInputFormStatus.EXPIRED if is_global else HumanInputFormStatus.TIMEOUT,
+ reason="global_timeout" if is_global else "node_timeout",
+ )
+ assert record.workflow_run_id is not None, "workflow_run_id should not be None for non-test form"
+ if is_global:
+ _handle_global_timeout(
+ form_id=record.form_id,
+ workflow_run_id=record.workflow_run_id,
+ node_id=record.node_id,
+ session_factory=session_factory,
+ )
+ else:
+ service.enqueue_resume(record.workflow_run_id)
+ except Exception:
+ logger.exception(
+ "Failed to handle timeout for form_id=%s workflow_run_id=%s",
+ form_model.id,
+ form_model.workflow_run_id,
+ )
diff --git a/api/tasks/mail_human_input_delivery_task.py b/api/tasks/mail_human_input_delivery_task.py
new file mode 100644
index 0000000000..d1cd0fbadc
--- /dev/null
+++ b/api/tasks/mail_human_input_delivery_task.py
@@ -0,0 +1,190 @@
+import json
+import logging
+import time
+from dataclasses import dataclass
+from typing import Any
+
+import click
+from celery import shared_task
+from sqlalchemy import select
+from sqlalchemy.orm import Session, sessionmaker
+
+from configs import dify_config
+from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext
+from core.workflow.nodes.human_input.entities import EmailDeliveryConfig, EmailDeliveryMethod
+from core.workflow.runtime import GraphRuntimeState, VariablePool
+from extensions.ext_database import db
+from extensions.ext_mail import mail
+from models.human_input import (
+ DeliveryMethodType,
+ HumanInputDelivery,
+ HumanInputForm,
+ HumanInputFormRecipient,
+ RecipientType,
+)
+from repositories.factory import DifyAPIRepositoryFactory
+from services.feature_service import FeatureService
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class _EmailRecipient:
+ email: str
+ token: str
+
+
+@dataclass(frozen=True)
+class _EmailDeliveryJob:
+ form_id: str
+ subject: str
+ body: str
+ form_content: str
+ recipients: list[_EmailRecipient]
+
+
+def _build_form_link(token: str) -> str:
+ base_url = dify_config.APP_WEB_URL
+ return f"{base_url.rstrip('/')}/form/{token}"
+
+
+def _parse_recipient_payload(payload: str) -> tuple[str | None, RecipientType | None]:
+ try:
+ payload_dict: dict[str, Any] = json.loads(payload)
+ except Exception:
+ logger.exception("Failed to parse recipient payload")
+ return None, None
+
+ return payload_dict.get("email"), payload_dict.get("TYPE")
+
+
+def _load_email_jobs(session: Session, form: HumanInputForm) -> list[_EmailDeliveryJob]:
+ deliveries = session.scalars(
+ select(HumanInputDelivery).where(
+ HumanInputDelivery.form_id == form.id,
+ HumanInputDelivery.delivery_method_type == DeliveryMethodType.EMAIL,
+ )
+ ).all()
+ jobs: list[_EmailDeliveryJob] = []
+ for delivery in deliveries:
+ delivery_config = EmailDeliveryMethod.model_validate_json(delivery.channel_payload)
+
+ recipients = session.scalars(
+ select(HumanInputFormRecipient).where(HumanInputFormRecipient.delivery_id == delivery.id)
+ ).all()
+
+ recipient_entities: list[_EmailRecipient] = []
+ for recipient in recipients:
+ email, recipient_type = _parse_recipient_payload(recipient.recipient_payload)
+ if recipient_type not in {RecipientType.EMAIL_MEMBER, RecipientType.EMAIL_EXTERNAL}:
+ continue
+ if not email:
+ continue
+ token = recipient.access_token
+ if not token:
+ continue
+ recipient_entities.append(_EmailRecipient(email=email, token=token))
+
+ if not recipient_entities:
+ continue
+
+ jobs.append(
+ _EmailDeliveryJob(
+ form_id=form.id,
+ subject=delivery_config.config.subject,
+ body=delivery_config.config.body,
+ form_content=form.rendered_content,
+ recipients=recipient_entities,
+ )
+ )
+ return jobs
+
+
+def _render_body(
+ body_template: str,
+ form_link: str,
+ *,
+ variable_pool: VariablePool | None,
+) -> str:
+ body = EmailDeliveryConfig.render_body_template(
+ body=body_template,
+ url=form_link,
+ variable_pool=variable_pool,
+ )
+ return body
+
+
+def _load_variable_pool(workflow_run_id: str | None) -> VariablePool | None:
+ if not workflow_run_id:
+ return None
+
+ session_factory = sessionmaker(bind=db.engine, expire_on_commit=False)
+ workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_factory)
+ pause_entity = workflow_run_repo.get_workflow_pause(workflow_run_id)
+ if pause_entity is None:
+ logger.info("No pause state found for workflow run %s", workflow_run_id)
+ return None
+
+ try:
+ resumption_context = WorkflowResumptionContext.loads(pause_entity.get_state().decode())
+ except Exception:
+ logger.exception("Failed to load resumption context for workflow run %s", workflow_run_id)
+ return None
+
+ graph_runtime_state = GraphRuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
+ return graph_runtime_state.variable_pool
+
+
+def _open_session(session_factory: sessionmaker | Session | None):
+ if session_factory is None:
+ return Session(db.engine)
+ if isinstance(session_factory, Session):
+ return session_factory
+ return session_factory()
+
+
+@shared_task(queue="mail")
+def dispatch_human_input_email_task(form_id: str, node_title: str | None = None, session_factory=None):
+ if not mail.is_inited():
+ return
+
+ logger.info(click.style(f"Start human input email delivery for form {form_id}", fg="green"))
+ start_at = time.perf_counter()
+
+ try:
+ with _open_session(session_factory) as session:
+ form = session.get(HumanInputForm, form_id)
+ if form is None:
+ logger.warning("Human input form not found, form_id=%s", form_id)
+ return
+ features = FeatureService.get_features(form.tenant_id)
+ if not features.human_input_email_delivery_enabled:
+ logger.info(
+ "Human input email delivery is not available for tenant=%s, form_id=%s",
+ form.tenant_id,
+ form_id,
+ )
+ return
+ jobs = _load_email_jobs(session, form)
+
+ variable_pool = _load_variable_pool(form.workflow_run_id)
+
+ for job in jobs:
+ for recipient in job.recipients:
+ form_link = _build_form_link(recipient.token)
+ body = _render_body(job.body, form_link, variable_pool=variable_pool)
+
+ mail.send(
+ to=recipient.email,
+ subject=job.subject,
+ html=body,
+ )
+
+ end_at = time.perf_counter()
+ logger.info(
+ click.style(
+ f"Human input email delivery succeeded for form {form_id}: latency: {end_at - start_at}", fg="green"
+ )
+ )
+ except Exception:
+ logger.exception("Send human input email failed, form_id=%s", form_id)
diff --git a/api/tests/integration_tests/conftest.py b/api/tests/integration_tests/conftest.py
index 948cf8b3a0..44adadeaa5 100644
--- a/api/tests/integration_tests/conftest.py
+++ b/api/tests/integration_tests/conftest.py
@@ -1,3 +1,4 @@
+import logging
import os
import pathlib
import random
@@ -10,26 +11,34 @@ from flask.testing import FlaskClient
from sqlalchemy.orm import Session
from app_factory import create_app
+from configs.app_config import DifyConfig
from extensions.ext_database import db
from models import Account, DifySetup, Tenant, TenantAccountJoin
from services.account_service import AccountService, RegisterService
+_DEFUALT_TEST_ENV = ".env"
+_DEFAULT_VDB_TEST_ENV = "vdb.env"
+
+_logger = logging.getLogger(__name__)
+
# Loading the .env file if it exists
def _load_env():
current_file_path = pathlib.Path(__file__).absolute()
# Items later in the list have higher precedence.
- files_to_load = [".env", "vdb.env"]
+ env_file_paths = [
+ os.getenv("DIFY_TEST_ENV_FILE", str(current_file_path.parent / _DEFUALT_TEST_ENV)),
+ os.getenv("DIFY_VDB_TEST_ENV_FILE", str(current_file_path.parent / _DEFAULT_VDB_TEST_ENV)),
+ ]
- env_file_paths = [current_file_path.parent / i for i in files_to_load]
- for path in env_file_paths:
- if not path.exists():
- continue
+ for env_path_str in env_file_paths:
+ if not pathlib.Path(env_path_str).exists():
+ _logger.warning("specified configuration file %s not exist", env_path_str)
from dotenv import load_dotenv
# Set `override=True` to ensure values from `vdb.env` take priority over values from `.env`
- load_dotenv(str(path), override=True)
+ load_dotenv(str(env_path_str), override=True)
_load_env()
@@ -41,6 +50,12 @@ os.environ.setdefault("OPENDAL_SCHEME", "fs")
_CACHED_APP = create_app()
+@pytest.fixture(scope="session")
+def dify_config() -> DifyConfig:
+ config = DifyConfig() # type: ignore
+ return config
+
+
@pytest.fixture
def flask_app() -> Flask:
return _CACHED_APP
diff --git a/api/tests/integration_tests/libs/broadcast_channel/redis/utils/__init__.py b/api/tests/integration_tests/libs/broadcast_channel/redis/utils/__init__.py
new file mode 100644
index 0000000000..e3f0d8a96e
--- /dev/null
+++ b/api/tests/integration_tests/libs/broadcast_channel/redis/utils/__init__.py
@@ -0,0 +1,36 @@
+"""
+Utilities and helpers for Redis broadcast channel integration tests.
+
+This module provides utility classes and functions for testing
+Redis broadcast channel functionality.
+"""
+
+from .test_data import (
+ LARGE_MESSAGES,
+ SMALL_MESSAGES,
+ SPECIAL_MESSAGES,
+ BufferTestConfig,
+ ConcurrencyTestConfig,
+ ErrorTestConfig,
+)
+from .test_helpers import (
+ ConcurrentPublisher,
+ SubscriptionMonitor,
+ assert_message_order,
+ measure_throughput,
+ wait_for_condition,
+)
+
+__all__ = [
+ "LARGE_MESSAGES",
+ "SMALL_MESSAGES",
+ "SPECIAL_MESSAGES",
+ "BufferTestConfig",
+ "ConcurrencyTestConfig",
+ "ConcurrentPublisher",
+ "ErrorTestConfig",
+ "SubscriptionMonitor",
+ "assert_message_order",
+ "measure_throughput",
+ "wait_for_condition",
+]
diff --git a/api/tests/integration_tests/libs/broadcast_channel/redis/utils/test_data.py b/api/tests/integration_tests/libs/broadcast_channel/redis/utils/test_data.py
new file mode 100644
index 0000000000..2cccb08304
--- /dev/null
+++ b/api/tests/integration_tests/libs/broadcast_channel/redis/utils/test_data.py
@@ -0,0 +1,315 @@
+"""
+Test data and configuration classes for Redis broadcast channel integration tests.
+
+This module provides dataclasses and constants for test configurations,
+message sets, and test scenarios.
+"""
+
+import dataclasses
+from typing import Any
+
+from libs.broadcast_channel.channel import Overflow
+
+
+@dataclasses.dataclass(frozen=True)
+class BufferTestConfig:
+ """Configuration for buffer management tests."""
+
+ buffer_size: int
+ overflow_strategy: Overflow
+ message_count: int
+ expected_behavior: str
+ description: str
+
+
+@dataclasses.dataclass(frozen=True)
+class ConcurrencyTestConfig:
+ """Configuration for concurrency tests."""
+
+ publisher_count: int
+ subscriber_count: int
+ messages_per_publisher: int
+ test_duration: float
+ description: str
+
+
+@dataclasses.dataclass(frozen=True)
+class ErrorTestConfig:
+ """Configuration for error handling tests."""
+
+ error_type: str
+ test_input: Any
+ expected_exception: type[Exception]
+ description: str
+
+
+# Test message sets for different scenarios
+SMALL_MESSAGES = [
+ b"msg_1",
+ b"msg_2",
+ b"msg_3",
+ b"msg_4",
+ b"msg_5",
+]
+
+MEDIUM_MESSAGES = [
+ b"medium_message_1_with_more_content",
+ b"medium_message_2_with_more_content",
+ b"medium_message_3_with_more_content",
+ b"medium_message_4_with_more_content",
+ b"medium_message_5_with_more_content",
+]
+
+LARGE_MESSAGES = [
+ b"large_message_" + b"x" * 1000,
+ b"large_message_" + b"y" * 1000,
+ b"large_message_" + b"z" * 1000,
+]
+
+VERY_LARGE_MESSAGES = [
+ b"very_large_message_" + b"x" * 10000, # ~10KB
+ b"very_large_message_" + b"y" * 50000, # ~50KB
+ b"very_large_message_" + b"z" * 100000, # ~100KB
+]
+
+SPECIAL_MESSAGES = [
+ b"", # Empty message
+ b"\x00\x01\x02", # Binary data with null bytes
+ "unicode_test_你好".encode(), # Unicode
+ b"special_chars_!@#$%^&*()_+-=[]{}|;':\",./<>?", # Special characters
+ b"newlines\n\r\t", # Control characters
+]
+
+BINARY_MESSAGES = [
+ bytes(range(256)), # All possible byte values
+ b"\xff\xfe\xfd\xfc\xfb\xfa\xf9\xf8", # High byte values
+ b"\x00\x01\x02\x03\x04\x05\x06\x07", # Low byte values
+]
+
+# Buffer test configurations
+BUFFER_TEST_CONFIGS = [
+ BufferTestConfig(
+ buffer_size=3,
+ overflow_strategy=Overflow.DROP_OLDEST,
+ message_count=5,
+ expected_behavior="drop_oldest",
+ description="Drop oldest messages when buffer is full",
+ ),
+ BufferTestConfig(
+ buffer_size=3,
+ overflow_strategy=Overflow.DROP_NEWEST,
+ message_count=5,
+ expected_behavior="drop_newest",
+ description="Drop newest messages when buffer is full",
+ ),
+ BufferTestConfig(
+ buffer_size=3,
+ overflow_strategy=Overflow.BLOCK,
+ message_count=5,
+ expected_behavior="block",
+ description="Block when buffer is full",
+ ),
+]
+
+# Concurrency test configurations
+CONCURRENCY_TEST_CONFIGS = [
+ ConcurrencyTestConfig(
+ publisher_count=1,
+ subscriber_count=1,
+ messages_per_publisher=10,
+ test_duration=5.0,
+ description="Single publisher, single subscriber",
+ ),
+ ConcurrencyTestConfig(
+ publisher_count=3,
+ subscriber_count=1,
+ messages_per_publisher=10,
+ test_duration=5.0,
+ description="Multiple publishers, single subscriber",
+ ),
+ ConcurrencyTestConfig(
+ publisher_count=1,
+ subscriber_count=3,
+ messages_per_publisher=10,
+ test_duration=5.0,
+ description="Single publisher, multiple subscribers",
+ ),
+ ConcurrencyTestConfig(
+ publisher_count=3,
+ subscriber_count=3,
+ messages_per_publisher=10,
+ test_duration=5.0,
+ description="Multiple publishers, multiple subscribers",
+ ),
+]
+
+# Error test configurations
+ERROR_TEST_CONFIGS = [
+ ErrorTestConfig(
+ error_type="invalid_buffer_size",
+ test_input=0,
+ expected_exception=ValueError,
+ description="Zero buffer size should raise ValueError",
+ ),
+ ErrorTestConfig(
+ error_type="invalid_buffer_size",
+ test_input=-1,
+ expected_exception=ValueError,
+ description="Negative buffer size should raise ValueError",
+ ),
+ ErrorTestConfig(
+ error_type="invalid_buffer_size",
+ test_input=1.5,
+ expected_exception=TypeError,
+ description="Float buffer size should raise TypeError",
+ ),
+ ErrorTestConfig(
+ error_type="invalid_buffer_size",
+ test_input="invalid",
+ expected_exception=TypeError,
+ description="String buffer size should raise TypeError",
+ ),
+]
+
+# Topic name test cases
+TOPIC_NAME_TEST_CASES = [
+ "simple_topic",
+ "topic_with_underscores",
+ "topic-with-dashes",
+ "topic.with.dots",
+ "topic_with_numbers_123",
+ "UPPERCASE_TOPIC",
+ "mixed_Case_Topic",
+ "topic_with_symbols_!@#$%",
+ "very_long_topic_name_" + "x" * 100,
+ "unicode_topic_你好",
+ "topic:with:colons",
+ "topic/with/slashes",
+ "topic\\with\\backslashes",
+]
+
+# Performance test configurations
+PERFORMANCE_TEST_CONFIGS = [
+ {
+ "name": "small_messages_high_frequency",
+ "message_size": 50,
+ "message_count": 1000,
+ "description": "Many small messages",
+ },
+ {
+ "name": "medium_messages_medium_frequency",
+ "message_size": 500,
+ "message_count": 100,
+ "description": "Medium messages",
+ },
+ {
+ "name": "large_messages_low_frequency",
+ "message_size": 5000,
+ "message_count": 10,
+ "description": "Large messages",
+ },
+]
+
+# Stress test configurations
+STRESS_TEST_CONFIGS = [
+ {
+ "name": "high_frequency_publishing",
+ "publisher_count": 5,
+ "messages_per_publisher": 100,
+ "subscriber_count": 3,
+ "description": "High frequency publishing with multiple publishers",
+ },
+ {
+ "name": "many_subscribers",
+ "publisher_count": 1,
+ "messages_per_publisher": 50,
+ "subscriber_count": 10,
+ "description": "Many subscribers to single publisher",
+ },
+ {
+ "name": "mixed_load",
+ "publisher_count": 3,
+ "messages_per_publisher": 100,
+ "subscriber_count": 5,
+ "description": "Mixed load with multiple publishers and subscribers",
+ },
+]
+
+# Edge case test data
+EDGE_CASE_MESSAGES = [
+ b"", # Empty message
+ b"\x00", # Single null byte
+ b"\xff", # Single max byte value
+ b"a", # Single ASCII character
+ "ä".encode(), # Single unicode character (2 bytes)
+ "𐍈".encode(), # Unicode character outside BMP (4 bytes)
+ b"\x00" * 1000, # 1000 null bytes
+ b"\xff" * 1000, # 1000 max byte values
+]
+
+# Message validation test data
+MESSAGE_VALIDATION_TEST_CASES = [
+ {
+ "name": "valid_bytes",
+ "input": b"valid_message",
+ "should_pass": True,
+ "description": "Valid bytes message",
+ },
+ {
+ "name": "empty_bytes",
+ "input": b"",
+ "should_pass": True,
+ "description": "Empty bytes message",
+ },
+ {
+ "name": "binary_data",
+ "input": bytes(range(256)),
+ "should_pass": True,
+ "description": "Binary data with all byte values",
+ },
+ {
+ "name": "large_message",
+ "input": b"x" * 1000000, # 1MB
+ "should_pass": True,
+ "description": "Large message (1MB)",
+ },
+]
+
+# Redis connection test scenarios
+REDIS_CONNECTION_TEST_SCENARIOS = [
+ {
+ "name": "normal_connection",
+ "should_fail": False,
+ "description": "Normal Redis connection",
+ },
+ {
+ "name": "connection_timeout",
+ "should_fail": True,
+ "description": "Connection timeout scenario",
+ },
+ {
+ "name": "connection_refused",
+ "should_fail": True,
+ "description": "Connection refused scenario",
+ },
+]
+
+# Test constants
+DEFAULT_TIMEOUT = 10.0
+SHORT_TIMEOUT = 2.0
+LONG_TIMEOUT = 30.0
+
+# Message size limits for testing
+MAX_SMALL_MESSAGE_SIZE = 100
+MAX_MEDIUM_MESSAGE_SIZE = 1000
+MAX_LARGE_MESSAGE_SIZE = 10000
+
+# Thread counts for concurrency testing
+MIN_THREAD_COUNT = 1
+MAX_THREAD_COUNT = 10
+DEFAULT_THREAD_COUNT = 3
+
+# Buffer sizes for testing
+MIN_BUFFER_SIZE = 1
+MAX_BUFFER_SIZE = 1000
+DEFAULT_BUFFER_SIZE = 10
diff --git a/api/tests/integration_tests/libs/broadcast_channel/redis/utils/test_helpers.py b/api/tests/integration_tests/libs/broadcast_channel/redis/utils/test_helpers.py
new file mode 100644
index 0000000000..65f3007b01
--- /dev/null
+++ b/api/tests/integration_tests/libs/broadcast_channel/redis/utils/test_helpers.py
@@ -0,0 +1,396 @@
+"""
+Test helper utilities for Redis broadcast channel integration tests.
+
+This module provides utility classes and functions for testing concurrent
+operations, monitoring subscriptions, and measuring performance.
+"""
+
+import logging
+import threading
+import time
+from collections.abc import Callable
+from typing import Any
+
+_logger = logging.getLogger(__name__)
+
+
+class ConcurrentPublisher:
+ """
+ Utility class for publishing messages concurrently from multiple threads.
+
+ This class manages multiple publisher threads that can publish messages
+ to the same or different topics concurrently, useful for stress testing
+ and concurrency validation.
+ """
+
+ def __init__(self, producer, message_count: int = 10, delay: float = 0.0):
+ """
+ Initialize the concurrent publisher.
+
+ Args:
+ producer: The producer instance to publish with
+ message_count: Number of messages to publish per thread
+ delay: Delay between messages in seconds
+ """
+ self.producer = producer
+ self.message_count = message_count
+ self.delay = delay
+ self.threads: list[threading.Thread] = []
+ self.published_messages: list[list[bytes]] = []
+ self._lock = threading.Lock()
+ self._started = False
+
+ def start_publishers(self, thread_count: int = 3) -> None:
+ """
+ Start multiple publisher threads.
+
+ Args:
+ thread_count: Number of publisher threads to start
+ """
+ if self._started:
+ raise RuntimeError("Publishers already started")
+
+ self._started = True
+
+ def _publisher(thread_id: int) -> None:
+ messages: list[bytes] = []
+ for i in range(self.message_count):
+ message = f"thread_{thread_id}_msg_{i}".encode()
+ try:
+ self.producer.publish(message)
+ messages.append(message)
+ if self.delay > 0:
+ time.sleep(self.delay)
+ except Exception:
+ _logger.exception("Pubmsg=lisher %s", thread_id)
+
+ with self._lock:
+ self.published_messages.append(messages)
+
+ for thread_id in range(thread_count):
+ thread = threading.Thread(
+ target=_publisher,
+ args=(thread_id,),
+ name=f"publisher-{thread_id}",
+ daemon=True,
+ )
+ thread.start()
+ self.threads.append(thread)
+
+ def wait_for_completion(self, timeout: float = 30.0) -> bool:
+ """
+ Wait for all publisher threads to complete.
+
+ Args:
+ timeout: Maximum time to wait in seconds
+
+ Returns:
+ bool: True if all threads completed successfully
+ """
+ for thread in self.threads:
+ thread.join(timeout)
+ if thread.is_alive():
+ return False
+ return True
+
+ def get_all_messages(self) -> list[bytes]:
+ """
+ Get all messages published by all threads.
+
+ Returns:
+ list[bytes]: Flattened list of all published messages
+ """
+ with self._lock:
+ all_messages = []
+ for thread_messages in self.published_messages:
+ all_messages.extend(thread_messages)
+ return all_messages
+
+ def get_thread_messages(self, thread_id: int) -> list[bytes]:
+ """
+ Get messages published by a specific thread.
+
+ Args:
+ thread_id: ID of the thread
+
+ Returns:
+ list[bytes]: Messages published by the specified thread
+ """
+ with self._lock:
+ if 0 <= thread_id < len(self.published_messages):
+ return self.published_messages[thread_id].copy()
+ return []
+
+
+class SubscriptionMonitor:
+ """
+ Utility class for monitoring subscription activity in tests.
+
+ This class monitors a subscription and tracks message reception,
+ errors, and completion status for testing purposes.
+ """
+
+ def __init__(self, subscription, timeout: float = 10.0):
+ """
+ Initialize the subscription monitor.
+
+ Args:
+ subscription: The subscription to monitor
+ timeout: Default timeout for operations
+ """
+ self.subscription = subscription
+ self.timeout = timeout
+ self.messages: list[bytes] = []
+ self.errors: list[Exception] = []
+ self.completed = False
+ self._lock = threading.Lock()
+ self._condition = threading.Condition(self._lock)
+ self._monitor_thread: threading.Thread | None = None
+ self._start_time: float | None = None
+
+ def start_monitoring(self) -> None:
+ """Start monitoring the subscription in a separate thread."""
+ if self._monitor_thread is not None:
+ raise RuntimeError("Monitoring already started")
+
+ self._start_time = time.time()
+
+ def _monitor():
+ try:
+ for message in self.subscription:
+ with self._lock:
+ self.messages.append(message)
+ self._condition.notify_all()
+ except Exception as e:
+ with self._lock:
+ self.errors.append(e)
+ self._condition.notify_all()
+ finally:
+ with self._lock:
+ self.completed = True
+ self._condition.notify_all()
+
+ self._monitor_thread = threading.Thread(
+ target=_monitor,
+ name="subscription-monitor",
+ daemon=True,
+ )
+ self._monitor_thread.start()
+
+ def wait_for_messages(self, count: int, timeout: float | None = None) -> bool:
+ """
+ Wait for a specific number of messages.
+
+ Args:
+ count: Number of messages to wait for
+ timeout: Timeout in seconds (uses default if None)
+
+ Returns:
+ bool: True if expected messages were received
+ """
+ if timeout is None:
+ timeout = self.timeout
+
+ deadline = time.time() + timeout
+
+ with self._condition:
+ while len(self.messages) < count and not self.completed:
+ remaining = deadline - time.time()
+ if remaining <= 0:
+ return False
+ self._condition.wait(remaining)
+
+ return len(self.messages) >= count
+
+ def wait_for_completion(self, timeout: float | None = None) -> bool:
+ """
+ Wait for monitoring to complete.
+
+ Args:
+ timeout: Timeout in seconds (uses default if None)
+
+ Returns:
+ bool: True if monitoring completed successfully
+ """
+ if timeout is None:
+ timeout = self.timeout
+
+ deadline = time.time() + timeout
+
+ with self._condition:
+ while not self.completed:
+ remaining = deadline - time.time()
+ if remaining <= 0:
+ return False
+ self._condition.wait(remaining)
+
+ return True
+
+ def get_messages(self) -> list[bytes]:
+ """
+ Get all received messages.
+
+ Returns:
+ list[bytes]: Copy of received messages
+ """
+ with self._lock:
+ return self.messages.copy()
+
+ def get_error_count(self) -> int:
+ """
+ Get the number of errors encountered.
+
+ Returns:
+ int: Number of errors
+ """
+ with self._lock:
+ return len(self.errors)
+
+ def get_elapsed_time(self) -> float:
+ """
+ Get the elapsed monitoring time.
+
+ Returns:
+ float: Elapsed time in seconds
+ """
+ if self._start_time is None:
+ return 0.0
+ return time.time() - self._start_time
+
+ def stop(self) -> None:
+ """Stop monitoring and close the subscription."""
+ if self._monitor_thread is not None:
+ self.subscription.close()
+ self._monitor_thread.join(timeout=1.0)
+
+
+def assert_message_order(received: list[bytes], expected: list[bytes]) -> bool:
+ """
+ Assert that messages were received in the expected order.
+
+ Args:
+ received: List of received messages
+ expected: List of expected messages in order
+
+ Returns:
+ bool: True if order matches expected
+ """
+ if len(received) != len(expected):
+ return False
+
+ for i, (recv_msg, exp_msg) in enumerate(zip(received, expected)):
+ if recv_msg != exp_msg:
+ _logger.error("Message order mismatch at index %s: expected %s, got %s", i, exp_msg, recv_msg)
+ return False
+
+ return True
+
+
+def measure_throughput(
+ operation: Callable[[], Any],
+ duration: float = 1.0,
+) -> tuple[float, int]:
+ """
+ Measure the throughput of an operation over a specified duration.
+
+ Args:
+ operation: The operation to measure
+ duration: Duration to run the operation in seconds
+
+ Returns:
+ tuple[float, int]: (operations per second, total operations)
+ """
+ start_time = time.time()
+ end_time = start_time + duration
+ count = 0
+
+ while time.time() < end_time:
+ try:
+ operation()
+ count += 1
+ except Exception:
+ _logger.exception("Operation failed")
+ break
+
+ elapsed = time.time() - start_time
+ ops_per_sec = count / elapsed if elapsed > 0 else 0.0
+
+ return ops_per_sec, count
+
+
+def wait_for_condition(
+ condition: Callable[[], bool],
+ timeout: float = 10.0,
+ interval: float = 0.1,
+) -> bool:
+ """
+ Wait for a condition to become true.
+
+ Args:
+ condition: Function that returns True when condition is met
+ timeout: Maximum time to wait in seconds
+ interval: Check interval in seconds
+
+ Returns:
+ bool: True if condition was met within timeout
+ """
+ deadline = time.time() + timeout
+
+ while time.time() < deadline:
+ if condition():
+ return True
+ time.sleep(interval)
+
+ return False
+
+
+def create_stress_test_messages(
+ count: int,
+ size: int = 100,
+) -> list[bytes]:
+ """
+ Create messages for stress testing.
+
+ Args:
+ count: Number of messages to create
+ size: Size of each message in bytes
+
+ Returns:
+ list[bytes]: List of test messages
+ """
+ messages = []
+ for i in range(count):
+ message = f"stress_test_msg_{i:06d}_".ljust(size, "x").encode()
+ messages.append(message)
+ return messages
+
+
+def validate_message_integrity(
+ original_messages: list[bytes],
+ received_messages: list[bytes],
+) -> dict[str, Any]:
+ """
+ Validate the integrity of received messages.
+
+ Args:
+ original_messages: Messages that were sent
+ received_messages: Messages that were received
+
+ Returns:
+ dict[str, Any]: Validation results
+ """
+ original_set = set(original_messages)
+ received_set = set(received_messages)
+
+ missing_messages = original_set - received_set
+ extra_messages = received_set - original_set
+
+ return {
+ "total_sent": len(original_messages),
+ "total_received": len(received_messages),
+ "missing_count": len(missing_messages),
+ "extra_count": len(extra_messages),
+ "missing_messages": list(missing_messages),
+ "extra_messages": list(extra_messages),
+ "integrity_ok": len(missing_messages) == 0 and len(extra_messages) == 0,
+ }
diff --git a/api/tests/test_containers_integration_tests/controllers/console/app/test_chat_conversation_status_count_api.py b/api/tests/test_containers_integration_tests/controllers/console/app/test_chat_conversation_status_count_api.py
new file mode 100644
index 0000000000..7fad603a6d
--- /dev/null
+++ b/api/tests/test_containers_integration_tests/controllers/console/app/test_chat_conversation_status_count_api.py
@@ -0,0 +1,166 @@
+"""TestContainers integration tests for ChatConversationApi status_count behavior."""
+
+import json
+import uuid
+
+from flask.testing import FlaskClient
+from sqlalchemy.orm import Session
+
+from configs import dify_config
+from constants import HEADER_NAME_CSRF_TOKEN
+from core.workflow.enums import WorkflowExecutionStatus
+from libs.datetime_utils import naive_utc_now
+from libs.token import _real_cookie_name, generate_csrf_token
+from models import Account, DifySetup, Tenant, TenantAccountJoin
+from models.account import AccountStatus, TenantAccountRole
+from models.enums import CreatorUserRole
+from models.model import App, AppMode, Conversation, Message
+from models.workflow import WorkflowRun
+from services.account_service import AccountService
+
+
+def _create_account_and_tenant(db_session: Session) -> tuple[Account, Tenant]:
+ account = Account(
+ email=f"test-{uuid.uuid4()}@example.com",
+ name="Test User",
+ interface_language="en-US",
+ status=AccountStatus.ACTIVE,
+ )
+ account.initialized_at = naive_utc_now()
+ db_session.add(account)
+ db_session.commit()
+
+ tenant = Tenant(name="Test Tenant", status="normal")
+ db_session.add(tenant)
+ db_session.commit()
+
+ join = TenantAccountJoin(
+ tenant_id=tenant.id,
+ account_id=account.id,
+ role=TenantAccountRole.OWNER,
+ current=True,
+ )
+ db_session.add(join)
+ db_session.commit()
+
+ account.set_tenant_id(tenant.id)
+ account.timezone = "UTC"
+ db_session.commit()
+
+ dify_setup = DifySetup(version=dify_config.project.version)
+ db_session.add(dify_setup)
+ db_session.commit()
+
+ return account, tenant
+
+
+def _create_app(db_session: Session, tenant_id: str, account_id: str) -> App:
+ app = App(
+ tenant_id=tenant_id,
+ name="Test Chat App",
+ mode=AppMode.CHAT,
+ enable_site=True,
+ enable_api=True,
+ created_by=account_id,
+ )
+ db_session.add(app)
+ db_session.commit()
+ return app
+
+
+def _create_conversation(db_session: Session, app_id: str, account_id: str) -> Conversation:
+ conversation = Conversation(
+ app_id=app_id,
+ name="Test Conversation",
+ inputs={},
+ status="normal",
+ mode=AppMode.CHAT,
+ from_source=CreatorUserRole.ACCOUNT,
+ from_account_id=account_id,
+ )
+ db_session.add(conversation)
+ db_session.commit()
+ return conversation
+
+
+def _create_workflow_run(db_session: Session, app_id: str, tenant_id: str, account_id: str) -> WorkflowRun:
+ workflow_run = WorkflowRun(
+ tenant_id=tenant_id,
+ app_id=app_id,
+ workflow_id=str(uuid.uuid4()),
+ type="chat",
+ triggered_from="app-run",
+ version="1.0.0",
+ graph=json.dumps({"nodes": [], "edges": []}),
+ inputs=json.dumps({"query": "test"}),
+ status=WorkflowExecutionStatus.PAUSED,
+ outputs=json.dumps({}),
+ elapsed_time=0.0,
+ total_tokens=0,
+ total_steps=0,
+ created_by_role=CreatorUserRole.ACCOUNT,
+ created_by=account_id,
+ created_at=naive_utc_now(),
+ )
+ db_session.add(workflow_run)
+ db_session.commit()
+ return workflow_run
+
+
+def _create_message(
+ db_session: Session, app_id: str, conversation_id: str, workflow_run_id: str, account_id: str
+) -> Message:
+ message = Message(
+ app_id=app_id,
+ conversation_id=conversation_id,
+ query="Hello",
+ message={"type": "text", "content": "Hello"},
+ answer="Hi there",
+ message_tokens=1,
+ answer_tokens=1,
+ message_unit_price=0.001,
+ answer_unit_price=0.001,
+ message_price_unit=0.001,
+ answer_price_unit=0.001,
+ currency="USD",
+ status="normal",
+ from_source=CreatorUserRole.ACCOUNT,
+ from_account_id=account_id,
+ workflow_run_id=workflow_run_id,
+ inputs={"query": "Hello"},
+ )
+ db_session.add(message)
+ db_session.commit()
+ return message
+
+
+def test_chat_conversation_status_count_includes_paused(
+ db_session_with_containers: Session,
+ test_client_with_containers: FlaskClient,
+):
+ account, tenant = _create_account_and_tenant(db_session_with_containers)
+ app = _create_app(db_session_with_containers, tenant.id, account.id)
+ conversation = _create_conversation(db_session_with_containers, app.id, account.id)
+ conversation_id = conversation.id
+ workflow_run = _create_workflow_run(db_session_with_containers, app.id, tenant.id, account.id)
+ _create_message(db_session_with_containers, app.id, conversation.id, workflow_run.id, account.id)
+
+ access_token = AccountService.get_account_jwt_token(account)
+ csrf_token = generate_csrf_token(account.id)
+ cookie_name = _real_cookie_name("csrf_token")
+
+ test_client_with_containers.set_cookie(cookie_name, csrf_token, domain="localhost")
+ response = test_client_with_containers.get(
+ f"/console/api/apps/{app.id}/chat-conversations",
+ headers={
+ "Authorization": f"Bearer {access_token}",
+ HEADER_NAME_CSRF_TOKEN: csrf_token,
+ },
+ )
+
+ assert response.status_code == 200
+ payload = response.get_json()
+ assert payload is not None
+ assert payload["total"] == 1
+ assert payload["data"][0]["id"] == conversation_id
+ assert payload["data"][0]["status_count"]["paused"] == 1
diff --git a/api/tests/test_containers_integration_tests/core/repositories/test_human_input_form_repository_impl.py b/api/tests/test_containers_integration_tests/core/repositories/test_human_input_form_repository_impl.py
new file mode 100644
index 0000000000..079e4934bb
--- /dev/null
+++ b/api/tests/test_containers_integration_tests/core/repositories/test_human_input_form_repository_impl.py
@@ -0,0 +1,240 @@
+"""TestContainers integration tests for HumanInputFormRepositoryImpl."""
+
+from __future__ import annotations
+
+from uuid import uuid4
+
+from sqlalchemy import Engine, select
+from sqlalchemy.orm import Session
+
+from core.repositories.human_input_repository import HumanInputFormRepositoryImpl
+from core.workflow.nodes.human_input.entities import (
+ DeliveryChannelConfig,
+ EmailDeliveryConfig,
+ EmailDeliveryMethod,
+ EmailRecipients,
+ ExternalRecipient,
+ FormDefinition,
+ HumanInputNodeData,
+ MemberRecipient,
+ UserAction,
+ WebAppDeliveryMethod,
+)
+from core.workflow.repositories.human_input_form_repository import FormCreateParams
+from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
+from models.human_input import (
+ EmailExternalRecipientPayload,
+ EmailMemberRecipientPayload,
+ HumanInputForm,
+ HumanInputFormRecipient,
+ RecipientType,
+)
+
+
+def _create_tenant_with_members(session: Session, member_emails: list[str]) -> tuple[Tenant, list[Account]]:
+ tenant = Tenant(name="Test Tenant", status="normal")
+ session.add(tenant)
+ session.flush()
+
+ members: list[Account] = []
+ for index, email in enumerate(member_emails):
+ account = Account(
+ email=email,
+ name=f"Member {index}",
+ interface_language="en-US",
+ status="active",
+ )
+ session.add(account)
+ session.flush()
+
+ tenant_join = TenantAccountJoin(
+ tenant_id=tenant.id,
+ account_id=account.id,
+ role=TenantAccountRole.NORMAL,
+ current=True,
+ )
+ session.add(tenant_join)
+ members.append(account)
+
+ session.commit()
+ return tenant, members
+
+
+def _build_form_params(delivery_methods: list[DeliveryChannelConfig]) -> FormCreateParams:
+ form_config = HumanInputNodeData(
+ title="Human Approval",
+ delivery_methods=delivery_methods,
+ form_content="Approve?
",
+ user_actions=[UserAction(id="approve", title="Approve")],
+ )
+ return FormCreateParams(
+ app_id=str(uuid4()),
+ workflow_execution_id=str(uuid4()),
+ node_id="human-input-node",
+ form_config=form_config,
+ rendered_content="Approve?
",
+ delivery_methods=delivery_methods,
+ display_in_ui=False,
+ resolved_default_values={},
+ )
+
+
+def _build_email_delivery(
+ whole_workspace: bool, recipients: list[MemberRecipient | ExternalRecipient]
+) -> EmailDeliveryMethod:
+ return EmailDeliveryMethod(
+ config=EmailDeliveryConfig(
+ recipients=EmailRecipients(whole_workspace=whole_workspace, items=recipients),
+ subject="Approval Needed",
+ body="Please review",
+ )
+ )
+
+
+class TestHumanInputFormRepositoryImplWithContainers:
+ def test_create_form_with_whole_workspace_recipients(self, db_session_with_containers: Session) -> None:
+ engine = db_session_with_containers.get_bind()
+ assert isinstance(engine, Engine)
+ tenant, members = _create_tenant_with_members(
+ db_session_with_containers,
+ member_emails=["member1@example.com", "member2@example.com"],
+ )
+
+ repository = HumanInputFormRepositoryImpl(session_factory=engine, tenant_id=tenant.id)
+ params = _build_form_params(
+ delivery_methods=[_build_email_delivery(whole_workspace=True, recipients=[])],
+ )
+
+ form_entity = repository.create_form(params)
+
+ with Session(engine) as verification_session:
+ recipients = verification_session.scalars(
+ select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id == form_entity.id)
+ ).all()
+
+ assert len(recipients) == len(members)
+ member_payloads = [
+ EmailMemberRecipientPayload.model_validate_json(recipient.recipient_payload)
+ for recipient in recipients
+ if recipient.recipient_type == RecipientType.EMAIL_MEMBER
+ ]
+ member_emails = {payload.email for payload in member_payloads}
+ assert member_emails == {member.email for member in members}
+
+ def test_create_form_with_specific_members_and_external(self, db_session_with_containers: Session) -> None:
+ engine = db_session_with_containers.get_bind()
+ assert isinstance(engine, Engine)
+ tenant, members = _create_tenant_with_members(
+ db_session_with_containers,
+ member_emails=["primary@example.com", "secondary@example.com"],
+ )
+
+ repository = HumanInputFormRepositoryImpl(session_factory=engine, tenant_id=tenant.id)
+ params = _build_form_params(
+ delivery_methods=[
+ _build_email_delivery(
+ whole_workspace=False,
+ recipients=[
+ MemberRecipient(user_id=members[0].id),
+ ExternalRecipient(email="external@example.com"),
+ ],
+ )
+ ],
+ )
+
+ form_entity = repository.create_form(params)
+
+ with Session(engine) as verification_session:
+ recipients = verification_session.scalars(
+ select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id == form_entity.id)
+ ).all()
+
+ member_recipient_payloads = [
+ EmailMemberRecipientPayload.model_validate_json(recipient.recipient_payload)
+ for recipient in recipients
+ if recipient.recipient_type == RecipientType.EMAIL_MEMBER
+ ]
+ assert len(member_recipient_payloads) == 1
+ assert member_recipient_payloads[0].user_id == members[0].id
+
+ external_payloads = [
+ EmailExternalRecipientPayload.model_validate_json(recipient.recipient_payload)
+ for recipient in recipients
+ if recipient.recipient_type == RecipientType.EMAIL_EXTERNAL
+ ]
+ assert len(external_payloads) == 1
+ assert external_payloads[0].email == "external@example.com"
+
+ def test_create_form_persists_default_values(self, db_session_with_containers: Session) -> None:
+ engine = db_session_with_containers.get_bind()
+ assert isinstance(engine, Engine)
+ tenant, _ = _create_tenant_with_members(
+ db_session_with_containers,
+ member_emails=["prefill@example.com"],
+ )
+
+ repository = HumanInputFormRepositoryImpl(session_factory=engine, tenant_id=tenant.id)
+ resolved_values = {"greeting": "Hello!"}
+ params = FormCreateParams(
+ app_id=str(uuid4()),
+ workflow_execution_id=str(uuid4()),
+ node_id="human-input-node",
+ form_config=HumanInputNodeData(
+ title="Human Approval",
+ form_content="Approve?
",
+ inputs=[],
+ user_actions=[UserAction(id="approve", title="Approve")],
+ ),
+ rendered_content="Approve?
",
+ delivery_methods=[],
+ display_in_ui=False,
+ resolved_default_values=resolved_values,
+ )
+
+ form_entity = repository.create_form(params)
+
+ with Session(engine) as verification_session:
+ form_model = verification_session.scalars(
+ select(HumanInputForm).where(HumanInputForm.id == form_entity.id)
+ ).first()
+
+ assert form_model is not None
+ definition = FormDefinition.model_validate_json(form_model.form_definition)
+ assert definition.default_values == resolved_values
+
+ def test_create_form_persists_display_in_ui(self, db_session_with_containers: Session) -> None:
+ engine = db_session_with_containers.get_bind()
+ assert isinstance(engine, Engine)
+ tenant, _ = _create_tenant_with_members(
+ db_session_with_containers,
+ member_emails=["ui@example.com"],
+ )
+
+ repository = HumanInputFormRepositoryImpl(session_factory=engine, tenant_id=tenant.id)
+ params = FormCreateParams(
+ app_id=str(uuid4()),
+ workflow_execution_id=str(uuid4()),
+ node_id="human-input-node",
+ form_config=HumanInputNodeData(
+ title="Human Approval",
+ form_content="Approve?
",
+ inputs=[],
+ user_actions=[UserAction(id="approve", title="Approve")],
+ delivery_methods=[WebAppDeliveryMethod()],
+ ),
+ rendered_content="Approve?
",
+ delivery_methods=[WebAppDeliveryMethod()],
+ display_in_ui=True,
+ resolved_default_values={},
+ )
+
+ form_entity = repository.create_form(params)
+
+ with Session(engine) as verification_session:
+ form_model = verification_session.scalars(
+ select(HumanInputForm).where(HumanInputForm.id == form_entity.id)
+ ).first()
+
+ assert form_model is not None
+ definition = FormDefinition.model_validate_json(form_model.form_definition)
+ assert definition.display_in_ui is True
diff --git a/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py b/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py
new file mode 100644
index 0000000000..06d55177eb
--- /dev/null
+++ b/api/tests/test_containers_integration_tests/core/workflow/test_human_input_resume_node_execution.py
@@ -0,0 +1,336 @@
+import time
+import uuid
+from datetime import timedelta
+from unittest.mock import MagicMock
+
+import pytest
+from sqlalchemy import delete, select
+from sqlalchemy.orm import Session
+
+from core.app.app_config.entities import WorkflowUIBasedAppConfig
+from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
+from core.app.workflow.layers import PersistenceWorkflowInfo, WorkflowPersistenceLayer
+from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository
+from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository
+from core.workflow.entities import GraphInitParams
+from core.workflow.enums import WorkflowType
+from core.workflow.graph import Graph
+from core.workflow.graph_engine.command_channels.in_memory_channel import InMemoryChannel
+from core.workflow.graph_engine.graph_engine import GraphEngine
+from core.workflow.nodes.end.end_node import EndNode
+from core.workflow.nodes.end.entities import EndNodeData
+from core.workflow.nodes.human_input.entities import HumanInputNodeData, UserAction
+from core.workflow.nodes.human_input.enums import HumanInputFormStatus
+from core.workflow.nodes.human_input.human_input_node import HumanInputNode
+from core.workflow.nodes.start.entities import StartNodeData
+from core.workflow.nodes.start.start_node import StartNode
+from core.workflow.repositories.human_input_form_repository import HumanInputFormEntity, HumanInputFormRepository
+from core.workflow.runtime import GraphRuntimeState, VariablePool
+from core.workflow.system_variable import SystemVariable
+from libs.datetime_utils import naive_utc_now
+from models import Account
+from models.account import Tenant, TenantAccountJoin, TenantAccountRole
+from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
+from models.model import App, AppMode, IconType
+from models.workflow import Workflow, WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom, WorkflowRun
+
+
+def _mock_form_repository_without_submission() -> HumanInputFormRepository:
+ repo = MagicMock(spec=HumanInputFormRepository)
+ form_entity = MagicMock(spec=HumanInputFormEntity)
+ form_entity.id = "test-form-id"
+ form_entity.web_app_token = "test-form-token"
+ form_entity.recipients = []
+ form_entity.rendered_content = "rendered"
+ form_entity.submitted = False
+ repo.create_form.return_value = form_entity
+ repo.get_form.return_value = None
+ return repo
+
+
+def _mock_form_repository_with_submission(action_id: str) -> HumanInputFormRepository:
+ repo = MagicMock(spec=HumanInputFormRepository)
+ form_entity = MagicMock(spec=HumanInputFormEntity)
+ form_entity.id = "test-form-id"
+ form_entity.web_app_token = "test-form-token"
+ form_entity.recipients = []
+ form_entity.rendered_content = "rendered"
+ form_entity.submitted = True
+ form_entity.selected_action_id = action_id
+ form_entity.submitted_data = {}
+ form_entity.status = HumanInputFormStatus.WAITING
+ form_entity.expiration_time = naive_utc_now() + timedelta(hours=1)
+ repo.get_form.return_value = form_entity
+ return repo
+
+
+def _build_runtime_state(workflow_execution_id: str, app_id: str, workflow_id: str, user_id: str) -> GraphRuntimeState:
+ variable_pool = VariablePool(
+ system_variables=SystemVariable(
+ workflow_execution_id=workflow_execution_id,
+ app_id=app_id,
+ workflow_id=workflow_id,
+ user_id=user_id,
+ ),
+ user_inputs={},
+ conversation_variables=[],
+ )
+ return GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
+
+
+def _build_graph(
+ runtime_state: GraphRuntimeState,
+ tenant_id: str,
+ app_id: str,
+ workflow_id: str,
+ user_id: str,
+ form_repository: HumanInputFormRepository,
+) -> Graph:
+ graph_config: dict[str, object] = {"nodes": [], "edges": []}
+ params = GraphInitParams(
+ tenant_id=tenant_id,
+ app_id=app_id,
+ workflow_id=workflow_id,
+ graph_config=graph_config,
+ user_id=user_id,
+ user_from="account",
+ invoke_from="debugger",
+ call_depth=0,
+ )
+
+ start_data = StartNodeData(title="start", variables=[])
+ start_node = StartNode(
+ id="start",
+ config={"id": "start", "data": start_data.model_dump()},
+ graph_init_params=params,
+ graph_runtime_state=runtime_state,
+ )
+
+ human_data = HumanInputNodeData(
+ title="human",
+ form_content="Awaiting human input",
+ inputs=[],
+ user_actions=[
+ UserAction(id="continue", title="Continue"),
+ ],
+ )
+ human_node = HumanInputNode(
+ id="human",
+ config={"id": "human", "data": human_data.model_dump()},
+ graph_init_params=params,
+ graph_runtime_state=runtime_state,
+ form_repository=form_repository,
+ )
+
+ end_data = EndNodeData(
+ title="end",
+ outputs=[],
+ desc=None,
+ )
+ end_node = EndNode(
+ id="end",
+ config={"id": "end", "data": end_data.model_dump()},
+ graph_init_params=params,
+ graph_runtime_state=runtime_state,
+ )
+
+ return (
+ Graph.new()
+ .add_root(start_node)
+ .add_node(human_node)
+ .add_node(end_node, from_node_id="human", source_handle="continue")
+ .build()
+ )
+
+
+def _build_generate_entity(
+ tenant_id: str,
+ app_id: str,
+ workflow_id: str,
+ workflow_execution_id: str,
+ user_id: str,
+) -> WorkflowAppGenerateEntity:
+ app_config = WorkflowUIBasedAppConfig(
+ tenant_id=tenant_id,
+ app_id=app_id,
+ app_mode=AppMode.WORKFLOW,
+ workflow_id=workflow_id,
+ )
+ return WorkflowAppGenerateEntity(
+ task_id=str(uuid.uuid4()),
+ app_config=app_config,
+ inputs={},
+ files=[],
+ user_id=user_id,
+ stream=False,
+ invoke_from=InvokeFrom.DEBUGGER,
+ workflow_execution_id=workflow_execution_id,
+ )
+
+
+class TestHumanInputResumeNodeExecutionIntegration:
+ @pytest.fixture(autouse=True)
+ def setup_test_data(self, db_session_with_containers: Session):
+ tenant = Tenant(
+ name="Test Tenant",
+ status="normal",
+ )
+ db_session_with_containers.add(tenant)
+ db_session_with_containers.commit()
+
+ account = Account(
+ email="test@example.com",
+ name="Test User",
+ interface_language="en-US",
+ status="active",
+ )
+ db_session_with_containers.add(account)
+ db_session_with_containers.commit()
+
+ tenant_join = TenantAccountJoin(
+ tenant_id=tenant.id,
+ account_id=account.id,
+ role=TenantAccountRole.OWNER,
+ current=True,
+ )
+ db_session_with_containers.add(tenant_join)
+ db_session_with_containers.commit()
+
+ account.current_tenant = tenant
+
+ app = App(
+ tenant_id=tenant.id,
+ name="Test App",
+ description="",
+ mode=AppMode.WORKFLOW.value,
+ icon_type=IconType.EMOJI.value,
+ icon="rocket",
+ icon_background="#4ECDC4",
+ enable_site=False,
+ enable_api=False,
+ api_rpm=0,
+ api_rph=0,
+ is_demo=False,
+ is_public=False,
+ is_universal=False,
+ max_active_requests=None,
+ created_by=account.id,
+ updated_by=account.id,
+ )
+ db_session_with_containers.add(app)
+ db_session_with_containers.commit()
+
+ workflow = Workflow(
+ tenant_id=tenant.id,
+ app_id=app.id,
+ type="workflow",
+ version="draft",
+ graph='{"nodes": [], "edges": []}',
+ features='{"file_upload": {"enabled": false}}',
+ created_by=account.id,
+ created_at=naive_utc_now(),
+ )
+ db_session_with_containers.add(workflow)
+ db_session_with_containers.commit()
+
+ self.session = db_session_with_containers
+ self.tenant = tenant
+ self.account = account
+ self.app = app
+ self.workflow = workflow
+
+ yield
+
+ self.session.execute(delete(WorkflowNodeExecutionModel))
+ self.session.execute(delete(WorkflowRun))
+ self.session.execute(delete(Workflow).where(Workflow.id == self.workflow.id))
+ self.session.execute(delete(App).where(App.id == self.app.id))
+ self.session.execute(delete(TenantAccountJoin).where(TenantAccountJoin.tenant_id == self.tenant.id))
+ self.session.execute(delete(Account).where(Account.id == self.account.id))
+ self.session.execute(delete(Tenant).where(Tenant.id == self.tenant.id))
+ self.session.commit()
+
+ def _build_persistence_layer(self, execution_id: str) -> WorkflowPersistenceLayer:
+ generate_entity = _build_generate_entity(
+ tenant_id=self.tenant.id,
+ app_id=self.app.id,
+ workflow_id=self.workflow.id,
+ workflow_execution_id=execution_id,
+ user_id=self.account.id,
+ )
+ execution_repo = SQLAlchemyWorkflowExecutionRepository(
+ session_factory=self.session.get_bind(),
+ user=self.account,
+ app_id=self.app.id,
+ triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
+ )
+ node_execution_repo = SQLAlchemyWorkflowNodeExecutionRepository(
+ session_factory=self.session.get_bind(),
+ user=self.account,
+ app_id=self.app.id,
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
+ )
+ return WorkflowPersistenceLayer(
+ application_generate_entity=generate_entity,
+ workflow_info=PersistenceWorkflowInfo(
+ workflow_id=self.workflow.id,
+ workflow_type=WorkflowType.WORKFLOW,
+ version=self.workflow.version,
+ graph_data=self.workflow.graph_dict,
+ ),
+ workflow_execution_repository=execution_repo,
+ workflow_node_execution_repository=node_execution_repo,
+ )
+
+ def _run_graph(self, graph: Graph, runtime_state: GraphRuntimeState, execution_id: str) -> None:
+ engine = GraphEngine(
+ workflow_id=self.workflow.id,
+ graph=graph,
+ graph_runtime_state=runtime_state,
+ command_channel=InMemoryChannel(),
+ )
+ engine.layer(self._build_persistence_layer(execution_id))
+ for _ in engine.run():
+ continue
+
+ def test_resume_human_input_does_not_create_duplicate_node_execution(self):
+ execution_id = str(uuid.uuid4())
+ runtime_state = _build_runtime_state(
+ workflow_execution_id=execution_id,
+ app_id=self.app.id,
+ workflow_id=self.workflow.id,
+ user_id=self.account.id,
+ )
+ pause_repo = _mock_form_repository_without_submission()
+ paused_graph = _build_graph(
+ runtime_state,
+ self.tenant.id,
+ self.app.id,
+ self.workflow.id,
+ self.account.id,
+ pause_repo,
+ )
+ self._run_graph(paused_graph, runtime_state, execution_id)
+
+ snapshot = runtime_state.dumps()
+ resumed_state = GraphRuntimeState.from_snapshot(snapshot)
+ resume_repo = _mock_form_repository_with_submission(action_id="continue")
+ resumed_graph = _build_graph(
+ resumed_state,
+ self.tenant.id,
+ self.app.id,
+ self.workflow.id,
+ self.account.id,
+ resume_repo,
+ )
+ self._run_graph(resumed_graph, resumed_state, execution_id)
+
+ stmt = select(WorkflowNodeExecutionModel).where(
+ WorkflowNodeExecutionModel.workflow_run_id == execution_id,
+ WorkflowNodeExecutionModel.node_id == "human",
+ )
+ records = self.session.execute(stmt).scalars().all()
+ assert len(records) == 1
+ assert records[0].status != "paused"
+ assert records[0].triggered_from == WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN
+ assert records[0].created_by_role == CreatorUserRole.ACCOUNT
diff --git a/api/tests/test_containers_integration_tests/helpers/__init__.py b/api/tests/test_containers_integration_tests/helpers/__init__.py
new file mode 100644
index 0000000000..40d03889a9
--- /dev/null
+++ b/api/tests/test_containers_integration_tests/helpers/__init__.py
@@ -0,0 +1 @@
+"""Helper utilities for integration tests."""
diff --git a/api/tests/test_containers_integration_tests/helpers/execution_extra_content.py b/api/tests/test_containers_integration_tests/helpers/execution_extra_content.py
new file mode 100644
index 0000000000..19d7772c39
--- /dev/null
+++ b/api/tests/test_containers_integration_tests/helpers/execution_extra_content.py
@@ -0,0 +1,154 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from decimal import Decimal
+from uuid import uuid4
+
+from core.workflow.nodes.human_input.entities import FormDefinition, UserAction
+from models.account import Account, Tenant, TenantAccountJoin
+from models.execution_extra_content import HumanInputContent
+from models.human_input import HumanInputForm, HumanInputFormStatus
+from models.model import App, Conversation, Message
+
+
+@dataclass
+class HumanInputMessageFixture:
+ app: App
+ account: Account
+ conversation: Conversation
+ message: Message
+ form: HumanInputForm
+ action_id: str
+ action_text: str
+ node_title: str
+
+
+def create_human_input_message_fixture(db_session) -> HumanInputMessageFixture:
+ tenant = Tenant(name=f"Tenant {uuid4()}")
+ db_session.add(tenant)
+ db_session.flush()
+
+ account = Account(
+ name=f"Account {uuid4()}",
+ email=f"human_input_{uuid4()}@example.com",
+ password="hashed-password",
+ password_salt="salt",
+ interface_language="en-US",
+ timezone="UTC",
+ )
+ db_session.add(account)
+ db_session.flush()
+
+ tenant_join = TenantAccountJoin(
+ tenant_id=tenant.id,
+ account_id=account.id,
+ role="owner",
+ current=True,
+ )
+ db_session.add(tenant_join)
+ db_session.flush()
+
+ app = App(
+ tenant_id=tenant.id,
+ name=f"App {uuid4()}",
+ description="",
+ mode="chat",
+ icon_type="emoji",
+ icon="🤖",
+ icon_background="#FFFFFF",
+ enable_site=False,
+ enable_api=True,
+ api_rpm=100,
+ api_rph=100,
+ is_demo=False,
+ is_public=False,
+ is_universal=False,
+ created_by=account.id,
+ updated_by=account.id,
+ )
+ db_session.add(app)
+ db_session.flush()
+
+ conversation = Conversation(
+ app_id=app.id,
+ mode="chat",
+ name="Test Conversation",
+ summary="",
+ introduction="",
+ system_instruction="",
+ status="normal",
+ invoke_from="console",
+ from_source="console",
+ from_account_id=account.id,
+ from_end_user_id=None,
+ )
+ conversation.inputs = {}
+ db_session.add(conversation)
+ db_session.flush()
+
+ workflow_run_id = str(uuid4())
+ message = Message(
+ app_id=app.id,
+ conversation_id=conversation.id,
+ inputs={},
+ query="Human input query",
+ message={"messages": []},
+ answer="Human input answer",
+ message_tokens=50,
+ message_unit_price=Decimal("0.001"),
+ answer_tokens=80,
+ answer_unit_price=Decimal("0.001"),
+ provider_response_latency=0.5,
+ currency="USD",
+ from_source="console",
+ from_account_id=account.id,
+ workflow_run_id=workflow_run_id,
+ )
+ db_session.add(message)
+ db_session.flush()
+
+ action_id = "approve"
+ action_text = "Approve request"
+ node_title = "Approval"
+ form_definition = FormDefinition(
+ form_content="content",
+ inputs=[],
+ user_actions=[UserAction(id=action_id, title=action_text)],
+ rendered_content="Rendered block",
+ expiration_time=datetime.utcnow() + timedelta(days=1),
+ node_title=node_title,
+ display_in_ui=True,
+ )
+ form = HumanInputForm(
+ tenant_id=tenant.id,
+ app_id=app.id,
+ workflow_run_id=workflow_run_id,
+ node_id="node-id",
+ form_definition=form_definition.model_dump_json(),
+ rendered_content="Rendered block",
+ status=HumanInputFormStatus.SUBMITTED,
+ expiration_time=datetime.utcnow() + timedelta(days=1),
+ selected_action_id=action_id,
+ )
+ db_session.add(form)
+ db_session.flush()
+
+ content = HumanInputContent(
+ workflow_run_id=workflow_run_id,
+ message_id=message.id,
+ form_id=form.id,
+ )
+ db_session.add(content)
+ db_session.commit()
+
+ return HumanInputMessageFixture(
+ app=app,
+ account=account,
+ conversation=conversation,
+ message=message,
+ form=form,
+ action_id=action_id,
+ action_text=action_text,
+ node_title=node_title,
+ )
diff --git a/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_sharded_channel.py b/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_sharded_channel.py
index d612e70910..43915a204d 100644
--- a/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_sharded_channel.py
+++ b/api/tests/test_containers_integration_tests/libs/broadcast_channel/redis/test_sharded_channel.py
@@ -16,6 +16,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
import pytest
import redis
+from redis.cluster import RedisCluster
from testcontainers.redis import RedisContainer
from libs.broadcast_channel.channel import BroadcastChannel, Subscription, Topic
@@ -332,3 +333,95 @@ class TestShardedRedisBroadcastChannelIntegration:
# Verify subscriptions are cleaned up
topic_subscribers_after = self._get_sharded_numsub(redis_client, topic_name)
assert topic_subscribers_after == 0
+
+
+class TestShardedRedisBroadcastChannelClusterIntegration:
+ """Integration tests for sharded pub/sub with RedisCluster client."""
+
+ @pytest.fixture(scope="class")
+ def redis_cluster_container(self) -> Iterator[RedisContainer]:
+ """Create a Redis 7 container with cluster mode enabled."""
+ command = (
+ "redis-server --port 6379 "
+ "--cluster-enabled yes "
+ "--cluster-config-file nodes.conf "
+ "--cluster-node-timeout 5000 "
+ "--appendonly no "
+ "--protected-mode no"
+ )
+ with RedisContainer(image="redis:7-alpine").with_command(command) as container:
+ yield container
+
+ @classmethod
+ def _get_test_topic_name(cls) -> str:
+ return f"test_sharded_cluster_topic_{uuid.uuid4()}"
+
+ @staticmethod
+ def _ensure_single_node_cluster(host: str, port: int) -> None:
+ client = redis.Redis(host=host, port=port, decode_responses=False)
+ client.config_set("cluster-announce-ip", host)
+ client.config_set("cluster-announce-port", port)
+ slots = client.execute_command("CLUSTER", "SLOTS")
+ if not slots:
+ client.execute_command("CLUSTER", "ADDSLOTSRANGE", 0, 16383)
+
+ deadline = time.time() + 5.0
+ while time.time() < deadline:
+ info = client.execute_command("CLUSTER", "INFO")
+ info_text = info.decode("utf-8") if isinstance(info, (bytes, bytearray)) else str(info)
+ if "cluster_state:ok" in info_text:
+ return
+ time.sleep(0.05)
+ raise RuntimeError("Redis cluster did not become ready in time")
+
+ @pytest.fixture(scope="class")
+ def redis_cluster_client(self, redis_cluster_container: RedisContainer) -> RedisCluster:
+ host = redis_cluster_container.get_container_host_ip()
+ port = int(redis_cluster_container.get_exposed_port(6379))
+ self._ensure_single_node_cluster(host, port)
+ return RedisCluster(host=host, port=port, decode_responses=False)
+
+ @pytest.fixture
+ def broadcast_channel(self, redis_cluster_client: RedisCluster) -> BroadcastChannel:
+ return ShardedRedisBroadcastChannel(redis_cluster_client)
+
+ def test_cluster_sharded_pubsub_delivers_message(self, broadcast_channel: BroadcastChannel):
+ """Ensure sharded subscription receives messages when using RedisCluster client."""
+ topic_name = self._get_test_topic_name()
+ message = b"cluster sharded message"
+
+ topic = broadcast_channel.topic(topic_name)
+ producer = topic.as_producer()
+ subscription = topic.subscribe()
+ ready_event = threading.Event()
+
+ def consumer_thread() -> list[bytes]:
+ received = []
+ try:
+ _ = subscription.receive(0.01)
+ except SubscriptionClosedError:
+ return received
+ ready_event.set()
+ deadline = time.time() + 5.0
+ while time.time() < deadline:
+ msg = subscription.receive(timeout=0.1)
+ if msg is None:
+ continue
+ received.append(msg)
+ break
+ subscription.close()
+ return received
+
+ def producer_thread():
+ if not ready_event.wait(timeout=2.0):
+ pytest.fail("subscriber did not become ready before publish")
+ producer.publish(message)
+
+ with ThreadPoolExecutor(max_workers=2) as executor:
+ consumer_future = executor.submit(consumer_thread)
+ producer_future = executor.submit(producer_thread)
+
+ producer_future.result(timeout=5.0)
+ received_messages = consumer_future.result(timeout=5.0)
+
+ assert received_messages == [message]
diff --git a/api/tests/test_containers_integration_tests/libs/test_rate_limiter_integration.py b/api/tests/test_containers_integration_tests/libs/test_rate_limiter_integration.py
new file mode 100644
index 0000000000..178fc2e4fb
--- /dev/null
+++ b/api/tests/test_containers_integration_tests/libs/test_rate_limiter_integration.py
@@ -0,0 +1,25 @@
+"""
+Integration tests for RateLimiter using testcontainers Redis.
+"""
+
+import uuid
+
+import pytest
+
+from extensions.ext_redis import redis_client
+from libs import helper as helper_module
+
+
+@pytest.mark.usefixtures("flask_app_with_containers")
+def test_rate_limiter_counts_multiple_attempts_in_same_second(monkeypatch):
+ prefix = f"test_rate_limit:{uuid.uuid4().hex}"
+ limiter = helper_module.RateLimiter(prefix=prefix, max_attempts=2, time_window=60)
+ key = limiter._get_key("203.0.113.10")
+
+ redis_client.delete(key)
+ monkeypatch.setattr(helper_module.time, "time", lambda: 1_700_000_000)
+
+ limiter.increment_rate_limit("203.0.113.10")
+ limiter.increment_rate_limit("203.0.113.10")
+
+ assert limiter.is_rate_limited("203.0.113.10") is True
diff --git a/api/tests/test_containers_integration_tests/models/test_account.py b/api/tests/test_containers_integration_tests/models/test_account.py
new file mode 100644
index 0000000000..078dc0e8de
--- /dev/null
+++ b/api/tests/test_containers_integration_tests/models/test_account.py
@@ -0,0 +1,79 @@
+# import secrets
+
+# import pytest
+# from sqlalchemy import select
+# from sqlalchemy.orm import Session
+# from sqlalchemy.orm.exc import DetachedInstanceError
+
+# from libs.datetime_utils import naive_utc_now
+# from models.account import Account, Tenant, TenantAccountJoin
+
+
+# @pytest.fixture
+# def session(db_session_with_containers):
+# with Session(db_session_with_containers.get_bind()) as session:
+# yield session
+
+
+# @pytest.fixture
+# def account(session):
+# account = Account(
+# name="test account",
+# email=f"test_{secrets.token_hex(8)}@example.com",
+# )
+# session.add(account)
+# session.commit()
+# return account
+
+
+# @pytest.fixture
+# def tenant(session):
+# tenant = Tenant(name="test tenant")
+# session.add(tenant)
+# session.commit()
+# return tenant
+
+
+# @pytest.fixture
+# def tenant_account_join(session, account, tenant):
+# tenant_join = TenantAccountJoin(account_id=account.id, tenant_id=tenant.id)
+# session.add(tenant_join)
+# session.commit()
+# yield tenant_join
+# session.delete(tenant_join)
+# session.commit()
+
+
+# class TestAccountTenant:
+# def test_set_current_tenant_should_reload_tenant(
+# self,
+# db_session_with_containers,
+# account,
+# tenant,
+# tenant_account_join,
+# ):
+# with Session(db_session_with_containers.get_bind(), expire_on_commit=True) as session:
+# scoped_tenant = session.scalars(select(Tenant).where(Tenant.id == tenant.id)).one()
+# account.current_tenant = scoped_tenant
+# scoped_tenant.created_at = naive_utc_now()
+# # session.commit()
+
+# # Ensure the tenant used in assignment is detached.
+# with pytest.raises(DetachedInstanceError):
+# _ = scoped_tenant.name
+
+# assert account._current_tenant.id == tenant.id
+# assert account._current_tenant.id == tenant.id
+
+# def test_set_tenant_id_should_load_tenant_as_not_expire(
+# self,
+# flask_app_with_containers,
+# account,
+# tenant,
+# tenant_account_join,
+# ):
+# with flask_app_with_containers.test_request_context():
+# account.set_tenant_id(tenant.id)
+
+# assert account._current_tenant.id == tenant.id
+# assert account._current_tenant.id == tenant.id
diff --git a/api/tests/test_containers_integration_tests/repositories/test_execution_extra_content_repository.py b/api/tests/test_containers_integration_tests/repositories/test_execution_extra_content_repository.py
new file mode 100644
index 0000000000..c9058626d1
--- /dev/null
+++ b/api/tests/test_containers_integration_tests/repositories/test_execution_extra_content_repository.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from sqlalchemy.orm import sessionmaker
+
+from extensions.ext_database import db
+from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository
+from tests.test_containers_integration_tests.helpers.execution_extra_content import (
+ create_human_input_message_fixture,
+)
+
+
+def test_get_by_message_ids_returns_human_input_content(db_session_with_containers):
+ fixture = create_human_input_message_fixture(db_session_with_containers)
+ repository = SQLAlchemyExecutionExtraContentRepository(
+ session_maker=sessionmaker(bind=db.engine, expire_on_commit=False)
+ )
+
+ results = repository.get_by_message_ids([fixture.message.id])
+
+ assert len(results) == 1
+ assert len(results[0]) == 1
+ content = results[0][0]
+ assert content.submitted is True
+ assert content.form_submission_data is not None
+ assert content.form_submission_data.action_id == fixture.action_id
+ assert content.form_submission_data.action_text == fixture.action_text
+ assert content.form_submission_data.rendered_content == fixture.form.rendered_content
diff --git a/api/tests/test_containers_integration_tests/services/test_account_service.py b/api/tests/test_containers_integration_tests/services/test_account_service.py
index 4d4e77a802..4b6b5048a1 100644
--- a/api/tests/test_containers_integration_tests/services/test_account_service.py
+++ b/api/tests/test_containers_integration_tests/services/test_account_service.py
@@ -2293,6 +2293,12 @@ class TestRegisterService:
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
+ from extensions.ext_database import db
+ from models.model import DifySetup
+
+ db.session.query(DifySetup).delete()
+ db.session.commit()
+
# Execute setup
RegisterService.setup(
email=admin_email,
@@ -2303,9 +2309,7 @@ class TestRegisterService:
)
# Verify account was created
- from extensions.ext_database import db
from models import Account
- from models.model import DifySetup
account = db.session.query(Account).filter_by(email=admin_email).first()
assert account is not None
diff --git a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py
index 476f58585d..81bfa0ea20 100644
--- a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py
+++ b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py
@@ -1,5 +1,5 @@
import uuid
-from unittest.mock import MagicMock, patch
+from unittest.mock import ANY, MagicMock, patch
import pytest
from faker import Faker
@@ -26,6 +26,7 @@ class TestAppGenerateService:
patch("services.app_generate_service.AgentChatAppGenerator") as mock_agent_chat_generator,
patch("services.app_generate_service.AdvancedChatAppGenerator") as mock_advanced_chat_generator,
patch("services.app_generate_service.WorkflowAppGenerator") as mock_workflow_generator,
+ patch("services.app_generate_service.MessageBasedAppGenerator") as mock_message_based_generator,
patch("services.account_service.FeatureService") as mock_account_feature_service,
patch("services.app_generate_service.dify_config") as mock_dify_config,
patch("configs.dify_config") as mock_global_dify_config,
@@ -38,9 +39,13 @@ class TestAppGenerateService:
# Setup default mock returns for workflow service
mock_workflow_service_instance = mock_workflow_service.return_value
- mock_workflow_service_instance.get_published_workflow.return_value = MagicMock(spec=Workflow)
- mock_workflow_service_instance.get_draft_workflow.return_value = MagicMock(spec=Workflow)
- mock_workflow_service_instance.get_published_workflow_by_id.return_value = MagicMock(spec=Workflow)
+ mock_published_workflow = MagicMock(spec=Workflow)
+ mock_published_workflow.id = str(uuid.uuid4())
+ mock_workflow_service_instance.get_published_workflow.return_value = mock_published_workflow
+ mock_draft_workflow = MagicMock(spec=Workflow)
+ mock_draft_workflow.id = str(uuid.uuid4())
+ mock_workflow_service_instance.get_draft_workflow.return_value = mock_draft_workflow
+ mock_workflow_service_instance.get_published_workflow_by_id.return_value = mock_published_workflow
# Setup default mock returns for rate limiting
mock_rate_limit_instance = mock_rate_limit.return_value
@@ -66,6 +71,8 @@ class TestAppGenerateService:
mock_advanced_chat_generator_instance.generate.return_value = ["advanced_chat_response"]
mock_advanced_chat_generator_instance.single_iteration_generate.return_value = ["single_iteration_response"]
mock_advanced_chat_generator_instance.single_loop_generate.return_value = ["single_loop_response"]
+ mock_advanced_chat_generator_instance.retrieve_events.return_value = ["advanced_chat_events"]
+ mock_advanced_chat_generator_instance.convert_to_event_stream.return_value = ["advanced_chat_stream"]
mock_advanced_chat_generator.convert_to_event_stream.return_value = ["advanced_chat_stream"]
mock_workflow_generator_instance = mock_workflow_generator.return_value
@@ -76,6 +83,8 @@ class TestAppGenerateService:
mock_workflow_generator_instance.single_loop_generate.return_value = ["workflow_single_loop_response"]
mock_workflow_generator.convert_to_event_stream.return_value = ["workflow_stream"]
+ mock_message_based_generator.retrieve_events.return_value = ["workflow_events"]
+
# Setup default mock returns for account service
mock_account_feature_service.get_system_features.return_value.is_allow_register = True
@@ -88,6 +97,7 @@ class TestAppGenerateService:
mock_global_dify_config.BILLING_ENABLED = False
mock_global_dify_config.APP_MAX_ACTIVE_REQUESTS = 100
mock_global_dify_config.APP_DAILY_RATE_LIMIT = 1000
+ mock_global_dify_config.HOSTED_POOL_CREDITS = 1000
yield {
"billing_service": mock_billing_service,
@@ -98,6 +108,7 @@ class TestAppGenerateService:
"agent_chat_generator": mock_agent_chat_generator,
"advanced_chat_generator": mock_advanced_chat_generator,
"workflow_generator": mock_workflow_generator,
+ "message_based_generator": mock_message_based_generator,
"account_feature_service": mock_account_feature_service,
"dify_config": mock_dify_config,
"global_dify_config": mock_global_dify_config,
@@ -280,8 +291,10 @@ class TestAppGenerateService:
assert result == ["test_response"]
# Verify advanced chat generator was called
- mock_external_service_dependencies["advanced_chat_generator"].return_value.generate.assert_called_once()
- mock_external_service_dependencies["advanced_chat_generator"].convert_to_event_stream.assert_called_once()
+ mock_external_service_dependencies["advanced_chat_generator"].return_value.retrieve_events.assert_called_once()
+ mock_external_service_dependencies[
+ "advanced_chat_generator"
+ ].return_value.convert_to_event_stream.assert_called_once()
def test_generate_workflow_mode_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
@@ -304,7 +317,7 @@ class TestAppGenerateService:
assert result == ["test_response"]
# Verify workflow generator was called
- mock_external_service_dependencies["workflow_generator"].return_value.generate.assert_called_once()
+ mock_external_service_dependencies["message_based_generator"].retrieve_events.assert_called_once()
mock_external_service_dependencies["workflow_generator"].convert_to_event_stream.assert_called_once()
def test_generate_with_specific_workflow_id(self, db_session_with_containers, mock_external_service_dependencies):
@@ -970,14 +983,27 @@ class TestAppGenerateService:
}
# Execute the method under test
- result = AppGenerateService.generate(
- app_model=app, user=account, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=True
- )
+ with patch("services.app_generate_service.AppExecutionParams") as mock_exec_params:
+ mock_payload = MagicMock()
+ mock_payload.workflow_run_id = fake.uuid4()
+ mock_payload.model_dump_json.return_value = "{}"
+ mock_exec_params.new.return_value = mock_payload
+
+ result = AppGenerateService.generate(
+ app_model=app, user=account, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=True
+ )
# Verify the result
assert result == ["test_response"]
- # Verify workflow generator was called with complex args
- mock_external_service_dependencies["workflow_generator"].return_value.generate.assert_called_once()
- call_args = mock_external_service_dependencies["workflow_generator"].return_value.generate.call_args
- assert call_args[1]["args"] == args
+ # Verify payload was built with complex args
+ mock_exec_params.new.assert_called_once()
+ call_kwargs = mock_exec_params.new.call_args.kwargs
+ assert call_kwargs["args"] == args
+
+ # Verify workflow streaming event retrieval was used
+ mock_external_service_dependencies["message_based_generator"].retrieve_events.assert_called_once_with(
+ ANY,
+ mock_payload.workflow_run_id,
+ on_subscribe=ANY,
+ )
diff --git a/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py b/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py
new file mode 100644
index 0000000000..9c978f830f
--- /dev/null
+++ b/api/tests/test_containers_integration_tests/services/test_human_input_delivery_test.py
@@ -0,0 +1,112 @@
+import json
+import uuid
+from unittest.mock import MagicMock
+
+import pytest
+
+from core.workflow.enums import NodeType
+from core.workflow.nodes.human_input.entities import (
+ EmailDeliveryConfig,
+ EmailDeliveryMethod,
+ EmailRecipients,
+ ExternalRecipient,
+ HumanInputNodeData,
+)
+from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
+from models.model import App, AppMode
+from models.workflow import Workflow, WorkflowType
+from services.workflow_service import WorkflowService
+
+
+def _create_app_with_draft_workflow(session, *, delivery_method_id: uuid.UUID) -> tuple[App, Account]:
+ tenant = Tenant(name="Test Tenant")
+ account = Account(name="Tester", email="tester@example.com")
+ session.add_all([tenant, account])
+ session.flush()
+
+ session.add(
+ TenantAccountJoin(
+ tenant_id=tenant.id,
+ account_id=account.id,
+ current=True,
+ role=TenantAccountRole.OWNER.value,
+ )
+ )
+
+ app = App(
+ tenant_id=tenant.id,
+ name="Test App",
+ description="",
+ mode=AppMode.WORKFLOW.value,
+ icon_type="emoji",
+ icon="app",
+ icon_background="#ffffff",
+ enable_site=True,
+ enable_api=True,
+ created_by=account.id,
+ updated_by=account.id,
+ )
+ session.add(app)
+ session.flush()
+
+ email_method = EmailDeliveryMethod(
+ id=delivery_method_id,
+ enabled=True,
+ config=EmailDeliveryConfig(
+ recipients=EmailRecipients(
+ whole_workspace=False,
+ items=[ExternalRecipient(email="recipient@example.com")],
+ ),
+ subject="Test {{recipient_email}}",
+ body="Body {{#url#}} {{form_content}}",
+ ),
+ )
+ node_data = HumanInputNodeData(
+ title="Human Input",
+ delivery_methods=[email_method],
+ form_content="Hello Human Input",
+ inputs=[],
+ user_actions=[],
+ ).model_dump(mode="json")
+ node_data["type"] = NodeType.HUMAN_INPUT.value
+ graph = json.dumps({"nodes": [{"id": "human-node", "data": node_data}], "edges": []})
+
+ workflow = Workflow.new(
+ tenant_id=tenant.id,
+ app_id=app.id,
+ type=WorkflowType.WORKFLOW.value,
+ version=Workflow.VERSION_DRAFT,
+ graph=graph,
+ features=json.dumps({}),
+ created_by=account.id,
+ environment_variables=[],
+ conversation_variables=[],
+ rag_pipeline_variables=[],
+ )
+ session.add(workflow)
+ session.commit()
+
+ return app, account
+
+
+def test_human_input_delivery_test_sends_email(
+ db_session_with_containers,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ delivery_method_id = uuid.uuid4()
+ app, account = _create_app_with_draft_workflow(db_session_with_containers, delivery_method_id=delivery_method_id)
+
+ send_mock = MagicMock()
+ monkeypatch.setattr("services.human_input_delivery_test_service.mail.is_inited", lambda: True)
+ monkeypatch.setattr("services.human_input_delivery_test_service.mail.send", send_mock)
+
+ service = WorkflowService()
+ service.test_human_input_delivery(
+ app_model=app,
+ account=account,
+ node_id="human-node",
+ delivery_method_id=str(delivery_method_id),
+ )
+
+ assert send_mock.call_count == 1
+ assert send_mock.call_args.kwargs["to"] == "recipient@example.com"
diff --git a/api/tests/test_containers_integration_tests/services/test_message_service_execution_extra_content.py b/api/tests/test_containers_integration_tests/services/test_message_service_execution_extra_content.py
new file mode 100644
index 0000000000..44e5a82868
--- /dev/null
+++ b/api/tests/test_containers_integration_tests/services/test_message_service_execution_extra_content.py
@@ -0,0 +1,38 @@
+from __future__ import annotations
+
+import pytest
+
+from services.message_service import MessageService
+from tests.test_containers_integration_tests.helpers.execution_extra_content import (
+ create_human_input_message_fixture,
+)
+
+
+@pytest.mark.usefixtures("flask_req_ctx_with_containers")
+def test_pagination_returns_extra_contents(db_session_with_containers):
+ fixture = create_human_input_message_fixture(db_session_with_containers)
+
+ pagination = MessageService.pagination_by_first_id(
+ app_model=fixture.app,
+ user=fixture.account,
+ conversation_id=fixture.conversation.id,
+ first_id=None,
+ limit=10,
+ )
+
+ assert pagination.data
+ message = pagination.data[0]
+ assert message.extra_contents == [
+ {
+ "type": "human_input",
+ "workflow_run_id": fixture.message.workflow_run_id,
+ "submitted": True,
+ "form_submission_data": {
+ "node_id": fixture.form.node_id,
+ "node_title": fixture.node_title,
+ "rendered_content": fixture.form.rendered_content,
+ "action_id": fixture.action_id,
+ "action_text": fixture.action_text,
+ },
+ }
+ ]
diff --git a/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py b/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py
index 23c4eeb82f..3a88081db3 100644
--- a/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py
+++ b/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py
@@ -465,6 +465,27 @@ class TestWorkflowRunService:
db.session.add(node_execution)
node_executions.append(node_execution)
+ paused_node_execution = WorkflowNodeExecutionModel(
+ tenant_id=app.tenant_id,
+ app_id=app.id,
+ workflow_id=workflow_run.workflow_id,
+ triggered_from="workflow-run",
+ workflow_run_id=workflow_run.id,
+ index=99,
+ node_id="node_paused",
+ node_type="human_input",
+ title="Paused Node",
+ inputs=json.dumps({"input": "paused"}),
+ process_data=json.dumps({"process": "paused"}),
+ status="paused",
+ elapsed_time=0.5,
+ execution_metadata=json.dumps({"tokens": 0}),
+ created_by_role=CreatorUserRole.ACCOUNT,
+ created_by=account.id,
+ created_at=datetime.now(UTC),
+ )
+ db.session.add(paused_node_execution)
+
db.session.commit()
# Act: Execute the method under test
@@ -473,16 +494,19 @@ class TestWorkflowRunService:
# Assert: Verify the expected outcomes
assert result is not None
- assert len(result) == 3
+ assert len(result) == 4
# Verify node execution properties
+ statuses = [node_execution.status for node_execution in result]
+ assert "paused" in statuses
+ assert statuses.count("succeeded") == 3
+ assert statuses.count("paused") == 1
+
for node_execution in result:
assert node_execution.tenant_id == app.tenant_id
assert node_execution.app_id == app.id
assert node_execution.workflow_run_id == workflow_run.id
- assert node_execution.index in [0, 1, 2] # Check that index is one of the expected values
- assert node_execution.node_id.startswith("node_") # Check that node_id starts with "node_"
- assert node_execution.status == "succeeded"
+ assert node_execution.node_id.startswith("node_")
def test_get_workflow_run_node_executions_empty(
self, db_session_with_containers, mock_external_service_dependencies
diff --git a/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py b/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py
index 3d46735a1a..acd9d78c91 100644
--- a/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py
+++ b/api/tests/test_containers_integration_tests/services/tools/test_workflow_tools_manage_service.py
@@ -4,6 +4,7 @@ from unittest.mock import patch
import pytest
from faker import Faker
+from core.tools.errors import WorkflowToolHumanInputNotSupportedError
from models.tools import WorkflowToolProvider
from models.workflow import Workflow as WorkflowModel
from services.account_service import AccountService, TenantService
@@ -507,6 +508,62 @@ class TestWorkflowToolManageService:
assert tool_count == 0
+ def test_create_workflow_tool_human_input_node_error(
+ self, db_session_with_containers, mock_external_service_dependencies
+ ):
+ """
+ Test workflow tool creation fails when workflow contains human input nodes.
+
+ This test verifies:
+ - Human input nodes prevent workflow tool publishing
+ - Correct error message
+ - No database changes when workflow is invalid
+ """
+ fake = Faker()
+
+ # Create test data
+ app, account, workflow = self._create_test_app_and_account(
+ db_session_with_containers, mock_external_service_dependencies
+ )
+
+ workflow.graph = json.dumps(
+ {
+ "nodes": [
+ {
+ "id": "human_input_node",
+ "data": {"type": "human-input"},
+ }
+ ]
+ }
+ )
+
+ tool_parameters = self._create_test_workflow_tool_parameters()
+ with pytest.raises(WorkflowToolHumanInputNotSupportedError) as exc_info:
+ WorkflowToolManageService.create_workflow_tool(
+ user_id=account.id,
+ tenant_id=account.current_tenant.id,
+ workflow_app_id=app.id,
+ name=fake.word(),
+ label=fake.word(),
+ icon={"type": "emoji", "emoji": "🔧"},
+ description=fake.text(max_nb_chars=200),
+ parameters=tool_parameters,
+ )
+
+ assert exc_info.value.error_code == "workflow_tool_human_input_not_supported"
+
+ from extensions.ext_database import db
+
+ tool_count = (
+ db.session.query(WorkflowToolProvider)
+ .where(
+ WorkflowToolProvider.tenant_id == account.current_tenant.id,
+ )
+ .count()
+ )
+
+ assert tool_count == 0
+
def test_update_workflow_tool_success(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test successful workflow tool update with valid parameters.
@@ -593,6 +650,80 @@ class TestWorkflowToolManageService:
mock_external_service_dependencies["tool_label_manager"].update_tool_labels.assert_called()
mock_external_service_dependencies["tool_transform_service"].workflow_provider_to_controller.assert_called()
+ def test_update_workflow_tool_human_input_node_error(
+ self, db_session_with_containers, mock_external_service_dependencies
+ ):
+ """
+ Test workflow tool update fails when workflow contains human input nodes.
+
+ This test verifies:
+ - Human input nodes prevent workflow tool updates
+ - Correct error message
+ - Existing tool data remains unchanged
+ """
+ fake = Faker()
+
+ # Create test data
+ app, account, workflow = self._create_test_app_and_account(
+ db_session_with_containers, mock_external_service_dependencies
+ )
+
+ # Create initial workflow tool
+ initial_tool_name = fake.word()
+ initial_tool_parameters = self._create_test_workflow_tool_parameters()
+ WorkflowToolManageService.create_workflow_tool(
+ user_id=account.id,
+ tenant_id=account.current_tenant.id,
+ workflow_app_id=app.id,
+ name=initial_tool_name,
+ label=fake.word(),
+ icon={"type": "emoji", "emoji": "🔧"},
+ description=fake.text(max_nb_chars=200),
+ parameters=initial_tool_parameters,
+ )
+
+ from extensions.ext_database import db
+
+ created_tool = (
+ db.session.query(WorkflowToolProvider)
+ .where(
+ WorkflowToolProvider.tenant_id == account.current_tenant.id,
+ WorkflowToolProvider.app_id == app.id,
+ )
+ .first()
+ )
+
+ original_name = created_tool.name
+
+ workflow.graph = json.dumps(
+ {
+ "nodes": [
+ {
+ "id": "human_input_node",
+ "data": {"type": "human-input"},
+ }
+ ]
+ }
+ )
+ db.session.commit()
+
+ with pytest.raises(WorkflowToolHumanInputNotSupportedError) as exc_info:
+ WorkflowToolManageService.update_workflow_tool(
+ user_id=account.id,
+ tenant_id=account.current_tenant.id,
+ workflow_tool_id=created_tool.id,
+ name=fake.word(),
+ label=fake.word(),
+ icon={"type": "emoji", "emoji": "⚙️"},
+ description=fake.text(max_nb_chars=200),
+ parameters=initial_tool_parameters,
+ )
+
+ assert exc_info.value.error_code == "workflow_tool_human_input_not_supported"
+
+ db.session.refresh(created_tool)
+ assert created_tool.name == original_name
+
def test_update_workflow_tool_not_found_error(self, db_session_with_containers, mock_external_service_dependencies):
"""
Test workflow tool update fails when tool does not exist.
diff --git a/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py b/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py
new file mode 100644
index 0000000000..5fd6c56f7a
--- /dev/null
+++ b/api/tests/test_containers_integration_tests/tasks/test_mail_human_input_delivery_task.py
@@ -0,0 +1,214 @@
+import uuid
+from datetime import UTC, datetime
+from unittest.mock import patch
+
+import pytest
+
+from configs import dify_config
+from core.app.app_config.entities import WorkflowUIBasedAppConfig
+from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
+from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext
+from core.repositories.human_input_repository import FormCreateParams, HumanInputFormRepositoryImpl
+from core.workflow.enums import WorkflowExecutionStatus
+from core.workflow.nodes.human_input.entities import (
+ EmailDeliveryConfig,
+ EmailDeliveryMethod,
+ EmailRecipients,
+ ExternalRecipient,
+ HumanInputNodeData,
+ MemberRecipient,
+)
+from core.workflow.runtime import GraphRuntimeState, VariablePool
+from extensions.ext_storage import storage
+from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole
+from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
+from models.human_input import HumanInputDelivery, HumanInputForm, HumanInputFormRecipient
+from models.model import AppMode
+from models.workflow import WorkflowPause, WorkflowRun, WorkflowType
+from tasks.mail_human_input_delivery_task import dispatch_human_input_email_task
+
+
+@pytest.fixture(autouse=True)
+def cleanup_database(db_session_with_containers):
+ db_session_with_containers.query(HumanInputFormRecipient).delete()
+ db_session_with_containers.query(HumanInputDelivery).delete()
+ db_session_with_containers.query(HumanInputForm).delete()
+ db_session_with_containers.query(WorkflowPause).delete()
+ db_session_with_containers.query(WorkflowRun).delete()
+ db_session_with_containers.query(TenantAccountJoin).delete()
+ db_session_with_containers.query(Tenant).delete()
+ db_session_with_containers.query(Account).delete()
+ db_session_with_containers.commit()
+
+
+def _create_workspace_member(db_session_with_containers):
+ account = Account(
+ email="owner@example.com",
+ name="Owner",
+ password="password",
+ interface_language="en-US",
+ status=AccountStatus.ACTIVE,
+ )
+ account.created_at = datetime.now(UTC)
+ account.updated_at = datetime.now(UTC)
+ db_session_with_containers.add(account)
+ db_session_with_containers.commit()
+ db_session_with_containers.refresh(account)
+
+ tenant = Tenant(name="Test Tenant")
+ tenant.created_at = datetime.now(UTC)
+ tenant.updated_at = datetime.now(UTC)
+ db_session_with_containers.add(tenant)
+ db_session_with_containers.commit()
+ db_session_with_containers.refresh(tenant)
+
+ tenant_join = TenantAccountJoin(
+ tenant_id=tenant.id,
+ account_id=account.id,
+ role=TenantAccountRole.OWNER,
+ )
+ tenant_join.created_at = datetime.now(UTC)
+ tenant_join.updated_at = datetime.now(UTC)
+ db_session_with_containers.add(tenant_join)
+ db_session_with_containers.commit()
+
+ return tenant, account
+
+
+def _build_form(db_session_with_containers, tenant, account, *, app_id: str, workflow_execution_id: str):
+ delivery_method = EmailDeliveryMethod(
+ config=EmailDeliveryConfig(
+ recipients=EmailRecipients(
+ whole_workspace=False,
+ items=[
+ MemberRecipient(user_id=account.id),
+ ExternalRecipient(email="external@example.com"),
+ ],
+ ),
+ subject="Action needed {{ node_title }} {{#node1.value#}}",
+ body="Token {{ form_token }} link {{#url#}} content {{#node1.value#}}",
+ )
+ )
+
+ node_data = HumanInputNodeData(
+ title="Review",
+ form_content="Form content",
+ delivery_methods=[delivery_method],
+ )
+
+ engine = db_session_with_containers.get_bind()
+ repo = HumanInputFormRepositoryImpl(session_factory=engine, tenant_id=tenant.id)
+ params = FormCreateParams(
+ app_id=app_id,
+ workflow_execution_id=workflow_execution_id,
+ node_id="node-1",
+ form_config=node_data,
+ rendered_content="Rendered",
+ delivery_methods=node_data.delivery_methods,
+ display_in_ui=False,
+ resolved_default_values={},
+ )
+ return repo.create_form(params)
+
+
+def _create_workflow_pause_state(
+ db_session_with_containers,
+ *,
+ workflow_run_id: str,
+ workflow_id: str,
+ tenant_id: str,
+ app_id: str,
+ account_id: str,
+ variable_pool: VariablePool,
+):
+ workflow_run = WorkflowRun(
+ id=workflow_run_id,
+ tenant_id=tenant_id,
+ app_id=app_id,
+ workflow_id=workflow_id,
+ type=WorkflowType.WORKFLOW,
+ triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
+ version="1",
+ graph="{}",
+ inputs="{}",
+ status=WorkflowExecutionStatus.PAUSED,
+ created_by_role=CreatorUserRole.ACCOUNT,
+ created_by=account_id,
+ created_at=datetime.now(UTC),
+ )
+ db_session_with_containers.add(workflow_run)
+
+ runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0)
+ resumption_context = WorkflowResumptionContext(
+ generate_entity={
+ "type": AppMode.WORKFLOW,
+ "entity": WorkflowAppGenerateEntity(
+ task_id=str(uuid.uuid4()),
+ app_config=WorkflowUIBasedAppConfig(
+ tenant_id=tenant_id,
+ app_id=app_id,
+ app_mode=AppMode.WORKFLOW,
+ workflow_id=workflow_id,
+ ),
+ inputs={},
+ files=[],
+ user_id=account_id,
+ stream=False,
+ invoke_from=InvokeFrom.WEB_APP,
+ workflow_execution_id=workflow_run_id,
+ ),
+ },
+ serialized_graph_runtime_state=runtime_state.dumps(),
+ )
+
+ state_object_key = f"workflow_pause_states/{workflow_run_id}.json"
+ storage.save(state_object_key, resumption_context.dumps().encode())
+
+ pause_state = WorkflowPause(
+ workflow_id=workflow_id,
+ workflow_run_id=workflow_run_id,
+ state_object_key=state_object_key,
+ )
+ db_session_with_containers.add(pause_state)
+ db_session_with_containers.commit()
+
+
+def test_dispatch_human_input_email_task_integration(monkeypatch: pytest.MonkeyPatch, db_session_with_containers):
+ tenant, account = _create_workspace_member(db_session_with_containers)
+ workflow_run_id = str(uuid.uuid4())
+ workflow_id = str(uuid.uuid4())
+ app_id = str(uuid.uuid4())
+ variable_pool = VariablePool()
+ variable_pool.add(["node1", "value"], "OK")
+ _create_workflow_pause_state(
+ db_session_with_containers,
+ workflow_run_id=workflow_run_id,
+ workflow_id=workflow_id,
+ tenant_id=tenant.id,
+ app_id=app_id,
+ account_id=account.id,
+ variable_pool=variable_pool,
+ )
+ form_entity = _build_form(
+ db_session_with_containers,
+ tenant,
+ account,
+ app_id=app_id,
+ workflow_execution_id=workflow_run_id,
+ )
+
+ monkeypatch.setattr(dify_config, "APP_WEB_URL", "https://app.example.com")
+
+ with patch("tasks.mail_human_input_delivery_task.mail") as mock_mail:
+ mock_mail.is_inited.return_value = True
+
+ dispatch_human_input_email_task(form_id=form_entity.id, node_title="Approval")
+
+ assert mock_mail.send.call_count == 2
+ send_args = [call.kwargs for call in mock_mail.send.call_args_list]
+ recipients = {kwargs["to"] for kwargs in send_args}
+ assert recipients == {"owner@example.com", "external@example.com"}
+ assert all(kwargs["subject"] == "Action needed {{ node_title }} {{#node1.value#}}" for kwargs in send_args)
+ assert all("app.example.com/form/" in kwargs["html"] for kwargs in send_args)
+ assert all("content OK" in kwargs["html"] for kwargs in send_args)
+ assert all("{{ form_token }}" in kwargs["html"] for kwargs in send_args)
diff --git a/api/tests/test_containers_integration_tests/test_workflow_pause_integration.py b/api/tests/test_containers_integration_tests/test_workflow_pause_integration.py
index 889e3d1d83..5f4f28cf4f 100644
--- a/api/tests/test_containers_integration_tests/test_workflow_pause_integration.py
+++ b/api/tests/test_containers_integration_tests/test_workflow_pause_integration.py
@@ -94,11 +94,6 @@ class PrunePausesTestCase:
def pause_workflow_failure_cases() -> list[PauseWorkflowFailureCase]:
"""Create test cases for pause workflow failure scenarios."""
return [
- PauseWorkflowFailureCase(
- name="pause_already_paused_workflow",
- initial_status=WorkflowExecutionStatus.PAUSED,
- description="Should fail to pause an already paused workflow",
- ),
PauseWorkflowFailureCase(
name="pause_completed_workflow",
initial_status=WorkflowExecutionStatus.SUCCEEDED,
diff --git a/api/tests/unit_tests/configs/test_dify_config.py b/api/tests/unit_tests/configs/test_dify_config.py
index 6fce7849f9..cf52980e57 100644
--- a/api/tests/unit_tests/configs/test_dify_config.py
+++ b/api/tests/unit_tests/configs/test_dify_config.py
@@ -164,6 +164,62 @@ def test_db_extras_options_merging(monkeypatch: pytest.MonkeyPatch):
assert "timezone=UTC" in options
+def test_pubsub_redis_url_default(monkeypatch: pytest.MonkeyPatch):
+ os.environ.clear()
+
+ monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
+ monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
+ monkeypatch.setenv("DB_USERNAME", "postgres")
+ monkeypatch.setenv("DB_PASSWORD", "postgres")
+ monkeypatch.setenv("DB_HOST", "localhost")
+ monkeypatch.setenv("DB_PORT", "5432")
+ monkeypatch.setenv("DB_DATABASE", "dify")
+ monkeypatch.setenv("REDIS_HOST", "redis.example.com")
+ monkeypatch.setenv("REDIS_PORT", "6380")
+ monkeypatch.setenv("REDIS_USERNAME", "user")
+ monkeypatch.setenv("REDIS_PASSWORD", "pass@word")
+ monkeypatch.setenv("REDIS_DB", "2")
+ monkeypatch.setenv("REDIS_USE_SSL", "true")
+
+ config = DifyConfig()
+
+ assert config.normalized_pubsub_redis_url == "rediss://user:pass%40word@redis.example.com:6380/2"
+ assert config.PUBSUB_REDIS_CHANNEL_TYPE == "pubsub"
+
+
+def test_pubsub_redis_url_override(monkeypatch: pytest.MonkeyPatch):
+ os.environ.clear()
+
+ monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
+ monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
+ monkeypatch.setenv("DB_USERNAME", "postgres")
+ monkeypatch.setenv("DB_PASSWORD", "postgres")
+ monkeypatch.setenv("DB_HOST", "localhost")
+ monkeypatch.setenv("DB_PORT", "5432")
+ monkeypatch.setenv("DB_DATABASE", "dify")
+ monkeypatch.setenv("PUBSUB_REDIS_URL", "redis://pubsub-host:6381/5")
+
+ config = DifyConfig()
+
+ assert config.normalized_pubsub_redis_url == "redis://pubsub-host:6381/5"
+
+
+def test_pubsub_redis_url_required_when_default_unavailable(monkeypatch: pytest.MonkeyPatch):
+ os.environ.clear()
+
+ monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
+ monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
+ monkeypatch.setenv("DB_USERNAME", "postgres")
+ monkeypatch.setenv("DB_PASSWORD", "postgres")
+ monkeypatch.setenv("DB_HOST", "localhost")
+ monkeypatch.setenv("DB_PORT", "5432")
+ monkeypatch.setenv("DB_DATABASE", "dify")
+ monkeypatch.setenv("REDIS_HOST", "")
+
+ with pytest.raises(ValueError, match="PUBSUB_REDIS_URL must be set"):
+ _ = DifyConfig().normalized_pubsub_redis_url
+
+
@pytest.mark.parametrize(
("broker_url", "expected_host", "expected_port", "expected_username", "expected_password", "expected_db"),
[
diff --git a/api/tests/unit_tests/conftest.py b/api/tests/unit_tests/conftest.py
index e3c1a617f7..da957d3a81 100644
--- a/api/tests/unit_tests/conftest.py
+++ b/api/tests/unit_tests/conftest.py
@@ -51,6 +51,8 @@ def _patch_redis_clients_on_loaded_modules():
continue
if hasattr(module, "redis_client"):
module.redis_client = redis_mock
+ if hasattr(module, "pubsub_redis_client"):
+ module.pubsub_redis_client = redis_mock
@pytest.fixture
@@ -68,7 +70,10 @@ def _provide_app_context(app: Flask):
def _patch_redis_clients():
"""Patch redis_client to MagicMock only for unit test executions."""
- with patch.object(ext_redis, "redis_client", redis_mock):
+ with (
+ patch.object(ext_redis, "redis_client", redis_mock),
+ patch.object(ext_redis, "pubsub_redis_client", redis_mock),
+ ):
_patch_redis_clients_on_loaded_modules()
yield
diff --git a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py
index c557605916..2ac3dc037d 100644
--- a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py
+++ b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py
@@ -16,11 +16,9 @@ if not hasattr(builtins, "MethodView"):
builtins.MethodView = MethodView # type: ignore[attr-defined]
-def _load_app_module():
+@pytest.fixture(scope="module")
+def app_module():
module_name = "controllers.console.app.app"
- if module_name in sys.modules:
- return sys.modules[module_name]
-
root = Path(__file__).resolve().parents[5]
module_path = root / "controllers" / "console" / "app" / "app.py"
@@ -59,8 +57,12 @@ def _load_app_module():
stub_namespace = _StubNamespace()
- original_console = sys.modules.get("controllers.console")
- original_app_pkg = sys.modules.get("controllers.console.app")
+ original_modules: dict[str, ModuleType | None] = {
+ "controllers.console": sys.modules.get("controllers.console"),
+ "controllers.console.app": sys.modules.get("controllers.console.app"),
+ "controllers.common.schema": sys.modules.get("controllers.common.schema"),
+ module_name: sys.modules.get(module_name),
+ }
stubbed_modules: list[tuple[str, ModuleType | None]] = []
console_module = ModuleType("controllers.console")
@@ -105,35 +107,35 @@ def _load_app_module():
module = util.module_from_spec(spec)
sys.modules[module_name] = module
+ assert spec.loader is not None
+ spec.loader.exec_module(module)
+
try:
- assert spec.loader is not None
- spec.loader.exec_module(module)
+ yield module
finally:
for name, original in reversed(stubbed_modules):
if original is not None:
sys.modules[name] = original
else:
sys.modules.pop(name, None)
- if original_console is not None:
- sys.modules["controllers.console"] = original_console
- else:
- sys.modules.pop("controllers.console", None)
- if original_app_pkg is not None:
- sys.modules["controllers.console.app"] = original_app_pkg
- else:
- sys.modules.pop("controllers.console.app", None)
-
- return module
+ for name, original in original_modules.items():
+ if original is not None:
+ sys.modules[name] = original
+ else:
+ sys.modules.pop(name, None)
-_app_module = _load_app_module()
-AppDetailWithSite = _app_module.AppDetailWithSite
-AppPagination = _app_module.AppPagination
-AppPartial = _app_module.AppPartial
+@pytest.fixture(scope="module")
+def app_models(app_module):
+ return SimpleNamespace(
+ AppDetailWithSite=app_module.AppDetailWithSite,
+ AppPagination=app_module.AppPagination,
+ AppPartial=app_module.AppPartial,
+ )
@pytest.fixture(autouse=True)
-def patch_signed_url(monkeypatch):
+def patch_signed_url(monkeypatch, app_module):
"""Ensure icon URL generation uses a deterministic helper for tests."""
def _fake_signed_url(key: str | None) -> str | None:
@@ -141,7 +143,7 @@ def patch_signed_url(monkeypatch):
return None
return f"signed:{key}"
- monkeypatch.setattr(_app_module.file_helpers, "get_signed_file_url", _fake_signed_url)
+ monkeypatch.setattr(app_module.file_helpers, "get_signed_file_url", _fake_signed_url)
def _ts(hour: int = 12) -> datetime:
@@ -169,7 +171,8 @@ def _dummy_workflow():
)
-def test_app_partial_serialization_uses_aliases():
+def test_app_partial_serialization_uses_aliases(app_models):
+ AppPartial = app_models.AppPartial
created_at = _ts()
app_obj = SimpleNamespace(
id="app-1",
@@ -204,7 +207,8 @@ def test_app_partial_serialization_uses_aliases():
assert serialized["tags"][0]["name"] == "Utilities"
-def test_app_detail_with_site_includes_nested_serialization():
+def test_app_detail_with_site_includes_nested_serialization(app_models):
+ AppDetailWithSite = app_models.AppDetailWithSite
timestamp = _ts(14)
site = SimpleNamespace(
code="site-code",
@@ -253,7 +257,8 @@ def test_app_detail_with_site_includes_nested_serialization():
assert serialized["site"]["created_at"] == int(timestamp.timestamp())
-def test_app_pagination_aliases_per_page_and_has_next():
+def test_app_pagination_aliases_per_page_and_has_next(app_models):
+ AppPagination = app_models.AppPagination
item_one = SimpleNamespace(
id="app-10",
name="Paginated One",
diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py
new file mode 100644
index 0000000000..86a3b2bd93
--- /dev/null
+++ b/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py
@@ -0,0 +1,229 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+from flask import Flask
+from pydantic import ValidationError
+
+from controllers.console import wraps as console_wraps
+from controllers.console.app import workflow as workflow_module
+from controllers.console.app import wraps as app_wraps
+from libs import login as login_lib
+from models.account import Account, AccountStatus, TenantAccountRole
+from models.model import AppMode
+
+
+def _make_account() -> Account:
+ account = Account(name="tester", email="tester@example.com")
+ account.status = AccountStatus.ACTIVE
+ account.role = TenantAccountRole.OWNER
+ account.id = "account-123" # type: ignore[assignment]
+ account._current_tenant = SimpleNamespace(id="tenant-123") # type: ignore[attr-defined]
+ account._get_current_object = lambda: account # type: ignore[attr-defined]
+ return account
+
+
+def _make_app(mode: AppMode) -> SimpleNamespace:
+ return SimpleNamespace(id="app-123", tenant_id="tenant-123", mode=mode.value)
+
+
+def _patch_console_guards(monkeypatch: pytest.MonkeyPatch, account: Account, app_model: SimpleNamespace) -> None:
+ # Skip setup and auth guardrails
+ monkeypatch.setattr("configs.dify_config.EDITION", "CLOUD")
+ monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True)
+ monkeypatch.setattr(login_lib, "current_user", account)
+ monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
+ monkeypatch.setattr(login_lib, "check_csrf_token", lambda *_, **__: None)
+ monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
+ monkeypatch.setattr(app_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
+ monkeypatch.setattr(workflow_module, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
+ monkeypatch.setattr(console_wraps.dify_config, "EDITION", "CLOUD")
+ monkeypatch.delenv("INIT_PASSWORD", raising=False)
+
+ # Avoid hitting the database when resolving the app model
+ monkeypatch.setattr(app_wraps, "_load_app_model", lambda _app_id: app_model)
+
+
+@dataclass
+class PreviewCase:
+ resource_cls: type
+ path: str
+ mode: AppMode
+
+
+@pytest.mark.parametrize(
+ "case",
+ [
+ PreviewCase(
+ resource_cls=workflow_module.AdvancedChatDraftHumanInputFormPreviewApi,
+ path="/console/api/apps/app-123/advanced-chat/workflows/draft/human-input/nodes/node-42/form/preview",
+ mode=AppMode.ADVANCED_CHAT,
+ ),
+ PreviewCase(
+ resource_cls=workflow_module.WorkflowDraftHumanInputFormPreviewApi,
+ path="/console/api/apps/app-123/workflows/draft/human-input/nodes/node-42/form/preview",
+ mode=AppMode.WORKFLOW,
+ ),
+ ],
+)
+def test_human_input_preview_delegates_to_service(
+ app: Flask, monkeypatch: pytest.MonkeyPatch, case: PreviewCase
+) -> None:
+ account = _make_account()
+ app_model = _make_app(case.mode)
+ _patch_console_guards(monkeypatch, account, app_model)
+
+ preview_payload = {
+ "form_id": "node-42",
+ "form_content": "example
",
+ "inputs": [{"name": "topic"}],
+ "actions": [{"id": "continue"}],
+ }
+ service_instance = MagicMock()
+ service_instance.get_human_input_form_preview.return_value = preview_payload
+ monkeypatch.setattr(workflow_module, "WorkflowService", MagicMock(return_value=service_instance))
+
+ with app.test_request_context(case.path, method="POST", json={"inputs": {"topic": "tech"}}):
+ response = case.resource_cls().post(app_id=app_model.id, node_id="node-42")
+
+ assert response == preview_payload
+ service_instance.get_human_input_form_preview.assert_called_once_with(
+ app_model=app_model,
+ account=account,
+ node_id="node-42",
+ inputs={"topic": "tech"},
+ )
+
+
+@dataclass
+class SubmitCase:
+ resource_cls: type
+ path: str
+ mode: AppMode
+
+
+@pytest.mark.parametrize(
+ "case",
+ [
+ SubmitCase(
+ resource_cls=workflow_module.AdvancedChatDraftHumanInputFormRunApi,
+ path="/console/api/apps/app-123/advanced-chat/workflows/draft/human-input/nodes/node-99/form/run",
+ mode=AppMode.ADVANCED_CHAT,
+ ),
+ SubmitCase(
+ resource_cls=workflow_module.WorkflowDraftHumanInputFormRunApi,
+ path="/console/api/apps/app-123/workflows/draft/human-input/nodes/node-99/form/run",
+ mode=AppMode.WORKFLOW,
+ ),
+ ],
+)
+def test_human_input_submit_forwards_payload(app: Flask, monkeypatch: pytest.MonkeyPatch, case: SubmitCase) -> None:
+ account = _make_account()
+ app_model = _make_app(case.mode)
+ _patch_console_guards(monkeypatch, account, app_model)
+
+ result_payload = {"node_id": "node-99", "outputs": {"__rendered_content": "done
"}, "action": "approve"}
+ service_instance = MagicMock()
+ service_instance.submit_human_input_form_preview.return_value = result_payload
+ monkeypatch.setattr(workflow_module, "WorkflowService", MagicMock(return_value=service_instance))
+
+ with app.test_request_context(
+ case.path,
+ method="POST",
+ json={"form_inputs": {"answer": "42"}, "inputs": {"#node-1.result#": "LLM output"}, "action": "approve"},
+ ):
+ response = case.resource_cls().post(app_id=app_model.id, node_id="node-99")
+
+ assert response == result_payload
+ service_instance.submit_human_input_form_preview.assert_called_once_with(
+ app_model=app_model,
+ account=account,
+ node_id="node-99",
+ form_inputs={"answer": "42"},
+ inputs={"#node-1.result#": "LLM output"},
+ action="approve",
+ )
+
+
+@dataclass
+class DeliveryTestCase:
+ resource_cls: type
+ path: str
+ mode: AppMode
+
+
+@pytest.mark.parametrize(
+ "case",
+ [
+ DeliveryTestCase(
+ resource_cls=workflow_module.WorkflowDraftHumanInputDeliveryTestApi,
+ path="/console/api/apps/app-123/workflows/draft/human-input/nodes/node-7/delivery-test",
+ mode=AppMode.ADVANCED_CHAT,
+ ),
+ DeliveryTestCase(
+ resource_cls=workflow_module.WorkflowDraftHumanInputDeliveryTestApi,
+ path="/console/api/apps/app-123/workflows/draft/human-input/nodes/node-7/delivery-test",
+ mode=AppMode.WORKFLOW,
+ ),
+ ],
+)
+def test_human_input_delivery_test_calls_service(
+ app: Flask, monkeypatch: pytest.MonkeyPatch, case: DeliveryTestCase
+) -> None:
+ account = _make_account()
+ app_model = _make_app(case.mode)
+ _patch_console_guards(monkeypatch, account, app_model)
+
+ service_instance = MagicMock()
+ monkeypatch.setattr(workflow_module, "WorkflowService", MagicMock(return_value=service_instance))
+
+ with app.test_request_context(
+ case.path,
+ method="POST",
+ json={"delivery_method_id": "delivery-123"},
+ ):
+ response = case.resource_cls().post(app_id=app_model.id, node_id="node-7")
+
+ assert response == {}
+ service_instance.test_human_input_delivery.assert_called_once_with(
+ app_model=app_model,
+ account=account,
+ node_id="node-7",
+ delivery_method_id="delivery-123",
+ inputs={},
+ )
+
+
+def test_human_input_delivery_test_maps_validation_error(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
+ account = _make_account()
+ app_model = _make_app(AppMode.ADVANCED_CHAT)
+ _patch_console_guards(monkeypatch, account, app_model)
+
+ service_instance = MagicMock()
+ service_instance.test_human_input_delivery.side_effect = ValueError("bad delivery method")
+ monkeypatch.setattr(workflow_module, "WorkflowService", MagicMock(return_value=service_instance))
+
+ with app.test_request_context(
+ "/console/api/apps/app-123/workflows/draft/human-input/nodes/node-1/delivery-test",
+ method="POST",
+ json={"delivery_method_id": "bad"},
+ ):
+ with pytest.raises(ValueError):
+ workflow_module.WorkflowDraftHumanInputDeliveryTestApi().post(app_id=app_model.id, node_id="node-1")
+
+
+def test_human_input_preview_rejects_non_mapping(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
+ account = _make_account()
+ app_model = _make_app(AppMode.ADVANCED_CHAT)
+ _patch_console_guards(monkeypatch, account, app_model)
+
+ with app.test_request_context(
+ "/console/api/apps/app-123/advanced-chat/workflows/draft/human-input/nodes/node-1/form/preview",
+ method="POST",
+ json={"inputs": ["not-a-dict"]},
+ ):
+ with pytest.raises(ValidationError):
+ workflow_module.AdvancedChatDraftHumanInputFormPreviewApi().post(app_id=app_model.id, node_id="node-1")
diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py
new file mode 100644
index 0000000000..34d6a2232c
--- /dev/null
+++ b/api/tests/unit_tests/controllers/console/app/test_workflow_pause_details_api.py
@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+from datetime import datetime
+from types import SimpleNamespace
+from unittest.mock import Mock
+
+import pytest
+from flask import Flask
+
+from controllers.console import wraps as console_wraps
+from controllers.console.app import workflow_run as workflow_run_module
+from core.workflow.entities.pause_reason import HumanInputRequired
+from core.workflow.enums import WorkflowExecutionStatus
+from core.workflow.nodes.human_input.entities import FormInput, UserAction
+from core.workflow.nodes.human_input.enums import FormInputType
+from libs import login as login_lib
+from models.account import Account, AccountStatus, TenantAccountRole
+from models.workflow import WorkflowRun
+
+
+def _make_account() -> Account:
+ account = Account(name="tester", email="tester@example.com")
+ account.status = AccountStatus.ACTIVE
+ account.role = TenantAccountRole.OWNER
+ account.id = "account-123" # type: ignore[assignment]
+ account._current_tenant = SimpleNamespace(id="tenant-123") # type: ignore[attr-defined]
+ account._get_current_object = lambda: account # type: ignore[attr-defined]
+ return account
+
+
+def _patch_console_guards(monkeypatch: pytest.MonkeyPatch, account: Account) -> None:
+ monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True)
+ monkeypatch.setattr(login_lib, "current_user", account)
+ monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
+ monkeypatch.setattr(login_lib, "check_csrf_token", lambda *_, **__: None)
+ monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
+ monkeypatch.setattr(workflow_run_module, "current_user", account)
+ monkeypatch.setattr(console_wraps.dify_config, "EDITION", "CLOUD")
+
+
+class _PauseEntity:
+ def __init__(self, paused_at: datetime, reasons: list[HumanInputRequired]):
+ self.paused_at = paused_at
+ self._reasons = reasons
+
+ def get_pause_reasons(self):
+ return self._reasons
+
+
+def test_pause_details_returns_backstage_input_url(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
+ account = _make_account()
+ _patch_console_guards(monkeypatch, account)
+ monkeypatch.setattr(workflow_run_module.dify_config, "APP_WEB_URL", "https://web.example.com")
+
+ workflow_run = Mock(spec=WorkflowRun)
+ workflow_run.status = WorkflowExecutionStatus.PAUSED
+ workflow_run.created_at = datetime(2024, 1, 1, 12, 0, 0)
+ fake_db = SimpleNamespace(engine=Mock(), session=SimpleNamespace(get=lambda *_: workflow_run))
+ monkeypatch.setattr(workflow_run_module, "db", fake_db)
+
+ reason = HumanInputRequired(
+ form_id="form-1",
+ form_content="content",
+ inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")],
+ actions=[UserAction(id="approve", title="Approve")],
+ node_id="node-1",
+ node_title="Ask Name",
+ form_token="backstage-token",
+ )
+ pause_entity = _PauseEntity(paused_at=datetime(2024, 1, 1, 12, 0, 0), reasons=[reason])
+
+ repo = Mock()
+ repo.get_workflow_pause.return_value = pause_entity
+ monkeypatch.setattr(
+ workflow_run_module.DifyAPIRepositoryFactory,
+ "create_api_workflow_run_repository",
+ lambda *_, **__: repo,
+ )
+
+ with app.test_request_context("/console/api/workflow/run-1/pause-details", method="GET"):
+ response, status = workflow_run_module.ConsoleWorkflowPauseDetailsApi().get(workflow_run_id="run-1")
+
+ assert status == 200
+ assert response["paused_at"] == "2024-01-01T12:00:00Z"
+ assert response["paused_nodes"][0]["node_id"] == "node-1"
+ assert response["paused_nodes"][0]["pause_type"]["type"] == "human_input"
+ assert (
+ response["paused_nodes"][0]["pause_type"]["backstage_input_url"]
+ == "https://web.example.com/form/backstage-token"
+ )
+ assert "pending_human_inputs" not in response
diff --git a/api/tests/unit_tests/controllers/service_api/app/test_workflow_fields.py b/api/tests/unit_tests/controllers/service_api/app/test_workflow_fields.py
new file mode 100644
index 0000000000..fcaa61a871
--- /dev/null
+++ b/api/tests/unit_tests/controllers/service_api/app/test_workflow_fields.py
@@ -0,0 +1,25 @@
+from types import SimpleNamespace
+
+from controllers.service_api.app.workflow import WorkflowRunOutputsField, WorkflowRunStatusField
+from core.workflow.enums import WorkflowExecutionStatus
+
+
+def test_workflow_run_status_field_with_enum() -> None:
+ field = WorkflowRunStatusField()
+ obj = SimpleNamespace(status=WorkflowExecutionStatus.PAUSED)
+
+ assert field.output("status", obj) == "paused"
+
+
+def test_workflow_run_outputs_field_paused_returns_empty() -> None:
+ field = WorkflowRunOutputsField()
+ obj = SimpleNamespace(status=WorkflowExecutionStatus.PAUSED, outputs_dict={"foo": "bar"})
+
+ assert field.output("outputs", obj) == {}
+
+
+def test_workflow_run_outputs_field_running_returns_outputs() -> None:
+ field = WorkflowRunOutputsField()
+ obj = SimpleNamespace(status=WorkflowExecutionStatus.RUNNING, outputs_dict={"foo": "bar"})
+
+ assert field.output("outputs", obj) == {"foo": "bar"}
diff --git a/api/tests/unit_tests/controllers/web/test_human_input_form.py b/api/tests/unit_tests/controllers/web/test_human_input_form.py
new file mode 100644
index 0000000000..4fb735b033
--- /dev/null
+++ b/api/tests/unit_tests/controllers/web/test_human_input_form.py
@@ -0,0 +1,456 @@
+"""Unit tests for controllers.web.human_input_form endpoints."""
+
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import MagicMock
+
+import pytest
+from flask import Flask
+from werkzeug.exceptions import Forbidden
+
+import controllers.web.human_input_form as human_input_module
+import controllers.web.site as site_module
+from controllers.web.error import WebFormRateLimitExceededError
+from models.human_input import RecipientType
+from services.human_input_service import FormExpiredError
+
+HumanInputFormApi = human_input_module.HumanInputFormApi
+TenantStatus = human_input_module.TenantStatus
+
+
+@pytest.fixture
+def app() -> Flask:
+ """Configure a minimal Flask app for request contexts."""
+
+ app = Flask(__name__)
+ app.config["TESTING"] = True
+ return app
+
+
+class _FakeSession:
+ """Simple stand-in for db.session that returns pre-seeded objects."""
+
+ def __init__(self, mapping: dict[str, Any]):
+ self._mapping = mapping
+ self._model_name: str | None = None
+
+ def query(self, model):
+ self._model_name = model.__name__
+ return self
+
+ def where(self, *args, **kwargs):
+ return self
+
+ def first(self):
+ assert self._model_name is not None
+ return self._mapping.get(self._model_name)
+
+
+class _FakeDB:
+ """Minimal db stub exposing engine and session."""
+
+ def __init__(self, session: _FakeSession):
+ self.session = session
+ self.engine = object()
+
+
+def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
+ """GET returns form definition merged with site payload."""
+
+ expiration_time = datetime(2099, 1, 1, tzinfo=UTC)
+
+ class _FakeDefinition:
+ def model_dump(self):
+ return {
+ "form_content": "Raw content",
+ "rendered_content": "Rendered {{#$output.name#}}",
+ "inputs": [{"type": "text", "output_variable_name": "name", "default": None}],
+ "default_values": {"name": "Alice", "age": 30, "meta": {"k": "v"}},
+ "user_actions": [{"id": "approve", "title": "Approve", "button_style": "default"}],
+ }
+
+ class _FakeForm:
+ def __init__(self, expiration: datetime):
+ self.workflow_run_id = "workflow-1"
+ self.app_id = "app-1"
+ self.tenant_id = "tenant-1"
+ self.expiration_time = expiration
+ self.recipient_type = RecipientType.BACKSTAGE
+
+ def get_definition(self):
+ return _FakeDefinition()
+
+ form = _FakeForm(expiration_time)
+ limiter_mock = MagicMock()
+ limiter_mock.is_rate_limited.return_value = False
+ monkeypatch.setattr(human_input_module, "_FORM_ACCESS_RATE_LIMITER", limiter_mock)
+ monkeypatch.setattr(human_input_module, "extract_remote_ip", lambda req: "203.0.113.10")
+
+ tenant = SimpleNamespace(
+ id="tenant-1",
+ status=TenantStatus.NORMAL,
+ plan="basic",
+ custom_config_dict={"remove_webapp_brand": True, "replace_webapp_logo": False},
+ )
+ app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant, enable_site=True)
+ workflow_run = SimpleNamespace(app_id="app-1")
+ site_model = SimpleNamespace(
+ title="My Site",
+ icon_type="emoji",
+ icon="robot",
+ icon_background="#fff",
+ description="desc",
+ default_language="en",
+ chat_color_theme="light",
+ chat_color_theme_inverted=False,
+ copyright=None,
+ privacy_policy=None,
+ custom_disclaimer=None,
+ prompt_public=False,
+ show_workflow_steps=True,
+ use_icon_as_answer_icon=False,
+ )
+
+ # Patch service to return fake form.
+ service_mock = MagicMock()
+ service_mock.get_form_by_token.return_value = form
+ monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock)
+
+ # Patch db session.
+ db_stub = _FakeDB(_FakeSession({"WorkflowRun": workflow_run, "App": app_model, "Site": site_model}))
+ monkeypatch.setattr(human_input_module, "db", db_stub)
+
+ monkeypatch.setattr(
+ site_module.FeatureService,
+ "get_features",
+ lambda tenant_id: SimpleNamespace(can_replace_logo=True),
+ )
+
+ with app.test_request_context("/api/form/human_input/token-1", method="GET"):
+ response = HumanInputFormApi().get("token-1")
+
+ body = json.loads(response.get_data(as_text=True))
+ assert set(body.keys()) == {
+ "site",
+ "form_content",
+ "inputs",
+ "resolved_default_values",
+ "user_actions",
+ "expiration_time",
+ }
+ assert body["form_content"] == "Rendered {{#$output.name#}}"
+ assert body["inputs"] == [{"type": "text", "output_variable_name": "name", "default": None}]
+ assert body["resolved_default_values"] == {"name": "Alice", "age": "30", "meta": '{"k": "v"}'}
+ assert body["user_actions"] == [{"id": "approve", "title": "Approve", "button_style": "default"}]
+ assert body["expiration_time"] == int(expiration_time.timestamp())
+ assert body["site"] == {
+ "app_id": "app-1",
+ "end_user_id": None,
+ "enable_site": True,
+ "site": {
+ "title": "My Site",
+ "chat_color_theme": "light",
+ "chat_color_theme_inverted": False,
+ "icon_type": "emoji",
+ "icon": "robot",
+ "icon_background": "#fff",
+ "icon_url": None,
+ "description": "desc",
+ "copyright": None,
+ "privacy_policy": None,
+ "custom_disclaimer": None,
+ "default_language": "en",
+ "prompt_public": False,
+ "show_workflow_steps": True,
+ "use_icon_as_answer_icon": False,
+ },
+ "model_config": None,
+ "plan": "basic",
+ "can_replace_logo": True,
+ "custom_config": {
+ "remove_webapp_brand": True,
+ "replace_webapp_logo": None,
+ },
+ }
+ service_mock.get_form_by_token.assert_called_once_with("token-1")
+ limiter_mock.is_rate_limited.assert_called_once_with("203.0.113.10")
+ limiter_mock.increment_rate_limit.assert_called_once_with("203.0.113.10")
+
+
+def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: Flask):
+ """GET returns form payload for backstage token."""
+
+ expiration_time = datetime(2099, 1, 2, tzinfo=UTC)
+
+ class _FakeDefinition:
+ def model_dump(self):
+ return {
+ "form_content": "Raw content",
+ "rendered_content": "Rendered",
+ "inputs": [],
+ "default_values": {},
+ "user_actions": [],
+ }
+
+ class _FakeForm:
+ def __init__(self, expiration: datetime):
+ self.workflow_run_id = "workflow-1"
+ self.app_id = "app-1"
+ self.tenant_id = "tenant-1"
+ self.expiration_time = expiration
+
+ def get_definition(self):
+ return _FakeDefinition()
+
+ form = _FakeForm(expiration_time)
+ limiter_mock = MagicMock()
+ limiter_mock.is_rate_limited.return_value = False
+ monkeypatch.setattr(human_input_module, "_FORM_ACCESS_RATE_LIMITER", limiter_mock)
+ monkeypatch.setattr(human_input_module, "extract_remote_ip", lambda req: "203.0.113.10")
+ tenant = SimpleNamespace(
+ id="tenant-1",
+ status=TenantStatus.NORMAL,
+ plan="basic",
+ custom_config_dict={"remove_webapp_brand": True, "replace_webapp_logo": False},
+ )
+ app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant, enable_site=True)
+ workflow_run = SimpleNamespace(app_id="app-1")
+ site_model = SimpleNamespace(
+ title="My Site",
+ icon_type="emoji",
+ icon="robot",
+ icon_background="#fff",
+ description="desc",
+ default_language="en",
+ chat_color_theme="light",
+ chat_color_theme_inverted=False,
+ copyright=None,
+ privacy_policy=None,
+ custom_disclaimer=None,
+ prompt_public=False,
+ show_workflow_steps=True,
+ use_icon_as_answer_icon=False,
+ )
+
+ service_mock = MagicMock()
+ service_mock.get_form_by_token.return_value = form
+ monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock)
+
+ db_stub = _FakeDB(_FakeSession({"WorkflowRun": workflow_run, "App": app_model, "Site": site_model}))
+ monkeypatch.setattr(human_input_module, "db", db_stub)
+
+ monkeypatch.setattr(
+ site_module.FeatureService,
+ "get_features",
+ lambda tenant_id: SimpleNamespace(can_replace_logo=True),
+ )
+
+ with app.test_request_context("/api/form/human_input/token-1", method="GET"):
+ response = HumanInputFormApi().get("token-1")
+
+ body = json.loads(response.get_data(as_text=True))
+ assert set(body.keys()) == {
+ "site",
+ "form_content",
+ "inputs",
+ "resolved_default_values",
+ "user_actions",
+ "expiration_time",
+ }
+ assert body["form_content"] == "Rendered"
+ assert body["inputs"] == []
+ assert body["resolved_default_values"] == {}
+ assert body["user_actions"] == []
+ assert body["expiration_time"] == int(expiration_time.timestamp())
+ assert body["site"] == {
+ "app_id": "app-1",
+ "end_user_id": None,
+ "enable_site": True,
+ "site": {
+ "title": "My Site",
+ "chat_color_theme": "light",
+ "chat_color_theme_inverted": False,
+ "icon_type": "emoji",
+ "icon": "robot",
+ "icon_background": "#fff",
+ "icon_url": None,
+ "description": "desc",
+ "copyright": None,
+ "privacy_policy": None,
+ "custom_disclaimer": None,
+ "default_language": "en",
+ "prompt_public": False,
+ "show_workflow_steps": True,
+ "use_icon_as_answer_icon": False,
+ },
+ "model_config": None,
+ "plan": "basic",
+ "can_replace_logo": True,
+ "custom_config": {
+ "remove_webapp_brand": True,
+ "replace_webapp_logo": None,
+ },
+ }
+ service_mock.get_form_by_token.assert_called_once_with("token-1")
+ limiter_mock.is_rate_limited.assert_called_once_with("203.0.113.10")
+ limiter_mock.increment_rate_limit.assert_called_once_with("203.0.113.10")
+
+
+def test_get_form_raises_forbidden_when_site_missing(monkeypatch: pytest.MonkeyPatch, app: Flask):
+ """GET raises Forbidden if site cannot be resolved."""
+
+ expiration_time = datetime(2099, 1, 3, tzinfo=UTC)
+
+ class _FakeDefinition:
+ def model_dump(self):
+ return {
+ "form_content": "Raw content",
+ "rendered_content": "Rendered",
+ "inputs": [],
+ "default_values": {},
+ "user_actions": [],
+ }
+
+ class _FakeForm:
+ def __init__(self, expiration: datetime):
+ self.workflow_run_id = "workflow-1"
+ self.app_id = "app-1"
+ self.tenant_id = "tenant-1"
+ self.expiration_time = expiration
+
+ def get_definition(self):
+ return _FakeDefinition()
+
+ form = _FakeForm(expiration_time)
+ limiter_mock = MagicMock()
+ limiter_mock.is_rate_limited.return_value = False
+ monkeypatch.setattr(human_input_module, "_FORM_ACCESS_RATE_LIMITER", limiter_mock)
+ monkeypatch.setattr(human_input_module, "extract_remote_ip", lambda req: "203.0.113.10")
+ tenant = SimpleNamespace(status=TenantStatus.NORMAL)
+ app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant)
+ workflow_run = SimpleNamespace(app_id="app-1")
+
+ service_mock = MagicMock()
+ service_mock.get_form_by_token.return_value = form
+ monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock)
+
+ db_stub = _FakeDB(_FakeSession({"WorkflowRun": workflow_run, "App": app_model, "Site": None}))
+ monkeypatch.setattr(human_input_module, "db", db_stub)
+
+ with app.test_request_context("/api/form/human_input/token-1", method="GET"):
+ with pytest.raises(Forbidden):
+ HumanInputFormApi().get("token-1")
+ limiter_mock.is_rate_limited.assert_called_once_with("203.0.113.10")
+ limiter_mock.increment_rate_limit.assert_called_once_with("203.0.113.10")
+
+
+def test_submit_form_accepts_backstage_token(monkeypatch: pytest.MonkeyPatch, app: Flask):
+ """POST forwards backstage submissions to the service."""
+
+ class _FakeForm:
+ recipient_type = RecipientType.BACKSTAGE
+
+ form = _FakeForm()
+ limiter_mock = MagicMock()
+ limiter_mock.is_rate_limited.return_value = False
+ monkeypatch.setattr(human_input_module, "_FORM_SUBMIT_RATE_LIMITER", limiter_mock)
+ monkeypatch.setattr(human_input_module, "extract_remote_ip", lambda req: "203.0.113.10")
+ service_mock = MagicMock()
+ service_mock.get_form_by_token.return_value = form
+ monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock)
+ monkeypatch.setattr(human_input_module, "db", _FakeDB(_FakeSession({})))
+
+ with app.test_request_context(
+ "/api/form/human_input/token-1",
+ method="POST",
+ json={"inputs": {"content": "ok"}, "action": "approve"},
+ ):
+ response, status = HumanInputFormApi().post("token-1")
+
+ assert status == 200
+ assert response == {}
+ service_mock.submit_form_by_token.assert_called_once_with(
+ recipient_type=RecipientType.BACKSTAGE,
+ form_token="token-1",
+ selected_action_id="approve",
+ form_data={"content": "ok"},
+ submission_end_user_id=None,
+ )
+ limiter_mock.is_rate_limited.assert_called_once_with("203.0.113.10")
+ limiter_mock.increment_rate_limit.assert_called_once_with("203.0.113.10")
+
+
+def test_submit_form_rate_limited(monkeypatch: pytest.MonkeyPatch, app: Flask):
+ """POST rejects submissions when rate limit is exceeded."""
+
+ limiter_mock = MagicMock()
+ limiter_mock.is_rate_limited.return_value = True
+ monkeypatch.setattr(human_input_module, "_FORM_SUBMIT_RATE_LIMITER", limiter_mock)
+ monkeypatch.setattr(human_input_module, "extract_remote_ip", lambda req: "203.0.113.10")
+
+ service_mock = MagicMock()
+ service_mock.get_form_by_token.return_value = None
+ monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock)
+ monkeypatch.setattr(human_input_module, "db", _FakeDB(_FakeSession({})))
+
+ with app.test_request_context(
+ "/api/form/human_input/token-1",
+ method="POST",
+ json={"inputs": {"content": "ok"}, "action": "approve"},
+ ):
+ with pytest.raises(WebFormRateLimitExceededError):
+ HumanInputFormApi().post("token-1")
+
+ limiter_mock.is_rate_limited.assert_called_once_with("203.0.113.10")
+ limiter_mock.increment_rate_limit.assert_not_called()
+ service_mock.get_form_by_token.assert_not_called()
+
+
+def test_get_form_rate_limited(monkeypatch: pytest.MonkeyPatch, app: Flask):
+ """GET rejects requests when rate limit is exceeded."""
+
+ limiter_mock = MagicMock()
+ limiter_mock.is_rate_limited.return_value = True
+ monkeypatch.setattr(human_input_module, "_FORM_ACCESS_RATE_LIMITER", limiter_mock)
+ monkeypatch.setattr(human_input_module, "extract_remote_ip", lambda req: "203.0.113.10")
+
+ service_mock = MagicMock()
+ service_mock.get_form_by_token.return_value = None
+ monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock)
+ monkeypatch.setattr(human_input_module, "db", _FakeDB(_FakeSession({})))
+
+ with app.test_request_context("/api/form/human_input/token-1", method="GET"):
+ with pytest.raises(WebFormRateLimitExceededError):
+ HumanInputFormApi().get("token-1")
+
+ limiter_mock.is_rate_limited.assert_called_once_with("203.0.113.10")
+ limiter_mock.increment_rate_limit.assert_not_called()
+ service_mock.get_form_by_token.assert_not_called()
+
+
+def test_get_form_raises_expired(monkeypatch: pytest.MonkeyPatch, app: Flask):
+ class _FakeForm:
+ pass
+
+ form = _FakeForm()
+ limiter_mock = MagicMock()
+ limiter_mock.is_rate_limited.return_value = False
+ monkeypatch.setattr(human_input_module, "_FORM_ACCESS_RATE_LIMITER", limiter_mock)
+ monkeypatch.setattr(human_input_module, "extract_remote_ip", lambda req: "203.0.113.10")
+ service_mock = MagicMock()
+ service_mock.get_form_by_token.return_value = form
+ service_mock.ensure_form_active.side_effect = FormExpiredError("form-id")
+ monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock)
+ monkeypatch.setattr(human_input_module, "db", _FakeDB(_FakeSession({})))
+
+ with app.test_request_context("/api/form/human_input/token-1", method="GET"):
+ with pytest.raises(FormExpiredError):
+ HumanInputFormApi().get("token-1")
+
+ service_mock.ensure_form_active.assert_called_once_with(form)
+ limiter_mock.is_rate_limited.assert_called_once_with("203.0.113.10")
+ limiter_mock.increment_rate_limit.assert_called_once_with("203.0.113.10")
diff --git a/api/tests/unit_tests/controllers/web/test_message_list.py b/api/tests/unit_tests/controllers/web/test_message_list.py
index 2835f7ffbf..1c096bfbcf 100644
--- a/api/tests/unit_tests/controllers/web/test_message_list.py
+++ b/api/tests/unit_tests/controllers/web/test_message_list.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import builtins
+import uuid
from datetime import datetime
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
@@ -12,6 +13,8 @@ import pytest
from flask import Flask
from flask.views import MethodView
+from core.entities.execution_extra_content import HumanInputContent
+
# Ensure flask_restx.api finds MethodView during import.
if not hasattr(builtins, "MethodView"):
builtins.MethodView = MethodView # type: ignore[attr-defined]
@@ -137,6 +140,12 @@ def test_message_list_mapping(app: Flask) -> None:
status="success",
error=None,
message_metadata_dict={"meta": "value"},
+ extra_contents=[
+ HumanInputContent(
+ workflow_run_id=str(uuid.uuid4()),
+ submitted=True,
+ )
+ ],
)
pagination = SimpleNamespace(limit=20, has_more=False, data=[message])
@@ -169,6 +178,8 @@ def test_message_list_mapping(app: Flask) -> None:
assert item["agent_thoughts"][0]["chain_id"] == "chain-1"
assert item["agent_thoughts"][0]["created_at"] == int(thought_created_at.timestamp())
+ assert item["extra_contents"][0]["workflow_run_id"] == message.extra_contents[0].workflow_run_id
+ assert item["extra_contents"][0]["submitted"] == message.extra_contents[0].submitted
assert item["message_files"][0]["id"] == "file-dict"
assert item["message_files"][1]["id"] == "file-obj"
diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_extra_contents.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_extra_contents.py
new file mode 100644
index 0000000000..a94b5445f7
--- /dev/null
+++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_extra_contents.py
@@ -0,0 +1,187 @@
+from __future__ import annotations
+
+from contextlib import contextmanager
+from datetime import datetime
+from types import SimpleNamespace
+from unittest import mock
+
+import pytest
+
+from core.app.apps.advanced_chat import generate_task_pipeline as pipeline_module
+from core.app.entities.app_invoke_entities import InvokeFrom
+from core.app.entities.queue_entities import QueueTextChunkEvent, QueueWorkflowPausedEvent
+from core.workflow.entities.pause_reason import HumanInputRequired
+from models.enums import MessageStatus
+from models.execution_extra_content import HumanInputContent
+from models.model import EndUser
+
+
+def _build_pipeline() -> pipeline_module.AdvancedChatAppGenerateTaskPipeline:
+ pipeline = pipeline_module.AdvancedChatAppGenerateTaskPipeline.__new__(
+ pipeline_module.AdvancedChatAppGenerateTaskPipeline
+ )
+ pipeline._workflow_run_id = "run-1"
+ pipeline._message_id = "message-1"
+ pipeline._workflow_tenant_id = "tenant-1"
+ return pipeline
+
+
+def test_persist_human_input_extra_content_adds_record(monkeypatch: pytest.MonkeyPatch) -> None:
+ pipeline = _build_pipeline()
+ monkeypatch.setattr(pipeline, "_load_human_input_form_id", lambda **kwargs: "form-1")
+
+ captured_session: dict[str, mock.Mock] = {}
+
+ @contextmanager
+ def fake_session():
+ session = mock.Mock()
+ session.scalar.return_value = None
+ captured_session["session"] = session
+ yield session
+
+ pipeline._database_session = fake_session # type: ignore[method-assign]
+
+ pipeline._persist_human_input_extra_content(node_id="node-1")
+
+ session = captured_session["session"]
+ session.add.assert_called_once()
+ content = session.add.call_args.args[0]
+ assert isinstance(content, HumanInputContent)
+ assert content.workflow_run_id == "run-1"
+ assert content.message_id == "message-1"
+ assert content.form_id == "form-1"
+
+
+def test_persist_human_input_extra_content_skips_when_form_missing(monkeypatch: pytest.MonkeyPatch) -> None:
+ pipeline = _build_pipeline()
+ monkeypatch.setattr(pipeline, "_load_human_input_form_id", lambda **kwargs: None)
+
+ called = {"value": False}
+
+ @contextmanager
+ def fake_session():
+ called["value"] = True
+ session = mock.Mock()
+ yield session
+
+ pipeline._database_session = fake_session # type: ignore[method-assign]
+
+ pipeline._persist_human_input_extra_content(node_id="node-1")
+
+ assert called["value"] is False
+
+
+def test_persist_human_input_extra_content_skips_when_existing(monkeypatch: pytest.MonkeyPatch) -> None:
+ pipeline = _build_pipeline()
+ monkeypatch.setattr(pipeline, "_load_human_input_form_id", lambda **kwargs: "form-1")
+
+ captured_session: dict[str, mock.Mock] = {}
+
+ @contextmanager
+ def fake_session():
+ session = mock.Mock()
+ session.scalar.return_value = HumanInputContent(
+ workflow_run_id="run-1",
+ message_id="message-1",
+ form_id="form-1",
+ )
+ captured_session["session"] = session
+ yield session
+
+ pipeline._database_session = fake_session # type: ignore[method-assign]
+
+ pipeline._persist_human_input_extra_content(node_id="node-1")
+
+ session = captured_session["session"]
+ session.add.assert_not_called()
+
+
+def test_handle_workflow_paused_event_persists_human_input_extra_content() -> None:
+ pipeline = _build_pipeline()
+ pipeline._application_generate_entity = SimpleNamespace(task_id="task-1")
+ pipeline._workflow_response_converter = mock.Mock()
+ pipeline._workflow_response_converter.workflow_pause_to_stream_response.return_value = []
+ pipeline._ensure_graph_runtime_initialized = mock.Mock(
+ return_value=SimpleNamespace(
+ total_tokens=0,
+ node_run_steps=0,
+ ),
+ )
+ pipeline._save_message = mock.Mock()
+ message = SimpleNamespace(status=MessageStatus.NORMAL)
+ pipeline._get_message = mock.Mock(return_value=message)
+ pipeline._persist_human_input_extra_content = mock.Mock()
+ pipeline._base_task_pipeline = mock.Mock()
+ pipeline._base_task_pipeline.queue_manager = mock.Mock()
+ pipeline._message_saved_on_pause = False
+
+ @contextmanager
+ def fake_session():
+ session = mock.Mock()
+ yield session
+
+ pipeline._database_session = fake_session # type: ignore[method-assign]
+
+ reason = HumanInputRequired(
+ form_id="form-1",
+ form_content="content",
+ inputs=[],
+ actions=[],
+ node_id="node-1",
+ node_title="Approval",
+ form_token="token-1",
+ resolved_default_values={},
+ )
+ event = QueueWorkflowPausedEvent(reasons=[reason], outputs={}, paused_nodes=["node-1"])
+
+ list(pipeline._handle_workflow_paused_event(event))
+
+ pipeline._persist_human_input_extra_content.assert_called_once_with(form_id="form-1", node_id="node-1")
+ assert message.status == MessageStatus.PAUSED
+
+
+def test_resume_appends_chunks_to_paused_answer() -> None:
+ app_config = SimpleNamespace(app_id="app-1", tenant_id="tenant-1", sensitive_word_avoidance=None)
+ application_generate_entity = SimpleNamespace(
+ app_config=app_config,
+ files=[],
+ workflow_run_id="run-1",
+ query="hello",
+ invoke_from=InvokeFrom.WEB_APP,
+ inputs={},
+ task_id="task-1",
+ )
+ queue_manager = SimpleNamespace(graph_runtime_state=None)
+ conversation = SimpleNamespace(id="conversation-1", mode="advanced-chat")
+ message = SimpleNamespace(
+ id="message-1",
+ created_at=datetime(2024, 1, 1),
+ query="hello",
+ answer="before",
+ status=MessageStatus.PAUSED,
+ )
+ user = EndUser()
+ user.id = "user-1"
+ user.session_id = "session-1"
+ workflow = SimpleNamespace(id="workflow-1", tenant_id="tenant-1", features_dict={})
+
+ pipeline = pipeline_module.AdvancedChatAppGenerateTaskPipeline(
+ application_generate_entity=application_generate_entity,
+ workflow=workflow,
+ queue_manager=queue_manager,
+ conversation=conversation,
+ message=message,
+ user=user,
+ stream=True,
+ dialogue_count=1,
+ draft_var_saver_factory=SimpleNamespace(),
+ )
+
+ pipeline._get_message = mock.Mock(return_value=message)
+ pipeline._recorded_files = []
+
+ list(pipeline._handle_text_chunk_event(QueueTextChunkEvent(text="after")))
+ pipeline._save_message(session=mock.Mock())
+
+ assert message.answer == "beforeafter"
+ assert message.status == MessageStatus.NORMAL
diff --git a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py
new file mode 100644
index 0000000000..1c36b4d12b
--- /dev/null
+++ b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py
@@ -0,0 +1,87 @@
+from datetime import UTC, datetime
+from types import SimpleNamespace
+
+from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
+from core.app.entities.app_invoke_entities import InvokeFrom
+from core.app.entities.queue_entities import QueueHumanInputFormFilledEvent, QueueHumanInputFormTimeoutEvent
+from core.workflow.entities.workflow_start_reason import WorkflowStartReason
+from core.workflow.runtime import GraphRuntimeState, VariablePool
+from core.workflow.system_variable import SystemVariable
+
+
+def _build_converter():
+ system_variables = SystemVariable(
+ files=[],
+ user_id="user-1",
+ app_id="app-1",
+ workflow_id="wf-1",
+ workflow_execution_id="run-1",
+ )
+ runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
+ app_entity = SimpleNamespace(
+ task_id="task-1",
+ app_config=SimpleNamespace(app_id="app-1", tenant_id="tenant-1"),
+ invoke_from=InvokeFrom.EXPLORE,
+ files=[],
+ inputs={},
+ workflow_execution_id="run-1",
+ call_depth=0,
+ )
+ account = SimpleNamespace(id="acc-1", name="tester", email="tester@example.com")
+ return WorkflowResponseConverter(
+ application_generate_entity=app_entity,
+ user=account,
+ system_variables=system_variables,
+ )
+
+
+def test_human_input_form_filled_stream_response_contains_rendered_content():
+ converter = _build_converter()
+ converter.workflow_start_to_stream_response(
+ task_id="task-1",
+ workflow_run_id="run-1",
+ workflow_id="wf-1",
+ reason=WorkflowStartReason.INITIAL,
+ )
+
+ queue_event = QueueHumanInputFormFilledEvent(
+ node_execution_id="exec-1",
+ node_id="node-1",
+ node_type="human-input",
+ node_title="Human Input",
+ rendered_content="# Title\nvalue",
+ action_id="Approve",
+ action_text="Approve",
+ )
+
+ resp = converter.human_input_form_filled_to_stream_response(event=queue_event, task_id="task-1")
+
+ assert resp.workflow_run_id == "run-1"
+ assert resp.data.node_id == "node-1"
+ assert resp.data.node_title == "Human Input"
+ assert resp.data.rendered_content.startswith("# Title")
+ assert resp.data.action_id == "Approve"
+
+
+def test_human_input_form_timeout_stream_response_contains_timeout_metadata():
+ converter = _build_converter()
+ converter.workflow_start_to_stream_response(
+ task_id="task-1",
+ workflow_run_id="run-1",
+ workflow_id="wf-1",
+ reason=WorkflowStartReason.INITIAL,
+ )
+
+ queue_event = QueueHumanInputFormTimeoutEvent(
+ node_id="node-1",
+ node_type="human-input",
+ node_title="Human Input",
+ expiration_time=datetime(2025, 1, 1, tzinfo=UTC),
+ )
+
+ resp = converter.human_input_form_timeout_to_stream_response(event=queue_event, task_id="task-1")
+
+ assert resp.workflow_run_id == "run-1"
+ assert resp.data.node_id == "node-1"
+ assert resp.data.node_title == "Human Input"
+ assert resp.data.expiration_time == 1735689600
diff --git a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_resumption.py b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_resumption.py
new file mode 100644
index 0000000000..0a9794e41c
--- /dev/null
+++ b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_resumption.py
@@ -0,0 +1,56 @@
+from types import SimpleNamespace
+
+from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
+from core.app.entities.app_invoke_entities import InvokeFrom
+from core.workflow.entities.workflow_start_reason import WorkflowStartReason
+from core.workflow.runtime import GraphRuntimeState, VariablePool
+from core.workflow.system_variable import SystemVariable
+
+
+def _build_converter() -> WorkflowResponseConverter:
+ """Construct a minimal WorkflowResponseConverter for testing."""
+ system_variables = SystemVariable(
+ files=[],
+ user_id="user-1",
+ app_id="app-1",
+ workflow_id="wf-1",
+ workflow_execution_id="run-1",
+ )
+ runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
+ app_entity = SimpleNamespace(
+ task_id="task-1",
+ app_config=SimpleNamespace(app_id="app-1", tenant_id="tenant-1"),
+ invoke_from=InvokeFrom.EXPLORE,
+ files=[],
+ inputs={},
+ workflow_execution_id="run-1",
+ call_depth=0,
+ )
+ account = SimpleNamespace(id="acc-1", name="tester", email="tester@example.com")
+ return WorkflowResponseConverter(
+ application_generate_entity=app_entity,
+ user=account,
+ system_variables=system_variables,
+ )
+
+
+def test_workflow_start_stream_response_carries_resumption_reason():
+ converter = _build_converter()
+ resp = converter.workflow_start_to_stream_response(
+ task_id="task-1",
+ workflow_run_id="run-1",
+ workflow_id="wf-1",
+ reason=WorkflowStartReason.RESUMPTION,
+ )
+ assert resp.data.reason is WorkflowStartReason.RESUMPTION
+
+
+def test_workflow_start_stream_response_carries_initial_reason():
+ converter = _build_converter()
+ resp = converter.workflow_start_to_stream_response(
+ task_id="task-1",
+ workflow_run_id="run-1",
+ workflow_id="wf-1",
+ reason=WorkflowStartReason.INITIAL,
+ )
+ assert resp.data.reason is WorkflowStartReason.INITIAL
diff --git a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_truncation.py b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_truncation.py
index 6b40bf462b..d25bff92dc 100644
--- a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_truncation.py
+++ b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_truncation.py
@@ -23,6 +23,7 @@ from core.app.entities.queue_entities import (
QueueNodeStartedEvent,
QueueNodeSucceededEvent,
)
+from core.workflow.entities.workflow_start_reason import WorkflowStartReason
from core.workflow.enums import NodeType
from core.workflow.system_variable import SystemVariable
from libs.datetime_utils import naive_utc_now
@@ -124,7 +125,12 @@ class TestWorkflowResponseConverter:
original_data = {"large_field": "x" * 10000, "metadata": "info"}
truncated_data = {"large_field": "[TRUNCATED]", "metadata": "info"}
- converter.workflow_start_to_stream_response(task_id="bootstrap", workflow_run_id="run-id", workflow_id="wf-id")
+ converter.workflow_start_to_stream_response(
+ task_id="bootstrap",
+ workflow_run_id="run-id",
+ workflow_id="wf-id",
+ reason=WorkflowStartReason.INITIAL,
+ )
start_event = self.create_node_started_event()
converter.workflow_node_start_to_stream_response(
event=start_event,
@@ -160,7 +166,12 @@ class TestWorkflowResponseConverter:
original_data = {"small": "data"}
- converter.workflow_start_to_stream_response(task_id="bootstrap", workflow_run_id="run-id", workflow_id="wf-id")
+ converter.workflow_start_to_stream_response(
+ task_id="bootstrap",
+ workflow_run_id="run-id",
+ workflow_id="wf-id",
+ reason=WorkflowStartReason.INITIAL,
+ )
start_event = self.create_node_started_event()
converter.workflow_node_start_to_stream_response(
event=start_event,
@@ -191,7 +202,12 @@ class TestWorkflowResponseConverter:
"""Test node finish response when process_data is None."""
converter = self.create_workflow_response_converter()
- converter.workflow_start_to_stream_response(task_id="bootstrap", workflow_run_id="run-id", workflow_id="wf-id")
+ converter.workflow_start_to_stream_response(
+ task_id="bootstrap",
+ workflow_run_id="run-id",
+ workflow_id="wf-id",
+ reason=WorkflowStartReason.INITIAL,
+ )
start_event = self.create_node_started_event()
converter.workflow_node_start_to_stream_response(
event=start_event,
@@ -225,7 +241,12 @@ class TestWorkflowResponseConverter:
original_data = {"large_field": "x" * 10000, "metadata": "info"}
truncated_data = {"large_field": "[TRUNCATED]", "metadata": "info"}
- converter.workflow_start_to_stream_response(task_id="bootstrap", workflow_run_id="run-id", workflow_id="wf-id")
+ converter.workflow_start_to_stream_response(
+ task_id="bootstrap",
+ workflow_run_id="run-id",
+ workflow_id="wf-id",
+ reason=WorkflowStartReason.INITIAL,
+ )
start_event = self.create_node_started_event()
converter.workflow_node_start_to_stream_response(
event=start_event,
@@ -261,7 +282,12 @@ class TestWorkflowResponseConverter:
original_data = {"small": "data"}
- converter.workflow_start_to_stream_response(task_id="bootstrap", workflow_run_id="run-id", workflow_id="wf-id")
+ converter.workflow_start_to_stream_response(
+ task_id="bootstrap",
+ workflow_run_id="run-id",
+ workflow_id="wf-id",
+ reason=WorkflowStartReason.INITIAL,
+ )
start_event = self.create_node_started_event()
converter.workflow_node_start_to_stream_response(
event=start_event,
@@ -400,6 +426,7 @@ class TestWorkflowResponseConverterServiceApiTruncation:
task_id="test-task-id",
workflow_run_id="test-workflow-run-id",
workflow_id="test-workflow-id",
+ reason=WorkflowStartReason.INITIAL,
)
return converter
diff --git a/api/tests/unit_tests/core/app/apps/test_advanced_chat_app_generator.py b/api/tests/unit_tests/core/app/apps/test_advanced_chat_app_generator.py
new file mode 100644
index 0000000000..f0d9afc0db
--- /dev/null
+++ b/api/tests/unit_tests/core/app/apps/test_advanced_chat_app_generator.py
@@ -0,0 +1,139 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+
+from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig
+from core.app.apps import message_based_app_generator
+from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
+from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom
+from core.app.task_pipeline import message_cycle_manager
+from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
+from models.model import AppMode, Conversation, Message
+
+
+def _make_app_config() -> WorkflowUIBasedAppConfig:
+ return WorkflowUIBasedAppConfig(
+ tenant_id="tenant-id",
+ app_id="app-id",
+ app_mode=AppMode.ADVANCED_CHAT,
+ workflow_id="workflow-id",
+ additional_features=AppAdditionalFeatures(),
+ variables=[],
+ )
+
+
+def _make_generate_entity(app_config: WorkflowUIBasedAppConfig) -> AdvancedChatAppGenerateEntity:
+ return AdvancedChatAppGenerateEntity(
+ task_id="task-id",
+ app_config=app_config,
+ file_upload_config=None,
+ conversation_id=None,
+ inputs={},
+ query="hello",
+ files=[],
+ parent_message_id=None,
+ user_id="user-id",
+ stream=True,
+ invoke_from=InvokeFrom.WEB_APP,
+ extras={},
+ workflow_run_id="workflow-run-id",
+ )
+
+
+@pytest.fixture(autouse=True)
+def _mock_db_session(monkeypatch):
+ session = MagicMock()
+
+ def refresh_side_effect(obj):
+ if isinstance(obj, Conversation) and obj.id is None:
+ obj.id = "generated-conversation-id"
+ if isinstance(obj, Message) and obj.id is None:
+ obj.id = "generated-message-id"
+
+ session.refresh.side_effect = refresh_side_effect
+ session.add.return_value = None
+ session.commit.return_value = None
+
+ monkeypatch.setattr(message_based_app_generator, "db", SimpleNamespace(session=session))
+ return session
+
+
+def test_init_generate_records_sets_conversation_metadata():
+ app_config = _make_app_config()
+ entity = _make_generate_entity(app_config)
+
+ generator = AdvancedChatAppGenerator()
+
+ conversation, _ = generator._init_generate_records(entity, conversation=None)
+
+ assert entity.conversation_id == "generated-conversation-id"
+ assert conversation.id == "generated-conversation-id"
+ assert entity.is_new_conversation is True
+
+
+def test_init_generate_records_marks_existing_conversation():
+ app_config = _make_app_config()
+ entity = _make_generate_entity(app_config)
+
+ existing_conversation = Conversation(
+ app_id=app_config.app_id,
+ app_model_config_id=None,
+ model_provider=None,
+ override_model_configs=None,
+ model_id=None,
+ mode=app_config.app_mode.value,
+ name="existing",
+ inputs={},
+ introduction="",
+ system_instruction="",
+ system_instruction_tokens=0,
+ status="normal",
+ invoke_from=InvokeFrom.WEB_APP.value,
+ from_source="api",
+ from_end_user_id="user-id",
+ from_account_id=None,
+ )
+ existing_conversation.id = "existing-conversation-id"
+
+ generator = AdvancedChatAppGenerator()
+
+ conversation, _ = generator._init_generate_records(entity, conversation=existing_conversation)
+
+ assert entity.conversation_id == "existing-conversation-id"
+ assert conversation is existing_conversation
+ assert entity.is_new_conversation is False
+
+
+def test_message_cycle_manager_uses_new_conversation_flag(monkeypatch):
+ app_config = _make_app_config()
+ entity = _make_generate_entity(app_config)
+ entity.conversation_id = "existing-conversation-id"
+ entity.is_new_conversation = True
+ entity.extras = {"auto_generate_conversation_name": True}
+
+ captured = {}
+
+ class DummyThread:
+ def __init__(self, **kwargs):
+ self.kwargs = kwargs
+ self.started = False
+
+ def start(self):
+ self.started = True
+
+ def fake_thread(**kwargs):
+ thread = DummyThread(**kwargs)
+ captured["thread"] = thread
+ return thread
+
+ monkeypatch.setattr(message_cycle_manager, "Thread", fake_thread)
+
+ manager = MessageCycleManager(application_generate_entity=entity, task_state=MagicMock())
+ thread = manager.generate_conversation_name(conversation_id="existing-conversation-id", query="hello")
+
+ assert thread is captured["thread"]
+ assert thread.started is True
+ assert entity.is_new_conversation is False
diff --git a/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py b/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py
new file mode 100644
index 0000000000..87b8dc51e7
--- /dev/null
+++ b/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py
@@ -0,0 +1,127 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+
+from core.app.app_config.entities import (
+ AppAdditionalFeatures,
+ EasyUIBasedAppConfig,
+ EasyUIBasedAppModelConfigFrom,
+ ModelConfigEntity,
+ PromptTemplateEntity,
+)
+from core.app.apps import message_based_app_generator
+from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
+from core.app.entities.app_invoke_entities import ChatAppGenerateEntity, InvokeFrom
+from models.model import AppMode, Conversation, Message
+
+
+class DummyModelConf:
+ def __init__(self, provider: str = "mock-provider", model: str = "mock-model") -> None:
+ self.provider = provider
+ self.model = model
+
+
+class DummyCompletionGenerateEntity:
+ __slots__ = ("app_config", "invoke_from", "user_id", "query", "inputs", "files", "model_conf")
+ app_config: EasyUIBasedAppConfig
+ invoke_from: InvokeFrom
+ user_id: str
+ query: str
+ inputs: dict
+ files: list
+ model_conf: DummyModelConf
+
+ def __init__(self, app_config: EasyUIBasedAppConfig) -> None:
+ self.app_config = app_config
+ self.invoke_from = InvokeFrom.WEB_APP
+ self.user_id = "user-id"
+ self.query = "hello"
+ self.inputs = {}
+ self.files = []
+ self.model_conf = DummyModelConf()
+
+
+def _make_app_config(app_mode: AppMode) -> EasyUIBasedAppConfig:
+ return EasyUIBasedAppConfig(
+ tenant_id="tenant-id",
+ app_id="app-id",
+ app_mode=app_mode,
+ app_model_config_from=EasyUIBasedAppModelConfigFrom.APP_LATEST_CONFIG,
+ app_model_config_id="model-config-id",
+ app_model_config_dict={},
+ model=ModelConfigEntity(provider="mock-provider", model="mock-model", mode="chat"),
+ prompt_template=PromptTemplateEntity(
+ prompt_type=PromptTemplateEntity.PromptType.SIMPLE,
+ simple_prompt_template="Hello",
+ ),
+ additional_features=AppAdditionalFeatures(),
+ variables=[],
+ )
+
+
+def _make_chat_generate_entity(app_config: EasyUIBasedAppConfig) -> ChatAppGenerateEntity:
+ return ChatAppGenerateEntity.model_construct(
+ task_id="task-id",
+ app_config=app_config,
+ model_conf=DummyModelConf(),
+ file_upload_config=None,
+ conversation_id=None,
+ inputs={},
+ query="hello",
+ files=[],
+ parent_message_id=None,
+ user_id="user-id",
+ stream=False,
+ invoke_from=InvokeFrom.WEB_APP,
+ extras={},
+ call_depth=0,
+ trace_manager=None,
+ )
+
+
+@pytest.fixture(autouse=True)
+def _mock_db_session(monkeypatch):
+ session = MagicMock()
+
+ def refresh_side_effect(obj):
+ if isinstance(obj, Conversation) and obj.id is None:
+ obj.id = "generated-conversation-id"
+ if isinstance(obj, Message) and obj.id is None:
+ obj.id = "generated-message-id"
+
+ session.refresh.side_effect = refresh_side_effect
+ session.add.return_value = None
+ session.commit.return_value = None
+
+ monkeypatch.setattr(message_based_app_generator, "db", SimpleNamespace(session=session))
+ return session
+
+
+def test_init_generate_records_skips_conversation_fields_for_non_conversation_entity():
+ app_config = _make_app_config(AppMode.COMPLETION)
+ entity = DummyCompletionGenerateEntity(app_config=app_config)
+
+ generator = MessageBasedAppGenerator()
+
+ conversation, message = generator._init_generate_records(entity, conversation=None)
+
+ assert conversation.id == "generated-conversation-id"
+ assert message.id == "generated-message-id"
+ assert hasattr(entity, "conversation_id") is False
+ assert hasattr(entity, "is_new_conversation") is False
+
+
+def test_init_generate_records_sets_conversation_fields_for_chat_entity():
+ app_config = _make_app_config(AppMode.CHAT)
+ entity = _make_chat_generate_entity(app_config)
+
+ generator = MessageBasedAppGenerator()
+
+ conversation, _ = generator._init_generate_records(entity, conversation=None)
+
+ assert entity.conversation_id == "generated-conversation-id"
+ assert entity.is_new_conversation is True
+ assert conversation.id == "generated-conversation-id"
diff --git a/api/tests/unit_tests/core/app/apps/test_pause_resume.py b/api/tests/unit_tests/core/app/apps/test_pause_resume.py
new file mode 100644
index 0000000000..97c993928e
--- /dev/null
+++ b/api/tests/unit_tests/core/app/apps/test_pause_resume.py
@@ -0,0 +1,287 @@
+import sys
+import time
+from pathlib import Path
+from types import ModuleType, SimpleNamespace
+from typing import Any
+
+API_DIR = str(Path(__file__).resolve().parents[5])
+if API_DIR not in sys.path:
+ sys.path.insert(0, API_DIR)
+
+import core.workflow.nodes.human_input.entities # noqa: F401
+from core.app.apps.advanced_chat import app_generator as adv_app_gen_module
+from core.app.apps.workflow import app_generator as wf_app_gen_module
+from core.app.entities.app_invoke_entities import InvokeFrom
+from core.app.workflow.node_factory import DifyNodeFactory
+from core.workflow.entities import GraphInitParams
+from core.workflow.entities.pause_reason import SchedulingPause
+from core.workflow.entities.workflow_start_reason import WorkflowStartReason
+from core.workflow.enums import NodeType, WorkflowNodeExecutionStatus
+from core.workflow.graph import Graph
+from core.workflow.graph_engine import GraphEngine
+from core.workflow.graph_engine.command_channels.in_memory_channel import InMemoryChannel
+from core.workflow.graph_events import (
+ GraphEngineEvent,
+ GraphRunPausedEvent,
+ GraphRunStartedEvent,
+ GraphRunSucceededEvent,
+ NodeRunSucceededEvent,
+)
+from core.workflow.node_events import NodeRunResult, PauseRequestedEvent
+from core.workflow.nodes.base.entities import BaseNodeData, OutputVariableEntity, RetryConfig
+from core.workflow.nodes.base.node import Node
+from core.workflow.nodes.end.entities import EndNodeData
+from core.workflow.nodes.start.entities import StartNodeData
+from core.workflow.runtime import GraphRuntimeState, VariablePool
+from core.workflow.system_variable import SystemVariable
+
+if "core.ops.ops_trace_manager" not in sys.modules:
+ ops_stub = ModuleType("core.ops.ops_trace_manager")
+
+ class _StubTraceQueueManager:
+ def __init__(self, *_, **__):
+ pass
+
+ ops_stub.TraceQueueManager = _StubTraceQueueManager
+ sys.modules["core.ops.ops_trace_manager"] = ops_stub
+
+
+class _StubToolNodeData(BaseNodeData):
+ pause_on: bool = False
+
+
+class _StubToolNode(Node[_StubToolNodeData]):
+ node_type = NodeType.TOOL
+
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+
+ def init_node_data(self, data):
+ self._node_data = _StubToolNodeData.model_validate(data)
+
+ def _get_error_strategy(self):
+ return self._node_data.error_strategy
+
+ def _get_retry_config(self) -> RetryConfig:
+ return self._node_data.retry_config
+
+ def _get_title(self) -> str:
+ return self._node_data.title
+
+ def _get_description(self):
+ return self._node_data.desc
+
+ def _get_default_value_dict(self) -> dict[str, Any]:
+ return self._node_data.default_value_dict
+
+ def get_base_node_data(self) -> BaseNodeData:
+ return self._node_data
+
+ def _run(self):
+ if self.node_data.pause_on:
+ yield PauseRequestedEvent(reason=SchedulingPause(message="test pause"))
+ return
+
+ result = NodeRunResult(
+ status=WorkflowNodeExecutionStatus.SUCCEEDED,
+ outputs={"value": f"{self.id}-done"},
+ )
+ yield self._convert_node_run_result_to_graph_node_event(result)
+
+
+def _patch_tool_node(mocker):
+ original_create_node = DifyNodeFactory.create_node
+
+ def _patched_create_node(self, node_config: dict[str, object]) -> Node:
+ node_data = node_config.get("data", {})
+ if isinstance(node_data, dict) and node_data.get("type") == NodeType.TOOL.value:
+ return _StubToolNode(
+ id=str(node_config["id"]),
+ config=node_config,
+ graph_init_params=self.graph_init_params,
+ graph_runtime_state=self.graph_runtime_state,
+ )
+ return original_create_node(self, node_config)
+
+ mocker.patch.object(DifyNodeFactory, "create_node", _patched_create_node)
+
+
+def _node_data(node_type: NodeType, data: BaseNodeData) -> dict[str, object]:
+ node_data = data.model_dump()
+ node_data["type"] = node_type.value
+ return node_data
+
+
+def _build_graph_config(*, pause_on: str | None) -> dict[str, object]:
+ start_data = StartNodeData(title="start", variables=[])
+ tool_data_a = _StubToolNodeData(title="tool", pause_on=pause_on == "tool_a")
+ tool_data_b = _StubToolNodeData(title="tool", pause_on=pause_on == "tool_b")
+ tool_data_c = _StubToolNodeData(title="tool", pause_on=pause_on == "tool_c")
+ end_data = EndNodeData(
+ title="end",
+ outputs=[OutputVariableEntity(variable="result", value_selector=["tool_c", "value"])],
+ desc=None,
+ )
+
+ nodes = [
+ {"id": "start", "data": _node_data(NodeType.START, start_data)},
+ {"id": "tool_a", "data": _node_data(NodeType.TOOL, tool_data_a)},
+ {"id": "tool_b", "data": _node_data(NodeType.TOOL, tool_data_b)},
+ {"id": "tool_c", "data": _node_data(NodeType.TOOL, tool_data_c)},
+ {"id": "end", "data": _node_data(NodeType.END, end_data)},
+ ]
+ edges = [
+ {"source": "start", "target": "tool_a"},
+ {"source": "tool_a", "target": "tool_b"},
+ {"source": "tool_b", "target": "tool_c"},
+ {"source": "tool_c", "target": "end"},
+ ]
+ return {"nodes": nodes, "edges": edges}
+
+
+def _build_graph(runtime_state: GraphRuntimeState, *, pause_on: str | None) -> Graph:
+ graph_config = _build_graph_config(pause_on=pause_on)
+ params = GraphInitParams(
+ tenant_id="tenant",
+ app_id="app",
+ workflow_id="workflow",
+ graph_config=graph_config,
+ user_id="user",
+ user_from="account",
+ invoke_from="service-api",
+ call_depth=0,
+ )
+
+ node_factory = DifyNodeFactory(
+ graph_init_params=params,
+ graph_runtime_state=runtime_state,
+ )
+
+ return Graph.init(graph_config=graph_config, node_factory=node_factory)
+
+
+def _build_runtime_state(run_id: str) -> GraphRuntimeState:
+ variable_pool = VariablePool(
+ system_variables=SystemVariable(user_id="user", app_id="app", workflow_id="workflow"),
+ user_inputs={},
+ conversation_variables=[],
+ )
+ variable_pool.system_variables.workflow_execution_id = run_id
+ return GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
+
+
+def _run_with_optional_pause(runtime_state: GraphRuntimeState, *, pause_on: str | None) -> list[GraphEngineEvent]:
+ command_channel = InMemoryChannel()
+ graph = _build_graph(runtime_state, pause_on=pause_on)
+ engine = GraphEngine(
+ workflow_id="workflow",
+ graph=graph,
+ graph_runtime_state=runtime_state,
+ command_channel=command_channel,
+ )
+
+ events: list[GraphEngineEvent] = []
+ for event in engine.run():
+ events.append(event)
+ return events
+
+
+def _node_successes(events: list[GraphEngineEvent]) -> list[str]:
+ return [evt.node_id for evt in events if isinstance(evt, NodeRunSucceededEvent)]
+
+
+def test_workflow_app_pause_resume_matches_baseline(mocker):
+ _patch_tool_node(mocker)
+
+ baseline_state = _build_runtime_state("baseline")
+ baseline_events = _run_with_optional_pause(baseline_state, pause_on=None)
+ assert isinstance(baseline_events[-1], GraphRunSucceededEvent)
+ baseline_nodes = _node_successes(baseline_events)
+ baseline_outputs = baseline_state.outputs
+
+ paused_state = _build_runtime_state("paused-run")
+ paused_events = _run_with_optional_pause(paused_state, pause_on="tool_a")
+ assert isinstance(paused_events[-1], GraphRunPausedEvent)
+ paused_nodes = _node_successes(paused_events)
+ snapshot = paused_state.dumps()
+
+ resumed_state = GraphRuntimeState.from_snapshot(snapshot)
+
+ generator = wf_app_gen_module.WorkflowAppGenerator()
+
+ def _fake_generate(**kwargs):
+ state: GraphRuntimeState = kwargs["graph_runtime_state"]
+ events = _run_with_optional_pause(state, pause_on=None)
+ return _node_successes(events)
+
+ mocker.patch.object(generator, "_generate", side_effect=_fake_generate)
+
+ resumed_nodes = generator.resume(
+ app_model=SimpleNamespace(mode="workflow"),
+ workflow=SimpleNamespace(),
+ user=SimpleNamespace(),
+ application_generate_entity=SimpleNamespace(stream=False, invoke_from=InvokeFrom.SERVICE_API),
+ graph_runtime_state=resumed_state,
+ workflow_execution_repository=SimpleNamespace(),
+ workflow_node_execution_repository=SimpleNamespace(),
+ )
+
+ assert paused_nodes + resumed_nodes == baseline_nodes
+ assert resumed_state.outputs == baseline_outputs
+
+
+def test_advanced_chat_pause_resume_matches_baseline(mocker):
+ _patch_tool_node(mocker)
+
+ baseline_state = _build_runtime_state("adv-baseline")
+ baseline_events = _run_with_optional_pause(baseline_state, pause_on=None)
+ assert isinstance(baseline_events[-1], GraphRunSucceededEvent)
+ baseline_nodes = _node_successes(baseline_events)
+ baseline_outputs = baseline_state.outputs
+
+ paused_state = _build_runtime_state("adv-paused")
+ paused_events = _run_with_optional_pause(paused_state, pause_on="tool_a")
+ assert isinstance(paused_events[-1], GraphRunPausedEvent)
+ paused_nodes = _node_successes(paused_events)
+ snapshot = paused_state.dumps()
+
+ resumed_state = GraphRuntimeState.from_snapshot(snapshot)
+
+ generator = adv_app_gen_module.AdvancedChatAppGenerator()
+
+ def _fake_generate(**kwargs):
+ state: GraphRuntimeState = kwargs["graph_runtime_state"]
+ events = _run_with_optional_pause(state, pause_on=None)
+ return _node_successes(events)
+
+ mocker.patch.object(generator, "_generate", side_effect=_fake_generate)
+
+ resumed_nodes = generator.resume(
+ app_model=SimpleNamespace(mode="workflow"),
+ workflow=SimpleNamespace(),
+ user=SimpleNamespace(),
+ conversation=SimpleNamespace(id="conv"),
+ message=SimpleNamespace(id="msg"),
+ application_generate_entity=SimpleNamespace(stream=False, invoke_from=InvokeFrom.SERVICE_API),
+ workflow_execution_repository=SimpleNamespace(),
+ workflow_node_execution_repository=SimpleNamespace(),
+ graph_runtime_state=resumed_state,
+ )
+
+ assert paused_nodes + resumed_nodes == baseline_nodes
+ assert resumed_state.outputs == baseline_outputs
+
+
+def test_resume_emits_resumption_start_reason(mocker) -> None:
+ _patch_tool_node(mocker)
+
+ paused_state = _build_runtime_state("resume-reason")
+ paused_events = _run_with_optional_pause(paused_state, pause_on="tool_a")
+ initial_start = next(event for event in paused_events if isinstance(event, GraphRunStartedEvent))
+ assert initial_start.reason == WorkflowStartReason.INITIAL
+
+ resumed_state = GraphRuntimeState.from_snapshot(paused_state.dumps())
+ resumed_events = _run_with_optional_pause(resumed_state, pause_on=None)
+ resume_start = next(event for event in resumed_events if isinstance(event, GraphRunStartedEvent))
+ assert resume_start.reason == WorkflowStartReason.RESUMPTION
diff --git a/api/tests/unit_tests/core/app/apps/test_streaming_utils.py b/api/tests/unit_tests/core/app/apps/test_streaming_utils.py
new file mode 100644
index 0000000000..7b5447c01e
--- /dev/null
+++ b/api/tests/unit_tests/core/app/apps/test_streaming_utils.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+import json
+import queue
+
+import pytest
+
+from core.app.apps.message_based_app_generator import MessageBasedAppGenerator
+from core.app.entities.task_entities import StreamEvent
+from models.model import AppMode
+
+
+class FakeSubscription:
+ def __init__(self, message_queue: queue.Queue[bytes], state: dict[str, bool]) -> None:
+ self._queue = message_queue
+ self._state = state
+ self._closed = False
+
+ def __enter__(self):
+ self._state["subscribed"] = True
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ self.close()
+
+ def close(self) -> None:
+ self._closed = True
+
+ def receive(self, timeout: float | None = 0.1) -> bytes | None:
+ if self._closed:
+ return None
+ try:
+ if timeout is None:
+ return self._queue.get()
+ return self._queue.get(timeout=timeout)
+ except queue.Empty:
+ return None
+
+
+class FakeTopic:
+ def __init__(self) -> None:
+ self._queue: queue.Queue[bytes] = queue.Queue()
+ self._state = {"subscribed": False}
+
+ def subscribe(self) -> FakeSubscription:
+ return FakeSubscription(self._queue, self._state)
+
+ def publish(self, payload: bytes) -> None:
+ self._queue.put(payload)
+
+ @property
+ def subscribed(self) -> bool:
+ return self._state["subscribed"]
+
+
+def test_retrieve_events_calls_on_subscribe_after_subscription(monkeypatch):
+ topic = FakeTopic()
+
+ def fake_get_response_topic(cls, app_mode, workflow_run_id):
+ return topic
+
+ monkeypatch.setattr(MessageBasedAppGenerator, "get_response_topic", classmethod(fake_get_response_topic))
+
+ def on_subscribe() -> None:
+ assert topic.subscribed is True
+ event = {"event": StreamEvent.WORKFLOW_FINISHED.value}
+ topic.publish(json.dumps(event).encode())
+
+ generator = MessageBasedAppGenerator.retrieve_events(
+ AppMode.WORKFLOW,
+ "workflow-run-id",
+ idle_timeout=0.5,
+ on_subscribe=on_subscribe,
+ )
+
+ assert next(generator) == StreamEvent.PING.value
+ event = next(generator)
+ assert event["event"] == StreamEvent.WORKFLOW_FINISHED.value
+ with pytest.raises(StopIteration):
+ next(generator)
diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py
index 83ac3a5591..7e8367c6c4 100644
--- a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py
+++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py
@@ -1,3 +1,6 @@
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY, WorkflowAppGenerator
@@ -17,3 +20,193 @@ def test_should_prepare_user_inputs_keeps_validation_when_flag_false():
args = {"inputs": {}, SKIP_PREPARE_USER_INPUTS_KEY: False}
assert WorkflowAppGenerator()._should_prepare_user_inputs(args)
+
+
+def test_resume_delegates_to_generate(mocker):
+ generator = WorkflowAppGenerator()
+ mock_generate = mocker.patch.object(generator, "_generate", return_value="ok")
+
+ application_generate_entity = SimpleNamespace(stream=False, invoke_from="debugger")
+ runtime_state = MagicMock(name="runtime-state")
+ pause_config = MagicMock(name="pause-config")
+
+ result = generator.resume(
+ app_model=MagicMock(),
+ workflow=MagicMock(),
+ user=MagicMock(),
+ application_generate_entity=application_generate_entity,
+ graph_runtime_state=runtime_state,
+ workflow_execution_repository=MagicMock(),
+ workflow_node_execution_repository=MagicMock(),
+ graph_engine_layers=("layer",),
+ pause_state_config=pause_config,
+ variable_loader=MagicMock(),
+ )
+
+ assert result == "ok"
+ mock_generate.assert_called_once()
+ kwargs = mock_generate.call_args.kwargs
+ assert kwargs["graph_runtime_state"] is runtime_state
+ assert kwargs["pause_state_config"] is pause_config
+ assert kwargs["streaming"] is False
+ assert kwargs["invoke_from"] == "debugger"
+
+
+def test_generate_appends_pause_layer_and_forwards_state(mocker):
+ generator = WorkflowAppGenerator()
+
+ mock_queue_manager = MagicMock()
+ mocker.patch("core.app.apps.workflow.app_generator.WorkflowAppQueueManager", return_value=mock_queue_manager)
+
+ fake_current_app = MagicMock()
+ fake_current_app._get_current_object.return_value = MagicMock()
+ mocker.patch("core.app.apps.workflow.app_generator.current_app", fake_current_app)
+
+ mocker.patch(
+ "core.app.apps.workflow.app_generator.WorkflowAppGenerateResponseConverter.convert",
+ return_value="converted",
+ )
+ mocker.patch.object(WorkflowAppGenerator, "_handle_response", return_value="response")
+ mocker.patch.object(WorkflowAppGenerator, "_get_draft_var_saver_factory", return_value=MagicMock())
+
+ pause_layer = MagicMock(name="pause-layer")
+ mocker.patch(
+ "core.app.apps.workflow.app_generator.PauseStatePersistenceLayer",
+ return_value=pause_layer,
+ )
+
+ dummy_session = MagicMock()
+ dummy_session.close = MagicMock()
+ mocker.patch("core.app.apps.workflow.app_generator.db.session", dummy_session)
+
+ worker_kwargs: dict[str, object] = {}
+
+ class DummyThread:
+ def __init__(self, target, kwargs):
+ worker_kwargs["target"] = target
+ worker_kwargs["kwargs"] = kwargs
+
+ def start(self):
+ return None
+
+ mocker.patch("core.app.apps.workflow.app_generator.threading.Thread", DummyThread)
+
+ app_model = SimpleNamespace(mode="workflow")
+ app_config = SimpleNamespace(app_id="app", tenant_id="tenant", workflow_id="wf")
+ application_generate_entity = SimpleNamespace(
+ task_id="task",
+ user_id="user",
+ invoke_from="service-api",
+ app_config=app_config,
+ files=[],
+ stream=True,
+ workflow_execution_id="run",
+ )
+
+ graph_runtime_state = MagicMock()
+
+ result = generator._generate(
+ app_model=app_model,
+ workflow=MagicMock(),
+ user=MagicMock(),
+ application_generate_entity=application_generate_entity,
+ invoke_from="service-api",
+ workflow_execution_repository=MagicMock(),
+ workflow_node_execution_repository=MagicMock(),
+ streaming=True,
+ graph_engine_layers=("base-layer",),
+ graph_runtime_state=graph_runtime_state,
+ pause_state_config=SimpleNamespace(session_factory=MagicMock(), state_owner_user_id="owner"),
+ )
+
+ assert result == "converted"
+ assert worker_kwargs["kwargs"]["graph_engine_layers"] == ("base-layer", pause_layer)
+ assert worker_kwargs["kwargs"]["graph_runtime_state"] is graph_runtime_state
+
+
+def test_resume_path_runs_worker_with_runtime_state(mocker):
+ generator = WorkflowAppGenerator()
+ runtime_state = MagicMock(name="runtime-state")
+
+ pause_layer = MagicMock(name="pause-layer")
+ mocker.patch("core.app.apps.workflow.app_generator.PauseStatePersistenceLayer", return_value=pause_layer)
+
+ queue_manager = MagicMock()
+ mocker.patch("core.app.apps.workflow.app_generator.WorkflowAppQueueManager", return_value=queue_manager)
+
+ mocker.patch.object(generator, "_handle_response", return_value="raw-response")
+ mocker.patch(
+ "core.app.apps.workflow.app_generator.WorkflowAppGenerateResponseConverter.convert",
+ side_effect=lambda response, invoke_from: response,
+ )
+
+ fake_db = SimpleNamespace(session=MagicMock(), engine=MagicMock())
+ mocker.patch("core.app.apps.workflow.app_generator.db", fake_db)
+
+ workflow = SimpleNamespace(
+ id="workflow", tenant_id="tenant", app_id="app", graph_dict={}, type="workflow", version="1"
+ )
+ end_user = SimpleNamespace(session_id="end-user-session")
+ app_record = SimpleNamespace(id="app")
+
+ session = MagicMock()
+ session.__enter__.return_value = session
+ session.__exit__.return_value = False
+ session.scalar.side_effect = [workflow, end_user, app_record]
+ mocker.patch("core.app.apps.workflow.app_generator.session_factory", return_value=session)
+
+ runner_instance = MagicMock()
+
+ def runner_ctor(**kwargs):
+ assert kwargs["graph_runtime_state"] is runtime_state
+ return runner_instance
+
+ mocker.patch("core.app.apps.workflow.app_generator.WorkflowAppRunner", side_effect=runner_ctor)
+
+ class ImmediateThread:
+ def __init__(self, target, kwargs):
+ target(**kwargs)
+
+ def start(self):
+ return None
+
+ mocker.patch("core.app.apps.workflow.app_generator.threading.Thread", ImmediateThread)
+
+ mocker.patch(
+ "core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository",
+ return_value=MagicMock(),
+ )
+ mocker.patch(
+ "core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository",
+ return_value=MagicMock(),
+ )
+
+ pause_config = SimpleNamespace(session_factory=MagicMock(), state_owner_user_id="owner")
+
+ app_model = SimpleNamespace(mode="workflow")
+ app_config = SimpleNamespace(app_id="app", tenant_id="tenant", workflow_id="workflow")
+ application_generate_entity = SimpleNamespace(
+ task_id="task",
+ user_id="user",
+ invoke_from="service-api",
+ app_config=app_config,
+ files=[],
+ stream=True,
+ workflow_execution_id="run",
+ trace_manager=MagicMock(),
+ )
+
+ result = generator.resume(
+ app_model=app_model,
+ workflow=workflow,
+ user=MagicMock(),
+ application_generate_entity=application_generate_entity,
+ graph_runtime_state=runtime_state,
+ workflow_execution_repository=MagicMock(),
+ workflow_node_execution_repository=MagicMock(),
+ pause_state_config=pause_config,
+ )
+
+ assert result == "raw-response"
+ runner_instance.run.assert_called_once()
+ queue_manager.graph_runtime_state = runtime_state
diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_notifications.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_notifications.py
new file mode 100644
index 0000000000..f4efb240c0
--- /dev/null
+++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_notifications.py
@@ -0,0 +1,59 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner
+from core.app.entities.queue_entities import QueueWorkflowPausedEvent
+from core.workflow.entities.pause_reason import HumanInputRequired
+from core.workflow.graph_events.graph import GraphRunPausedEvent
+
+
+class _DummyQueueManager:
+ def __init__(self):
+ self.published = []
+
+ def publish(self, event, _from):
+ self.published.append(event)
+
+
+class _DummyRuntimeState:
+ def get_paused_nodes(self):
+ return ["node-1"]
+
+
+class _DummyGraphEngine:
+ def __init__(self):
+ self.graph_runtime_state = _DummyRuntimeState()
+
+
+class _DummyWorkflowEntry:
+ def __init__(self):
+ self.graph_engine = _DummyGraphEngine()
+
+
+def test_handle_pause_event_enqueues_email_task(monkeypatch: pytest.MonkeyPatch):
+ queue_manager = _DummyQueueManager()
+ runner = WorkflowBasedAppRunner(queue_manager=queue_manager, app_id="app-id")
+ workflow_entry = _DummyWorkflowEntry()
+
+ reason = HumanInputRequired(
+ form_id="form-123",
+ form_content="content",
+ inputs=[],
+ actions=[],
+ node_id="node-1",
+ node_title="Review",
+ )
+ event = GraphRunPausedEvent(reasons=[reason], outputs={})
+
+ email_task = MagicMock()
+ monkeypatch.setattr("core.app.apps.workflow_app_runner.dispatch_human_input_email_task", email_task)
+
+ runner._handle_event(workflow_entry, event)
+
+ email_task.apply_async.assert_called_once()
+ kwargs = email_task.apply_async.call_args.kwargs["kwargs"]
+ assert kwargs["form_id"] == "form-123"
+ assert kwargs["node_title"] == "Review"
+
+ assert any(isinstance(evt, QueueWorkflowPausedEvent) for evt in queue_manager.published)
diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py b/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py
new file mode 100644
index 0000000000..c30b925d88
--- /dev/null
+++ b/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py
@@ -0,0 +1,183 @@
+from datetime import UTC, datetime
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+
+from core.app.apps.common import workflow_response_converter
+from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
+from core.app.apps.workflow.app_runner import WorkflowAppRunner
+from core.app.entities.app_invoke_entities import InvokeFrom
+from core.app.entities.queue_entities import QueueWorkflowPausedEvent
+from core.app.entities.task_entities import HumanInputRequiredResponse, WorkflowPauseStreamResponse
+from core.workflow.entities.pause_reason import HumanInputRequired
+from core.workflow.entities.workflow_start_reason import WorkflowStartReason
+from core.workflow.graph_events.graph import GraphRunPausedEvent
+from core.workflow.nodes.human_input.entities import FormInput, UserAction
+from core.workflow.nodes.human_input.enums import FormInputType
+from core.workflow.system_variable import SystemVariable
+from models.account import Account
+
+
+class _RecordingWorkflowAppRunner(WorkflowAppRunner):
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self.published_events = []
+
+ def _publish_event(self, event):
+ self.published_events.append(event)
+
+
+class _FakeRuntimeState:
+ def get_paused_nodes(self):
+ return ["node-pause-1"]
+
+
+def _build_runner():
+ app_entity = SimpleNamespace(
+ app_config=SimpleNamespace(app_id="app-id"),
+ inputs={},
+ files=[],
+ invoke_from=InvokeFrom.SERVICE_API,
+ single_iteration_run=None,
+ single_loop_run=None,
+ workflow_execution_id="run-id",
+ user_id="user-id",
+ )
+ workflow = SimpleNamespace(
+ graph_dict={},
+ tenant_id="tenant-id",
+ environment_variables={},
+ id="workflow-id",
+ )
+ queue_manager = SimpleNamespace(publish=lambda event, pub_from: None)
+ return _RecordingWorkflowAppRunner(
+ application_generate_entity=app_entity,
+ queue_manager=queue_manager,
+ variable_loader=MagicMock(),
+ workflow=workflow,
+ system_user_id="sys-user",
+ root_node_id=None,
+ workflow_execution_repository=MagicMock(),
+ workflow_node_execution_repository=MagicMock(),
+ graph_engine_layers=(),
+ graph_runtime_state=None,
+ )
+
+
+def test_graph_run_paused_event_emits_queue_pause_event():
+ runner = _build_runner()
+ reason = HumanInputRequired(
+ form_id="form-1",
+ form_content="content",
+ inputs=[],
+ actions=[],
+ node_id="node-human",
+ node_title="Human Step",
+ form_token="tok",
+ )
+ event = GraphRunPausedEvent(reasons=[reason], outputs={"foo": "bar"})
+ workflow_entry = SimpleNamespace(
+ graph_engine=SimpleNamespace(graph_runtime_state=_FakeRuntimeState()),
+ )
+
+ runner._handle_event(workflow_entry, event)
+
+ assert len(runner.published_events) == 1
+ queue_event = runner.published_events[0]
+ assert isinstance(queue_event, QueueWorkflowPausedEvent)
+ assert queue_event.reasons == [reason]
+ assert queue_event.outputs == {"foo": "bar"}
+ assert queue_event.paused_nodes == ["node-pause-1"]
+
+
+def _build_converter():
+ application_generate_entity = SimpleNamespace(
+ inputs={},
+ files=[],
+ invoke_from=InvokeFrom.SERVICE_API,
+ app_config=SimpleNamespace(app_id="app-id", tenant_id="tenant-id"),
+ )
+ system_variables = SystemVariable(
+ user_id="user",
+ app_id="app-id",
+ workflow_id="workflow-id",
+ workflow_execution_id="run-id",
+ )
+ user = MagicMock(spec=Account)
+ user.id = "account-id"
+ user.name = "Tester"
+ user.email = "tester@example.com"
+ return WorkflowResponseConverter(
+ application_generate_entity=application_generate_entity,
+ user=user,
+ system_variables=system_variables,
+ )
+
+
+def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.MonkeyPatch):
+ converter = _build_converter()
+ converter.workflow_start_to_stream_response(
+ task_id="task",
+ workflow_run_id="run-id",
+ workflow_id="workflow-id",
+ reason=WorkflowStartReason.INITIAL,
+ )
+
+ expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
+
+ class _FakeSession:
+ def execute(self, _stmt):
+ return [("form-1", expiration_time)]
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, tb):
+ return False
+
+ monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: _FakeSession())
+ monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object()))
+
+ reason = HumanInputRequired(
+ form_id="form-1",
+ form_content="Rendered",
+ inputs=[
+ FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="field", default=None),
+ ],
+ actions=[UserAction(id="approve", title="Approve")],
+ display_in_ui=True,
+ node_id="node-id",
+ node_title="Human Step",
+ form_token="token",
+ )
+ queue_event = QueueWorkflowPausedEvent(
+ reasons=[reason],
+ outputs={"answer": "value"},
+ paused_nodes=["node-id"],
+ )
+
+ runtime_state = SimpleNamespace(total_tokens=0, node_run_steps=0)
+ responses = converter.workflow_pause_to_stream_response(
+ event=queue_event,
+ task_id="task",
+ graph_runtime_state=runtime_state,
+ )
+
+ assert isinstance(responses[-1], WorkflowPauseStreamResponse)
+ pause_resp = responses[-1]
+ assert pause_resp.workflow_run_id == "run-id"
+ assert pause_resp.data.paused_nodes == ["node-id"]
+ assert pause_resp.data.outputs == {}
+ assert pause_resp.data.reasons[0]["form_id"] == "form-1"
+ assert pause_resp.data.reasons[0]["display_in_ui"] is True
+
+ assert isinstance(responses[0], HumanInputRequiredResponse)
+ hi_resp = responses[0]
+ assert hi_resp.data.form_id == "form-1"
+ assert hi_resp.data.node_id == "node-id"
+ assert hi_resp.data.node_title == "Human Step"
+ assert hi_resp.data.inputs[0].output_variable_name == "field"
+ assert hi_resp.data.actions[0].id == "approve"
+ assert hi_resp.data.display_in_ui is True
+ assert hi_resp.data.expiration_time == int(expiration_time.timestamp())
diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py
new file mode 100644
index 0000000000..32cb1ed47c
--- /dev/null
+++ b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py
@@ -0,0 +1,96 @@
+import time
+from contextlib import contextmanager
+from unittest.mock import MagicMock
+
+from core.app.app_config.entities import WorkflowUIBasedAppConfig
+from core.app.apps.base_app_queue_manager import AppQueueManager
+from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline
+from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
+from core.app.entities.queue_entities import QueueWorkflowStartedEvent
+from core.workflow.entities.workflow_start_reason import WorkflowStartReason
+from core.workflow.runtime import GraphRuntimeState, VariablePool
+from core.workflow.system_variable import SystemVariable
+from models.account import Account
+from models.model import AppMode
+
+
+def _build_workflow_app_config() -> WorkflowUIBasedAppConfig:
+ return WorkflowUIBasedAppConfig(
+ tenant_id="tenant-id",
+ app_id="app-id",
+ app_mode=AppMode.WORKFLOW,
+ workflow_id="workflow-id",
+ )
+
+
+def _build_generate_entity(run_id: str) -> WorkflowAppGenerateEntity:
+ return WorkflowAppGenerateEntity(
+ task_id="task-id",
+ app_config=_build_workflow_app_config(),
+ inputs={},
+ files=[],
+ user_id="user-id",
+ stream=False,
+ invoke_from=InvokeFrom.SERVICE_API,
+ workflow_execution_id=run_id,
+ )
+
+
+def _build_runtime_state(run_id: str) -> GraphRuntimeState:
+ variable_pool = VariablePool(
+ system_variables=SystemVariable(workflow_execution_id=run_id),
+ user_inputs={},
+ conversation_variables=[],
+ )
+ return GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
+
+
+@contextmanager
+def _noop_session():
+ yield MagicMock()
+
+
+def _build_pipeline(run_id: str) -> WorkflowAppGenerateTaskPipeline:
+ queue_manager = MagicMock(spec=AppQueueManager)
+ queue_manager.invoke_from = InvokeFrom.SERVICE_API
+ queue_manager.graph_runtime_state = _build_runtime_state(run_id)
+ workflow = MagicMock()
+ workflow.id = "workflow-id"
+ workflow.features_dict = {}
+ user = Account(name="user", email="user@example.com")
+ pipeline = WorkflowAppGenerateTaskPipeline(
+ application_generate_entity=_build_generate_entity(run_id),
+ workflow=workflow,
+ queue_manager=queue_manager,
+ user=user,
+ stream=False,
+ draft_var_saver_factory=MagicMock(),
+ )
+ pipeline._database_session = _noop_session
+ return pipeline
+
+
+def test_workflow_app_log_saved_only_on_initial_start() -> None:
+ run_id = "run-initial"
+ pipeline = _build_pipeline(run_id)
+ pipeline._save_workflow_app_log = MagicMock()
+
+ event = QueueWorkflowStartedEvent(reason=WorkflowStartReason.INITIAL)
+ list(pipeline._handle_workflow_started_event(event))
+
+ pipeline._save_workflow_app_log.assert_called_once()
+ _, kwargs = pipeline._save_workflow_app_log.call_args
+ assert kwargs["workflow_run_id"] == run_id
+ assert pipeline._workflow_execution_id == run_id
+
+
+def test_workflow_app_log_skipped_on_resumption_start() -> None:
+ run_id = "run-resume"
+ pipeline = _build_pipeline(run_id)
+ pipeline._save_workflow_app_log = MagicMock()
+
+ event = QueueWorkflowStartedEvent(reason=WorkflowStartReason.RESUMPTION)
+ list(pipeline._handle_workflow_started_event(event))
+
+ pipeline._save_workflow_app_log.assert_not_called()
+ assert pipeline._workflow_execution_id == run_id
diff --git a/api/tests/unit_tests/core/app/entities/test_app_invoke_entities.py b/api/tests/unit_tests/core/app/entities/test_app_invoke_entities.py
new file mode 100644
index 0000000000..86c80985c4
--- /dev/null
+++ b/api/tests/unit_tests/core/app/entities/test_app_invoke_entities.py
@@ -0,0 +1,143 @@
+import json
+from collections.abc import Callable
+from dataclasses import dataclass
+
+import pytest
+
+from core.app.app_config.entities import WorkflowUIBasedAppConfig
+from core.app.entities.app_invoke_entities import (
+ AdvancedChatAppGenerateEntity,
+ InvokeFrom,
+ WorkflowAppGenerateEntity,
+)
+from core.app.layers.pause_state_persist_layer import (
+ WorkflowResumptionContext,
+ _AdvancedChatAppGenerateEntityWrapper,
+ _WorkflowGenerateEntityWrapper,
+)
+from core.ops.ops_trace_manager import TraceQueueManager
+from models.model import AppMode
+
+
+class TraceQueueManagerStub(TraceQueueManager):
+ """Minimal TraceQueueManager stub that avoids Flask dependencies."""
+
+ def __init__(self):
+ # Skip parent initialization to avoid starting timers or accessing Flask globals.
+ pass
+
+
+def _build_workflow_app_config(app_mode: AppMode) -> WorkflowUIBasedAppConfig:
+ return WorkflowUIBasedAppConfig(
+ tenant_id="tenant-id",
+ app_id="app-id",
+ app_mode=app_mode,
+ workflow_id=f"{app_mode.value}-workflow-id",
+ )
+
+
+def _create_workflow_generate_entity(trace_manager: TraceQueueManager | None = None) -> WorkflowAppGenerateEntity:
+ return WorkflowAppGenerateEntity(
+ task_id="workflow-task",
+ app_config=_build_workflow_app_config(AppMode.WORKFLOW),
+ inputs={"topic": "serialization"},
+ files=[],
+ user_id="user-workflow",
+ stream=True,
+ invoke_from=InvokeFrom.DEBUGGER,
+ call_depth=1,
+ trace_manager=trace_manager,
+ workflow_execution_id="workflow-exec-id",
+ extras={"external_trace_id": "trace-id"},
+ )
+
+
+def _create_advanced_chat_generate_entity(
+ trace_manager: TraceQueueManager | None = None,
+) -> AdvancedChatAppGenerateEntity:
+ return AdvancedChatAppGenerateEntity(
+ task_id="advanced-task",
+ app_config=_build_workflow_app_config(AppMode.ADVANCED_CHAT),
+ conversation_id="conversation-id",
+ inputs={"topic": "roundtrip"},
+ files=[],
+ user_id="user-advanced",
+ stream=False,
+ invoke_from=InvokeFrom.DEBUGGER,
+ query="Explain serialization",
+ extras={"auto_generate_conversation_name": True},
+ trace_manager=trace_manager,
+ workflow_run_id="workflow-run-id",
+ )
+
+
+def test_workflow_app_generate_entity_roundtrip_excludes_trace_manager():
+ entity = _create_workflow_generate_entity(trace_manager=TraceQueueManagerStub())
+
+ serialized = entity.model_dump_json()
+ payload = json.loads(serialized)
+
+ assert "trace_manager" not in payload
+
+ restored = WorkflowAppGenerateEntity.model_validate_json(serialized)
+
+ assert restored.model_dump() == entity.model_dump()
+ assert restored.trace_manager is None
+
+
+def test_advanced_chat_generate_entity_roundtrip_excludes_trace_manager():
+ entity = _create_advanced_chat_generate_entity(trace_manager=TraceQueueManagerStub())
+
+ serialized = entity.model_dump_json()
+ payload = json.loads(serialized)
+
+ assert "trace_manager" not in payload
+
+ restored = AdvancedChatAppGenerateEntity.model_validate_json(serialized)
+
+ assert restored.model_dump() == entity.model_dump()
+ assert restored.trace_manager is None
+
+
+@dataclass(frozen=True)
+class ResumptionContextCase:
+ name: str
+ context_factory: Callable[[], tuple[WorkflowResumptionContext, type]]
+
+
+def _workflow_resumption_case() -> tuple[WorkflowResumptionContext, type]:
+ entity = _create_workflow_generate_entity(trace_manager=TraceQueueManagerStub())
+ context = WorkflowResumptionContext(
+ serialized_graph_runtime_state=json.dumps({"state": "workflow"}),
+ generate_entity=_WorkflowGenerateEntityWrapper(entity=entity),
+ )
+ return context, WorkflowAppGenerateEntity
+
+
+def _advanced_chat_resumption_case() -> tuple[WorkflowResumptionContext, type]:
+ entity = _create_advanced_chat_generate_entity(trace_manager=TraceQueueManagerStub())
+ context = WorkflowResumptionContext(
+ serialized_graph_runtime_state=json.dumps({"state": "advanced"}),
+ generate_entity=_AdvancedChatAppGenerateEntityWrapper(entity=entity),
+ )
+ return context, AdvancedChatAppGenerateEntity
+
+
+@pytest.mark.parametrize(
+ "case",
+ [
+ pytest.param(ResumptionContextCase("workflow", _workflow_resumption_case), id="workflow"),
+ pytest.param(ResumptionContextCase("advanced_chat", _advanced_chat_resumption_case), id="advanced_chat"),
+ ],
+)
+def test_workflow_resumption_context_roundtrip(case: ResumptionContextCase):
+ context, expected_type = case.context_factory()
+
+ serialized = context.dumps()
+ restored = WorkflowResumptionContext.loads(serialized)
+
+ assert restored.serialized_graph_runtime_state == context.serialized_graph_runtime_state
+ entity = restored.get_generate_entity()
+ assert isinstance(entity, expected_type)
+ assert entity.model_dump() == context.get_generate_entity().model_dump()
+ assert entity.trace_manager is None
diff --git a/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py b/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py
new file mode 100644
index 0000000000..811ed2143b
--- /dev/null
+++ b/api/tests/unit_tests/core/repositories/test_human_input_form_repository_impl.py
@@ -0,0 +1,574 @@
+"""Unit tests for HumanInputFormRepositoryImpl private helpers."""
+
+from __future__ import annotations
+
+import dataclasses
+from datetime import datetime
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+
+from core.repositories.human_input_repository import (
+ HumanInputFormRecord,
+ HumanInputFormRepositoryImpl,
+ HumanInputFormSubmissionRepository,
+ _WorkspaceMemberInfo,
+)
+from core.workflow.nodes.human_input.entities import (
+ EmailDeliveryConfig,
+ EmailDeliveryMethod,
+ EmailRecipients,
+ ExternalRecipient,
+ FormDefinition,
+ MemberRecipient,
+ UserAction,
+)
+from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
+from libs.datetime_utils import naive_utc_now
+from models.human_input import (
+ EmailExternalRecipientPayload,
+ EmailMemberRecipientPayload,
+ HumanInputFormRecipient,
+ RecipientType,
+)
+
+
+def _build_repository() -> HumanInputFormRepositoryImpl:
+ return HumanInputFormRepositoryImpl(session_factory=MagicMock(), tenant_id="tenant-id")
+
+
+def _patch_recipient_factory(monkeypatch: pytest.MonkeyPatch) -> list[SimpleNamespace]:
+ created: list[SimpleNamespace] = []
+
+ def fake_new(cls, form_id: str, delivery_id: str, payload): # type: ignore[no-untyped-def]
+ recipient = SimpleNamespace(
+ form_id=form_id,
+ delivery_id=delivery_id,
+ recipient_type=payload.TYPE,
+ recipient_payload=payload.model_dump_json(),
+ )
+ created.append(recipient)
+ return recipient
+
+ monkeypatch.setattr(HumanInputFormRecipient, "new", classmethod(fake_new))
+ return created
+
+
+@pytest.fixture(autouse=True)
+def _stub_selectinload(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Avoid SQLAlchemy mapper configuration in tests using fake sessions."""
+
+ class _FakeSelect:
+ def options(self, *_args, **_kwargs): # type: ignore[no-untyped-def]
+ return self
+
+ def where(self, *_args, **_kwargs): # type: ignore[no-untyped-def]
+ return self
+
+ monkeypatch.setattr(
+ "core.repositories.human_input_repository.selectinload", lambda *args, **kwargs: "_loader_option"
+ )
+ monkeypatch.setattr("core.repositories.human_input_repository.select", lambda *args, **kwargs: _FakeSelect())
+
+
+class TestHumanInputFormRepositoryImplHelpers:
+ def test_build_email_recipients_with_member_and_external(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ repo = _build_repository()
+ session_stub = object()
+ _patch_recipient_factory(monkeypatch)
+
+ def fake_query(self, session, restrict_to_user_ids): # type: ignore[no-untyped-def]
+ assert session is session_stub
+ assert restrict_to_user_ids == ["member-1"]
+ return [_WorkspaceMemberInfo(user_id="member-1", email="member@example.com")]
+
+ monkeypatch.setattr(HumanInputFormRepositoryImpl, "_query_workspace_members_by_ids", fake_query)
+
+ recipients = repo._build_email_recipients(
+ session=session_stub,
+ form_id="form-id",
+ delivery_id="delivery-id",
+ recipients_config=EmailRecipients(
+ whole_workspace=False,
+ items=[
+ MemberRecipient(user_id="member-1"),
+ ExternalRecipient(email="external@example.com"),
+ ],
+ ),
+ )
+
+ assert len(recipients) == 2
+ member_recipient = next(r for r in recipients if r.recipient_type == RecipientType.EMAIL_MEMBER)
+ external_recipient = next(r for r in recipients if r.recipient_type == RecipientType.EMAIL_EXTERNAL)
+
+ member_payload = EmailMemberRecipientPayload.model_validate_json(member_recipient.recipient_payload)
+ assert member_payload.user_id == "member-1"
+ assert member_payload.email == "member@example.com"
+
+ external_payload = EmailExternalRecipientPayload.model_validate_json(external_recipient.recipient_payload)
+ assert external_payload.email == "external@example.com"
+
+ def test_build_email_recipients_skips_unknown_members(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ repo = _build_repository()
+ session_stub = object()
+ created = _patch_recipient_factory(monkeypatch)
+
+ def fake_query(self, session, restrict_to_user_ids): # type: ignore[no-untyped-def]
+ assert session is session_stub
+ assert restrict_to_user_ids == ["missing-member"]
+ return []
+
+ monkeypatch.setattr(HumanInputFormRepositoryImpl, "_query_workspace_members_by_ids", fake_query)
+
+ recipients = repo._build_email_recipients(
+ session=session_stub,
+ form_id="form-id",
+ delivery_id="delivery-id",
+ recipients_config=EmailRecipients(
+ whole_workspace=False,
+ items=[
+ MemberRecipient(user_id="missing-member"),
+ ExternalRecipient(email="external@example.com"),
+ ],
+ ),
+ )
+
+ assert len(recipients) == 1
+ assert recipients[0].recipient_type == RecipientType.EMAIL_EXTERNAL
+ assert len(created) == 1 # only external recipient created via factory
+
+ def test_build_email_recipients_whole_workspace_uses_all_members(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ repo = _build_repository()
+ session_stub = object()
+ _patch_recipient_factory(monkeypatch)
+
+ def fake_query(self, session): # type: ignore[no-untyped-def]
+ assert session is session_stub
+ return [
+ _WorkspaceMemberInfo(user_id="member-1", email="member1@example.com"),
+ _WorkspaceMemberInfo(user_id="member-2", email="member2@example.com"),
+ ]
+
+ monkeypatch.setattr(HumanInputFormRepositoryImpl, "_query_all_workspace_members", fake_query)
+
+ recipients = repo._build_email_recipients(
+ session=session_stub,
+ form_id="form-id",
+ delivery_id="delivery-id",
+ recipients_config=EmailRecipients(
+ whole_workspace=True,
+ items=[],
+ ),
+ )
+
+ assert len(recipients) == 2
+ emails = {EmailMemberRecipientPayload.model_validate_json(r.recipient_payload).email for r in recipients}
+ assert emails == {"member1@example.com", "member2@example.com"}
+
+ def test_build_email_recipients_dedupes_external_by_email(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ repo = _build_repository()
+ session_stub = object()
+ created = _patch_recipient_factory(monkeypatch)
+
+ def fake_query(self, session, restrict_to_user_ids): # type: ignore[no-untyped-def]
+ assert session is session_stub
+ assert restrict_to_user_ids == []
+ return []
+
+ monkeypatch.setattr(HumanInputFormRepositoryImpl, "_query_workspace_members_by_ids", fake_query)
+
+ recipients = repo._build_email_recipients(
+ session=session_stub,
+ form_id="form-id",
+ delivery_id="delivery-id",
+ recipients_config=EmailRecipients(
+ whole_workspace=False,
+ items=[
+ ExternalRecipient(email="external@example.com"),
+ ExternalRecipient(email="external@example.com"),
+ ],
+ ),
+ )
+
+ assert len(recipients) == 1
+ assert len(created) == 1
+
+ def test_build_email_recipients_prefers_member_over_external_by_email(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ repo = _build_repository()
+ session_stub = object()
+ _patch_recipient_factory(monkeypatch)
+
+ def fake_query(self, session, restrict_to_user_ids): # type: ignore[no-untyped-def]
+ assert session is session_stub
+ assert restrict_to_user_ids == ["member-1"]
+ return [_WorkspaceMemberInfo(user_id="member-1", email="shared@example.com")]
+
+ monkeypatch.setattr(HumanInputFormRepositoryImpl, "_query_workspace_members_by_ids", fake_query)
+
+ recipients = repo._build_email_recipients(
+ session=session_stub,
+ form_id="form-id",
+ delivery_id="delivery-id",
+ recipients_config=EmailRecipients(
+ whole_workspace=False,
+ items=[
+ MemberRecipient(user_id="member-1"),
+ ExternalRecipient(email="shared@example.com"),
+ ],
+ ),
+ )
+
+ assert len(recipients) == 1
+ assert recipients[0].recipient_type == RecipientType.EMAIL_MEMBER
+
+ def test_delivery_method_to_model_includes_external_recipients_with_whole_workspace(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ repo = _build_repository()
+ session_stub = object()
+ _patch_recipient_factory(monkeypatch)
+
+ def fake_query(self, session): # type: ignore[no-untyped-def]
+ assert session is session_stub
+ return [
+ _WorkspaceMemberInfo(user_id="member-1", email="member1@example.com"),
+ _WorkspaceMemberInfo(user_id="member-2", email="member2@example.com"),
+ ]
+
+ monkeypatch.setattr(HumanInputFormRepositoryImpl, "_query_all_workspace_members", fake_query)
+
+ method = EmailDeliveryMethod(
+ config=EmailDeliveryConfig(
+ recipients=EmailRecipients(
+ whole_workspace=True,
+ items=[ExternalRecipient(email="external@example.com")],
+ ),
+ subject="subject",
+ body="body",
+ )
+ )
+
+ result = repo._delivery_method_to_model(session=session_stub, form_id="form-id", delivery_method=method)
+
+ assert len(result.recipients) == 3
+ member_emails = {
+ EmailMemberRecipientPayload.model_validate_json(r.recipient_payload).email
+ for r in result.recipients
+ if r.recipient_type == RecipientType.EMAIL_MEMBER
+ }
+ assert member_emails == {"member1@example.com", "member2@example.com"}
+ external_payload = EmailExternalRecipientPayload.model_validate_json(
+ next(r for r in result.recipients if r.recipient_type == RecipientType.EMAIL_EXTERNAL).recipient_payload
+ )
+ assert external_payload.email == "external@example.com"
+
+
+def _make_form_definition() -> str:
+ return FormDefinition(
+ form_content="hello",
+ inputs=[],
+ user_actions=[UserAction(id="submit", title="Submit")],
+ rendered_content="hello
",
+ expiration_time=datetime.utcnow(),
+ ).model_dump_json()
+
+
+@dataclasses.dataclass
+class _DummyForm:
+ id: str
+ workflow_run_id: str
+ node_id: str
+ tenant_id: str
+ app_id: str
+ form_definition: str
+ rendered_content: str
+ expiration_time: datetime
+ form_kind: HumanInputFormKind = HumanInputFormKind.RUNTIME
+ created_at: datetime = dataclasses.field(default_factory=naive_utc_now)
+ selected_action_id: str | None = None
+ submitted_data: str | None = None
+ submitted_at: datetime | None = None
+ submission_user_id: str | None = None
+ submission_end_user_id: str | None = None
+ completed_by_recipient_id: str | None = None
+ status: HumanInputFormStatus = HumanInputFormStatus.WAITING
+
+
+@dataclasses.dataclass
+class _DummyRecipient:
+ id: str
+ form_id: str
+ recipient_type: RecipientType
+ access_token: str
+ form: _DummyForm | None = None
+
+
+class _FakeScalarResult:
+ def __init__(self, obj):
+ self._obj = obj
+
+ def first(self):
+ if isinstance(self._obj, list):
+ return self._obj[0] if self._obj else None
+ return self._obj
+
+ def all(self):
+ if isinstance(self._obj, list):
+ return list(self._obj)
+ if self._obj is None:
+ return []
+ return [self._obj]
+
+
+class _FakeSession:
+ def __init__(
+ self,
+ *,
+ scalars_result=None,
+ scalars_results: list[object] | None = None,
+ forms: dict[str, _DummyForm] | None = None,
+ recipients: dict[str, _DummyRecipient] | None = None,
+ ):
+ if scalars_results is not None:
+ self._scalars_queue = list(scalars_results)
+ elif scalars_result is not None:
+ self._scalars_queue = [scalars_result]
+ else:
+ self._scalars_queue = []
+ self.forms = forms or {}
+ self.recipients = recipients or {}
+
+ def scalars(self, _query):
+ if self._scalars_queue:
+ result = self._scalars_queue.pop(0)
+ else:
+ result = None
+ return _FakeScalarResult(result)
+
+ def get(self, model_cls, obj_id): # type: ignore[no-untyped-def]
+ if getattr(model_cls, "__name__", None) == "HumanInputForm":
+ return self.forms.get(obj_id)
+ if getattr(model_cls, "__name__", None) == "HumanInputFormRecipient":
+ return self.recipients.get(obj_id)
+ return None
+
+ def add(self, _obj):
+ return None
+
+ def flush(self):
+ return None
+
+ def refresh(self, _obj):
+ return None
+
+ def begin(self):
+ return self
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, tb):
+ return None
+
+
+def _session_factory(session: _FakeSession):
+ class _SessionContext:
+ def __enter__(self):
+ return session
+
+ def __exit__(self, exc_type, exc, tb):
+ return None
+
+ def _factory(*_args, **_kwargs):
+ return _SessionContext()
+
+ return _factory
+
+
+class TestHumanInputFormRepositoryImplPublicMethods:
+ def test_get_form_returns_entity_and_recipients(self):
+ form = _DummyForm(
+ id="form-1",
+ workflow_run_id="run-1",
+ node_id="node-1",
+ tenant_id="tenant-id",
+ app_id="app-id",
+ form_definition=_make_form_definition(),
+ rendered_content="hello
",
+ expiration_time=naive_utc_now(),
+ )
+ recipient = _DummyRecipient(
+ id="recipient-1",
+ form_id=form.id,
+ recipient_type=RecipientType.STANDALONE_WEB_APP,
+ access_token="token-123",
+ )
+ session = _FakeSession(scalars_results=[form, [recipient]])
+ repo = HumanInputFormRepositoryImpl(_session_factory(session), tenant_id="tenant-id")
+
+ entity = repo.get_form(form.workflow_run_id, form.node_id)
+
+ assert entity is not None
+ assert entity.id == form.id
+ assert entity.web_app_token == "token-123"
+ assert len(entity.recipients) == 1
+ assert entity.recipients[0].token == "token-123"
+
+ def test_get_form_returns_none_when_missing(self):
+ session = _FakeSession(scalars_results=[None])
+ repo = HumanInputFormRepositoryImpl(_session_factory(session), tenant_id="tenant-id")
+
+ assert repo.get_form("run-1", "node-1") is None
+
+ def test_get_form_returns_unsubmitted_state(self):
+ form = _DummyForm(
+ id="form-1",
+ workflow_run_id="run-1",
+ node_id="node-1",
+ tenant_id="tenant-id",
+ app_id="app-id",
+ form_definition=_make_form_definition(),
+ rendered_content="hello
",
+ expiration_time=naive_utc_now(),
+ )
+ session = _FakeSession(scalars_results=[form, []])
+ repo = HumanInputFormRepositoryImpl(_session_factory(session), tenant_id="tenant-id")
+
+ entity = repo.get_form(form.workflow_run_id, form.node_id)
+
+ assert entity is not None
+ assert entity.submitted is False
+ assert entity.selected_action_id is None
+ assert entity.submitted_data is None
+
+ def test_get_form_returns_submission_when_completed(self):
+ form = _DummyForm(
+ id="form-1",
+ workflow_run_id="run-1",
+ node_id="node-1",
+ tenant_id="tenant-id",
+ app_id="app-id",
+ form_definition=_make_form_definition(),
+ rendered_content="hello
",
+ expiration_time=naive_utc_now(),
+ selected_action_id="approve",
+ submitted_data='{"field": "value"}',
+ submitted_at=naive_utc_now(),
+ )
+ session = _FakeSession(scalars_results=[form, []])
+ repo = HumanInputFormRepositoryImpl(_session_factory(session), tenant_id="tenant-id")
+
+ entity = repo.get_form(form.workflow_run_id, form.node_id)
+
+ assert entity is not None
+ assert entity.submitted is True
+ assert entity.selected_action_id == "approve"
+ assert entity.submitted_data == {"field": "value"}
+
+
+class TestHumanInputFormSubmissionRepository:
+ def test_get_by_token_returns_record(self):
+ form = _DummyForm(
+ id="form-1",
+ workflow_run_id="run-1",
+ node_id="node-1",
+ tenant_id="tenant-1",
+ app_id="app-1",
+ form_definition=_make_form_definition(),
+ rendered_content="hello
",
+ expiration_time=naive_utc_now(),
+ )
+ recipient = _DummyRecipient(
+ id="recipient-1",
+ form_id=form.id,
+ recipient_type=RecipientType.STANDALONE_WEB_APP,
+ access_token="token-123",
+ form=form,
+ )
+ session = _FakeSession(scalars_result=recipient)
+ repo = HumanInputFormSubmissionRepository(_session_factory(session))
+
+ record = repo.get_by_token("token-123")
+
+ assert record is not None
+ assert record.form_id == form.id
+ assert record.recipient_type == RecipientType.STANDALONE_WEB_APP
+ assert record.submitted is False
+
+ def test_get_by_form_id_and_recipient_type_uses_recipient(self):
+ form = _DummyForm(
+ id="form-1",
+ workflow_run_id="run-1",
+ node_id="node-1",
+ tenant_id="tenant-1",
+ app_id="app-1",
+ form_definition=_make_form_definition(),
+ rendered_content="hello
",
+ expiration_time=naive_utc_now(),
+ )
+ recipient = _DummyRecipient(
+ id="recipient-1",
+ form_id=form.id,
+ recipient_type=RecipientType.STANDALONE_WEB_APP,
+ access_token="token-123",
+ form=form,
+ )
+ session = _FakeSession(scalars_result=recipient)
+ repo = HumanInputFormSubmissionRepository(_session_factory(session))
+
+ record = repo.get_by_form_id_and_recipient_type(
+ form_id=form.id,
+ recipient_type=RecipientType.STANDALONE_WEB_APP,
+ )
+
+ assert record is not None
+ assert record.recipient_id == recipient.id
+ assert record.access_token == recipient.access_token
+
+ def test_mark_submitted_updates_fields(self, monkeypatch: pytest.MonkeyPatch):
+ fixed_now = datetime(2024, 1, 1, 0, 0, 0)
+ monkeypatch.setattr("core.repositories.human_input_repository.naive_utc_now", lambda: fixed_now)
+
+ form = _DummyForm(
+ id="form-1",
+ workflow_run_id="run-1",
+ node_id="node-1",
+ tenant_id="tenant-1",
+ app_id="app-1",
+ form_definition=_make_form_definition(),
+ rendered_content="hello
",
+ expiration_time=fixed_now,
+ )
+ recipient = _DummyRecipient(
+ id="recipient-1",
+ form_id="form-1",
+ recipient_type=RecipientType.STANDALONE_WEB_APP,
+ access_token="token-123",
+ )
+ session = _FakeSession(
+ forms={form.id: form},
+ recipients={recipient.id: recipient},
+ )
+ repo = HumanInputFormSubmissionRepository(_session_factory(session))
+
+ record: HumanInputFormRecord = repo.mark_submitted(
+ form_id=form.id,
+ recipient_id=recipient.id,
+ selected_action_id="approve",
+ form_data={"field": "value"},
+ submission_user_id="user-1",
+ submission_end_user_id="end-user-1",
+ )
+
+ assert form.selected_action_id == "approve"
+ assert form.completed_by_recipient_id == recipient.id
+ assert form.submission_user_id == "user-1"
+ assert form.submission_end_user_id == "end-user-1"
+ assert form.submitted_at == fixed_now
+ assert record.submitted is True
+ assert record.selected_action_id == "approve"
+ assert record.submitted_data == {"field": "value"}
diff --git a/api/tests/unit_tests/core/tools/utils/test_workflow_configuration_sync.py b/api/tests/unit_tests/core/tools/utils/test_workflow_configuration_sync.py
new file mode 100644
index 0000000000..c46e31d90f
--- /dev/null
+++ b/api/tests/unit_tests/core/tools/utils/test_workflow_configuration_sync.py
@@ -0,0 +1,33 @@
+import pytest
+
+from core.tools.errors import WorkflowToolHumanInputNotSupportedError
+from core.tools.utils.workflow_configuration_sync import WorkflowToolConfigurationUtils
+
+
+def test_ensure_no_human_input_nodes_passes_for_non_human_input():
+ graph = {
+ "nodes": [
+ {
+ "id": "start_node",
+ "data": {"type": "start"},
+ }
+ ]
+ }
+
+ WorkflowToolConfigurationUtils.ensure_no_human_input_nodes(graph)
+
+
+def test_ensure_no_human_input_nodes_raises_for_human_input():
+ graph = {
+ "nodes": [
+ {
+ "id": "human_input_node",
+ "data": {"type": "human-input"},
+ }
+ ]
+ }
+
+ with pytest.raises(WorkflowToolHumanInputNotSupportedError) as exc_info:
+ WorkflowToolConfigurationUtils.ensure_no_human_input_nodes(graph)
+
+ assert exc_info.value.error_code == "workflow_tool_human_input_not_supported"
diff --git a/api/tests/unit_tests/core/workflow/entities/test_graph_runtime_state.py b/api/tests/unit_tests/core/workflow/entities/test_graph_runtime_state.py
index deff06fc5d..1b6d03e36a 100644
--- a/api/tests/unit_tests/core/workflow/entities/test_graph_runtime_state.py
+++ b/api/tests/unit_tests/core/workflow/entities/test_graph_runtime_state.py
@@ -118,7 +118,6 @@ class TestGraphRuntimeState:
from core.workflow.graph_engine.ready_queue import InMemoryReadyQueue
assert isinstance(queue, InMemoryReadyQueue)
- assert state.ready_queue is queue
def test_graph_execution_lazy_instantiation(self):
state = GraphRuntimeState(variable_pool=VariablePool(), start_at=time())
diff --git a/api/tests/unit_tests/core/workflow/entities/test_pause_reason.py b/api/tests/unit_tests/core/workflow/entities/test_pause_reason.py
new file mode 100644
index 0000000000..6144df06e0
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/entities/test_pause_reason.py
@@ -0,0 +1,88 @@
+"""
+Tests for PauseReason discriminated union serialization/deserialization.
+"""
+
+import pytest
+from pydantic import BaseModel, ValidationError
+
+from core.workflow.entities.pause_reason import (
+ HumanInputRequired,
+ PauseReason,
+ SchedulingPause,
+)
+
+
+class _Holder(BaseModel):
+ """Helper model that embeds PauseReason for union tests."""
+
+ reason: PauseReason
+
+
+class TestPauseReasonDiscriminator:
+ """Test suite for PauseReason union discriminator."""
+
+ @pytest.mark.parametrize(
+ ("dict_value", "expected"),
+ [
+ pytest.param(
+ {
+ "reason": {
+ "TYPE": "human_input_required",
+ "form_id": "form_id",
+ "form_content": "form_content",
+ "node_id": "node_id",
+ "node_title": "node_title",
+ },
+ },
+ HumanInputRequired(
+ form_id="form_id",
+ form_content="form_content",
+ node_id="node_id",
+ node_title="node_title",
+ ),
+ id="HumanInputRequired",
+ ),
+ pytest.param(
+ {
+ "reason": {
+ "TYPE": "scheduled_pause",
+ "message": "Hold on",
+ }
+ },
+ SchedulingPause(message="Hold on"),
+ id="SchedulingPause",
+ ),
+ ],
+ )
+ def test_model_validate(self, dict_value, expected):
+ """Ensure scheduled pause payloads with lowercase TYPE deserialize."""
+ holder = _Holder.model_validate(dict_value)
+
+ assert type(holder.reason) == type(expected)
+ assert holder.reason == expected
+
+ @pytest.mark.parametrize(
+ "reason",
+ [
+ HumanInputRequired(
+ form_id="form_id",
+ form_content="form_content",
+ node_id="node_id",
+ node_title="node_title",
+ ),
+ SchedulingPause(message="Hold on"),
+ ],
+ ids=lambda x: type(x).__name__,
+ )
+ def test_model_construct(self, reason):
+ holder = _Holder(reason=reason)
+ assert holder.reason == reason
+
+ def test_model_construct_with_invalid_type(self):
+ with pytest.raises(ValidationError):
+ holder = _Holder(reason=object()) # type: ignore
+
+ def test_unknown_type_fails_validation(self):
+ """Unknown TYPE values should raise a validation error."""
+ with pytest.raises(ValidationError):
+ _Holder.model_validate({"reason": {"TYPE": "UNKNOWN"}})
diff --git a/api/tests/unit_tests/core/workflow/graph_engine/human_input_test_utils.py b/api/tests/unit_tests/core/workflow/graph_engine/human_input_test_utils.py
new file mode 100644
index 0000000000..2ef23c7f0f
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/graph_engine/human_input_test_utils.py
@@ -0,0 +1,131 @@
+"""Utilities for testing HumanInputNode without database dependencies."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from typing import Any
+
+from core.workflow.nodes.human_input.enums import HumanInputFormStatus
+from core.workflow.repositories.human_input_form_repository import (
+ FormCreateParams,
+ HumanInputFormEntity,
+ HumanInputFormRecipientEntity,
+ HumanInputFormRepository,
+)
+from libs.datetime_utils import naive_utc_now
+
+
+class _InMemoryFormRecipient(HumanInputFormRecipientEntity):
+ """Minimal recipient entity required by the repository interface."""
+
+ def __init__(self, recipient_id: str, token: str) -> None:
+ self._id = recipient_id
+ self._token = token
+
+ @property
+ def id(self) -> str:
+ return self._id
+
+ @property
+ def token(self) -> str:
+ return self._token
+
+
+@dataclass
+class _InMemoryFormEntity(HumanInputFormEntity):
+ form_id: str
+ rendered: str
+ token: str | None = None
+ action_id: str | None = None
+ data: Mapping[str, Any] | None = None
+ is_submitted: bool = False
+ status_value: HumanInputFormStatus = HumanInputFormStatus.WAITING
+ expiration: datetime = naive_utc_now()
+
+ @property
+ def id(self) -> str:
+ return self.form_id
+
+ @property
+ def web_app_token(self) -> str | None:
+ return self.token
+
+ @property
+ def recipients(self) -> list[HumanInputFormRecipientEntity]:
+ return []
+
+ @property
+ def rendered_content(self) -> str:
+ return self.rendered
+
+ @property
+ def selected_action_id(self) -> str | None:
+ return self.action_id
+
+ @property
+ def submitted_data(self) -> Mapping[str, Any] | None:
+ return self.data
+
+ @property
+ def submitted(self) -> bool:
+ return self.is_submitted
+
+ @property
+ def status(self) -> HumanInputFormStatus:
+ return self.status_value
+
+ @property
+ def expiration_time(self) -> datetime:
+ return self.expiration
+
+
+class InMemoryHumanInputFormRepository(HumanInputFormRepository):
+ """Pure in-memory repository used by workflow graph engine tests."""
+
+ def __init__(self) -> None:
+ self._form_counter = 0
+ self.created_params: list[FormCreateParams] = []
+ self.created_forms: list[_InMemoryFormEntity] = []
+ self._forms_by_key: dict[tuple[str, str], _InMemoryFormEntity] = {}
+
+ def create_form(self, params: FormCreateParams) -> HumanInputFormEntity:
+ self.created_params.append(params)
+ self._form_counter += 1
+ form_id = f"form-{self._form_counter}"
+ token = f"console-{form_id}" if params.console_recipient_required else f"token-{form_id}"
+ entity = _InMemoryFormEntity(
+ form_id=form_id,
+ rendered=params.rendered_content,
+ token=token,
+ )
+ self.created_forms.append(entity)
+ self._forms_by_key[(params.workflow_execution_id, params.node_id)] = entity
+ return entity
+
+ def get_form(self, workflow_execution_id: str, node_id: str) -> HumanInputFormEntity | None:
+ return self._forms_by_key.get((workflow_execution_id, node_id))
+
+ # Convenience helpers for tests -------------------------------------
+
+ def set_submission(self, *, action_id: str, form_data: Mapping[str, Any] | None = None) -> None:
+ """Simulate a human submission for the next repository lookup."""
+
+ if not self.created_forms:
+ raise AssertionError("no form has been created to attach submission data")
+ entity = self.created_forms[-1]
+ entity.action_id = action_id
+ entity.data = form_data or {}
+ entity.is_submitted = True
+ entity.status_value = HumanInputFormStatus.SUBMITTED
+ entity.expiration = naive_utc_now() + timedelta(days=1)
+
+ def clear_submission(self) -> None:
+ if not self.created_forms:
+ return
+ for form in self.created_forms:
+ form.action_id = None
+ form.data = None
+ form.is_submitted = False
+ form.status_value = HumanInputFormStatus.WAITING
diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_dispatcher_pause_drain.py b/api/tests/unit_tests/core/workflow/graph_engine/test_dispatcher_pause_drain.py
new file mode 100644
index 0000000000..6038a15211
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/graph_engine/test_dispatcher_pause_drain.py
@@ -0,0 +1,74 @@
+import queue
+import threading
+from datetime import datetime
+
+from core.workflow.enums import NodeType, WorkflowNodeExecutionStatus
+from core.workflow.graph_engine.orchestration.dispatcher import Dispatcher
+from core.workflow.graph_events import NodeRunSucceededEvent
+from core.workflow.node_events import NodeRunResult
+
+
+class StubExecutionCoordinator:
+ def __init__(self, paused: bool) -> None:
+ self._paused = paused
+ self.mark_complete_called = False
+ self.failed_error: Exception | None = None
+
+ @property
+ def aborted(self) -> bool:
+ return False
+
+ @property
+ def paused(self) -> bool:
+ return self._paused
+
+ @property
+ def execution_complete(self) -> bool:
+ return False
+
+ def check_scaling(self) -> None:
+ return None
+
+ def process_commands(self) -> None:
+ return None
+
+ def mark_complete(self) -> None:
+ self.mark_complete_called = True
+
+ def mark_failed(self, error: Exception) -> None:
+ self.failed_error = error
+
+
+class StubEventHandler:
+ def __init__(self) -> None:
+ self.events: list[object] = []
+
+ def dispatch(self, event: object) -> None:
+ self.events.append(event)
+
+
+def test_dispatcher_drains_events_when_paused() -> None:
+ event_queue: queue.Queue = queue.Queue()
+ event = NodeRunSucceededEvent(
+ id="exec-1",
+ node_id="node-1",
+ node_type=NodeType.START,
+ start_at=datetime.utcnow(),
+ node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED),
+ )
+ event_queue.put(event)
+
+ handler = StubEventHandler()
+ coordinator = StubExecutionCoordinator(paused=True)
+ dispatcher = Dispatcher(
+ event_queue=event_queue,
+ event_handler=handler,
+ execution_coordinator=coordinator,
+ event_emitter=None,
+ stop_event=threading.Event(),
+ )
+
+ dispatcher._dispatcher_loop()
+
+ assert handler.events == [event]
+ assert coordinator.mark_complete_called is True
diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_execution_coordinator.py b/api/tests/unit_tests/core/workflow/graph_engine/test_execution_coordinator.py
index 0d67a76169..53de8908a8 100644
--- a/api/tests/unit_tests/core/workflow/graph_engine/test_execution_coordinator.py
+++ b/api/tests/unit_tests/core/workflow/graph_engine/test_execution_coordinator.py
@@ -2,6 +2,8 @@
from unittest.mock import MagicMock
+import pytest
+
from core.workflow.graph_engine.command_processing.command_processor import CommandProcessor
from core.workflow.graph_engine.domain.graph_execution import GraphExecution
from core.workflow.graph_engine.graph_state_manager import GraphStateManager
@@ -48,3 +50,13 @@ def test_handle_pause_noop_when_execution_running() -> None:
worker_pool.stop.assert_not_called()
state_manager.clear_executing.assert_not_called()
+
+
+def test_has_executing_nodes_requires_pause() -> None:
+ graph_execution = GraphExecution(workflow_id="workflow")
+ graph_execution.start()
+
+ coordinator, _, _ = _build_coordinator(graph_execution)
+
+ with pytest.raises(AssertionError):
+ coordinator.has_executing_nodes()
diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_graph_state_snapshot.py b/api/tests/unit_tests/core/workflow/graph_engine/test_graph_state_snapshot.py
new file mode 100644
index 0000000000..65d34c2009
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/graph_engine/test_graph_state_snapshot.py
@@ -0,0 +1,189 @@
+import time
+from collections.abc import Mapping
+
+from core.model_runtime.entities.llm_entities import LLMMode
+from core.model_runtime.entities.message_entities import PromptMessageRole
+from core.workflow.entities import GraphInitParams
+from core.workflow.enums import NodeState
+from core.workflow.graph import Graph
+from core.workflow.graph_engine.graph_state_manager import GraphStateManager
+from core.workflow.graph_engine.ready_queue import InMemoryReadyQueue
+from core.workflow.nodes.end.end_node import EndNode
+from core.workflow.nodes.end.entities import EndNodeData
+from core.workflow.nodes.llm.entities import (
+ ContextConfig,
+ LLMNodeChatModelMessage,
+ LLMNodeData,
+ ModelConfig,
+ VisionConfig,
+)
+from core.workflow.nodes.start.entities import StartNodeData
+from core.workflow.nodes.start.start_node import StartNode
+from core.workflow.runtime import GraphRuntimeState, VariablePool
+from core.workflow.system_variable import SystemVariable
+
+from .test_mock_config import MockConfig
+from .test_mock_nodes import MockLLMNode
+
+
+def _build_runtime_state() -> GraphRuntimeState:
+ variable_pool = VariablePool(
+ system_variables=SystemVariable(
+ user_id="user",
+ app_id="app",
+ workflow_id="workflow",
+ workflow_execution_id="exec-1",
+ ),
+ user_inputs={},
+ conversation_variables=[],
+ )
+ return GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
+
+
+def _build_llm_node(
+ *,
+ node_id: str,
+ runtime_state: GraphRuntimeState,
+ graph_init_params: GraphInitParams,
+ mock_config: MockConfig,
+) -> MockLLMNode:
+ llm_data = LLMNodeData(
+ title=f"LLM {node_id}",
+ model=ModelConfig(provider="openai", name="gpt-3.5-turbo", mode=LLMMode.CHAT, completion_params={}),
+ prompt_template=[
+ LLMNodeChatModelMessage(
+ text=f"Prompt {node_id}",
+ role=PromptMessageRole.USER,
+ edition_type="basic",
+ )
+ ],
+ context=ContextConfig(enabled=False, variable_selector=None),
+ vision=VisionConfig(enabled=False),
+ reasoning_format="tagged",
+ )
+ llm_config = {"id": node_id, "data": llm_data.model_dump()}
+ return MockLLMNode(
+ id=llm_config["id"],
+ config=llm_config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ mock_config=mock_config,
+ )
+
+
+def _build_graph(runtime_state: GraphRuntimeState) -> Graph:
+ graph_config: dict[str, object] = {"nodes": [], "edges": []}
+ graph_init_params = GraphInitParams(
+ tenant_id="tenant",
+ app_id="app",
+ workflow_id="workflow",
+ graph_config=graph_config,
+ user_id="user",
+ user_from="account",
+ invoke_from="debugger",
+ call_depth=0,
+ )
+
+ start_config = {"id": "start", "data": StartNodeData(title="Start", variables=[]).model_dump()}
+ start_node = StartNode(
+ id=start_config["id"],
+ config=start_config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ )
+
+ mock_config = MockConfig()
+ llm_a = _build_llm_node(
+ node_id="llm_a",
+ runtime_state=runtime_state,
+ graph_init_params=graph_init_params,
+ mock_config=mock_config,
+ )
+ llm_b = _build_llm_node(
+ node_id="llm_b",
+ runtime_state=runtime_state,
+ graph_init_params=graph_init_params,
+ mock_config=mock_config,
+ )
+
+ end_data = EndNodeData(title="End", outputs=[], desc=None)
+ end_config = {"id": "end", "data": end_data.model_dump()}
+ end_node = EndNode(
+ id=end_config["id"],
+ config=end_config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ )
+
+ builder = (
+ Graph.new()
+ .add_root(start_node)
+ .add_node(llm_a, from_node_id="start")
+ .add_node(llm_b, from_node_id="start")
+ .add_node(end_node, from_node_id="llm_a")
+ )
+ return builder.connect(tail="llm_b", head="end").build()
+
+
+def _edge_state_map(graph: Graph) -> Mapping[tuple[str, str, str], NodeState]:
+ return {(edge.tail, edge.head, edge.source_handle): edge.state for edge in graph.edges.values()}
+
+
+def test_runtime_state_snapshot_restores_graph_states() -> None:
+ runtime_state = _build_runtime_state()
+ graph = _build_graph(runtime_state)
+ runtime_state.attach_graph(graph)
+
+ graph.nodes["llm_a"].state = NodeState.TAKEN
+ graph.nodes["llm_b"].state = NodeState.SKIPPED
+
+ for edge in graph.edges.values():
+ if edge.tail == "start" and edge.head == "llm_a":
+ edge.state = NodeState.TAKEN
+ elif edge.tail == "start" and edge.head == "llm_b":
+ edge.state = NodeState.SKIPPED
+ elif edge.head == "end" and edge.tail == "llm_a":
+ edge.state = NodeState.TAKEN
+ elif edge.head == "end" and edge.tail == "llm_b":
+ edge.state = NodeState.SKIPPED
+
+ snapshot = runtime_state.dumps()
+
+ resumed_state = GraphRuntimeState.from_snapshot(snapshot)
+ resumed_graph = _build_graph(resumed_state)
+ resumed_state.attach_graph(resumed_graph)
+
+ assert resumed_graph.nodes["llm_a"].state == NodeState.TAKEN
+ assert resumed_graph.nodes["llm_b"].state == NodeState.SKIPPED
+ assert _edge_state_map(resumed_graph) == _edge_state_map(graph)
+
+
+def test_join_readiness_uses_restored_edge_states() -> None:
+ runtime_state = _build_runtime_state()
+ graph = _build_graph(runtime_state)
+ runtime_state.attach_graph(graph)
+
+ ready_queue = InMemoryReadyQueue()
+ state_manager = GraphStateManager(graph, ready_queue)
+
+ for edge in graph.get_incoming_edges("end"):
+ if edge.tail == "llm_a":
+ edge.state = NodeState.TAKEN
+ if edge.tail == "llm_b":
+ edge.state = NodeState.UNKNOWN
+
+ assert state_manager.is_node_ready("end") is False
+
+ for edge in graph.get_incoming_edges("end"):
+ if edge.tail == "llm_b":
+ edge.state = NodeState.TAKEN
+
+ assert state_manager.is_node_ready("end") is True
+
+ snapshot = runtime_state.dumps()
+ resumed_state = GraphRuntimeState.from_snapshot(snapshot)
+ resumed_graph = _build_graph(resumed_state)
+ resumed_state.attach_graph(resumed_graph)
+
+ resumed_state_manager = GraphStateManager(resumed_graph, InMemoryReadyQueue())
+ assert resumed_state_manager.is_node_ready("end") is True
diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_human_input_pause_multi_branch.py b/api/tests/unit_tests/core/workflow/graph_engine/test_human_input_pause_multi_branch.py
index c398e4e8c1..194d009288 100644
--- a/api/tests/unit_tests/core/workflow/graph_engine/test_human_input_pause_multi_branch.py
+++ b/api/tests/unit_tests/core/workflow/graph_engine/test_human_input_pause_multi_branch.py
@@ -1,5 +1,7 @@
+import datetime
import time
from collections.abc import Iterable
+from unittest.mock import MagicMock
from core.model_runtime.entities.llm_entities import LLMMode
from core.model_runtime.entities.message_entities import PromptMessageRole
@@ -14,11 +16,12 @@ from core.workflow.graph_events import (
NodeRunStreamChunkEvent,
NodeRunSucceededEvent,
)
+from core.workflow.graph_events.node import NodeRunHumanInputFormFilledEvent
from core.workflow.nodes.base.entities import OutputVariableEntity, OutputVariableType
from core.workflow.nodes.end.end_node import EndNode
from core.workflow.nodes.end.entities import EndNodeData
-from core.workflow.nodes.human_input import HumanInputNode
-from core.workflow.nodes.human_input.entities import HumanInputNodeData
+from core.workflow.nodes.human_input.entities import HumanInputNodeData, UserAction
+from core.workflow.nodes.human_input.human_input_node import HumanInputNode
from core.workflow.nodes.llm.entities import (
ContextConfig,
LLMNodeChatModelMessage,
@@ -28,15 +31,21 @@ from core.workflow.nodes.llm.entities import (
)
from core.workflow.nodes.start.entities import StartNodeData
from core.workflow.nodes.start.start_node import StartNode
+from core.workflow.repositories.human_input_form_repository import HumanInputFormEntity, HumanInputFormRepository
from core.workflow.runtime import GraphRuntimeState, VariablePool
from core.workflow.system_variable import SystemVariable
+from libs.datetime_utils import naive_utc_now
from .test_mock_config import MockConfig
from .test_mock_nodes import MockLLMNode
from .test_table_runner import TableTestRunner, WorkflowTestCase
-def _build_branching_graph(mock_config: MockConfig) -> tuple[Graph, GraphRuntimeState]:
+def _build_branching_graph(
+ mock_config: MockConfig,
+ form_repository: HumanInputFormRepository,
+ graph_runtime_state: GraphRuntimeState | None = None,
+) -> tuple[Graph, GraphRuntimeState]:
graph_config: dict[str, object] = {"nodes": [], "edges": []}
graph_init_params = GraphInitParams(
tenant_id="tenant",
@@ -49,12 +58,18 @@ def _build_branching_graph(mock_config: MockConfig) -> tuple[Graph, GraphRuntime
call_depth=0,
)
- variable_pool = VariablePool(
- system_variables=SystemVariable(user_id="user", app_id="app", workflow_id="workflow"),
- user_inputs={},
- conversation_variables=[],
- )
- graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
+ if graph_runtime_state is None:
+ variable_pool = VariablePool(
+ system_variables=SystemVariable(
+ user_id="user",
+ app_id="app",
+ workflow_id="workflow",
+ workflow_execution_id="test-execution-id",
+ ),
+ user_inputs={},
+ conversation_variables=[],
+ )
+ graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
start_config = {"id": "start", "data": StartNodeData(title="Start", variables=[]).model_dump()}
start_node = StartNode(
@@ -93,15 +108,21 @@ def _build_branching_graph(mock_config: MockConfig) -> tuple[Graph, GraphRuntime
human_data = HumanInputNodeData(
title="Human Input",
- required_variables=["human.input_ready"],
- pause_reason="Awaiting human input",
+ form_content="Human input required",
+ inputs=[],
+ user_actions=[
+ UserAction(id="primary", title="Primary"),
+ UserAction(id="secondary", title="Secondary"),
+ ],
)
+
human_config = {"id": "human", "data": human_data.model_dump()}
human_node = HumanInputNode(
id=human_config["id"],
config=human_config,
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
+ form_repository=form_repository,
)
llm_primary = _create_llm_node("llm_primary", "Primary LLM", "Primary stream output")
@@ -219,8 +240,18 @@ def test_human_input_llm_streaming_across_multiple_branches() -> None:
for scenario in branch_scenarios:
runner = TableTestRunner()
- def initial_graph_factory() -> tuple[Graph, GraphRuntimeState]:
- return _build_branching_graph(mock_config)
+ mock_create_repo = MagicMock(spec=HumanInputFormRepository)
+ mock_create_repo.get_form.return_value = None
+ mock_form_entity = MagicMock(spec=HumanInputFormEntity)
+ mock_form_entity.id = "test_form_id"
+ mock_form_entity.web_app_token = "test_web_app_token"
+ mock_form_entity.recipients = []
+ mock_form_entity.rendered_content = "rendered"
+ mock_form_entity.submitted = False
+ mock_create_repo.create_form.return_value = mock_form_entity
+
+ def initial_graph_factory(mock_create_repo=mock_create_repo) -> tuple[Graph, GraphRuntimeState]:
+ return _build_branching_graph(mock_config, mock_create_repo)
initial_case = WorkflowTestCase(
description="HumanInput pause before branching decision",
@@ -242,23 +273,16 @@ def test_human_input_llm_streaming_across_multiple_branches() -> None:
assert initial_result.success, initial_result.event_mismatch_details
assert not any(isinstance(event, NodeRunStreamChunkEvent) for event in initial_result.events)
- graph_runtime_state = initial_result.graph_runtime_state
- graph = initial_result.graph
- assert graph_runtime_state is not None
- assert graph is not None
-
- graph_runtime_state.variable_pool.add(("human", "input_ready"), True)
- graph_runtime_state.variable_pool.add(("human", "edge_source_handle"), scenario["handle"])
- graph_runtime_state.graph_execution.pause_reason = None
-
pre_chunk_count = sum(len(chunks) for _, chunks in scenario["expected_pre_chunks"])
post_chunk_count = sum(len(chunks) for _, chunks in scenario["expected_post_chunks"])
+ expected_pre_chunk_events_in_resumption = [
+ GraphRunStartedEvent,
+ NodeRunStartedEvent,
+ NodeRunHumanInputFormFilledEvent,
+ ]
expected_resume_sequence: list[type] = (
- [
- GraphRunStartedEvent,
- NodeRunStartedEvent,
- ]
+ expected_pre_chunk_events_in_resumption
+ [NodeRunStreamChunkEvent] * pre_chunk_count
+ [
NodeRunSucceededEvent,
@@ -273,11 +297,25 @@ def test_human_input_llm_streaming_across_multiple_branches() -> None:
]
)
+ mock_get_repo = MagicMock(spec=HumanInputFormRepository)
+ submitted_form = MagicMock(spec=HumanInputFormEntity)
+ submitted_form.id = mock_form_entity.id
+ submitted_form.web_app_token = mock_form_entity.web_app_token
+ submitted_form.recipients = []
+ submitted_form.rendered_content = mock_form_entity.rendered_content
+ submitted_form.submitted = True
+ submitted_form.selected_action_id = scenario["handle"]
+ submitted_form.submitted_data = {}
+ submitted_form.expiration_time = naive_utc_now() + datetime.timedelta(days=1)
+ mock_get_repo.get_form.return_value = submitted_form
+
def resume_graph_factory(
- graph_snapshot: Graph = graph,
- state_snapshot: GraphRuntimeState = graph_runtime_state,
+ initial_result=initial_result, mock_get_repo=mock_get_repo
) -> tuple[Graph, GraphRuntimeState]:
- return graph_snapshot, state_snapshot
+ assert initial_result.graph_runtime_state is not None
+ serialized_runtime_state = initial_result.graph_runtime_state.dumps()
+ resume_runtime_state = GraphRuntimeState.from_snapshot(serialized_runtime_state)
+ return _build_branching_graph(mock_config, mock_get_repo, resume_runtime_state)
resume_case = WorkflowTestCase(
description=f"HumanInput resumes via {scenario['handle']} branch",
@@ -321,7 +359,8 @@ def test_human_input_llm_streaming_across_multiple_branches() -> None:
for index, event in enumerate(resume_events)
if isinstance(event, NodeRunStreamChunkEvent) and index < human_success_index
]
- assert pre_indices == list(range(2, 2 + pre_chunk_count))
+ expected_pre_chunk_events_count_in_resumption = len(expected_pre_chunk_events_in_resumption)
+ assert pre_indices == list(range(expected_pre_chunk_events_count_in_resumption, human_success_index))
resume_chunk_indices = [
index
diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_human_input_pause_single_branch.py b/api/tests/unit_tests/core/workflow/graph_engine/test_human_input_pause_single_branch.py
index ece69b080b..d8f229205b 100644
--- a/api/tests/unit_tests/core/workflow/graph_engine/test_human_input_pause_single_branch.py
+++ b/api/tests/unit_tests/core/workflow/graph_engine/test_human_input_pause_single_branch.py
@@ -1,4 +1,6 @@
+import datetime
import time
+from unittest.mock import MagicMock
from core.model_runtime.entities.llm_entities import LLMMode
from core.model_runtime.entities.message_entities import PromptMessageRole
@@ -13,11 +15,12 @@ from core.workflow.graph_events import (
NodeRunStreamChunkEvent,
NodeRunSucceededEvent,
)
+from core.workflow.graph_events.node import NodeRunHumanInputFormFilledEvent
from core.workflow.nodes.base.entities import OutputVariableEntity, OutputVariableType
from core.workflow.nodes.end.end_node import EndNode
from core.workflow.nodes.end.entities import EndNodeData
-from core.workflow.nodes.human_input import HumanInputNode
-from core.workflow.nodes.human_input.entities import HumanInputNodeData
+from core.workflow.nodes.human_input.entities import HumanInputNodeData, UserAction
+from core.workflow.nodes.human_input.human_input_node import HumanInputNode
from core.workflow.nodes.llm.entities import (
ContextConfig,
LLMNodeChatModelMessage,
@@ -27,15 +30,21 @@ from core.workflow.nodes.llm.entities import (
)
from core.workflow.nodes.start.entities import StartNodeData
from core.workflow.nodes.start.start_node import StartNode
+from core.workflow.repositories.human_input_form_repository import HumanInputFormEntity, HumanInputFormRepository
from core.workflow.runtime import GraphRuntimeState, VariablePool
from core.workflow.system_variable import SystemVariable
+from libs.datetime_utils import naive_utc_now
from .test_mock_config import MockConfig
from .test_mock_nodes import MockLLMNode
from .test_table_runner import TableTestRunner, WorkflowTestCase
-def _build_llm_human_llm_graph(mock_config: MockConfig) -> tuple[Graph, GraphRuntimeState]:
+def _build_llm_human_llm_graph(
+ mock_config: MockConfig,
+ form_repository: HumanInputFormRepository,
+ graph_runtime_state: GraphRuntimeState | None = None,
+) -> tuple[Graph, GraphRuntimeState]:
graph_config: dict[str, object] = {"nodes": [], "edges": []}
graph_init_params = GraphInitParams(
tenant_id="tenant",
@@ -48,12 +57,15 @@ def _build_llm_human_llm_graph(mock_config: MockConfig) -> tuple[Graph, GraphRun
call_depth=0,
)
- variable_pool = VariablePool(
- system_variables=SystemVariable(user_id="user", app_id="app", workflow_id="workflow"),
- user_inputs={},
- conversation_variables=[],
- )
- graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
+ if graph_runtime_state is None:
+ variable_pool = VariablePool(
+ system_variables=SystemVariable(
+ user_id="user", app_id="app", workflow_id="workflow", workflow_execution_id="test-execution-id,"
+ ),
+ user_inputs={},
+ conversation_variables=[],
+ )
+ graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
start_config = {"id": "start", "data": StartNodeData(title="Start", variables=[]).model_dump()}
start_node = StartNode(
@@ -92,15 +104,21 @@ def _build_llm_human_llm_graph(mock_config: MockConfig) -> tuple[Graph, GraphRun
human_data = HumanInputNodeData(
title="Human Input",
- required_variables=["human.input_ready"],
- pause_reason="Awaiting human input",
+ form_content="Human input required",
+ inputs=[],
+ user_actions=[
+ UserAction(id="accept", title="Accept"),
+ UserAction(id="reject", title="Reject"),
+ ],
)
+
human_config = {"id": "human", "data": human_data.model_dump()}
human_node = HumanInputNode(
id=human_config["id"],
config=human_config,
graph_init_params=graph_init_params,
graph_runtime_state=graph_runtime_state,
+ form_repository=form_repository,
)
llm_second = _create_llm_node("llm_resume", "Follow-up LLM", "Follow-up prompt")
@@ -130,7 +148,7 @@ def _build_llm_human_llm_graph(mock_config: MockConfig) -> tuple[Graph, GraphRun
.add_root(start_node)
.add_node(llm_first)
.add_node(human_node)
- .add_node(llm_second)
+ .add_node(llm_second, source_handle="accept")
.add_node(end_node)
.build()
)
@@ -167,8 +185,18 @@ def test_human_input_llm_streaming_order_across_pause() -> None:
GraphRunPausedEvent, # graph run pauses awaiting resume
]
+ mock_create_repo = MagicMock(spec=HumanInputFormRepository)
+ mock_create_repo.get_form.return_value = None
+ mock_form_entity = MagicMock(spec=HumanInputFormEntity)
+ mock_form_entity.id = "test_form_id"
+ mock_form_entity.web_app_token = "test_web_app_token"
+ mock_form_entity.recipients = []
+ mock_form_entity.rendered_content = "rendered"
+ mock_form_entity.submitted = False
+ mock_create_repo.create_form.return_value = mock_form_entity
+
def graph_factory() -> tuple[Graph, GraphRuntimeState]:
- return _build_llm_human_llm_graph(mock_config)
+ return _build_llm_human_llm_graph(mock_config, mock_create_repo)
initial_case = WorkflowTestCase(
description="HumanInput pause preserves LLM streaming order",
@@ -210,6 +238,8 @@ def test_human_input_llm_streaming_order_across_pause() -> None:
expected_resume_sequence: list[type] = [
GraphRunStartedEvent, # resumed graph run begins
NodeRunStartedEvent, # human node restarts
+ # Form Filled should be generated first, then the node execution ends and stream chunk is generated.
+ NodeRunHumanInputFormFilledEvent,
NodeRunStreamChunkEvent, # cached llm_initial chunk 1
NodeRunStreamChunkEvent, # cached llm_initial chunk 2
NodeRunStreamChunkEvent, # cached llm_initial final chunk
@@ -225,12 +255,27 @@ def test_human_input_llm_streaming_order_across_pause() -> None:
GraphRunSucceededEvent, # graph run succeeds after resume
]
+ mock_get_repo = MagicMock(spec=HumanInputFormRepository)
+ submitted_form = MagicMock(spec=HumanInputFormEntity)
+ submitted_form.id = mock_form_entity.id
+ submitted_form.web_app_token = mock_form_entity.web_app_token
+ submitted_form.recipients = []
+ submitted_form.rendered_content = mock_form_entity.rendered_content
+ submitted_form.submitted = True
+ submitted_form.selected_action_id = "accept"
+ submitted_form.submitted_data = {}
+ submitted_form.expiration_time = naive_utc_now() + datetime.timedelta(days=1)
+ mock_get_repo.get_form.return_value = submitted_form
+
def resume_graph_factory() -> tuple[Graph, GraphRuntimeState]:
- assert graph_runtime_state is not None
- assert graph is not None
- graph_runtime_state.variable_pool.add(("human", "input_ready"), True)
- graph_runtime_state.graph_execution.pause_reason = None
- return graph, graph_runtime_state
+ # restruct the graph runtime state
+ serialized_runtime_state = initial_result.graph_runtime_state.dumps()
+ resume_runtime_state = GraphRuntimeState.from_snapshot(serialized_runtime_state)
+ return _build_llm_human_llm_graph(
+ mock_config,
+ mock_get_repo,
+ resume_runtime_state,
+ )
resume_case = WorkflowTestCase(
description="HumanInput resume continues LLM streaming order",
diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py
new file mode 100644
index 0000000000..a6aab81f6c
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py
@@ -0,0 +1,270 @@
+import time
+from collections.abc import Mapping
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from typing import Any, Protocol
+
+from core.workflow.entities import GraphInitParams
+from core.workflow.entities.workflow_start_reason import WorkflowStartReason
+from core.workflow.graph import Graph
+from core.workflow.graph_engine.command_channels.in_memory_channel import InMemoryChannel
+from core.workflow.graph_engine.config import GraphEngineConfig
+from core.workflow.graph_engine.graph_engine import GraphEngine
+from core.workflow.graph_events import (
+ GraphRunPausedEvent,
+ GraphRunStartedEvent,
+ GraphRunSucceededEvent,
+ NodeRunSucceededEvent,
+)
+from core.workflow.nodes.base.entities import OutputVariableEntity
+from core.workflow.nodes.end.end_node import EndNode
+from core.workflow.nodes.end.entities import EndNodeData
+from core.workflow.nodes.human_input.entities import HumanInputNodeData, UserAction
+from core.workflow.nodes.human_input.enums import HumanInputFormStatus
+from core.workflow.nodes.human_input.human_input_node import HumanInputNode
+from core.workflow.nodes.start.entities import StartNodeData
+from core.workflow.nodes.start.start_node import StartNode
+from core.workflow.repositories.human_input_form_repository import (
+ FormCreateParams,
+ HumanInputFormEntity,
+ HumanInputFormRepository,
+)
+from core.workflow.runtime import GraphRuntimeState, VariablePool
+from core.workflow.system_variable import SystemVariable
+from libs.datetime_utils import naive_utc_now
+
+
+class PauseStateStore(Protocol):
+ def save(self, runtime_state: GraphRuntimeState) -> None: ...
+
+ def load(self) -> GraphRuntimeState: ...
+
+
+class InMemoryPauseStore:
+ def __init__(self) -> None:
+ self._snapshot: str | None = None
+
+ def save(self, runtime_state: GraphRuntimeState) -> None:
+ self._snapshot = runtime_state.dumps()
+
+ def load(self) -> GraphRuntimeState:
+ assert self._snapshot is not None
+ return GraphRuntimeState.from_snapshot(self._snapshot)
+
+
+@dataclass
+class StaticForm(HumanInputFormEntity):
+ form_id: str
+ rendered: str
+ is_submitted: bool
+ action_id: str | None = None
+ data: Mapping[str, Any] | None = None
+ status_value: HumanInputFormStatus = HumanInputFormStatus.WAITING
+ expiration: datetime = naive_utc_now() + timedelta(days=1)
+
+ @property
+ def id(self) -> str:
+ return self.form_id
+
+ @property
+ def web_app_token(self) -> str | None:
+ return "token"
+
+ @property
+ def recipients(self) -> list:
+ return []
+
+ @property
+ def rendered_content(self) -> str:
+ return self.rendered
+
+ @property
+ def selected_action_id(self) -> str | None:
+ return self.action_id
+
+ @property
+ def submitted_data(self) -> Mapping[str, Any] | None:
+ return self.data
+
+ @property
+ def submitted(self) -> bool:
+ return self.is_submitted
+
+ @property
+ def status(self) -> HumanInputFormStatus:
+ return self.status_value
+
+ @property
+ def expiration_time(self) -> datetime:
+ return self.expiration
+
+
+class StaticRepo(HumanInputFormRepository):
+ def __init__(self, forms_by_node_id: Mapping[str, HumanInputFormEntity]) -> None:
+ self._forms_by_node_id = dict(forms_by_node_id)
+
+ def get_form(self, workflow_execution_id: str, node_id: str) -> HumanInputFormEntity | None:
+ return self._forms_by_node_id.get(node_id)
+
+ def create_form(self, params: FormCreateParams) -> HumanInputFormEntity:
+ raise AssertionError("create_form should not be called in resume scenario")
+
+
+def _build_runtime_state() -> GraphRuntimeState:
+ variable_pool = VariablePool(
+ system_variables=SystemVariable(
+ user_id="user",
+ app_id="app",
+ workflow_id="workflow",
+ workflow_execution_id="exec-1",
+ ),
+ user_inputs={},
+ conversation_variables=[],
+ )
+ return GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
+
+
+def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepository) -> Graph:
+ graph_config: dict[str, object] = {"nodes": [], "edges": []}
+ graph_init_params = GraphInitParams(
+ tenant_id="tenant",
+ app_id="app",
+ workflow_id="workflow",
+ graph_config=graph_config,
+ user_id="user",
+ user_from="account",
+ invoke_from="debugger",
+ call_depth=0,
+ )
+
+ start_config = {"id": "start", "data": StartNodeData(title="Start", variables=[]).model_dump()}
+ start_node = StartNode(
+ id=start_config["id"],
+ config=start_config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ )
+
+ human_data = HumanInputNodeData(
+ title="Human Input",
+ form_content="Human input required",
+ inputs=[],
+ user_actions=[UserAction(id="approve", title="Approve")],
+ )
+
+ human_a_config = {"id": "human_a", "data": human_data.model_dump()}
+ human_a = HumanInputNode(
+ id=human_a_config["id"],
+ config=human_a_config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ form_repository=repo,
+ )
+
+ human_b_config = {"id": "human_b", "data": human_data.model_dump()}
+ human_b = HumanInputNode(
+ id=human_b_config["id"],
+ config=human_b_config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ form_repository=repo,
+ )
+
+ end_data = EndNodeData(
+ title="End",
+ outputs=[
+ OutputVariableEntity(variable="res_a", value_selector=["human_a", "__action_id"]),
+ OutputVariableEntity(variable="res_b", value_selector=["human_b", "__action_id"]),
+ ],
+ desc=None,
+ )
+ end_config = {"id": "end", "data": end_data.model_dump()}
+ end_node = EndNode(
+ id=end_config["id"],
+ config=end_config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ )
+
+ builder = (
+ Graph.new()
+ .add_root(start_node)
+ .add_node(human_a, from_node_id="start")
+ .add_node(human_b, from_node_id="start")
+ .add_node(end_node, from_node_id="human_a", source_handle="approve")
+ )
+ return builder.connect(tail="human_b", head="end", source_handle="approve").build()
+
+
+def _run_graph(graph: Graph, runtime_state: GraphRuntimeState) -> list[object]:
+ engine = GraphEngine(
+ workflow_id="workflow",
+ graph=graph,
+ graph_runtime_state=runtime_state,
+ command_channel=InMemoryChannel(),
+ config=GraphEngineConfig(
+ min_workers=2,
+ max_workers=2,
+ scale_up_threshold=1,
+ scale_down_idle_time=30.0,
+ ),
+ )
+ return list(engine.run())
+
+
+def _form(submitted: bool, action_id: str | None) -> StaticForm:
+ return StaticForm(
+ form_id="form",
+ rendered="rendered",
+ is_submitted=submitted,
+ action_id=action_id,
+ data={},
+ status_value=HumanInputFormStatus.SUBMITTED if submitted else HumanInputFormStatus.WAITING,
+ )
+
+
+def test_parallel_human_input_join_completes_after_second_resume() -> None:
+ pause_store: PauseStateStore = InMemoryPauseStore()
+
+ initial_state = _build_runtime_state()
+ initial_repo = StaticRepo(
+ {
+ "human_a": _form(submitted=False, action_id=None),
+ "human_b": _form(submitted=False, action_id=None),
+ }
+ )
+ initial_graph = _build_graph(initial_state, initial_repo)
+ initial_events = _run_graph(initial_graph, initial_state)
+
+ assert isinstance(initial_events[-1], GraphRunPausedEvent)
+ pause_store.save(initial_state)
+
+ first_resume_state = pause_store.load()
+ first_resume_repo = StaticRepo(
+ {
+ "human_a": _form(submitted=True, action_id="approve"),
+ "human_b": _form(submitted=False, action_id=None),
+ }
+ )
+ first_resume_graph = _build_graph(first_resume_state, first_resume_repo)
+ first_resume_events = _run_graph(first_resume_graph, first_resume_state)
+
+ assert isinstance(first_resume_events[0], GraphRunStartedEvent)
+ assert first_resume_events[0].reason is WorkflowStartReason.RESUMPTION
+ assert isinstance(first_resume_events[-1], GraphRunPausedEvent)
+ pause_store.save(first_resume_state)
+
+ second_resume_state = pause_store.load()
+ second_resume_repo = StaticRepo(
+ {
+ "human_a": _form(submitted=True, action_id="approve"),
+ "human_b": _form(submitted=True, action_id="approve"),
+ }
+ )
+ second_resume_graph = _build_graph(second_resume_state, second_resume_repo)
+ second_resume_events = _run_graph(second_resume_graph, second_resume_state)
+
+ assert isinstance(second_resume_events[0], GraphRunStartedEvent)
+ assert second_resume_events[0].reason is WorkflowStartReason.RESUMPTION
+ assert isinstance(second_resume_events[-1], GraphRunSucceededEvent)
+ assert any(isinstance(event, NodeRunSucceededEvent) and event.node_id == "end" for event in second_resume_events)
diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_pause_missing_finish.py b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_pause_missing_finish.py
new file mode 100644
index 0000000000..62aa56fc57
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_pause_missing_finish.py
@@ -0,0 +1,333 @@
+import time
+from collections.abc import Mapping
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from typing import Any
+
+from core.model_runtime.entities.llm_entities import LLMMode
+from core.model_runtime.entities.message_entities import PromptMessageRole
+from core.workflow.entities import GraphInitParams
+from core.workflow.entities.workflow_start_reason import WorkflowStartReason
+from core.workflow.graph import Graph
+from core.workflow.graph_engine.command_channels.in_memory_channel import InMemoryChannel
+from core.workflow.graph_engine.config import GraphEngineConfig
+from core.workflow.graph_engine.graph_engine import GraphEngine
+from core.workflow.graph_events import (
+ GraphRunPausedEvent,
+ GraphRunStartedEvent,
+ NodeRunPauseRequestedEvent,
+ NodeRunStartedEvent,
+ NodeRunSucceededEvent,
+)
+from core.workflow.nodes.human_input.entities import HumanInputNodeData, UserAction
+from core.workflow.nodes.human_input.enums import HumanInputFormStatus
+from core.workflow.nodes.human_input.human_input_node import HumanInputNode
+from core.workflow.nodes.llm.entities import (
+ ContextConfig,
+ LLMNodeChatModelMessage,
+ LLMNodeData,
+ ModelConfig,
+ VisionConfig,
+)
+from core.workflow.nodes.start.entities import StartNodeData
+from core.workflow.nodes.start.start_node import StartNode
+from core.workflow.repositories.human_input_form_repository import (
+ FormCreateParams,
+ HumanInputFormEntity,
+ HumanInputFormRepository,
+)
+from core.workflow.runtime import GraphRuntimeState, VariablePool
+from core.workflow.system_variable import SystemVariable
+from libs.datetime_utils import naive_utc_now
+
+from .test_mock_config import MockConfig, NodeMockConfig
+from .test_mock_nodes import MockLLMNode
+
+
+@dataclass
+class StaticForm(HumanInputFormEntity):
+ form_id: str
+ rendered: str
+ is_submitted: bool
+ action_id: str | None = None
+ data: Mapping[str, Any] | None = None
+ status_value: HumanInputFormStatus = HumanInputFormStatus.WAITING
+ expiration: datetime = naive_utc_now() + timedelta(days=1)
+
+ @property
+ def id(self) -> str:
+ return self.form_id
+
+ @property
+ def web_app_token(self) -> str | None:
+ return "token"
+
+ @property
+ def recipients(self) -> list:
+ return []
+
+ @property
+ def rendered_content(self) -> str:
+ return self.rendered
+
+ @property
+ def selected_action_id(self) -> str | None:
+ return self.action_id
+
+ @property
+ def submitted_data(self) -> Mapping[str, Any] | None:
+ return self.data
+
+ @property
+ def submitted(self) -> bool:
+ return self.is_submitted
+
+ @property
+ def status(self) -> HumanInputFormStatus:
+ return self.status_value
+
+ @property
+ def expiration_time(self) -> datetime:
+ return self.expiration
+
+
+class StaticRepo(HumanInputFormRepository):
+ def __init__(self, forms_by_node_id: Mapping[str, HumanInputFormEntity]) -> None:
+ self._forms_by_node_id = dict(forms_by_node_id)
+
+ def get_form(self, workflow_execution_id: str, node_id: str) -> HumanInputFormEntity | None:
+ return self._forms_by_node_id.get(node_id)
+
+ def create_form(self, params: FormCreateParams) -> HumanInputFormEntity:
+ raise AssertionError("create_form should not be called in resume scenario")
+
+
+class DelayedHumanInputNode(HumanInputNode):
+ def __init__(self, delay_seconds: float, **kwargs: Any) -> None:
+ super().__init__(**kwargs)
+ self._delay_seconds = delay_seconds
+
+ def _run(self):
+ if self._delay_seconds > 0:
+ time.sleep(self._delay_seconds)
+ yield from super()._run()
+
+
+def _build_runtime_state() -> GraphRuntimeState:
+ variable_pool = VariablePool(
+ system_variables=SystemVariable(
+ user_id="user",
+ app_id="app",
+ workflow_id="workflow",
+ workflow_execution_id="exec-1",
+ ),
+ user_inputs={},
+ conversation_variables=[],
+ )
+ return GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
+
+
+def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepository, mock_config: MockConfig) -> Graph:
+ graph_config: dict[str, object] = {"nodes": [], "edges": []}
+ graph_init_params = GraphInitParams(
+ tenant_id="tenant",
+ app_id="app",
+ workflow_id="workflow",
+ graph_config=graph_config,
+ user_id="user",
+ user_from="account",
+ invoke_from="debugger",
+ call_depth=0,
+ )
+
+ start_config = {"id": "start", "data": StartNodeData(title="Start", variables=[]).model_dump()}
+ start_node = StartNode(
+ id=start_config["id"],
+ config=start_config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ )
+
+ human_data = HumanInputNodeData(
+ title="Human Input",
+ form_content="Human input required",
+ inputs=[],
+ user_actions=[UserAction(id="approve", title="Approve")],
+ )
+
+ human_a_config = {"id": "human_a", "data": human_data.model_dump()}
+ human_a = HumanInputNode(
+ id=human_a_config["id"],
+ config=human_a_config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ form_repository=repo,
+ )
+
+ human_b_config = {"id": "human_b", "data": human_data.model_dump()}
+ human_b = DelayedHumanInputNode(
+ id=human_b_config["id"],
+ config=human_b_config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ form_repository=repo,
+ delay_seconds=0.2,
+ )
+
+ llm_data = LLMNodeData(
+ title="LLM A",
+ model=ModelConfig(provider="openai", name="gpt-3.5-turbo", mode=LLMMode.CHAT, completion_params={}),
+ prompt_template=[
+ LLMNodeChatModelMessage(
+ text="Prompt A",
+ role=PromptMessageRole.USER,
+ edition_type="basic",
+ )
+ ],
+ context=ContextConfig(enabled=False, variable_selector=None),
+ vision=VisionConfig(enabled=False),
+ reasoning_format="tagged",
+ structured_output_enabled=False,
+ )
+ llm_config = {"id": "llm_a", "data": llm_data.model_dump()}
+ llm_a = MockLLMNode(
+ id=llm_config["id"],
+ config=llm_config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ mock_config=mock_config,
+ )
+
+ return (
+ Graph.new()
+ .add_root(start_node)
+ .add_node(human_a, from_node_id="start")
+ .add_node(human_b, from_node_id="start")
+ .add_node(llm_a, from_node_id="human_a", source_handle="approve")
+ .build()
+ )
+
+
+def test_parallel_human_input_pause_preserves_node_finished() -> None:
+ runtime_state = _build_runtime_state()
+
+ runtime_state.graph_execution.start()
+ runtime_state.register_paused_node("human_a")
+ runtime_state.register_paused_node("human_b")
+
+ submitted = StaticForm(
+ form_id="form-a",
+ rendered="rendered",
+ is_submitted=True,
+ action_id="approve",
+ data={},
+ status_value=HumanInputFormStatus.SUBMITTED,
+ )
+ pending = StaticForm(
+ form_id="form-b",
+ rendered="rendered",
+ is_submitted=False,
+ action_id=None,
+ data=None,
+ status_value=HumanInputFormStatus.WAITING,
+ )
+ repo = StaticRepo({"human_a": submitted, "human_b": pending})
+
+ mock_config = MockConfig()
+ mock_config.simulate_delays = True
+ mock_config.set_node_config(
+ "llm_a",
+ NodeMockConfig(node_id="llm_a", outputs={"text": "LLM A output"}, delay=0.5),
+ )
+
+ graph = _build_graph(runtime_state, repo, mock_config)
+ engine = GraphEngine(
+ workflow_id="workflow",
+ graph=graph,
+ graph_runtime_state=runtime_state,
+ command_channel=InMemoryChannel(),
+ config=GraphEngineConfig(
+ min_workers=2,
+ max_workers=2,
+ scale_up_threshold=1,
+ scale_down_idle_time=30.0,
+ ),
+ )
+
+ events = list(engine.run())
+
+ llm_started = any(isinstance(e, NodeRunStartedEvent) and e.node_id == "llm_a" for e in events)
+ llm_succeeded = any(isinstance(e, NodeRunSucceededEvent) and e.node_id == "llm_a" for e in events)
+ human_b_pause = any(isinstance(e, NodeRunPauseRequestedEvent) and e.node_id == "human_b" for e in events)
+ graph_paused = any(isinstance(e, GraphRunPausedEvent) for e in events)
+ graph_started = any(isinstance(e, GraphRunStartedEvent) for e in events)
+
+ assert graph_started
+ assert graph_paused
+ assert human_b_pause
+ assert llm_started
+ assert llm_succeeded
+
+
+def test_parallel_human_input_pause_preserves_node_finished_after_snapshot_resume() -> None:
+ base_state = _build_runtime_state()
+ base_state.graph_execution.start()
+ base_state.register_paused_node("human_a")
+ base_state.register_paused_node("human_b")
+ snapshot = base_state.dumps()
+
+ resumed_state = GraphRuntimeState.from_snapshot(snapshot)
+
+ submitted = StaticForm(
+ form_id="form-a",
+ rendered="rendered",
+ is_submitted=True,
+ action_id="approve",
+ data={},
+ status_value=HumanInputFormStatus.SUBMITTED,
+ )
+ pending = StaticForm(
+ form_id="form-b",
+ rendered="rendered",
+ is_submitted=False,
+ action_id=None,
+ data=None,
+ status_value=HumanInputFormStatus.WAITING,
+ )
+ repo = StaticRepo({"human_a": submitted, "human_b": pending})
+
+ mock_config = MockConfig()
+ mock_config.simulate_delays = True
+ mock_config.set_node_config(
+ "llm_a",
+ NodeMockConfig(node_id="llm_a", outputs={"text": "LLM A output"}, delay=0.5),
+ )
+
+ graph = _build_graph(resumed_state, repo, mock_config)
+ engine = GraphEngine(
+ workflow_id="workflow",
+ graph=graph,
+ graph_runtime_state=resumed_state,
+ command_channel=InMemoryChannel(),
+ config=GraphEngineConfig(
+ min_workers=2,
+ max_workers=2,
+ scale_up_threshold=1,
+ scale_down_idle_time=30.0,
+ ),
+ )
+
+ events = list(engine.run())
+
+ start_event = next(e for e in events if isinstance(e, GraphRunStartedEvent))
+ assert start_event.reason is WorkflowStartReason.RESUMPTION
+
+ llm_started = any(isinstance(e, NodeRunStartedEvent) and e.node_id == "llm_a" for e in events)
+ llm_succeeded = any(isinstance(e, NodeRunSucceededEvent) and e.node_id == "llm_a" for e in events)
+ human_b_pause = any(isinstance(e, NodeRunPauseRequestedEvent) and e.node_id == "human_b" for e in events)
+ graph_paused = any(isinstance(e, GraphRunPausedEvent) for e in events)
+
+ assert graph_paused
+ assert human_b_pause
+ assert llm_started
+ assert llm_succeeded
diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_pause_deferred_ready_nodes.py b/api/tests/unit_tests/core/workflow/graph_engine/test_pause_deferred_ready_nodes.py
new file mode 100644
index 0000000000..156cfefcd6
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/graph_engine/test_pause_deferred_ready_nodes.py
@@ -0,0 +1,309 @@
+import time
+from collections.abc import Mapping
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from typing import Any
+
+from core.model_runtime.entities.llm_entities import LLMMode
+from core.model_runtime.entities.message_entities import PromptMessageRole
+from core.workflow.entities import GraphInitParams
+from core.workflow.entities.workflow_start_reason import WorkflowStartReason
+from core.workflow.graph import Graph
+from core.workflow.graph_engine.command_channels.in_memory_channel import InMemoryChannel
+from core.workflow.graph_engine.config import GraphEngineConfig
+from core.workflow.graph_engine.graph_engine import GraphEngine
+from core.workflow.graph_events import (
+ GraphRunPausedEvent,
+ GraphRunStartedEvent,
+ NodeRunStartedEvent,
+ NodeRunSucceededEvent,
+)
+from core.workflow.nodes.end.end_node import EndNode
+from core.workflow.nodes.end.entities import EndNodeData
+from core.workflow.nodes.human_input.entities import HumanInputNodeData, UserAction
+from core.workflow.nodes.human_input.enums import HumanInputFormStatus
+from core.workflow.nodes.human_input.human_input_node import HumanInputNode
+from core.workflow.nodes.llm.entities import (
+ ContextConfig,
+ LLMNodeChatModelMessage,
+ LLMNodeData,
+ ModelConfig,
+ VisionConfig,
+)
+from core.workflow.nodes.start.entities import StartNodeData
+from core.workflow.nodes.start.start_node import StartNode
+from core.workflow.repositories.human_input_form_repository import (
+ FormCreateParams,
+ HumanInputFormEntity,
+ HumanInputFormRepository,
+)
+from core.workflow.runtime import GraphRuntimeState, VariablePool
+from core.workflow.system_variable import SystemVariable
+from libs.datetime_utils import naive_utc_now
+
+from .test_mock_config import MockConfig, NodeMockConfig
+from .test_mock_nodes import MockLLMNode
+
+
+@dataclass
+class StaticForm(HumanInputFormEntity):
+ form_id: str
+ rendered: str
+ is_submitted: bool
+ action_id: str | None = None
+ data: Mapping[str, Any] | None = None
+ status_value: HumanInputFormStatus = HumanInputFormStatus.WAITING
+ expiration: datetime = naive_utc_now() + timedelta(days=1)
+
+ @property
+ def id(self) -> str:
+ return self.form_id
+
+ @property
+ def web_app_token(self) -> str | None:
+ return "token"
+
+ @property
+ def recipients(self) -> list:
+ return []
+
+ @property
+ def rendered_content(self) -> str:
+ return self.rendered
+
+ @property
+ def selected_action_id(self) -> str | None:
+ return self.action_id
+
+ @property
+ def submitted_data(self) -> Mapping[str, Any] | None:
+ return self.data
+
+ @property
+ def submitted(self) -> bool:
+ return self.is_submitted
+
+ @property
+ def status(self) -> HumanInputFormStatus:
+ return self.status_value
+
+ @property
+ def expiration_time(self) -> datetime:
+ return self.expiration
+
+
+class StaticRepo(HumanInputFormRepository):
+ def __init__(self, form: HumanInputFormEntity) -> None:
+ self._form = form
+
+ def get_form(self, workflow_execution_id: str, node_id: str) -> HumanInputFormEntity | None:
+ if node_id != "human_pause":
+ return None
+ return self._form
+
+ def create_form(self, params: FormCreateParams) -> HumanInputFormEntity:
+ raise AssertionError("create_form should not be called in this test")
+
+
+def _build_runtime_state() -> GraphRuntimeState:
+ variable_pool = VariablePool(
+ system_variables=SystemVariable(
+ user_id="user",
+ app_id="app",
+ workflow_id="workflow",
+ workflow_execution_id="exec-1",
+ ),
+ user_inputs={},
+ conversation_variables=[],
+ )
+ return GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
+
+
+def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepository, mock_config: MockConfig) -> Graph:
+ graph_config: dict[str, object] = {"nodes": [], "edges": []}
+ graph_init_params = GraphInitParams(
+ tenant_id="tenant",
+ app_id="app",
+ workflow_id="workflow",
+ graph_config=graph_config,
+ user_id="user",
+ user_from="account",
+ invoke_from="debugger",
+ call_depth=0,
+ )
+
+ start_config = {"id": "start", "data": StartNodeData(title="Start", variables=[]).model_dump()}
+ start_node = StartNode(
+ id=start_config["id"],
+ config=start_config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ )
+
+ llm_a_data = LLMNodeData(
+ title="LLM A",
+ model=ModelConfig(provider="openai", name="gpt-3.5-turbo", mode=LLMMode.CHAT, completion_params={}),
+ prompt_template=[
+ LLMNodeChatModelMessage(
+ text="Prompt A",
+ role=PromptMessageRole.USER,
+ edition_type="basic",
+ )
+ ],
+ context=ContextConfig(enabled=False, variable_selector=None),
+ vision=VisionConfig(enabled=False),
+ reasoning_format="tagged",
+ structured_output_enabled=False,
+ )
+ llm_a_config = {"id": "llm_a", "data": llm_a_data.model_dump()}
+ llm_a = MockLLMNode(
+ id=llm_a_config["id"],
+ config=llm_a_config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ mock_config=mock_config,
+ )
+
+ llm_b_data = LLMNodeData(
+ title="LLM B",
+ model=ModelConfig(provider="openai", name="gpt-3.5-turbo", mode=LLMMode.CHAT, completion_params={}),
+ prompt_template=[
+ LLMNodeChatModelMessage(
+ text="Prompt B",
+ role=PromptMessageRole.USER,
+ edition_type="basic",
+ )
+ ],
+ context=ContextConfig(enabled=False, variable_selector=None),
+ vision=VisionConfig(enabled=False),
+ reasoning_format="tagged",
+ structured_output_enabled=False,
+ )
+ llm_b_config = {"id": "llm_b", "data": llm_b_data.model_dump()}
+ llm_b = MockLLMNode(
+ id=llm_b_config["id"],
+ config=llm_b_config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ mock_config=mock_config,
+ )
+
+ human_data = HumanInputNodeData(
+ title="Human Input",
+ form_content="Pause here",
+ inputs=[],
+ user_actions=[UserAction(id="approve", title="Approve")],
+ )
+ human_config = {"id": "human_pause", "data": human_data.model_dump()}
+ human_node = HumanInputNode(
+ id=human_config["id"],
+ config=human_config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ form_repository=repo,
+ )
+
+ end_human_data = EndNodeData(title="End Human", outputs=[], desc=None)
+ end_human_config = {"id": "end_human", "data": end_human_data.model_dump()}
+ end_human = EndNode(
+ id=end_human_config["id"],
+ config=end_human_config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ )
+
+ return (
+ Graph.new()
+ .add_root(start_node)
+ .add_node(llm_a, from_node_id="start")
+ .add_node(human_node, from_node_id="start")
+ .add_node(llm_b, from_node_id="llm_a")
+ .add_node(end_human, from_node_id="human_pause", source_handle="approve")
+ .build()
+ )
+
+
+def _get_node_started_event(events: list[object], node_id: str) -> NodeRunStartedEvent | None:
+ for event in events:
+ if isinstance(event, NodeRunStartedEvent) and event.node_id == node_id:
+ return event
+ return None
+
+
+def test_pause_defers_ready_nodes_until_resume() -> None:
+ runtime_state = _build_runtime_state()
+
+ paused_form = StaticForm(
+ form_id="form-pause",
+ rendered="rendered",
+ is_submitted=False,
+ status_value=HumanInputFormStatus.WAITING,
+ )
+ pause_repo = StaticRepo(paused_form)
+
+ mock_config = MockConfig()
+ mock_config.simulate_delays = True
+ mock_config.set_node_config(
+ "llm_a",
+ NodeMockConfig(node_id="llm_a", outputs={"text": "LLM A output"}, delay=0.5),
+ )
+ mock_config.set_node_config(
+ "llm_b",
+ NodeMockConfig(node_id="llm_b", outputs={"text": "LLM B output"}, delay=0.0),
+ )
+
+ graph = _build_graph(runtime_state, pause_repo, mock_config)
+ engine = GraphEngine(
+ workflow_id="workflow",
+ graph=graph,
+ graph_runtime_state=runtime_state,
+ command_channel=InMemoryChannel(),
+ config=GraphEngineConfig(
+ min_workers=2,
+ max_workers=2,
+ scale_up_threshold=1,
+ scale_down_idle_time=30.0,
+ ),
+ )
+
+ paused_events = list(engine.run())
+
+ assert any(isinstance(e, GraphRunPausedEvent) for e in paused_events)
+ assert any(isinstance(e, NodeRunSucceededEvent) and e.node_id == "llm_a" for e in paused_events)
+ assert _get_node_started_event(paused_events, "llm_b") is None
+
+ snapshot = runtime_state.dumps()
+ resumed_state = GraphRuntimeState.from_snapshot(snapshot)
+
+ submitted_form = StaticForm(
+ form_id="form-pause",
+ rendered="rendered",
+ is_submitted=True,
+ action_id="approve",
+ data={},
+ status_value=HumanInputFormStatus.SUBMITTED,
+ )
+ resume_repo = StaticRepo(submitted_form)
+
+ resumed_graph = _build_graph(resumed_state, resume_repo, mock_config)
+ resumed_engine = GraphEngine(
+ workflow_id="workflow",
+ graph=resumed_graph,
+ graph_runtime_state=resumed_state,
+ command_channel=InMemoryChannel(),
+ config=GraphEngineConfig(
+ min_workers=2,
+ max_workers=2,
+ scale_up_threshold=1,
+ scale_down_idle_time=30.0,
+ ),
+ )
+
+ resumed_events = list(resumed_engine.run())
+
+ start_event = next(e for e in resumed_events if isinstance(e, GraphRunStartedEvent))
+ assert start_event.reason is WorkflowStartReason.RESUMPTION
+
+ llm_b_started = _get_node_started_event(resumed_events, "llm_b")
+ assert llm_b_started is not None
+ assert any(isinstance(e, NodeRunSucceededEvent) and e.node_id == "llm_b" for e in resumed_events)
diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_pause_resume_state.py b/api/tests/unit_tests/core/workflow/graph_engine/test_pause_resume_state.py
new file mode 100644
index 0000000000..700b3f4b8b
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/graph_engine/test_pause_resume_state.py
@@ -0,0 +1,217 @@
+import datetime
+import time
+from typing import Any
+from unittest.mock import MagicMock
+
+from core.workflow.entities import GraphInitParams
+from core.workflow.entities.workflow_start_reason import WorkflowStartReason
+from core.workflow.graph import Graph
+from core.workflow.graph_engine.command_channels.in_memory_channel import InMemoryChannel
+from core.workflow.graph_engine.graph_engine import GraphEngine
+from core.workflow.graph_events import (
+ GraphEngineEvent,
+ GraphRunPausedEvent,
+ GraphRunSucceededEvent,
+ NodeRunStartedEvent,
+ NodeRunSucceededEvent,
+)
+from core.workflow.graph_events.graph import GraphRunStartedEvent
+from core.workflow.nodes.base.entities import OutputVariableEntity
+from core.workflow.nodes.end.end_node import EndNode
+from core.workflow.nodes.end.entities import EndNodeData
+from core.workflow.nodes.human_input.entities import HumanInputNodeData, UserAction
+from core.workflow.nodes.human_input.human_input_node import HumanInputNode
+from core.workflow.nodes.start.entities import StartNodeData
+from core.workflow.nodes.start.start_node import StartNode
+from core.workflow.repositories.human_input_form_repository import (
+ HumanInputFormEntity,
+ HumanInputFormRepository,
+)
+from core.workflow.runtime import GraphRuntimeState, VariablePool
+from core.workflow.system_variable import SystemVariable
+from libs.datetime_utils import naive_utc_now
+
+
+def _build_runtime_state() -> GraphRuntimeState:
+ variable_pool = VariablePool(
+ system_variables=SystemVariable(
+ user_id="user",
+ app_id="app",
+ workflow_id="workflow",
+ workflow_execution_id="test-execution-id",
+ ),
+ user_inputs={},
+ conversation_variables=[],
+ )
+ return GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
+
+
+def _mock_form_repository_with_submission(action_id: str) -> HumanInputFormRepository:
+ repo = MagicMock(spec=HumanInputFormRepository)
+ form_entity = MagicMock(spec=HumanInputFormEntity)
+ form_entity.id = "test-form-id"
+ form_entity.web_app_token = "test-form-token"
+ form_entity.recipients = []
+ form_entity.rendered_content = "rendered"
+ form_entity.submitted = True
+ form_entity.selected_action_id = action_id
+ form_entity.submitted_data = {}
+ form_entity.expiration_time = naive_utc_now() + datetime.timedelta(days=1)
+ repo.get_form.return_value = form_entity
+ return repo
+
+
+def _mock_form_repository_without_submission() -> HumanInputFormRepository:
+ repo = MagicMock(spec=HumanInputFormRepository)
+ form_entity = MagicMock(spec=HumanInputFormEntity)
+ form_entity.id = "test-form-id"
+ form_entity.web_app_token = "test-form-token"
+ form_entity.recipients = []
+ form_entity.rendered_content = "rendered"
+ form_entity.submitted = False
+ repo.create_form.return_value = form_entity
+ repo.get_form.return_value = None
+ return repo
+
+
+def _build_human_input_graph(
+ runtime_state: GraphRuntimeState,
+ form_repository: HumanInputFormRepository,
+) -> Graph:
+ graph_config: dict[str, object] = {"nodes": [], "edges": []}
+ params = GraphInitParams(
+ tenant_id="tenant",
+ app_id="app",
+ workflow_id="workflow",
+ graph_config=graph_config,
+ user_id="user",
+ user_from="account",
+ invoke_from="service-api",
+ call_depth=0,
+ )
+
+ start_data = StartNodeData(title="start", variables=[])
+ start_node = StartNode(
+ id="start",
+ config={"id": "start", "data": start_data.model_dump()},
+ graph_init_params=params,
+ graph_runtime_state=runtime_state,
+ )
+
+ human_data = HumanInputNodeData(
+ title="human",
+ form_content="Awaiting human input",
+ inputs=[],
+ user_actions=[
+ UserAction(id="continue", title="Continue"),
+ ],
+ )
+ human_node = HumanInputNode(
+ id="human",
+ config={"id": "human", "data": human_data.model_dump()},
+ graph_init_params=params,
+ graph_runtime_state=runtime_state,
+ form_repository=form_repository,
+ )
+
+ end_data = EndNodeData(
+ title="end",
+ outputs=[
+ OutputVariableEntity(variable="result", value_selector=["human", "action_id"]),
+ ],
+ desc=None,
+ )
+ end_node = EndNode(
+ id="end",
+ config={"id": "end", "data": end_data.model_dump()},
+ graph_init_params=params,
+ graph_runtime_state=runtime_state,
+ )
+
+ return (
+ Graph.new()
+ .add_root(start_node)
+ .add_node(human_node)
+ .add_node(end_node, from_node_id="human", source_handle="continue")
+ .build()
+ )
+
+
+def _run_graph(graph: Graph, runtime_state: GraphRuntimeState) -> list[GraphEngineEvent]:
+ engine = GraphEngine(
+ workflow_id="workflow",
+ graph=graph,
+ graph_runtime_state=runtime_state,
+ command_channel=InMemoryChannel(),
+ )
+ return list(engine.run())
+
+
+def _node_successes(events: list[GraphEngineEvent]) -> list[str]:
+ return [event.node_id for event in events if isinstance(event, NodeRunSucceededEvent)]
+
+
+def _node_start_event(events: list[GraphEngineEvent], node_id: str) -> NodeRunStartedEvent | None:
+ for event in events:
+ if isinstance(event, NodeRunStartedEvent) and event.node_id == node_id:
+ return event
+ return None
+
+
+def _segment_value(variable_pool: VariablePool, selector: tuple[str, str]) -> Any:
+ segment = variable_pool.get(selector)
+ assert segment is not None
+ return getattr(segment, "value", segment)
+
+
+def test_engine_resume_restores_state_and_completion():
+ # Baseline run without pausing
+ baseline_state = _build_runtime_state()
+ baseline_repo = _mock_form_repository_with_submission(action_id="continue")
+ baseline_graph = _build_human_input_graph(baseline_state, baseline_repo)
+ baseline_events = _run_graph(baseline_graph, baseline_state)
+ assert baseline_events
+ first_paused_event = baseline_events[0]
+ assert isinstance(first_paused_event, GraphRunStartedEvent)
+ assert first_paused_event.reason is WorkflowStartReason.INITIAL
+ assert isinstance(baseline_events[-1], GraphRunSucceededEvent)
+ baseline_success_nodes = _node_successes(baseline_events)
+
+ # Run with pause
+ paused_state = _build_runtime_state()
+ pause_repo = _mock_form_repository_without_submission()
+ paused_graph = _build_human_input_graph(paused_state, pause_repo)
+ paused_events = _run_graph(paused_graph, paused_state)
+ assert paused_events
+ first_paused_event = paused_events[0]
+ assert isinstance(first_paused_event, GraphRunStartedEvent)
+ assert first_paused_event.reason is WorkflowStartReason.INITIAL
+ assert isinstance(paused_events[-1], GraphRunPausedEvent)
+ snapshot = paused_state.dumps()
+
+ # Resume from snapshot
+ resumed_state = GraphRuntimeState.from_snapshot(snapshot)
+ resume_repo = _mock_form_repository_with_submission(action_id="continue")
+ resumed_graph = _build_human_input_graph(resumed_state, resume_repo)
+ resumed_events = _run_graph(resumed_graph, resumed_state)
+ assert resumed_events
+ first_resumed_event = resumed_events[0]
+ assert isinstance(first_resumed_event, GraphRunStartedEvent)
+ assert first_resumed_event.reason is WorkflowStartReason.RESUMPTION
+ assert isinstance(resumed_events[-1], GraphRunSucceededEvent)
+
+ combined_success_nodes = _node_successes(paused_events) + _node_successes(resumed_events)
+ assert combined_success_nodes == baseline_success_nodes
+
+ paused_human_started = _node_start_event(paused_events, "human")
+ resumed_human_started = _node_start_event(resumed_events, "human")
+ assert paused_human_started is not None
+ assert resumed_human_started is not None
+ assert paused_human_started.id == resumed_human_started.id
+
+ assert baseline_state.outputs == resumed_state.outputs
+ assert _segment_value(baseline_state.variable_pool, ("human", "__action_id")) == _segment_value(
+ resumed_state.variable_pool, ("human", "__action_id")
+ )
+ assert baseline_state.graph_execution.completed
+ assert resumed_state.graph_execution.completed
diff --git a/api/tests/unit_tests/core/workflow/nodes/base/test_base_node.py b/api/tests/unit_tests/core/workflow/nodes/base/test_base_node.py
index 488b47761b..21a642c2f8 100644
--- a/api/tests/unit_tests/core/workflow/nodes/base/test_base_node.py
+++ b/api/tests/unit_tests/core/workflow/nodes/base/test_base_node.py
@@ -7,6 +7,7 @@ from core.workflow.nodes.base.node import Node
# Ensures that all node classes are imported.
from core.workflow.nodes.node_mapping import NODE_TYPE_CLASSES_MAPPING
+# Ensure `NODE_TYPE_CLASSES_MAPPING` is used and not automatically removed.
_ = NODE_TYPE_CLASSES_MAPPING
@@ -45,7 +46,9 @@ def test_ensure_subclasses_of_base_node_has_node_type_and_version_method_defined
assert isinstance(cls.node_type, NodeType)
assert isinstance(node_version, str)
node_type_and_version = (node_type, node_version)
- assert node_type_and_version not in type_version_set
+ assert node_type_and_version not in type_version_set, (
+ f"Duplicate node type and version for class: {cls=} {node_type_and_version=}"
+ )
type_version_set.add(node_type_and_version)
diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/__init__.py b/api/tests/unit_tests/core/workflow/nodes/human_input/__init__.py
new file mode 100644
index 0000000000..20807e9ef9
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/nodes/human_input/__init__.py
@@ -0,0 +1 @@
+# Unit tests for human input node
diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py
new file mode 100644
index 0000000000..ca4a887d20
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_email_delivery_config.py
@@ -0,0 +1,16 @@
+from core.workflow.nodes.human_input.entities import EmailDeliveryConfig, EmailRecipients
+from core.workflow.runtime import VariablePool
+
+
+def test_render_body_template_replaces_variable_values():
+ config = EmailDeliveryConfig(
+ recipients=EmailRecipients(),
+ subject="Subject",
+ body="Hello {{#node1.value#}} {{#url#}}",
+ )
+ variable_pool = VariablePool()
+ variable_pool.add(["node1", "value"], "World")
+
+ result = config.render_body_template(body=config.body, url="https://example.com", variable_pool=variable_pool)
+
+ assert result == "Hello World https://example.com"
diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py
new file mode 100644
index 0000000000..bfe7b03c13
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py
@@ -0,0 +1,597 @@
+"""
+Unit tests for human input node entities.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+from pydantic import ValidationError
+
+from core.workflow.entities import GraphInitParams
+from core.workflow.node_events import PauseRequestedEvent
+from core.workflow.node_events.node import StreamCompletedEvent
+from core.workflow.nodes.human_input.entities import (
+ EmailDeliveryConfig,
+ EmailDeliveryMethod,
+ EmailRecipients,
+ ExternalRecipient,
+ FormInput,
+ FormInputDefault,
+ HumanInputNodeData,
+ MemberRecipient,
+ UserAction,
+ WebAppDeliveryMethod,
+ _WebAppDeliveryConfig,
+)
+from core.workflow.nodes.human_input.enums import (
+ ButtonStyle,
+ DeliveryMethodType,
+ EmailRecipientType,
+ FormInputType,
+ PlaceholderType,
+ TimeoutUnit,
+)
+from core.workflow.nodes.human_input.human_input_node import HumanInputNode
+from core.workflow.repositories.human_input_form_repository import HumanInputFormRepository
+from core.workflow.runtime import GraphRuntimeState, VariablePool
+from core.workflow.system_variable import SystemVariable
+from tests.unit_tests.core.workflow.graph_engine.human_input_test_utils import InMemoryHumanInputFormRepository
+
+
+class TestDeliveryMethod:
+ """Test DeliveryMethod entity."""
+
+ def test_webapp_delivery_method(self):
+ """Test webapp delivery method creation."""
+ delivery_method = WebAppDeliveryMethod(enabled=True, config=_WebAppDeliveryConfig())
+
+ assert delivery_method.type == DeliveryMethodType.WEBAPP
+ assert delivery_method.enabled is True
+ assert isinstance(delivery_method.config, _WebAppDeliveryConfig)
+
+ def test_email_delivery_method(self):
+ """Test email delivery method creation."""
+ recipients = EmailRecipients(
+ whole_workspace=False,
+ items=[
+ MemberRecipient(type=EmailRecipientType.MEMBER, user_id="test-user-123"),
+ ExternalRecipient(type=EmailRecipientType.EXTERNAL, email="test@example.com"),
+ ],
+ )
+
+ config = EmailDeliveryConfig(
+ recipients=recipients, subject="Test Subject", body="Test body with {{#url#}} placeholder"
+ )
+
+ delivery_method = EmailDeliveryMethod(enabled=True, config=config)
+
+ assert delivery_method.type == DeliveryMethodType.EMAIL
+ assert delivery_method.enabled is True
+ assert isinstance(delivery_method.config, EmailDeliveryConfig)
+ assert delivery_method.config.subject == "Test Subject"
+ assert len(delivery_method.config.recipients.items) == 2
+
+
+class TestFormInput:
+ """Test FormInput entity."""
+
+ def test_text_input_with_constant_default(self):
+ """Test text input with constant default value."""
+ default = FormInputDefault(type=PlaceholderType.CONSTANT, value="Enter your response here...")
+
+ form_input = FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="user_input", default=default)
+
+ assert form_input.type == FormInputType.TEXT_INPUT
+ assert form_input.output_variable_name == "user_input"
+ assert form_input.default.type == PlaceholderType.CONSTANT
+ assert form_input.default.value == "Enter your response here..."
+
+ def test_text_input_with_variable_default(self):
+ """Test text input with variable default value."""
+ default = FormInputDefault(type=PlaceholderType.VARIABLE, selector=["node_123", "output_var"])
+
+ form_input = FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="user_input", default=default)
+
+ assert form_input.default.type == PlaceholderType.VARIABLE
+ assert form_input.default.selector == ["node_123", "output_var"]
+
+ def test_form_input_without_default(self):
+ """Test form input without default value."""
+ form_input = FormInput(type=FormInputType.PARAGRAPH, output_variable_name="description")
+
+ assert form_input.type == FormInputType.PARAGRAPH
+ assert form_input.output_variable_name == "description"
+ assert form_input.default is None
+
+
+class TestUserAction:
+ """Test UserAction entity."""
+
+ def test_user_action_creation(self):
+ """Test user action creation."""
+ action = UserAction(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY)
+
+ assert action.id == "approve"
+ assert action.title == "Approve"
+ assert action.button_style == ButtonStyle.PRIMARY
+
+ def test_user_action_default_button_style(self):
+ """Test user action with default button style."""
+ action = UserAction(id="cancel", title="Cancel")
+
+ assert action.button_style == ButtonStyle.DEFAULT
+
+ def test_user_action_length_boundaries(self):
+ """Test user action id and title length boundaries."""
+ action = UserAction(id="a" * 20, title="b" * 20)
+
+ assert action.id == "a" * 20
+ assert action.title == "b" * 20
+
+ @pytest.mark.parametrize(
+ ("field_name", "value"),
+ [
+ ("id", "a" * 21),
+ ("title", "b" * 21),
+ ],
+ )
+ def test_user_action_length_limits(self, field_name: str, value: str):
+ """User action fields should enforce max length."""
+ data = {"id": "approve", "title": "Approve"}
+ data[field_name] = value
+
+ with pytest.raises(ValidationError) as exc_info:
+ UserAction(**data)
+
+ errors = exc_info.value.errors()
+ assert any(error["loc"] == (field_name,) and error["type"] == "string_too_long" for error in errors)
+
+
+class TestHumanInputNodeData:
+ """Test HumanInputNodeData entity."""
+
+ def test_valid_node_data_creation(self):
+ """Test creating valid human input node data."""
+ delivery_methods = [WebAppDeliveryMethod(enabled=True, config=_WebAppDeliveryConfig())]
+
+ inputs = [
+ FormInput(
+ type=FormInputType.TEXT_INPUT,
+ output_variable_name="content",
+ default=FormInputDefault(type=PlaceholderType.CONSTANT, value="Enter content..."),
+ )
+ ]
+
+ user_actions = [UserAction(id="submit", title="Submit", button_style=ButtonStyle.PRIMARY)]
+
+ node_data = HumanInputNodeData(
+ title="Human Input Test",
+ desc="Test node description",
+ delivery_methods=delivery_methods,
+ form_content="# Test Form\n\nPlease provide input:\n\n{{#$output.content#}}",
+ inputs=inputs,
+ user_actions=user_actions,
+ timeout=24,
+ timeout_unit=TimeoutUnit.HOUR,
+ )
+
+ assert node_data.title == "Human Input Test"
+ assert node_data.desc == "Test node description"
+ assert len(node_data.delivery_methods) == 1
+ assert node_data.form_content.startswith("# Test Form")
+ assert len(node_data.inputs) == 1
+ assert len(node_data.user_actions) == 1
+ assert node_data.timeout == 24
+ assert node_data.timeout_unit == TimeoutUnit.HOUR
+
+ def test_node_data_with_multiple_delivery_methods(self):
+ """Test node data with multiple delivery methods."""
+ delivery_methods = [
+ WebAppDeliveryMethod(enabled=True, config=_WebAppDeliveryConfig()),
+ EmailDeliveryMethod(
+ enabled=False, # Disabled method should be fine
+ config=EmailDeliveryConfig(
+ subject="Hi there", body="", recipients=EmailRecipients(whole_workspace=True)
+ ),
+ ),
+ ]
+
+ node_data = HumanInputNodeData(
+ title="Test Node", delivery_methods=delivery_methods, timeout=1, timeout_unit=TimeoutUnit.DAY
+ )
+
+ assert len(node_data.delivery_methods) == 2
+ assert node_data.timeout == 1
+ assert node_data.timeout_unit == TimeoutUnit.DAY
+
+ def test_node_data_defaults(self):
+ """Test node data with default values."""
+ node_data = HumanInputNodeData(title="Test Node")
+
+ assert node_data.title == "Test Node"
+ assert node_data.desc is None
+ assert node_data.delivery_methods == []
+ assert node_data.form_content == ""
+ assert node_data.inputs == []
+ assert node_data.user_actions == []
+ assert node_data.timeout == 36
+ assert node_data.timeout_unit == TimeoutUnit.HOUR
+
+ def test_duplicate_input_output_variable_name_raises_validation_error(self):
+ """Duplicate form input output_variable_name should raise validation error."""
+ duplicate_inputs = [
+ FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="content"),
+ FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="content"),
+ ]
+
+ with pytest.raises(ValidationError, match="duplicated output_variable_name 'content'"):
+ HumanInputNodeData(title="Test Node", inputs=duplicate_inputs)
+
+ def test_duplicate_user_action_ids_raise_validation_error(self):
+ """Duplicate user action ids should raise validation error."""
+ duplicate_actions = [
+ UserAction(id="submit", title="Submit"),
+ UserAction(id="submit", title="Submit Again"),
+ ]
+
+ with pytest.raises(ValidationError, match="duplicated user action id 'submit'"):
+ HumanInputNodeData(title="Test Node", user_actions=duplicate_actions)
+
+ def test_extract_outputs_field_names(self):
+ content = r"""This is titile {{#start.title#}}
+
+ A content is required:
+
+ {{#$output.content#}}
+
+ A ending is required:
+
+ {{#$output.ending#}}
+ """
+
+ node_data = HumanInputNodeData(title="Human Input", form_content=content)
+ field_names = node_data.outputs_field_names()
+ assert field_names == ["content", "ending"]
+
+
+class TestRecipients:
+ """Test email recipient entities."""
+
+ def test_member_recipient(self):
+ """Test member recipient creation."""
+ recipient = MemberRecipient(type=EmailRecipientType.MEMBER, user_id="user-123")
+
+ assert recipient.type == EmailRecipientType.MEMBER
+ assert recipient.user_id == "user-123"
+
+ def test_external_recipient(self):
+ """Test external recipient creation."""
+ recipient = ExternalRecipient(type=EmailRecipientType.EXTERNAL, email="test@example.com")
+
+ assert recipient.type == EmailRecipientType.EXTERNAL
+ assert recipient.email == "test@example.com"
+
+ def test_email_recipients_whole_workspace(self):
+ """Test email recipients with whole workspace enabled."""
+ recipients = EmailRecipients(
+ whole_workspace=True, items=[MemberRecipient(type=EmailRecipientType.MEMBER, user_id="user-123")]
+ )
+
+ assert recipients.whole_workspace is True
+ assert len(recipients.items) == 1 # Items are preserved even when whole_workspace is True
+
+ def test_email_recipients_specific_users(self):
+ """Test email recipients with specific users."""
+ recipients = EmailRecipients(
+ whole_workspace=False,
+ items=[
+ MemberRecipient(type=EmailRecipientType.MEMBER, user_id="user-123"),
+ ExternalRecipient(type=EmailRecipientType.EXTERNAL, email="external@example.com"),
+ ],
+ )
+
+ assert recipients.whole_workspace is False
+ assert len(recipients.items) == 2
+ assert recipients.items[0].user_id == "user-123"
+ assert recipients.items[1].email == "external@example.com"
+
+
+class TestHumanInputNodeVariableResolution:
+ """Tests for resolving variable-based defaults in HumanInputNode."""
+
+ def test_resolves_variable_defaults(self):
+ variable_pool = VariablePool(
+ system_variables=SystemVariable(
+ user_id="user",
+ app_id="app",
+ workflow_id="workflow",
+ workflow_execution_id="exec-1",
+ ),
+ user_inputs={},
+ conversation_variables=[],
+ )
+ variable_pool.add(("start", "name"), "Jane Doe")
+ runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0)
+ graph_init_params = GraphInitParams(
+ tenant_id="tenant",
+ app_id="app",
+ workflow_id="workflow",
+ graph_config={"nodes": [], "edges": []},
+ user_id="user",
+ user_from="account",
+ invoke_from="debugger",
+ call_depth=0,
+ )
+
+ node_data = HumanInputNodeData(
+ title="Human Input",
+ form_content="Provide your name",
+ inputs=[
+ FormInput(
+ type=FormInputType.TEXT_INPUT,
+ output_variable_name="user_name",
+ default=FormInputDefault(type=PlaceholderType.VARIABLE, selector=["start", "name"]),
+ ),
+ FormInput(
+ type=FormInputType.TEXT_INPUT,
+ output_variable_name="user_email",
+ default=FormInputDefault(type=PlaceholderType.CONSTANT, value="foo@example.com"),
+ ),
+ ],
+ user_actions=[UserAction(id="submit", title="Submit")],
+ )
+ config = {"id": "human", "data": node_data.model_dump()}
+
+ mock_repo = MagicMock(spec=HumanInputFormRepository)
+ mock_repo.get_form.return_value = None
+ mock_repo.create_form.return_value = SimpleNamespace(
+ id="form-1",
+ rendered_content="Provide your name",
+ web_app_token="token",
+ recipients=[],
+ submitted=False,
+ )
+
+ node = HumanInputNode(
+ id=config["id"],
+ config=config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ form_repository=mock_repo,
+ )
+
+ run_result = node._run()
+ pause_event = next(run_result)
+
+ assert isinstance(pause_event, PauseRequestedEvent)
+ expected_values = {"user_name": "Jane Doe"}
+ assert pause_event.reason.resolved_default_values == expected_values
+
+ params = mock_repo.create_form.call_args.args[0]
+ assert params.resolved_default_values == expected_values
+
+ def test_debugger_falls_back_to_recipient_token_when_webapp_disabled(self):
+ variable_pool = VariablePool(
+ system_variables=SystemVariable(
+ user_id="user",
+ app_id="app",
+ workflow_id="workflow",
+ workflow_execution_id="exec-2",
+ ),
+ user_inputs={},
+ conversation_variables=[],
+ )
+ runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0)
+ graph_init_params = GraphInitParams(
+ tenant_id="tenant",
+ app_id="app",
+ workflow_id="workflow",
+ graph_config={"nodes": [], "edges": []},
+ user_id="user",
+ user_from="account",
+ invoke_from="debugger",
+ call_depth=0,
+ )
+
+ node_data = HumanInputNodeData(
+ title="Human Input",
+ form_content="Provide your name",
+ inputs=[],
+ user_actions=[UserAction(id="submit", title="Submit")],
+ )
+ config = {"id": "human", "data": node_data.model_dump()}
+
+ mock_repo = MagicMock(spec=HumanInputFormRepository)
+ mock_repo.get_form.return_value = None
+ mock_repo.create_form.return_value = SimpleNamespace(
+ id="form-2",
+ rendered_content="Provide your name",
+ web_app_token="console-token",
+ recipients=[SimpleNamespace(token="recipient-token")],
+ submitted=False,
+ )
+
+ node = HumanInputNode(
+ id=config["id"],
+ config=config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ form_repository=mock_repo,
+ )
+
+ run_result = node._run()
+ pause_event = next(run_result)
+
+ assert isinstance(pause_event, PauseRequestedEvent)
+ assert pause_event.reason.form_token == "console-token"
+
+ def test_debugger_debug_mode_overrides_email_recipients(self):
+ variable_pool = VariablePool(
+ system_variables=SystemVariable(
+ user_id="user-123",
+ app_id="app",
+ workflow_id="workflow",
+ workflow_execution_id="exec-3",
+ ),
+ user_inputs={},
+ conversation_variables=[],
+ )
+ runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0)
+ graph_init_params = GraphInitParams(
+ tenant_id="tenant",
+ app_id="app",
+ workflow_id="workflow",
+ graph_config={"nodes": [], "edges": []},
+ user_id="user-123",
+ user_from="account",
+ invoke_from="debugger",
+ call_depth=0,
+ )
+
+ node_data = HumanInputNodeData(
+ title="Human Input",
+ form_content="Provide your name",
+ inputs=[],
+ user_actions=[UserAction(id="submit", title="Submit")],
+ delivery_methods=[
+ EmailDeliveryMethod(
+ enabled=True,
+ config=EmailDeliveryConfig(
+ recipients=EmailRecipients(
+ whole_workspace=False,
+ items=[ExternalRecipient(type=EmailRecipientType.EXTERNAL, email="target@example.com")],
+ ),
+ subject="Subject",
+ body="Body",
+ debug_mode=True,
+ ),
+ )
+ ],
+ )
+ config = {"id": "human", "data": node_data.model_dump()}
+
+ mock_repo = MagicMock(spec=HumanInputFormRepository)
+ mock_repo.get_form.return_value = None
+ mock_repo.create_form.return_value = SimpleNamespace(
+ id="form-3",
+ rendered_content="Provide your name",
+ web_app_token="token",
+ recipients=[],
+ submitted=False,
+ )
+
+ node = HumanInputNode(
+ id=config["id"],
+ config=config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ form_repository=mock_repo,
+ )
+
+ run_result = node._run()
+ pause_event = next(run_result)
+ assert isinstance(pause_event, PauseRequestedEvent)
+
+ params = mock_repo.create_form.call_args.args[0]
+ assert len(params.delivery_methods) == 1
+ method = params.delivery_methods[0]
+ assert isinstance(method, EmailDeliveryMethod)
+ assert method.config.debug_mode is True
+ assert method.config.recipients.whole_workspace is False
+ assert len(method.config.recipients.items) == 1
+ recipient = method.config.recipients.items[0]
+ assert isinstance(recipient, MemberRecipient)
+ assert recipient.user_id == "user-123"
+
+
+class TestValidation:
+ """Test validation scenarios."""
+
+ def test_invalid_form_input_type(self):
+ """Test validation with invalid form input type."""
+ with pytest.raises(ValidationError):
+ FormInput(
+ type="invalid-type", # Invalid type
+ output_variable_name="test",
+ )
+
+ def test_invalid_button_style(self):
+ """Test validation with invalid button style."""
+ with pytest.raises(ValidationError):
+ UserAction(
+ id="test",
+ title="Test",
+ button_style="invalid-style", # Invalid style
+ )
+
+ def test_invalid_timeout_unit(self):
+ """Test validation with invalid timeout unit."""
+ with pytest.raises(ValidationError):
+ HumanInputNodeData(
+ title="Test",
+ timeout_unit="invalid-unit", # Invalid unit
+ )
+
+
+class TestHumanInputNodeRenderedContent:
+ """Tests for rendering submitted content."""
+
+ def test_replaces_outputs_placeholders_after_submission(self):
+ variable_pool = VariablePool(
+ system_variables=SystemVariable(
+ user_id="user",
+ app_id="app",
+ workflow_id="workflow",
+ workflow_execution_id="exec-1",
+ ),
+ user_inputs={},
+ conversation_variables=[],
+ )
+ runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0)
+ graph_init_params = GraphInitParams(
+ tenant_id="tenant",
+ app_id="app",
+ workflow_id="workflow",
+ graph_config={"nodes": [], "edges": []},
+ user_id="user",
+ user_from="account",
+ invoke_from="debugger",
+ call_depth=0,
+ )
+
+ node_data = HumanInputNodeData(
+ title="Human Input",
+ form_content="Name: {{#$output.name#}}",
+ inputs=[
+ FormInput(
+ type=FormInputType.TEXT_INPUT,
+ output_variable_name="name",
+ )
+ ],
+ user_actions=[UserAction(id="approve", title="Approve")],
+ )
+ config = {"id": "human", "data": node_data.model_dump()}
+
+ form_repository = InMemoryHumanInputFormRepository()
+ node = HumanInputNode(
+ id=config["id"],
+ config=config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=runtime_state,
+ form_repository=form_repository,
+ )
+
+ pause_gen = node._run()
+ pause_event = next(pause_gen)
+ assert isinstance(pause_event, PauseRequestedEvent)
+ with pytest.raises(StopIteration):
+ next(pause_gen)
+
+ form_repository.set_submission(action_id="approve", form_data={"name": "Alice"})
+
+ events = list(node._run())
+ last_event = events[-1]
+ assert isinstance(last_event, StreamCompletedEvent)
+ node_run_result = last_event.node_run_result
+ assert node_run_result.outputs["__rendered_content"] == "Name: Alice"
diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py
new file mode 100644
index 0000000000..a19ee4dee3
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py
@@ -0,0 +1,172 @@
+import datetime
+from types import SimpleNamespace
+
+from core.app.entities.app_invoke_entities import InvokeFrom
+from core.workflow.entities.graph_init_params import GraphInitParams
+from core.workflow.enums import NodeType
+from core.workflow.graph_events import (
+ NodeRunHumanInputFormFilledEvent,
+ NodeRunHumanInputFormTimeoutEvent,
+ NodeRunStartedEvent,
+)
+from core.workflow.nodes.human_input.enums import HumanInputFormStatus
+from core.workflow.nodes.human_input.human_input_node import HumanInputNode
+from core.workflow.runtime import GraphRuntimeState, VariablePool
+from core.workflow.system_variable import SystemVariable
+from libs.datetime_utils import naive_utc_now
+from models.enums import UserFrom
+
+
+class _FakeFormRepository:
+ def __init__(self, form):
+ self._form = form
+
+ def get_form(self, *_args, **_kwargs):
+ return self._form
+
+
+def _build_node(form_content: str = "Please enter your name:\n\n{{#$output.name#}}") -> HumanInputNode:
+ system_variables = SystemVariable.default()
+ graph_runtime_state = GraphRuntimeState(
+ variable_pool=VariablePool(system_variables=system_variables, user_inputs={}, environment_variables=[]),
+ start_at=0.0,
+ )
+ graph_init_params = GraphInitParams(
+ tenant_id="tenant",
+ app_id="app",
+ workflow_id="workflow",
+ graph_config={"nodes": [], "edges": []},
+ user_id="user",
+ user_from=UserFrom.ACCOUNT,
+ invoke_from=InvokeFrom.SERVICE_API,
+ call_depth=0,
+ )
+
+ config = {
+ "id": "node-1",
+ "type": NodeType.HUMAN_INPUT.value,
+ "data": {
+ "title": "Human Input",
+ "form_content": form_content,
+ "inputs": [
+ {
+ "type": "text_input",
+ "output_variable_name": "name",
+ "default": {"type": "constant", "value": ""},
+ }
+ ],
+ "user_actions": [
+ {
+ "id": "Accept",
+ "title": "Approve",
+ "button_style": "default",
+ }
+ ],
+ },
+ }
+
+ fake_form = SimpleNamespace(
+ id="form-1",
+ rendered_content=form_content,
+ submitted=True,
+ selected_action_id="Accept",
+ submitted_data={"name": "Alice"},
+ status=HumanInputFormStatus.SUBMITTED,
+ expiration_time=naive_utc_now() + datetime.timedelta(days=1),
+ )
+
+ repo = _FakeFormRepository(fake_form)
+ return HumanInputNode(
+ id="node-1",
+ config=config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=graph_runtime_state,
+ form_repository=repo,
+ )
+
+
+def _build_timeout_node() -> HumanInputNode:
+ system_variables = SystemVariable.default()
+ graph_runtime_state = GraphRuntimeState(
+ variable_pool=VariablePool(system_variables=system_variables, user_inputs={}, environment_variables=[]),
+ start_at=0.0,
+ )
+ graph_init_params = GraphInitParams(
+ tenant_id="tenant",
+ app_id="app",
+ workflow_id="workflow",
+ graph_config={"nodes": [], "edges": []},
+ user_id="user",
+ user_from=UserFrom.ACCOUNT,
+ invoke_from=InvokeFrom.SERVICE_API,
+ call_depth=0,
+ )
+
+ config = {
+ "id": "node-1",
+ "type": NodeType.HUMAN_INPUT.value,
+ "data": {
+ "title": "Human Input",
+ "form_content": "Please enter your name:\n\n{{#$output.name#}}",
+ "inputs": [
+ {
+ "type": "text_input",
+ "output_variable_name": "name",
+ "default": {"type": "constant", "value": ""},
+ }
+ ],
+ "user_actions": [
+ {
+ "id": "Accept",
+ "title": "Approve",
+ "button_style": "default",
+ }
+ ],
+ },
+ }
+
+ fake_form = SimpleNamespace(
+ id="form-1",
+ rendered_content="content",
+ submitted=False,
+ selected_action_id=None,
+ submitted_data=None,
+ status=HumanInputFormStatus.TIMEOUT,
+ expiration_time=naive_utc_now() - datetime.timedelta(minutes=1),
+ )
+
+ repo = _FakeFormRepository(fake_form)
+ return HumanInputNode(
+ id="node-1",
+ config=config,
+ graph_init_params=graph_init_params,
+ graph_runtime_state=graph_runtime_state,
+ form_repository=repo,
+ )
+
+
+def test_human_input_node_emits_form_filled_event_before_succeeded():
+ node = _build_node()
+
+ events = list(node.run())
+
+ assert isinstance(events[0], NodeRunStartedEvent)
+ assert isinstance(events[1], NodeRunHumanInputFormFilledEvent)
+
+ filled_event = events[1]
+ assert filled_event.node_title == "Human Input"
+ assert filled_event.rendered_content.endswith("Alice")
+ assert filled_event.action_id == "Accept"
+ assert filled_event.action_text == "Approve"
+
+
+def test_human_input_node_emits_timeout_event_before_succeeded():
+ node = _build_timeout_node()
+
+ events = list(node.run())
+
+ assert isinstance(events[0], NodeRunStartedEvent)
+ assert isinstance(events[1], NodeRunHumanInputFormTimeoutEvent)
+
+ timeout_event = events[1]
+ assert timeout_event.node_title == "Human Input"
diff --git a/api/tests/unit_tests/core/workflow/test_variable_pool_conver.py b/api/tests/unit_tests/core/workflow/test_variable_pool_conver.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/tests/unit_tests/extensions/test_celery_ssl.py b/api/tests/unit_tests/extensions/test_celery_ssl.py
index d3a4d69f07..38477409bb 100644
--- a/api/tests/unit_tests/extensions/test_celery_ssl.py
+++ b/api/tests/unit_tests/extensions/test_celery_ssl.py
@@ -104,6 +104,7 @@ class TestCelerySSLConfiguration:
def test_celery_init_applies_ssl_to_broker_and_backend(self):
"""Test that SSL options are applied to both broker and backend when using Redis."""
mock_config = MagicMock()
+ mock_config.HUMAN_INPUT_TIMEOUT_TASK_INTERVAL = 1
mock_config.CELERY_BROKER_URL = "redis://localhost:6379/0"
mock_config.CELERY_BACKEND = "redis"
mock_config.CELERY_RESULT_BACKEND = "redis://localhost:6379/0"
diff --git a/api/tests/unit_tests/extensions/test_pubsub_channel.py b/api/tests/unit_tests/extensions/test_pubsub_channel.py
new file mode 100644
index 0000000000..a5b41a7266
--- /dev/null
+++ b/api/tests/unit_tests/extensions/test_pubsub_channel.py
@@ -0,0 +1,20 @@
+from configs import dify_config
+from extensions import ext_redis
+from libs.broadcast_channel.redis.channel import BroadcastChannel as RedisBroadcastChannel
+from libs.broadcast_channel.redis.sharded_channel import ShardedRedisBroadcastChannel
+
+
+def test_get_pubsub_broadcast_channel_defaults_to_pubsub(monkeypatch):
+ monkeypatch.setattr(dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "pubsub")
+
+ channel = ext_redis.get_pubsub_broadcast_channel()
+
+ assert isinstance(channel, RedisBroadcastChannel)
+
+
+def test_get_pubsub_broadcast_channel_sharded(monkeypatch):
+ monkeypatch.setattr(dify_config, "PUBSUB_REDIS_CHANNEL_TYPE", "sharded")
+
+ channel = ext_redis.get_pubsub_broadcast_channel()
+
+ assert isinstance(channel, ShardedRedisBroadcastChannel)
diff --git a/api/tests/unit_tests/libs/_human_input/__init__.py b/api/tests/unit_tests/libs/_human_input/__init__.py
new file mode 100644
index 0000000000..66714e72f8
--- /dev/null
+++ b/api/tests/unit_tests/libs/_human_input/__init__.py
@@ -0,0 +1 @@
+# Treat this directory as a package so support modules can be imported relatively.
diff --git a/api/tests/unit_tests/libs/_human_input/support.py b/api/tests/unit_tests/libs/_human_input/support.py
new file mode 100644
index 0000000000..bd86c13a2c
--- /dev/null
+++ b/api/tests/unit_tests/libs/_human_input/support.py
@@ -0,0 +1,249 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta
+from typing import Any
+
+from core.workflow.nodes.human_input.entities import FormInput
+from core.workflow.nodes.human_input.enums import TimeoutUnit
+
+
+# Exceptions
+class HumanInputError(Exception):
+ error_code: str = "unknown"
+
+ def __init__(self, message: str = "", error_code: str | None = None):
+ super().__init__(message)
+ self.message = message or self.__class__.__name__
+ if error_code:
+ self.error_code = error_code
+
+
+class FormNotFoundError(HumanInputError):
+ error_code = "form_not_found"
+
+
+class FormExpiredError(HumanInputError):
+ error_code = "human_input_form_expired"
+
+
+class FormAlreadySubmittedError(HumanInputError):
+ error_code = "human_input_form_submitted"
+
+
+class InvalidFormDataError(HumanInputError):
+ error_code = "invalid_form_data"
+
+
+# Models
+@dataclass
+class HumanInputForm:
+ form_id: str
+ workflow_run_id: str
+ node_id: str
+ tenant_id: str
+ app_id: str | None
+ form_content: str
+ inputs: list[FormInput]
+ user_actions: list[dict[str, Any]]
+ timeout: int
+ timeout_unit: TimeoutUnit
+ form_token: str | None = None
+ created_at: datetime = field(default_factory=datetime.utcnow)
+ expires_at: datetime | None = None
+ submitted_at: datetime | None = None
+ submitted_data: dict[str, Any] | None = None
+ submitted_action: str | None = None
+
+ def __post_init__(self) -> None:
+ if self.expires_at is None:
+ self.calculate_expiration()
+
+ @property
+ def is_expired(self) -> bool:
+ return self.expires_at is not None and datetime.utcnow() > self.expires_at
+
+ @property
+ def is_submitted(self) -> bool:
+ return self.submitted_at is not None
+
+ def mark_submitted(self, inputs: dict[str, Any], action: str) -> None:
+ self.submitted_data = inputs
+ self.submitted_action = action
+ self.submitted_at = datetime.utcnow()
+
+ def submit(self, inputs: dict[str, Any], action: str) -> None:
+ self.mark_submitted(inputs, action)
+
+ def calculate_expiration(self) -> None:
+ start = self.created_at
+ if self.timeout_unit == TimeoutUnit.HOUR:
+ self.expires_at = start + timedelta(hours=self.timeout)
+ elif self.timeout_unit == TimeoutUnit.DAY:
+ self.expires_at = start + timedelta(days=self.timeout)
+ else:
+ raise ValueError(f"Unsupported timeout unit {self.timeout_unit}")
+
+ def to_response_dict(self, *, include_site_info: bool) -> dict[str, Any]:
+ inputs_response = [
+ {
+ "type": form_input.type.name.lower().replace("_", "-"),
+ "output_variable_name": form_input.output_variable_name,
+ }
+ for form_input in self.inputs
+ ]
+ response = {
+ "form_content": self.form_content,
+ "inputs": inputs_response,
+ "user_actions": self.user_actions,
+ }
+ if include_site_info:
+ response["site"] = {"app_id": self.app_id, "title": "Workflow Form"}
+ return response
+
+
+@dataclass
+class FormSubmissionData:
+ form_id: str
+ inputs: dict[str, Any]
+ action: str
+ submitted_at: datetime = field(default_factory=datetime.utcnow)
+
+ @classmethod
+ def from_request(cls, form_id: str, request: FormSubmissionRequest) -> FormSubmissionData: # type: ignore
+ return cls(form_id=form_id, inputs=request.inputs, action=request.action)
+
+
+@dataclass
+class FormSubmissionRequest:
+ inputs: dict[str, Any]
+ action: str
+
+
+# Repository
+class InMemoryFormRepository:
+ """
+ Simple in-memory repository used by unit tests.
+ """
+
+ def __init__(self):
+ self._forms: dict[str, HumanInputForm] = {}
+
+ @property
+ def forms(self) -> dict[str, HumanInputForm]:
+ return self._forms
+
+ def save(self, form: HumanInputForm) -> None:
+ self._forms[form.form_id] = form
+
+ def get_by_id(self, form_id: str) -> HumanInputForm | None:
+ return self._forms.get(form_id)
+
+ def get_by_token(self, token: str) -> HumanInputForm | None:
+ for form in self._forms.values():
+ if form.form_token == token:
+ return form
+ return None
+
+ def delete(self, form_id: str) -> None:
+ self._forms.pop(form_id, None)
+
+
+# Service
+class FormService:
+ """Service layer for managing human input forms in tests."""
+
+ def __init__(self, repository: InMemoryFormRepository):
+ self.repository = repository
+
+ def create_form(
+ self,
+ *,
+ form_id: str,
+ workflow_run_id: str,
+ node_id: str,
+ tenant_id: str,
+ app_id: str | None,
+ form_content: str,
+ inputs,
+ user_actions,
+ timeout: int,
+ timeout_unit: TimeoutUnit,
+ form_token: str | None = None,
+ ) -> HumanInputForm:
+ form = HumanInputForm(
+ form_id=form_id,
+ workflow_run_id=workflow_run_id,
+ node_id=node_id,
+ tenant_id=tenant_id,
+ app_id=app_id,
+ form_content=form_content,
+ inputs=list(inputs),
+ user_actions=[{"id": action.id, "title": action.title} for action in user_actions],
+ timeout=timeout,
+ timeout_unit=timeout_unit,
+ form_token=form_token,
+ )
+ form.calculate_expiration()
+ self.repository.save(form)
+ return form
+
+ def get_form_by_id(self, form_id: str) -> HumanInputForm:
+ form = self.repository.get_by_id(form_id)
+ if form is None:
+ raise FormNotFoundError()
+ return form
+
+ def get_form_by_token(self, token: str) -> HumanInputForm:
+ form = self.repository.get_by_token(token)
+ if form is None:
+ raise FormNotFoundError()
+ return form
+
+ def get_form_definition(self, form_id: str, *, is_token: bool) -> dict:
+ form = self.get_form_by_token(form_id) if is_token else self.get_form_by_id(form_id)
+ if form.is_expired:
+ raise FormExpiredError()
+ if form.is_submitted:
+ raise FormAlreadySubmittedError()
+
+ definition = {
+ "form_content": form.form_content,
+ "inputs": form.inputs,
+ "user_actions": form.user_actions,
+ }
+ if is_token:
+ definition["site"] = {"title": "Workflow Form"}
+ return definition
+
+ def submit_form(self, form_id: str, submission_data: FormSubmissionData, *, is_token: bool) -> None:
+ form = self.get_form_by_token(form_id) if is_token else self.get_form_by_id(form_id)
+ if form.is_expired:
+ raise FormExpiredError()
+ if form.is_submitted:
+ raise FormAlreadySubmittedError()
+
+ self._validate_submission(form=form, submission_data=submission_data)
+ form.mark_submitted(inputs=submission_data.inputs, action=submission_data.action)
+ self.repository.save(form)
+
+ def cleanup_expired_forms(self) -> int:
+ expired_ids = [form_id for form_id, form in list(self.repository.forms.items()) if form.is_expired]
+ for form_id in expired_ids:
+ self.repository.delete(form_id)
+ return len(expired_ids)
+
+ def _validate_submission(self, form: HumanInputForm, submission_data: FormSubmissionData) -> None:
+ defined_actions = {action["id"] for action in form.user_actions}
+ if submission_data.action not in defined_actions:
+ raise InvalidFormDataError(f"Invalid action: {submission_data.action}")
+
+ missing_inputs = []
+ for form_input in form.inputs:
+ if form_input.output_variable_name not in submission_data.inputs:
+ missing_inputs.append(form_input.output_variable_name)
+
+ if missing_inputs:
+ raise InvalidFormDataError(f"Missing required inputs: {', '.join(missing_inputs)}")
+
+ # Extra inputs are allowed; no further validation required.
diff --git a/api/tests/unit_tests/libs/_human_input/test_form_service.py b/api/tests/unit_tests/libs/_human_input/test_form_service.py
new file mode 100644
index 0000000000..15e7d41e85
--- /dev/null
+++ b/api/tests/unit_tests/libs/_human_input/test_form_service.py
@@ -0,0 +1,326 @@
+"""
+Unit tests for FormService.
+"""
+
+from datetime import datetime, timedelta
+
+import pytest
+
+from core.workflow.nodes.human_input.entities import (
+ FormInput,
+ UserAction,
+)
+from core.workflow.nodes.human_input.enums import (
+ FormInputType,
+ TimeoutUnit,
+)
+from libs.datetime_utils import naive_utc_now
+
+from .support import (
+ FormAlreadySubmittedError,
+ FormExpiredError,
+ FormNotFoundError,
+ FormService,
+ FormSubmissionData,
+ InMemoryFormRepository,
+ InvalidFormDataError,
+)
+
+
+class TestFormService:
+ """Test FormService functionality."""
+
+ @pytest.fixture
+ def repository(self):
+ """Create in-memory repository for testing."""
+ return InMemoryFormRepository()
+
+ @pytest.fixture
+ def form_service(self, repository):
+ """Create FormService with in-memory repository."""
+ return FormService(repository)
+
+ @pytest.fixture
+ def sample_form_data(self):
+ """Create sample form data."""
+ return {
+ "form_id": "form-123",
+ "workflow_run_id": "run-456",
+ "node_id": "node-789",
+ "tenant_id": "tenant-abc",
+ "app_id": "app-def",
+ "form_content": "# Test Form\n\nInput: {{#$output.input#}}",
+ "inputs": [FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="input", default=None)],
+ "user_actions": [UserAction(id="submit", title="Submit")],
+ "timeout": 1,
+ "timeout_unit": TimeoutUnit.HOUR,
+ "form_token": "token-xyz",
+ }
+
+ def test_create_form(self, form_service, sample_form_data):
+ """Test form creation."""
+ form = form_service.create_form(**sample_form_data)
+
+ assert form.form_id == "form-123"
+ assert form.workflow_run_id == "run-456"
+ assert form.node_id == "node-789"
+ assert form.tenant_id == "tenant-abc"
+ assert form.app_id == "app-def"
+ assert form.form_token == "token-xyz"
+ assert form.timeout == 1
+ assert form.timeout_unit == TimeoutUnit.HOUR
+ assert form.expires_at is not None
+ assert not form.is_expired
+ assert not form.is_submitted
+
+ def test_get_form_by_id(self, form_service, sample_form_data):
+ """Test getting form by ID."""
+ # Create form first
+ created_form = form_service.create_form(**sample_form_data)
+
+ # Retrieve form
+ retrieved_form = form_service.get_form_by_id("form-123")
+
+ assert retrieved_form.form_id == created_form.form_id
+ assert retrieved_form.workflow_run_id == created_form.workflow_run_id
+
+ def test_get_form_by_id_not_found(self, form_service):
+ """Test getting non-existent form by ID."""
+ with pytest.raises(FormNotFoundError) as exc_info:
+ form_service.get_form_by_id("non-existent-form")
+
+ assert exc_info.value.error_code == "form_not_found"
+
+ def test_get_form_by_token(self, form_service, sample_form_data):
+ """Test getting form by token."""
+ # Create form first
+ created_form = form_service.create_form(**sample_form_data)
+
+ # Retrieve form by token
+ retrieved_form = form_service.get_form_by_token("token-xyz")
+
+ assert retrieved_form.form_id == created_form.form_id
+ assert retrieved_form.form_token == "token-xyz"
+
+ def test_get_form_by_token_not_found(self, form_service):
+ """Test getting non-existent form by token."""
+ with pytest.raises(FormNotFoundError) as exc_info:
+ form_service.get_form_by_token("non-existent-token")
+
+ assert exc_info.value.error_code == "form_not_found"
+
+ def test_get_form_definition_by_id(self, form_service, sample_form_data):
+ """Test getting form definition by ID."""
+ # Create form first
+ form_service.create_form(**sample_form_data)
+
+ # Get form definition
+ definition = form_service.get_form_definition("form-123", is_token=False)
+
+ assert "form_content" in definition
+ assert "inputs" in definition
+ assert definition["form_content"] == "# Test Form\n\nInput: {{#$output.input#}}"
+ assert len(definition["inputs"]) == 1
+ assert "site" not in definition # Should not include site info for ID-based access
+
+ def test_get_form_definition_by_token(self, form_service, sample_form_data):
+ """Test getting form definition by token."""
+ # Create form first
+ form_service.create_form(**sample_form_data)
+
+ # Get form definition
+ definition = form_service.get_form_definition("token-xyz", is_token=True)
+
+ assert "form_content" in definition
+ assert "inputs" in definition
+ assert "site" in definition # Should include site info for token-based access
+
+ def test_get_form_definition_expired_form(self, form_service, sample_form_data):
+ """Test getting definition for expired form."""
+ # Create form with past expiry
+ form_service.create_form(**sample_form_data)
+
+ # Manually expire the form by modifying expiry time
+ form = form_service.get_form_by_id("form-123")
+ form.expires_at = datetime.utcnow() - timedelta(hours=1)
+ form_service.repository.save(form)
+
+ # Should raise FormExpiredError
+ with pytest.raises(FormExpiredError) as exc_info:
+ form_service.get_form_definition("form-123", is_token=False)
+
+ assert exc_info.value.error_code == "human_input_form_expired"
+
+ def test_get_form_definition_submitted_form(self, form_service, sample_form_data):
+ """Test getting definition for already submitted form."""
+ # Create form first
+ form_service.create_form(**sample_form_data)
+
+ # Submit the form
+ submission_data = FormSubmissionData(form_id="form-123", inputs={"input": "test value"}, action="submit")
+ form_service.submit_form("form-123", submission_data, is_token=False)
+
+ # Should raise FormAlreadySubmittedError
+ with pytest.raises(FormAlreadySubmittedError) as exc_info:
+ form_service.get_form_definition("form-123", is_token=False)
+
+ assert exc_info.value.error_code == "human_input_form_submitted"
+
+ def test_submit_form_success(self, form_service, sample_form_data):
+ """Test successful form submission."""
+ # Create form first
+ form_service.create_form(**sample_form_data)
+
+ # Submit form
+ submission_data = FormSubmissionData(form_id="form-123", inputs={"input": "test value"}, action="submit")
+
+ # Should not raise any exception
+ form_service.submit_form("form-123", submission_data, is_token=False)
+
+ # Verify form is marked as submitted
+ form = form_service.get_form_by_id("form-123")
+ assert form.is_submitted
+ assert form.submitted_data == {"input": "test value"}
+ assert form.submitted_action == "submit"
+ assert form.submitted_at is not None
+
+ def test_submit_form_missing_inputs(self, form_service, sample_form_data):
+ """Test form submission with missing inputs."""
+ # Create form first
+ form_service.create_form(**sample_form_data)
+
+ # Submit form with missing required input
+ submission_data = FormSubmissionData(
+ form_id="form-123",
+ inputs={}, # Missing required "input" field
+ action="submit",
+ )
+
+ with pytest.raises(InvalidFormDataError) as exc_info:
+ form_service.submit_form("form-123", submission_data, is_token=False)
+
+ assert "Missing required inputs" in exc_info.value.message
+ assert "input" in exc_info.value.message
+
+ def test_submit_form_invalid_action(self, form_service, sample_form_data):
+ """Test form submission with invalid action."""
+ # Create form first
+ form_service.create_form(**sample_form_data)
+
+ # Submit form with invalid action
+ submission_data = FormSubmissionData(
+ form_id="form-123",
+ inputs={"input": "test value"},
+ action="invalid_action", # Not in the allowed actions
+ )
+
+ with pytest.raises(InvalidFormDataError) as exc_info:
+ form_service.submit_form("form-123", submission_data, is_token=False)
+
+ assert "Invalid action" in exc_info.value.message
+ assert "invalid_action" in exc_info.value.message
+
+ def test_submit_form_expired(self, form_service, sample_form_data):
+ """Test submitting expired form."""
+ # Create form first
+ form_service.create_form(**sample_form_data)
+
+ # Manually expire the form
+ form = form_service.get_form_by_id("form-123")
+ form.expires_at = datetime.utcnow() - timedelta(hours=1)
+ form_service.repository.save(form)
+
+ # Try to submit expired form
+ submission_data = FormSubmissionData(form_id="form-123", inputs={"input": "test value"}, action="submit")
+
+ with pytest.raises(FormExpiredError) as exc_info:
+ form_service.submit_form("form-123", submission_data, is_token=False)
+
+ assert exc_info.value.error_code == "human_input_form_expired"
+
+ def test_submit_form_already_submitted(self, form_service, sample_form_data):
+ """Test submitting form that's already submitted."""
+ # Create and submit form first
+ form_service.create_form(**sample_form_data)
+
+ submission_data = FormSubmissionData(form_id="form-123", inputs={"input": "first submission"}, action="submit")
+ form_service.submit_form("form-123", submission_data, is_token=False)
+
+ # Try to submit again
+ second_submission = FormSubmissionData(
+ form_id="form-123", inputs={"input": "second submission"}, action="submit"
+ )
+
+ with pytest.raises(FormAlreadySubmittedError) as exc_info:
+ form_service.submit_form("form-123", second_submission, is_token=False)
+
+ assert exc_info.value.error_code == "human_input_form_submitted"
+
+ def test_cleanup_expired_forms(self, form_service, sample_form_data):
+ """Test cleanup of expired forms."""
+ # Create multiple forms
+ for i in range(3):
+ data = sample_form_data.copy()
+ data["form_id"] = f"form-{i}"
+ data["form_token"] = f"token-{i}"
+ form_service.create_form(**data)
+
+ # Manually expire some forms
+ for i in range(2): # Expire first 2 forms
+ form = form_service.get_form_by_id(f"form-{i}")
+ form.expires_at = naive_utc_now() - timedelta(hours=1)
+ form_service.repository.save(form)
+
+ # Clean up expired forms
+ cleaned_count = form_service.cleanup_expired_forms()
+
+ assert cleaned_count == 2
+
+ # Verify expired forms are gone
+ with pytest.raises(FormNotFoundError):
+ form_service.get_form_by_id("form-0")
+
+ with pytest.raises(FormNotFoundError):
+ form_service.get_form_by_id("form-1")
+
+ # Verify non-expired form still exists
+ form = form_service.get_form_by_id("form-2")
+ assert form.form_id == "form-2"
+
+
+class TestFormValidation:
+ """Test form validation logic."""
+
+ def test_validate_submission_with_extra_inputs(self):
+ """Test validation allows extra inputs that aren't defined in form."""
+ repository = InMemoryFormRepository()
+ form_service = FormService(repository)
+
+ # Create form with one input
+ form_data = {
+ "form_id": "form-123",
+ "workflow_run_id": "run-456",
+ "node_id": "node-789",
+ "tenant_id": "tenant-abc",
+ "app_id": "app-def",
+ "form_content": "Test form",
+ "inputs": [FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="required_input", default=None)],
+ "user_actions": [UserAction(id="submit", title="Submit")],
+ "timeout": 1,
+ "timeout_unit": TimeoutUnit.HOUR,
+ }
+
+ form_service.create_form(**form_data)
+
+ # Submit with extra input (should be allowed)
+ submission_data = FormSubmissionData(
+ form_id="form-123",
+ inputs={
+ "required_input": "value1",
+ "extra_input": "value2", # Extra input not defined in form
+ },
+ action="submit",
+ )
+
+ # Should not raise any exception
+ form_service.submit_form("form-123", submission_data, is_token=False)
diff --git a/api/tests/unit_tests/libs/_human_input/test_models.py b/api/tests/unit_tests/libs/_human_input/test_models.py
new file mode 100644
index 0000000000..962eeb9e11
--- /dev/null
+++ b/api/tests/unit_tests/libs/_human_input/test_models.py
@@ -0,0 +1,232 @@
+"""
+Unit tests for human input form models.
+"""
+
+from datetime import datetime, timedelta
+
+import pytest
+
+from core.workflow.nodes.human_input.entities import (
+ FormInput,
+ UserAction,
+)
+from core.workflow.nodes.human_input.enums import (
+ FormInputType,
+ TimeoutUnit,
+)
+
+from .support import FormSubmissionData, FormSubmissionRequest, HumanInputForm
+
+
+class TestHumanInputForm:
+ """Test HumanInputForm model."""
+
+ @pytest.fixture
+ def sample_form_data(self):
+ """Create sample form data."""
+ return {
+ "form_id": "form-123",
+ "workflow_run_id": "run-456",
+ "node_id": "node-789",
+ "tenant_id": "tenant-abc",
+ "app_id": "app-def",
+ "form_content": "# Test Form\n\nInput: {{#$output.input#}}",
+ "inputs": [FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="input", default=None)],
+ "user_actions": [UserAction(id="submit", title="Submit")],
+ "timeout": 2,
+ "timeout_unit": TimeoutUnit.HOUR,
+ "form_token": "token-xyz",
+ }
+
+ def test_form_creation(self, sample_form_data):
+ """Test form creation."""
+ form = HumanInputForm(**sample_form_data)
+
+ assert form.form_id == "form-123"
+ assert form.workflow_run_id == "run-456"
+ assert form.node_id == "node-789"
+ assert form.tenant_id == "tenant-abc"
+ assert form.app_id == "app-def"
+ assert form.form_token == "token-xyz"
+ assert form.timeout == 2
+ assert form.timeout_unit == TimeoutUnit.HOUR
+ assert form.created_at is not None
+ assert form.expires_at is not None
+ assert form.submitted_at is None
+ assert form.submitted_data is None
+ assert form.submitted_action is None
+
+ def test_form_expiry_calculation_hours(self, sample_form_data):
+ """Test form expiry calculation for hours."""
+ form = HumanInputForm(**sample_form_data)
+
+ # Should expire 2 hours after creation
+ expected_expiry = form.created_at + timedelta(hours=2)
+ assert abs((form.expires_at - expected_expiry).total_seconds()) < 1 # Within 1 second
+
+ def test_form_expiry_calculation_days(self, sample_form_data):
+ """Test form expiry calculation for days."""
+ sample_form_data["timeout"] = 3
+ sample_form_data["timeout_unit"] = TimeoutUnit.DAY
+
+ form = HumanInputForm(**sample_form_data)
+
+ # Should expire 3 days after creation
+ expected_expiry = form.created_at + timedelta(days=3)
+ assert abs((form.expires_at - expected_expiry).total_seconds()) < 1 # Within 1 second
+
+ def test_form_expiry_property_not_expired(self, sample_form_data):
+ """Test is_expired property for non-expired form."""
+ form = HumanInputForm(**sample_form_data)
+ assert not form.is_expired
+
+ def test_form_expiry_property_expired(self, sample_form_data):
+ """Test is_expired property for expired form."""
+ # Create form with past expiry
+ past_time = datetime.utcnow() - timedelta(hours=1)
+ sample_form_data["created_at"] = past_time
+
+ form = HumanInputForm(**sample_form_data)
+ # Manually set expiry to past time
+ form.expires_at = past_time
+
+ assert form.is_expired
+
+ def test_form_submission_property_not_submitted(self, sample_form_data):
+ """Test is_submitted property for non-submitted form."""
+ form = HumanInputForm(**sample_form_data)
+ assert not form.is_submitted
+
+ def test_form_submission_property_submitted(self, sample_form_data):
+ """Test is_submitted property for submitted form."""
+ form = HumanInputForm(**sample_form_data)
+ form.submit({"input": "test value"}, "submit")
+
+ assert form.is_submitted
+ assert form.submitted_at is not None
+ assert form.submitted_data == {"input": "test value"}
+ assert form.submitted_action == "submit"
+
+ def test_form_submit_method(self, sample_form_data):
+ """Test form submit method."""
+ form = HumanInputForm(**sample_form_data)
+
+ submission_time_before = datetime.utcnow()
+ form.submit({"input": "test value"}, "submit")
+ submission_time_after = datetime.utcnow()
+
+ assert form.is_submitted
+ assert form.submitted_data == {"input": "test value"}
+ assert form.submitted_action == "submit"
+ assert submission_time_before <= form.submitted_at <= submission_time_after
+
+ def test_form_to_response_dict_without_site_info(self, sample_form_data):
+ """Test converting form to response dict without site info."""
+ form = HumanInputForm(**sample_form_data)
+
+ response = form.to_response_dict(include_site_info=False)
+
+ assert "form_content" in response
+ assert "inputs" in response
+ assert "site" not in response
+ assert response["form_content"] == "# Test Form\n\nInput: {{#$output.input#}}"
+ assert len(response["inputs"]) == 1
+ assert response["inputs"][0]["type"] == "text-input"
+ assert response["inputs"][0]["output_variable_name"] == "input"
+
+ def test_form_to_response_dict_with_site_info(self, sample_form_data):
+ """Test converting form to response dict with site info."""
+ form = HumanInputForm(**sample_form_data)
+
+ response = form.to_response_dict(include_site_info=True)
+
+ assert "form_content" in response
+ assert "inputs" in response
+ assert "site" in response
+ assert response["site"]["app_id"] == "app-def"
+ assert response["site"]["title"] == "Workflow Form"
+
+ def test_form_without_web_app_token(self, sample_form_data):
+ """Test form creation without web app token."""
+ sample_form_data["form_token"] = None
+
+ form = HumanInputForm(**sample_form_data)
+
+ assert form.form_token is None
+ assert form.form_id == "form-123" # Other fields should still work
+
+ def test_form_with_explicit_timestamps(self):
+ """Test form creation with explicit timestamps."""
+ created_time = datetime(2024, 1, 15, 10, 30, 0)
+ expires_time = datetime(2024, 1, 15, 12, 30, 0)
+
+ form = HumanInputForm(
+ form_id="form-123",
+ workflow_run_id="run-456",
+ node_id="node-789",
+ tenant_id="tenant-abc",
+ app_id="app-def",
+ form_content="Test content",
+ inputs=[],
+ user_actions=[],
+ timeout=2,
+ timeout_unit=TimeoutUnit.HOUR,
+ created_at=created_time,
+ expires_at=expires_time,
+ )
+
+ assert form.created_at == created_time
+ assert form.expires_at == expires_time
+
+
+class TestFormSubmissionData:
+ """Test FormSubmissionData model."""
+
+ def test_submission_data_creation(self):
+ """Test submission data creation."""
+ submission_data = FormSubmissionData(
+ form_id="form-123", inputs={"field1": "value1", "field2": "value2"}, action="submit"
+ )
+
+ assert submission_data.form_id == "form-123"
+ assert submission_data.inputs == {"field1": "value1", "field2": "value2"}
+ assert submission_data.action == "submit"
+ assert submission_data.submitted_at is not None
+
+ def test_submission_data_from_request(self):
+ """Test creating submission data from API request."""
+ request = FormSubmissionRequest(inputs={"input": "test value"}, action="confirm")
+
+ submission_data = FormSubmissionData.from_request("form-456", request)
+
+ assert submission_data.form_id == "form-456"
+ assert submission_data.inputs == {"input": "test value"}
+ assert submission_data.action == "confirm"
+ assert submission_data.submitted_at is not None
+
+ def test_submission_data_with_empty_inputs(self):
+ """Test submission data with empty inputs."""
+ submission_data = FormSubmissionData(form_id="form-123", inputs={}, action="cancel")
+
+ assert submission_data.inputs == {}
+ assert submission_data.action == "cancel"
+
+ def test_submission_data_timestamps(self):
+ """Test submission data timestamp handling."""
+ before_time = datetime.utcnow()
+
+ submission_data = FormSubmissionData(form_id="form-123", inputs={"test": "value"}, action="submit")
+
+ after_time = datetime.utcnow()
+
+ assert before_time <= submission_data.submitted_at <= after_time
+
+ def test_submission_data_with_explicit_timestamp(self):
+ """Test submission data with explicit timestamp."""
+ specific_time = datetime(2024, 1, 15, 14, 30, 0)
+
+ submission_data = FormSubmissionData(
+ form_id="form-123", inputs={"test": "value"}, action="submit", submitted_at=specific_time
+ )
+
+ assert submission_data.submitted_at == specific_time
diff --git a/api/tests/unit_tests/libs/test_helper.py b/api/tests/unit_tests/libs/test_helper.py
index de74eff82f..1a93dbbca1 100644
--- a/api/tests/unit_tests/libs/test_helper.py
+++ b/api/tests/unit_tests/libs/test_helper.py
@@ -1,6 +1,8 @@
+from datetime import datetime
+
import pytest
-from libs.helper import escape_like_pattern, extract_tenant_id
+from libs.helper import OptionalTimestampField, escape_like_pattern, extract_tenant_id
from models.account import Account
from models.model import EndUser
@@ -65,6 +67,19 @@ class TestExtractTenantId:
extract_tenant_id(dict_user)
+class TestOptionalTimestampField:
+ def test_format_returns_none_for_none(self):
+ field = OptionalTimestampField()
+
+ assert field.format(None) is None
+
+ def test_format_returns_unix_timestamp_for_datetime(self):
+ field = OptionalTimestampField()
+ value = datetime(2024, 1, 2, 3, 4, 5)
+
+ assert field.format(value) == int(value.timestamp())
+
+
class TestEscapeLikePattern:
"""Test cases for the escape_like_pattern utility function."""
diff --git a/api/tests/unit_tests/libs/test_rate_limiter.py b/api/tests/unit_tests/libs/test_rate_limiter.py
new file mode 100644
index 0000000000..9d44b07b5e
--- /dev/null
+++ b/api/tests/unit_tests/libs/test_rate_limiter.py
@@ -0,0 +1,68 @@
+from unittest.mock import MagicMock
+
+from libs import helper as helper_module
+
+
+class _FakeRedis:
+ def __init__(self) -> None:
+ self._zsets: dict[str, dict[str, float]] = {}
+ self._expiry: dict[str, int] = {}
+
+ def zadd(self, key: str, mapping: dict[str, float]) -> int:
+ zset = self._zsets.setdefault(key, {})
+ for member, score in mapping.items():
+ zset[str(member)] = float(score)
+ return len(mapping)
+
+ def zremrangebyscore(self, key: str, min_score: str | float, max_score: str | float) -> int:
+ zset = self._zsets.get(key, {})
+ min_value = float("-inf") if min_score == "-inf" else float(min_score)
+ max_value = float("inf") if max_score == "+inf" else float(max_score)
+ to_delete = [member for member, score in zset.items() if min_value <= score <= max_value]
+ for member in to_delete:
+ del zset[member]
+ return len(to_delete)
+
+ def zcard(self, key: str) -> int:
+ return len(self._zsets.get(key, {}))
+
+ def expire(self, key: str, ttl: int) -> bool:
+ self._expiry[key] = ttl
+ return True
+
+
+def test_rate_limiter_counts_attempts_within_same_second(monkeypatch):
+ fake_redis = _FakeRedis()
+ monkeypatch.setattr(helper_module.time, "time", lambda: 1000)
+
+ limiter = helper_module.RateLimiter(
+ prefix="test_rate_limit",
+ max_attempts=2,
+ time_window=60,
+ redis_client=fake_redis,
+ )
+
+ limiter.increment_rate_limit("203.0.113.10")
+ limiter.increment_rate_limit("203.0.113.10")
+
+ assert limiter.is_rate_limited("203.0.113.10") is True
+
+
+def test_rate_limiter_uses_injected_redis(monkeypatch):
+ redis_client = MagicMock()
+ redis_client.zcard.return_value = 1
+ monkeypatch.setattr(helper_module.time, "time", lambda: 1000)
+
+ limiter = helper_module.RateLimiter(
+ prefix="test_rate_limit",
+ max_attempts=1,
+ time_window=60,
+ redis_client=redis_client,
+ )
+
+ limiter.increment_rate_limit("203.0.113.10")
+ limiter.is_rate_limited("203.0.113.10")
+
+ assert redis_client.zadd.called is True
+ assert redis_client.zremrangebyscore.called is True
+ assert redis_client.zcard.called is True
diff --git a/api/tests/unit_tests/models/test_app_models.py b/api/tests/unit_tests/models/test_app_models.py
index 8be2eea121..c6dfd41803 100644
--- a/api/tests/unit_tests/models/test_app_models.py
+++ b/api/tests/unit_tests/models/test_app_models.py
@@ -1296,6 +1296,7 @@ class TestConversationStatusCount:
assert result["success"] == 1 # One SUCCEEDED
assert result["failed"] == 1 # One FAILED
assert result["partial_success"] == 1 # One PARTIAL_SUCCEEDED
+ assert result["paused"] == 0
def test_status_count_app_id_filtering(self):
"""Test that status_count filters workflow runs by app_id for security."""
@@ -1350,6 +1351,7 @@ class TestConversationStatusCount:
assert result["success"] == 0
assert result["failed"] == 0
assert result["partial_success"] == 0
+ assert result["paused"] == 0
def test_status_count_handles_invalid_workflow_status(self):
"""Test that status_count gracefully handles invalid workflow status values."""
@@ -1404,3 +1406,57 @@ class TestConversationStatusCount:
assert result["success"] == 0
assert result["failed"] == 0
assert result["partial_success"] == 0
+ assert result["paused"] == 0
+
+ def test_status_count_paused(self):
+ """Test status_count includes paused workflow runs."""
+ # Arrange
+ from core.workflow.enums import WorkflowExecutionStatus
+
+ app_id = str(uuid4())
+ conversation_id = str(uuid4())
+ workflow_run_id = str(uuid4())
+
+ conversation = Conversation(
+ app_id=app_id,
+ mode=AppMode.CHAT,
+ name="Test Conversation",
+ status="normal",
+ from_source="api",
+ )
+ conversation.id = conversation_id
+
+ mock_messages = [
+ MagicMock(
+ conversation_id=conversation_id,
+ workflow_run_id=workflow_run_id,
+ ),
+ ]
+
+ mock_workflow_runs = [
+ MagicMock(
+ id=workflow_run_id,
+ status=WorkflowExecutionStatus.PAUSED.value,
+ app_id=app_id,
+ ),
+ ]
+
+ with patch("models.model.db.session.scalars") as mock_scalars:
+
+ def mock_scalars_side_effect(query):
+ mock_result = MagicMock()
+ if "messages" in str(query):
+ mock_result.all.return_value = mock_messages
+ elif "workflow_runs" in str(query):
+ mock_result.all.return_value = mock_workflow_runs
+ else:
+ mock_result.all.return_value = []
+ return mock_result
+
+ mock_scalars.side_effect = mock_scalars_side_effect
+
+ # Act
+ result = conversation.status_count
+
+ # Assert
+ assert result["paused"] == 1
diff --git a/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_node_execution_repository.py b/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_node_execution_repository.py
new file mode 100644
index 0000000000..ceb1406a4b
--- /dev/null
+++ b/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_node_execution_repository.py
@@ -0,0 +1,40 @@
+"""Unit tests for DifyAPISQLAlchemyWorkflowNodeExecutionRepository implementation."""
+
+from unittest.mock import Mock
+
+from sqlalchemy.orm import Session, sessionmaker
+
+from repositories.sqlalchemy_api_workflow_node_execution_repository import (
+ DifyAPISQLAlchemyWorkflowNodeExecutionRepository,
+)
+
+
+class TestDifyAPISQLAlchemyWorkflowNodeExecutionRepository:
+ def test_get_executions_by_workflow_run_keeps_paused_records(self):
+ mock_session = Mock(spec=Session)
+ execute_result = Mock()
+ execute_result.scalars.return_value.all.return_value = []
+ mock_session.execute.return_value = execute_result
+
+ session_maker = Mock(spec=sessionmaker)
+ context_manager = Mock()
+ context_manager.__enter__ = Mock(return_value=mock_session)
+ context_manager.__exit__ = Mock(return_value=None)
+ session_maker.return_value = context_manager
+
+ repository = DifyAPISQLAlchemyWorkflowNodeExecutionRepository(session_maker)
+
+ repository.get_executions_by_workflow_run(
+ tenant_id="tenant-123",
+ app_id="app-123",
+ workflow_run_id="workflow-run-123",
+ )
+
+ stmt = mock_session.execute.call_args[0][0]
+ where_clauses = list(getattr(stmt, "_where_criteria", []) or [])
+ where_strs = [str(clause).lower() for clause in where_clauses]
+
+ assert any("tenant_id" in clause for clause in where_strs)
+ assert any("app_id" in clause for clause in where_strs)
+ assert any("workflow_run_id" in clause for clause in where_strs)
+ assert not any("paused" in clause for clause in where_strs)
diff --git a/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py b/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py
index d443c4c9a5..4caaa056ff 100644
--- a/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py
+++ b/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py
@@ -1,5 +1,6 @@
"""Unit tests for DifyAPISQLAlchemyWorkflowRunRepository implementation."""
+import secrets
from datetime import UTC, datetime
from unittest.mock import Mock, patch
@@ -7,12 +8,17 @@ import pytest
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import Session, sessionmaker
+from core.workflow.entities.pause_reason import HumanInputRequired, PauseReasonType
from core.workflow.enums import WorkflowExecutionStatus
+from core.workflow.nodes.human_input.entities import FormDefinition, FormInput, UserAction
+from core.workflow.nodes.human_input.enums import FormInputType, HumanInputFormStatus
+from models.human_input import BackstageRecipientPayload, HumanInputForm, HumanInputFormRecipient, RecipientType
from models.workflow import WorkflowPause as WorkflowPauseModel
-from models.workflow import WorkflowRun
+from models.workflow import WorkflowPauseReason, WorkflowRun
from repositories.entities.workflow_pause import WorkflowPauseEntity
from repositories.sqlalchemy_api_workflow_run_repository import (
DifyAPISQLAlchemyWorkflowRunRepository,
+ _build_human_input_required_reason,
_PrivateWorkflowPauseEntity,
_WorkflowRunError,
)
@@ -205,11 +211,11 @@ class TestCreateWorkflowPause(TestDifyAPISQLAlchemyWorkflowRunRepository):
):
"""Test workflow pause creation when workflow not in RUNNING status."""
# Arrange
- sample_workflow_run.status = WorkflowExecutionStatus.PAUSED
+ sample_workflow_run.status = WorkflowExecutionStatus.SUCCEEDED
mock_session.get.return_value = sample_workflow_run
# Act & Assert
- with pytest.raises(_WorkflowRunError, match="Only WorkflowRun with RUNNING status can be paused"):
+ with pytest.raises(_WorkflowRunError, match="Only WorkflowRun with RUNNING or PAUSED status can be paused"):
repository.create_workflow_pause(
workflow_run_id="workflow-run-123",
state_owner_user_id="user-123",
@@ -295,6 +301,7 @@ class TestResumeWorkflowPause(TestDifyAPISQLAlchemyWorkflowRunRepository):
sample_workflow_pause.resumed_at = None
mock_session.scalar.return_value = sample_workflow_run
+ mock_session.scalars.return_value.all.return_value = []
with patch("repositories.sqlalchemy_api_workflow_run_repository.naive_utc_now") as mock_now:
mock_now.return_value = datetime.now(UTC)
@@ -455,3 +462,53 @@ class TestPrivateWorkflowPauseEntity(TestDifyAPISQLAlchemyWorkflowRunRepository)
assert result1 == expected_state
assert result2 == expected_state
mock_storage.load.assert_called_once() # Only called once due to caching
+
+
+class TestBuildHumanInputRequiredReason:
+ def test_prefers_backstage_token_when_available(self):
+ expiration_time = datetime.now(UTC)
+ form_definition = FormDefinition(
+ form_content="content",
+ inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")],
+ user_actions=[UserAction(id="approve", title="Approve")],
+ rendered_content="rendered",
+ expiration_time=expiration_time,
+ default_values={"name": "Alice"},
+ node_title="Ask Name",
+ display_in_ui=True,
+ )
+ form_model = HumanInputForm(
+ id="form-1",
+ tenant_id="tenant-1",
+ app_id="app-1",
+ workflow_run_id="run-1",
+ node_id="node-1",
+ form_definition=form_definition.model_dump_json(),
+ rendered_content="rendered",
+ status=HumanInputFormStatus.WAITING,
+ expiration_time=expiration_time,
+ )
+ reason_model = WorkflowPauseReason(
+ pause_id="pause-1",
+ type_=PauseReasonType.HUMAN_INPUT_REQUIRED,
+ form_id="form-1",
+ node_id="node-1",
+ message="",
+ )
+ access_token = secrets.token_urlsafe(8)
+ backstage_recipient = HumanInputFormRecipient(
+ form_id="form-1",
+ delivery_id="delivery-1",
+ recipient_type=RecipientType.BACKSTAGE,
+ recipient_payload=BackstageRecipientPayload().model_dump_json(),
+ access_token=access_token,
+ )
+
+ reason = _build_human_input_required_reason(reason_model, form_model, [backstage_recipient])
+
+ assert isinstance(reason, HumanInputRequired)
+ assert reason.form_token == access_token
+ assert reason.node_title == "Ask Name"
+ assert reason.form_content == "content"
+ assert reason.inputs[0].output_variable_name == "name"
+ assert reason.actions[0].id == "approve"
diff --git a/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py b/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py
new file mode 100644
index 0000000000..f5428b46ff
--- /dev/null
+++ b/api/tests/unit_tests/repositories/test_sqlalchemy_execution_extra_content_repository.py
@@ -0,0 +1,180 @@
+from __future__ import annotations
+
+from collections.abc import Sequence
+from dataclasses import dataclass
+from datetime import UTC, datetime, timedelta
+
+from core.entities.execution_extra_content import HumanInputContent as HumanInputContentDomain
+from core.entities.execution_extra_content import HumanInputFormSubmissionData
+from core.workflow.nodes.human_input.entities import (
+ FormDefinition,
+ UserAction,
+)
+from core.workflow.nodes.human_input.enums import HumanInputFormStatus
+from models.execution_extra_content import HumanInputContent as HumanInputContentModel
+from models.human_input import ConsoleRecipientPayload, HumanInputForm, HumanInputFormRecipient, RecipientType
+from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository
+
+
+class _FakeScalarResult:
+ def __init__(self, values: Sequence[HumanInputContentModel]):
+ self._values = list(values)
+
+ def all(self) -> list[HumanInputContentModel]:
+ return list(self._values)
+
+
+class _FakeSession:
+ def __init__(self, values: Sequence[Sequence[object]]):
+ self._values = list(values)
+
+ def scalars(self, _stmt):
+ if not self._values:
+ return _FakeScalarResult([])
+ return _FakeScalarResult(self._values.pop(0))
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, tb):
+ return False
+
+
+@dataclass
+class _FakeSessionMaker:
+ session: _FakeSession
+
+ def __call__(self) -> _FakeSession:
+ return self.session
+
+
+def _build_form(action_id: str, action_title: str, rendered_content: str) -> HumanInputForm:
+ expiration_time = datetime.now(UTC) + timedelta(days=1)
+ definition = FormDefinition(
+ form_content="content",
+ inputs=[],
+ user_actions=[UserAction(id=action_id, title=action_title)],
+ rendered_content="rendered",
+ expiration_time=expiration_time,
+ node_title="Approval",
+ display_in_ui=True,
+ )
+ form = HumanInputForm(
+ id=f"form-{action_id}",
+ tenant_id="tenant-id",
+ app_id="app-id",
+ workflow_run_id="workflow-run",
+ node_id="node-id",
+ form_definition=definition.model_dump_json(),
+ rendered_content=rendered_content,
+ status=HumanInputFormStatus.SUBMITTED,
+ expiration_time=expiration_time,
+ )
+ form.selected_action_id = action_id
+ return form
+
+
+def _build_content(message_id: str, action_id: str, action_title: str) -> HumanInputContentModel:
+ form = _build_form(
+ action_id=action_id,
+ action_title=action_title,
+ rendered_content=f"Rendered {action_title}",
+ )
+ content = HumanInputContentModel(
+ id=f"content-{message_id}",
+ form_id=form.id,
+ message_id=message_id,
+ workflow_run_id=form.workflow_run_id,
+ )
+ content.form = form
+ return content
+
+
+def test_get_by_message_ids_groups_contents_by_message() -> None:
+ message_ids = ["msg-1", "msg-2"]
+ contents = [_build_content("msg-1", "approve", "Approve")]
+ repository = SQLAlchemyExecutionExtraContentRepository(
+ session_maker=_FakeSessionMaker(session=_FakeSession(values=[contents, []]))
+ )
+
+ result = repository.get_by_message_ids(message_ids)
+
+ assert len(result) == 2
+ assert [content.model_dump(mode="json", exclude_none=True) for content in result[0]] == [
+ HumanInputContentDomain(
+ workflow_run_id="workflow-run",
+ submitted=True,
+ form_submission_data=HumanInputFormSubmissionData(
+ node_id="node-id",
+ node_title="Approval",
+ rendered_content="Rendered Approve",
+ action_id="approve",
+ action_text="Approve",
+ ),
+ ).model_dump(mode="json", exclude_none=True)
+ ]
+ assert result[1] == []
+
+
+def test_get_by_message_ids_returns_unsubmitted_form_definition() -> None:
+ expiration_time = datetime.now(UTC) + timedelta(days=1)
+ definition = FormDefinition(
+ form_content="content",
+ inputs=[],
+ user_actions=[UserAction(id="approve", title="Approve")],
+ rendered_content="rendered",
+ expiration_time=expiration_time,
+ default_values={"name": "John"},
+ node_title="Approval",
+ display_in_ui=True,
+ )
+ form = HumanInputForm(
+ id="form-1",
+ tenant_id="tenant-id",
+ app_id="app-id",
+ workflow_run_id="workflow-run",
+ node_id="node-id",
+ form_definition=definition.model_dump_json(),
+ rendered_content="Rendered block",
+ status=HumanInputFormStatus.WAITING,
+ expiration_time=expiration_time,
+ )
+ content = HumanInputContentModel(
+ id="content-msg-1",
+ form_id=form.id,
+ message_id="msg-1",
+ workflow_run_id=form.workflow_run_id,
+ )
+ content.form = form
+
+ recipient = HumanInputFormRecipient(
+ form_id=form.id,
+ delivery_id="delivery-1",
+ recipient_type=RecipientType.CONSOLE,
+ recipient_payload=ConsoleRecipientPayload(account_id=None).model_dump_json(),
+ access_token="token-1",
+ )
+
+ repository = SQLAlchemyExecutionExtraContentRepository(
+ session_maker=_FakeSessionMaker(session=_FakeSession(values=[[content], [recipient]]))
+ )
+
+ result = repository.get_by_message_ids(["msg-1"])
+
+ assert len(result) == 1
+ assert len(result[0]) == 1
+ domain_content = result[0][0]
+ assert domain_content.submitted is False
+ assert domain_content.workflow_run_id == "workflow-run"
+ assert domain_content.form_definition is not None
+ assert domain_content.form_definition.expiration_time == int(form.expiration_time.timestamp())
+ assert domain_content.form_definition is not None
+ form_definition = domain_content.form_definition
+ assert form_definition.form_id == "form-1"
+ assert form_definition.node_id == "node-id"
+ assert form_definition.node_title == "Approval"
+ assert form_definition.form_content == "Rendered block"
+ assert form_definition.display_in_ui is True
+ assert form_definition.form_token == "token-1"
+ assert form_definition.resolved_default_values == {"name": "John"}
+ assert form_definition.expiration_time == int(form.expiration_time.timestamp())
diff --git a/api/tests/unit_tests/services/test_conversation_service.py b/api/tests/unit_tests/services/test_conversation_service.py
index 81135dbbdf..eca1d44d23 100644
--- a/api/tests/unit_tests/services/test_conversation_service.py
+++ b/api/tests/unit_tests/services/test_conversation_service.py
@@ -508,9 +508,12 @@ class TestConversationServiceMessageCreation:
within conversations.
"""
+ @patch("services.message_service._create_execution_extra_content_repository")
@patch("services.message_service.db.session")
@patch("services.message_service.ConversationService.get_conversation")
- def test_pagination_by_first_id_without_first_id(self, mock_get_conversation, mock_db_session):
+ def test_pagination_by_first_id_without_first_id(
+ self, mock_get_conversation, mock_db_session, mock_create_extra_repo
+ ):
"""
Test message pagination without specifying first_id.
@@ -540,6 +543,9 @@ class TestConversationServiceMessageCreation:
mock_query.order_by.return_value = mock_query # ORDER BY returns self for chaining
mock_query.limit.return_value = mock_query # LIMIT returns self for chaining
mock_query.all.return_value = messages # Final .all() returns the messages
+ mock_repository = MagicMock()
+ mock_repository.get_by_message_ids.return_value = [[] for _ in messages]
+ mock_create_extra_repo.return_value = mock_repository
# Act - Call the pagination method without first_id
result = MessageService.pagination_by_first_id(
@@ -556,9 +562,10 @@ class TestConversationServiceMessageCreation:
# Verify conversation was looked up with correct parameters
mock_get_conversation.assert_called_once_with(app_model=app_model, user=user, conversation_id=conversation.id)
+ @patch("services.message_service._create_execution_extra_content_repository")
@patch("services.message_service.db.session")
@patch("services.message_service.ConversationService.get_conversation")
- def test_pagination_by_first_id_with_first_id(self, mock_get_conversation, mock_db_session):
+ def test_pagination_by_first_id_with_first_id(self, mock_get_conversation, mock_db_session, mock_create_extra_repo):
"""
Test message pagination with first_id specified.
@@ -590,6 +597,9 @@ class TestConversationServiceMessageCreation:
mock_query.limit.return_value = mock_query # LIMIT returns self for chaining
mock_query.first.return_value = first_message # First message returned
mock_query.all.return_value = messages # Remaining messages returned
+ mock_repository = MagicMock()
+ mock_repository.get_by_message_ids.return_value = [[] for _ in messages]
+ mock_create_extra_repo.return_value = mock_repository
# Act - Call the pagination method with first_id
result = MessageService.pagination_by_first_id(
@@ -684,9 +694,10 @@ class TestConversationServiceMessageCreation:
assert result.data == []
assert result.has_more is False
+ @patch("services.message_service._create_execution_extra_content_repository")
@patch("services.message_service.db.session")
@patch("services.message_service.ConversationService.get_conversation")
- def test_pagination_with_has_more_flag(self, mock_get_conversation, mock_db_session):
+ def test_pagination_with_has_more_flag(self, mock_get_conversation, mock_db_session, mock_create_extra_repo):
"""
Test that has_more flag is correctly set when there are more messages.
@@ -716,6 +727,9 @@ class TestConversationServiceMessageCreation:
mock_query.order_by.return_value = mock_query # ORDER BY returns self for chaining
mock_query.limit.return_value = mock_query # LIMIT returns self for chaining
mock_query.all.return_value = messages # Final .all() returns the messages
+ mock_repository = MagicMock()
+ mock_repository.get_by_message_ids.return_value = [[] for _ in messages]
+ mock_create_extra_repo.return_value = mock_repository
# Act
result = MessageService.pagination_by_first_id(
@@ -730,9 +744,10 @@ class TestConversationServiceMessageCreation:
assert len(result.data) == limit # Extra message should be removed
assert result.has_more is True # Flag should be set
+ @patch("services.message_service._create_execution_extra_content_repository")
@patch("services.message_service.db.session")
@patch("services.message_service.ConversationService.get_conversation")
- def test_pagination_with_ascending_order(self, mock_get_conversation, mock_db_session):
+ def test_pagination_with_ascending_order(self, mock_get_conversation, mock_db_session, mock_create_extra_repo):
"""
Test message pagination with ascending order.
@@ -761,6 +776,9 @@ class TestConversationServiceMessageCreation:
mock_query.order_by.return_value = mock_query # ORDER BY returns self for chaining
mock_query.limit.return_value = mock_query # LIMIT returns self for chaining
mock_query.all.return_value = messages # Final .all() returns the messages
+ mock_repository = MagicMock()
+ mock_repository.get_by_message_ids.return_value = [[] for _ in messages]
+ mock_create_extra_repo.return_value = mock_repository
# Act
result = MessageService.pagination_by_first_id(
diff --git a/api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py b/api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py
new file mode 100644
index 0000000000..ab141a7b2d
--- /dev/null
+++ b/api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py
@@ -0,0 +1,104 @@
+from dataclasses import dataclass
+
+import pytest
+
+from enums.cloud_plan import CloudPlan
+from services import feature_service as feature_service_module
+from services.feature_service import FeatureModel, FeatureService
+
+
+@dataclass(frozen=True)
+class HumanInputEmailDeliveryCase:
+ name: str
+ enterprise_enabled: bool
+ billing_enabled: bool
+ tenant_id: str | None
+ billing_feature_enabled: bool
+ plan: str
+ expected: bool
+
+
+CASES = [
+ HumanInputEmailDeliveryCase(
+ name="enterprise_enabled",
+ enterprise_enabled=True,
+ billing_enabled=True,
+ tenant_id=None,
+ billing_feature_enabled=False,
+ plan=CloudPlan.SANDBOX,
+ expected=True,
+ ),
+ HumanInputEmailDeliveryCase(
+ name="billing_disabled",
+ enterprise_enabled=False,
+ billing_enabled=False,
+ tenant_id=None,
+ billing_feature_enabled=False,
+ plan=CloudPlan.SANDBOX,
+ expected=True,
+ ),
+ HumanInputEmailDeliveryCase(
+ name="billing_enabled_requires_tenant",
+ enterprise_enabled=False,
+ billing_enabled=True,
+ tenant_id=None,
+ billing_feature_enabled=True,
+ plan=CloudPlan.PROFESSIONAL,
+ expected=False,
+ ),
+ HumanInputEmailDeliveryCase(
+ name="billing_feature_off",
+ enterprise_enabled=False,
+ billing_enabled=True,
+ tenant_id="tenant-1",
+ billing_feature_enabled=False,
+ plan=CloudPlan.PROFESSIONAL,
+ expected=False,
+ ),
+ HumanInputEmailDeliveryCase(
+ name="professional_plan",
+ enterprise_enabled=False,
+ billing_enabled=True,
+ tenant_id="tenant-1",
+ billing_feature_enabled=True,
+ plan=CloudPlan.PROFESSIONAL,
+ expected=True,
+ ),
+ HumanInputEmailDeliveryCase(
+ name="team_plan",
+ enterprise_enabled=False,
+ billing_enabled=True,
+ tenant_id="tenant-1",
+ billing_feature_enabled=True,
+ plan=CloudPlan.TEAM,
+ expected=True,
+ ),
+ HumanInputEmailDeliveryCase(
+ name="sandbox_plan",
+ enterprise_enabled=False,
+ billing_enabled=True,
+ tenant_id="tenant-1",
+ billing_feature_enabled=True,
+ plan=CloudPlan.SANDBOX,
+ expected=False,
+ ),
+]
+
+
+@pytest.mark.parametrize("case", CASES, ids=lambda case: case.name)
+def test_resolve_human_input_email_delivery_enabled_matrix(
+ monkeypatch: pytest.MonkeyPatch,
+ case: HumanInputEmailDeliveryCase,
+):
+ monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", case.enterprise_enabled)
+ monkeypatch.setattr(feature_service_module.dify_config, "BILLING_ENABLED", case.billing_enabled)
+ features = FeatureModel()
+ features.billing.enabled = case.billing_feature_enabled
+ features.billing.subscription.plan = case.plan
+
+ result = FeatureService._resolve_human_input_email_delivery_enabled(
+ features=features,
+ tenant_id=case.tenant_id,
+ )
+
+ assert result is case.expected
diff --git a/api/tests/unit_tests/services/test_human_input_delivery_test_service.py b/api/tests/unit_tests/services/test_human_input_delivery_test_service.py
new file mode 100644
index 0000000000..e0d6ad1b39
--- /dev/null
+++ b/api/tests/unit_tests/services/test_human_input_delivery_test_service.py
@@ -0,0 +1,97 @@
+from types import SimpleNamespace
+
+import pytest
+
+from core.workflow.nodes.human_input.entities import (
+ EmailDeliveryConfig,
+ EmailDeliveryMethod,
+ EmailRecipients,
+ ExternalRecipient,
+)
+from core.workflow.runtime import VariablePool
+from services import human_input_delivery_test_service as service_module
+from services.human_input_delivery_test_service import (
+ DeliveryTestContext,
+ DeliveryTestError,
+ EmailDeliveryTestHandler,
+)
+
+
+def _make_email_method() -> EmailDeliveryMethod:
+ return EmailDeliveryMethod(
+ config=EmailDeliveryConfig(
+ recipients=EmailRecipients(
+ whole_workspace=False,
+ items=[ExternalRecipient(email="tester@example.com")],
+ ),
+ subject="Test subject",
+ body="Test body",
+ )
+ )
+
+
+def test_email_delivery_test_handler_rejects_when_feature_disabled(monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setattr(
+ service_module.FeatureService,
+ "get_features",
+ lambda _tenant_id: SimpleNamespace(human_input_email_delivery_enabled=False),
+ )
+
+ handler = EmailDeliveryTestHandler(session_factory=object())
+ context = DeliveryTestContext(
+ tenant_id="tenant-1",
+ app_id="app-1",
+ node_id="node-1",
+ node_title="Human Input",
+ rendered_content="content",
+ )
+ method = _make_email_method()
+
+ with pytest.raises(DeliveryTestError, match="Email delivery is not available"):
+ handler.send_test(context=context, method=method)
+
+
+def test_email_delivery_test_handler_replaces_body_variables(monkeypatch: pytest.MonkeyPatch):
+ class DummyMail:
+ def __init__(self):
+ self.sent: list[dict[str, str]] = []
+
+ def is_inited(self) -> bool:
+ return True
+
+ def send(self, *, to: str, subject: str, html: str):
+ self.sent.append({"to": to, "subject": subject, "html": html})
+
+ mail = DummyMail()
+ monkeypatch.setattr(service_module, "mail", mail)
+ monkeypatch.setattr(service_module, "render_email_template", lambda template, _substitutions: template)
+ monkeypatch.setattr(
+ service_module.FeatureService,
+ "get_features",
+ lambda _tenant_id: SimpleNamespace(human_input_email_delivery_enabled=True),
+ )
+
+ handler = EmailDeliveryTestHandler(session_factory=object())
+ handler._resolve_recipients = lambda **_kwargs: ["tester@example.com"] # type: ignore[assignment]
+
+ method = EmailDeliveryMethod(
+ config=EmailDeliveryConfig(
+ recipients=EmailRecipients(whole_workspace=False, items=[ExternalRecipient(email="tester@example.com")]),
+ subject="Subject",
+ body="Value {{#node1.value#}}",
+ )
+ )
+ variable_pool = VariablePool()
+ variable_pool.add(["node1", "value"], "OK")
+ context = DeliveryTestContext(
+ tenant_id="tenant-1",
+ app_id="app-1",
+ node_id="node-1",
+ node_title="Human Input",
+ rendered_content="content",
+ variable_pool=variable_pool,
+ )
+
+ handler.send_test(context=context, method=method)
+
+ assert mail.sent[0]["html"] == "Value OK"
diff --git a/api/tests/unit_tests/services/test_human_input_service.py b/api/tests/unit_tests/services/test_human_input_service.py
new file mode 100644
index 0000000000..72e19447bd
--- /dev/null
+++ b/api/tests/unit_tests/services/test_human_input_service.py
@@ -0,0 +1,290 @@
+import dataclasses
+from datetime import datetime, timedelta
+from unittest.mock import MagicMock
+
+import pytest
+
+import services.human_input_service as human_input_service_module
+from core.repositories.human_input_repository import (
+ HumanInputFormRecord,
+ HumanInputFormSubmissionRepository,
+)
+from core.workflow.nodes.human_input.entities import (
+ FormDefinition,
+ FormInput,
+ UserAction,
+)
+from core.workflow.nodes.human_input.enums import FormInputType, HumanInputFormKind, HumanInputFormStatus
+from models.human_input import RecipientType
+from services.human_input_service import Form, FormExpiredError, HumanInputService, InvalidFormDataError
+from tasks.app_generate.workflow_execute_task import WORKFLOW_BASED_APP_EXECUTION_QUEUE
+
+
+@pytest.fixture
+def mock_session_factory():
+ session = MagicMock()
+ session_cm = MagicMock()
+ session_cm.__enter__.return_value = session
+ session_cm.__exit__.return_value = None
+
+ factory = MagicMock()
+ factory.return_value = session_cm
+ return factory, session
+
+
+@pytest.fixture
+def sample_form_record():
+ return HumanInputFormRecord(
+ form_id="form-id",
+ workflow_run_id="workflow-run-id",
+ node_id="node-id",
+ tenant_id="tenant-id",
+ app_id="app-id",
+ form_kind=HumanInputFormKind.RUNTIME,
+ definition=FormDefinition(
+ form_content="hello",
+ inputs=[],
+ user_actions=[UserAction(id="submit", title="Submit")],
+ rendered_content="hello
",
+ expiration_time=datetime.utcnow() + timedelta(hours=1),
+ ),
+ rendered_content="hello
",
+ created_at=datetime.utcnow(),
+ expiration_time=datetime.utcnow() + timedelta(hours=1),
+ status=HumanInputFormStatus.WAITING,
+ selected_action_id=None,
+ submitted_data=None,
+ submitted_at=None,
+ submission_user_id=None,
+ submission_end_user_id=None,
+ completed_by_recipient_id=None,
+ recipient_id="recipient-id",
+ recipient_type=RecipientType.STANDALONE_WEB_APP,
+ access_token="token",
+ )
+
+
+def test_enqueue_resume_dispatches_task_for_workflow(mocker, mock_session_factory):
+ session_factory, session = mock_session_factory
+ service = HumanInputService(session_factory)
+
+ workflow_run = MagicMock()
+ workflow_run.app_id = "app-id"
+
+ workflow_run_repo = MagicMock()
+ workflow_run_repo.get_workflow_run_by_id_without_tenant.return_value = workflow_run
+ mocker.patch(
+ "services.human_input_service.DifyAPIRepositoryFactory.create_api_workflow_run_repository",
+ return_value=workflow_run_repo,
+ )
+
+ app = MagicMock()
+ app.mode = "workflow"
+ session.execute.return_value.scalar_one_or_none.return_value = app
+
+ resume_task = mocker.patch("services.human_input_service.resume_app_execution")
+
+ service.enqueue_resume("workflow-run-id")
+
+ resume_task.apply_async.assert_called_once()
+ call_kwargs = resume_task.apply_async.call_args.kwargs
+ assert call_kwargs["queue"] == WORKFLOW_BASED_APP_EXECUTION_QUEUE
+ assert call_kwargs["kwargs"]["payload"]["workflow_run_id"] == "workflow-run-id"
+
+
+def test_ensure_form_active_respects_global_timeout(monkeypatch, sample_form_record, mock_session_factory):
+ session_factory, _ = mock_session_factory
+ service = HumanInputService(session_factory)
+ expired_record = dataclasses.replace(
+ sample_form_record,
+ created_at=datetime.utcnow() - timedelta(hours=2),
+ expiration_time=datetime.utcnow() + timedelta(hours=2),
+ )
+ monkeypatch.setattr(human_input_service_module.dify_config, "HITL_GLOBAL_TIMEOUT_SECONDS", 3600)
+
+ with pytest.raises(FormExpiredError):
+ service.ensure_form_active(Form(expired_record))
+
+
+def test_enqueue_resume_dispatches_task_for_advanced_chat(mocker, mock_session_factory):
+ session_factory, session = mock_session_factory
+ service = HumanInputService(session_factory)
+
+ workflow_run = MagicMock()
+ workflow_run.app_id = "app-id"
+
+ workflow_run_repo = MagicMock()
+ workflow_run_repo.get_workflow_run_by_id_without_tenant.return_value = workflow_run
+ mocker.patch(
+ "services.human_input_service.DifyAPIRepositoryFactory.create_api_workflow_run_repository",
+ return_value=workflow_run_repo,
+ )
+
+ app = MagicMock()
+ app.mode = "advanced-chat"
+ session.execute.return_value.scalar_one_or_none.return_value = app
+
+ resume_task = mocker.patch("services.human_input_service.resume_app_execution")
+
+ service.enqueue_resume("workflow-run-id")
+
+ resume_task.apply_async.assert_called_once()
+ call_kwargs = resume_task.apply_async.call_args.kwargs
+ assert call_kwargs["queue"] == WORKFLOW_BASED_APP_EXECUTION_QUEUE
+ assert call_kwargs["kwargs"]["payload"]["workflow_run_id"] == "workflow-run-id"
+
+
+def test_enqueue_resume_skips_unsupported_app_mode(mocker, mock_session_factory):
+ session_factory, session = mock_session_factory
+ service = HumanInputService(session_factory)
+
+ workflow_run = MagicMock()
+ workflow_run.app_id = "app-id"
+
+ workflow_run_repo = MagicMock()
+ workflow_run_repo.get_workflow_run_by_id_without_tenant.return_value = workflow_run
+ mocker.patch(
+ "services.human_input_service.DifyAPIRepositoryFactory.create_api_workflow_run_repository",
+ return_value=workflow_run_repo,
+ )
+
+ app = MagicMock()
+ app.mode = "completion"
+ session.execute.return_value.scalar_one_or_none.return_value = app
+
+ resume_task = mocker.patch("services.human_input_service.resume_app_execution")
+
+ service.enqueue_resume("workflow-run-id")
+
+ resume_task.apply_async.assert_not_called()
+
+
+def test_get_form_definition_by_token_for_console_uses_repository(sample_form_record, mock_session_factory):
+ session_factory, _ = mock_session_factory
+ repo = MagicMock(spec=HumanInputFormSubmissionRepository)
+ console_record = dataclasses.replace(sample_form_record, recipient_type=RecipientType.CONSOLE)
+ repo.get_by_token.return_value = console_record
+
+ service = HumanInputService(session_factory, form_repository=repo)
+ form = service.get_form_definition_by_token_for_console("token")
+
+ repo.get_by_token.assert_called_once_with("token")
+ assert form is not None
+ assert form.get_definition() == console_record.definition
+
+
+def test_submit_form_by_token_calls_repository_and_enqueue(sample_form_record, mock_session_factory, mocker):
+ session_factory, _ = mock_session_factory
+ repo = MagicMock(spec=HumanInputFormSubmissionRepository)
+ repo.get_by_token.return_value = sample_form_record
+ repo.mark_submitted.return_value = sample_form_record
+ service = HumanInputService(session_factory, form_repository=repo)
+ enqueue_spy = mocker.patch.object(service, "enqueue_resume")
+
+ service.submit_form_by_token(
+ recipient_type=RecipientType.STANDALONE_WEB_APP,
+ form_token="token",
+ selected_action_id="submit",
+ form_data={"field": "value"},
+ submission_end_user_id="end-user-id",
+ )
+
+ repo.get_by_token.assert_called_once_with("token")
+ repo.mark_submitted.assert_called_once()
+ call_kwargs = repo.mark_submitted.call_args.kwargs
+ assert call_kwargs["form_id"] == sample_form_record.form_id
+ assert call_kwargs["recipient_id"] == sample_form_record.recipient_id
+ assert call_kwargs["selected_action_id"] == "submit"
+ assert call_kwargs["form_data"] == {"field": "value"}
+ assert call_kwargs["submission_end_user_id"] == "end-user-id"
+ enqueue_spy.assert_called_once_with(sample_form_record.workflow_run_id)
+
+
+def test_submit_form_by_token_skips_enqueue_for_delivery_test(sample_form_record, mock_session_factory, mocker):
+ session_factory, _ = mock_session_factory
+ repo = MagicMock(spec=HumanInputFormSubmissionRepository)
+ test_record = dataclasses.replace(
+ sample_form_record,
+ form_kind=HumanInputFormKind.DELIVERY_TEST,
+ workflow_run_id=None,
+ )
+ repo.get_by_token.return_value = test_record
+ repo.mark_submitted.return_value = test_record
+ service = HumanInputService(session_factory, form_repository=repo)
+ enqueue_spy = mocker.patch.object(service, "enqueue_resume")
+
+ service.submit_form_by_token(
+ recipient_type=RecipientType.STANDALONE_WEB_APP,
+ form_token="token",
+ selected_action_id="submit",
+ form_data={"field": "value"},
+ )
+
+ enqueue_spy.assert_not_called()
+
+
+def test_submit_form_by_token_passes_submission_user_id(sample_form_record, mock_session_factory, mocker):
+ session_factory, _ = mock_session_factory
+ repo = MagicMock(spec=HumanInputFormSubmissionRepository)
+ repo.get_by_token.return_value = sample_form_record
+ repo.mark_submitted.return_value = sample_form_record
+ service = HumanInputService(session_factory, form_repository=repo)
+ enqueue_spy = mocker.patch.object(service, "enqueue_resume")
+
+ service.submit_form_by_token(
+ recipient_type=RecipientType.STANDALONE_WEB_APP,
+ form_token="token",
+ selected_action_id="submit",
+ form_data={"field": "value"},
+ submission_user_id="account-id",
+ )
+
+ call_kwargs = repo.mark_submitted.call_args.kwargs
+ assert call_kwargs["submission_user_id"] == "account-id"
+ assert call_kwargs["submission_end_user_id"] is None
+ enqueue_spy.assert_called_once_with(sample_form_record.workflow_run_id)
+
+
+def test_submit_form_by_token_invalid_action(sample_form_record, mock_session_factory):
+ session_factory, _ = mock_session_factory
+ repo = MagicMock(spec=HumanInputFormSubmissionRepository)
+ repo.get_by_token.return_value = dataclasses.replace(sample_form_record)
+ service = HumanInputService(session_factory, form_repository=repo)
+
+ with pytest.raises(InvalidFormDataError) as exc_info:
+ service.submit_form_by_token(
+ recipient_type=RecipientType.STANDALONE_WEB_APP,
+ form_token="token",
+ selected_action_id="invalid",
+ form_data={},
+ )
+
+ assert "Invalid action" in str(exc_info.value)
+ repo.mark_submitted.assert_not_called()
+
+
+def test_submit_form_by_token_missing_inputs(sample_form_record, mock_session_factory):
+ session_factory, _ = mock_session_factory
+ repo = MagicMock(spec=HumanInputFormSubmissionRepository)
+
+ definition_with_input = FormDefinition(
+ form_content="hello",
+ inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="content")],
+ user_actions=sample_form_record.definition.user_actions,
+ rendered_content="hello
",
+ expiration_time=sample_form_record.expiration_time,
+ )
+ form_with_input = dataclasses.replace(sample_form_record, definition=definition_with_input)
+ repo.get_by_token.return_value = form_with_input
+ service = HumanInputService(session_factory, form_repository=repo)
+
+ with pytest.raises(InvalidFormDataError) as exc_info:
+ service.submit_form_by_token(
+ recipient_type=RecipientType.STANDALONE_WEB_APP,
+ form_token="token",
+ selected_action_id="submit",
+ form_data={},
+ )
+
+ assert "Missing required inputs" in str(exc_info.value)
+ repo.mark_submitted.assert_not_called()
diff --git a/api/tests/unit_tests/services/test_message_service_extra_contents.py b/api/tests/unit_tests/services/test_message_service_extra_contents.py
new file mode 100644
index 0000000000..3c8e301caa
--- /dev/null
+++ b/api/tests/unit_tests/services/test_message_service_extra_contents.py
@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+import pytest
+
+from core.entities.execution_extra_content import HumanInputContent, HumanInputFormSubmissionData
+from services import message_service
+
+
+class _FakeMessage:
+ def __init__(self, message_id: str):
+ self.id = message_id
+ self.extra_contents = None
+
+ def set_extra_contents(self, contents):
+ self.extra_contents = contents
+
+
+def test_attach_message_extra_contents_assigns_serialized_payload(monkeypatch: pytest.MonkeyPatch) -> None:
+ messages = [_FakeMessage("msg-1"), _FakeMessage("msg-2")]
+ repo = type(
+ "Repo",
+ (),
+ {
+ "get_by_message_ids": lambda _self, message_ids: [
+ [
+ HumanInputContent(
+ workflow_run_id="workflow-run-1",
+ submitted=True,
+ form_submission_data=HumanInputFormSubmissionData(
+ node_id="node-1",
+ node_title="Approval",
+ rendered_content="Rendered",
+ action_id="approve",
+ action_text="Approve",
+ ),
+ )
+ ],
+ [],
+ ]
+ },
+ )()
+
+ monkeypatch.setattr(message_service, "_create_execution_extra_content_repository", lambda: repo)
+
+ message_service.attach_message_extra_contents(messages)
+
+ assert messages[0].extra_contents == [
+ {
+ "type": "human_input",
+ "workflow_run_id": "workflow-run-1",
+ "submitted": True,
+ "form_submission_data": {
+ "node_id": "node-1",
+ "node_title": "Approval",
+ "rendered_content": "Rendered",
+ "action_id": "approve",
+ "action_text": "Approve",
+ },
+ }
+ ]
+ assert messages[1].extra_contents == []
diff --git a/api/tests/unit_tests/services/test_workflow_run_service_pause.py b/api/tests/unit_tests/services/test_workflow_run_service_pause.py
index f45a72927e..ded141f01a 100644
--- a/api/tests/unit_tests/services/test_workflow_run_service_pause.py
+++ b/api/tests/unit_tests/services/test_workflow_run_service_pause.py
@@ -35,7 +35,6 @@ class TestDataFactory:
app_id: str = "app-789",
workflow_id: str = "workflow-101",
status: str | WorkflowExecutionStatus = "paused",
- pause_id: str | None = None,
**kwargs,
) -> MagicMock:
"""Create a mock WorkflowRun object."""
@@ -45,7 +44,6 @@ class TestDataFactory:
mock_run.app_id = app_id
mock_run.workflow_id = workflow_id
mock_run.status = status
- mock_run.pause_id = pause_id
for key, value in kwargs.items():
setattr(mock_run, key, value)
diff --git a/api/tests/unit_tests/services/tools/test_workflow_tools_manage_service.py b/api/tests/unit_tests/services/tools/test_workflow_tools_manage_service.py
new file mode 100644
index 0000000000..d6c92f1013
--- /dev/null
+++ b/api/tests/unit_tests/services/tools/test_workflow_tools_manage_service.py
@@ -0,0 +1,158 @@
+import json
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+
+from core.tools.errors import WorkflowToolHumanInputNotSupportedError
+from models.model import App
+from models.tools import WorkflowToolProvider
+from services.tools import workflow_tools_manage_service
+
+
+class DummyWorkflow:
+ def __init__(self, graph_dict: dict, version: str = "1.0.0") -> None:
+ self._graph_dict = graph_dict
+ self.version = version
+
+ @property
+ def graph_dict(self) -> dict:
+ return self._graph_dict
+
+
+class FakeQuery:
+ def __init__(self, result):
+ self._result = result
+
+ def where(self, *args, **kwargs):
+ return self
+
+ def first(self):
+ return self._result
+
+
+class DummySession:
+ def __init__(self) -> None:
+ self.added: list[object] = []
+
+ def __enter__(self) -> "DummySession":
+ return self
+
+ def __exit__(self, exc_type, exc, tb) -> bool:
+ return False
+
+ def add(self, obj) -> None:
+ self.added.append(obj)
+
+ def begin(self):
+ return DummyBegin(self)
+
+
+class DummyBegin:
+ def __init__(self, session: DummySession) -> None:
+ self._session = session
+
+ def __enter__(self) -> DummySession:
+ return self._session
+
+ def __exit__(self, exc_type, exc, tb) -> bool:
+ return False
+
+
+class DummySessionContext:
+ def __init__(self, session: DummySession) -> None:
+ self._session = session
+
+ def __enter__(self) -> DummySession:
+ return self._session
+
+ def __exit__(self, exc_type, exc, tb) -> bool:
+ return False
+
+
+class DummySessionFactory:
+ def __init__(self, session: DummySession) -> None:
+ self._session = session
+
+ def create_session(self) -> DummySessionContext:
+ return DummySessionContext(self._session)
+
+
+def _build_fake_session(app) -> SimpleNamespace:
+ def query(model):
+ if model is WorkflowToolProvider:
+ return FakeQuery(None)
+ if model is App:
+ return FakeQuery(app)
+ return FakeQuery(None)
+
+ return SimpleNamespace(query=query)
+
+
+def test_create_workflow_tool_rejects_human_input_nodes(monkeypatch):
+ workflow = DummyWorkflow(graph_dict={"nodes": [{"id": "node_1", "data": {"type": "human-input"}}]})
+ app = SimpleNamespace(workflow=workflow)
+
+ fake_session = _build_fake_session(app)
+ monkeypatch.setattr(workflow_tools_manage_service.db, "session", fake_session)
+
+ mock_from_db = MagicMock()
+ monkeypatch.setattr(workflow_tools_manage_service.WorkflowToolProviderController, "from_db", mock_from_db)
+ mock_invalidate = MagicMock()
+
+ parameters = [{"name": "input", "description": "input", "form": "form"}]
+
+ with pytest.raises(WorkflowToolHumanInputNotSupportedError) as exc_info:
+ workflow_tools_manage_service.WorkflowToolManageService.create_workflow_tool(
+ user_id="user-id",
+ tenant_id="tenant-id",
+ workflow_app_id="app-id",
+ name="tool_name",
+ label="Tool",
+ icon={"type": "emoji", "emoji": "tool"},
+ description="desc",
+ parameters=parameters,
+ )
+
+ assert exc_info.value.error_code == "workflow_tool_human_input_not_supported"
+ mock_from_db.assert_not_called()
+ mock_invalidate.assert_not_called()
+
+
+def test_create_workflow_tool_success(monkeypatch):
+ workflow = DummyWorkflow(graph_dict={"nodes": [{"id": "node_1", "data": {"type": "start"}}]})
+ app = SimpleNamespace(workflow=workflow)
+
+ fake_db = MagicMock()
+ fake_session = _build_fake_session(app)
+ fake_db.session = fake_session
+ monkeypatch.setattr(workflow_tools_manage_service, "db", fake_db)
+
+ dummy_session = DummySession()
+ monkeypatch.setattr(workflow_tools_manage_service, "Session", lambda *_, **__: dummy_session)
+
+ mock_from_db = MagicMock()
+ monkeypatch.setattr(workflow_tools_manage_service.WorkflowToolProviderController, "from_db", mock_from_db)
+
+ parameters = [{"name": "input", "description": "input", "form": "form"}]
+ icon = {"type": "emoji", "emoji": "tool"}
+
+ result = workflow_tools_manage_service.WorkflowToolManageService.create_workflow_tool(
+ user_id="user-id",
+ tenant_id="tenant-id",
+ workflow_app_id="app-id",
+ name="tool_name",
+ label="Tool",
+ icon=icon,
+ description="desc",
+ parameters=parameters,
+ )
+
+ assert result == {"result": "success"}
+ assert len(dummy_session.added) == 1
+ created_provider = dummy_session.added[0]
+ assert created_provider.name == "tool_name"
+ assert created_provider.label == "Tool"
+ assert created_provider.icon == json.dumps(icon)
+ assert created_provider.version == workflow.version
+ mock_from_db.assert_called_once()
diff --git a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py
new file mode 100644
index 0000000000..844dab8976
--- /dev/null
+++ b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py
@@ -0,0 +1,226 @@
+from __future__ import annotations
+
+import json
+import queue
+from collections.abc import Sequence
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from threading import Event
+
+import pytest
+
+from core.app.app_config.entities import WorkflowUIBasedAppConfig
+from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
+from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper
+from core.workflow.entities.pause_reason import HumanInputRequired
+from core.workflow.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
+from core.workflow.runtime import GraphRuntimeState, VariablePool
+from models.enums import CreatorUserRole
+from models.model import AppMode
+from models.workflow import WorkflowRun
+from repositories.api_workflow_node_execution_repository import WorkflowNodeExecutionSnapshot
+from repositories.entities.workflow_pause import WorkflowPauseEntity
+from services.workflow_event_snapshot_service import (
+ BufferState,
+ MessageContext,
+ _build_snapshot_events,
+ _resolve_task_id,
+)
+
+
+@dataclass(frozen=True)
+class _FakePauseEntity(WorkflowPauseEntity):
+ pause_id: str
+ workflow_run_id: str
+ paused_at_value: datetime
+ pause_reasons: Sequence[HumanInputRequired]
+
+ @property
+ def id(self) -> str:
+ return self.pause_id
+
+ @property
+ def workflow_execution_id(self) -> str:
+ return self.workflow_run_id
+
+ def get_state(self) -> bytes:
+ raise AssertionError("state is not required for snapshot tests")
+
+ @property
+ def resumed_at(self) -> datetime | None:
+ return None
+
+ @property
+ def paused_at(self) -> datetime:
+ return self.paused_at_value
+
+ def get_pause_reasons(self) -> Sequence[HumanInputRequired]:
+ return self.pause_reasons
+
+
+def _build_workflow_run(status: WorkflowExecutionStatus) -> WorkflowRun:
+ return WorkflowRun(
+ id="run-1",
+ tenant_id="tenant-1",
+ app_id="app-1",
+ workflow_id="workflow-1",
+ type="workflow",
+ triggered_from="app-run",
+ version="v1",
+ graph=None,
+ inputs=json.dumps({"input": "value"}),
+ status=status,
+ outputs=json.dumps({}),
+ error=None,
+ elapsed_time=0.0,
+ total_tokens=0,
+ total_steps=0,
+ created_by_role=CreatorUserRole.END_USER,
+ created_by="user-1",
+ created_at=datetime(2024, 1, 1, tzinfo=UTC),
+ )
+
+
+def _build_snapshot(status: WorkflowNodeExecutionStatus) -> WorkflowNodeExecutionSnapshot:
+ created_at = datetime(2024, 1, 1, tzinfo=UTC)
+ finished_at = datetime(2024, 1, 1, 0, 0, 5, tzinfo=UTC)
+ return WorkflowNodeExecutionSnapshot(
+ execution_id="exec-1",
+ node_id="node-1",
+ node_type="human-input",
+ title="Human Input",
+ index=1,
+ status=status.value,
+ elapsed_time=0.5,
+ created_at=created_at,
+ finished_at=finished_at,
+ iteration_id=None,
+ loop_id=None,
+ )
+
+
+def _build_resumption_context(task_id: str) -> WorkflowResumptionContext:
+ app_config = WorkflowUIBasedAppConfig(
+ tenant_id="tenant-1",
+ app_id="app-1",
+ app_mode=AppMode.WORKFLOW,
+ workflow_id="workflow-1",
+ )
+ generate_entity = WorkflowAppGenerateEntity(
+ task_id=task_id,
+ app_config=app_config,
+ inputs={},
+ files=[],
+ user_id="user-1",
+ stream=True,
+ invoke_from=InvokeFrom.EXPLORE,
+ call_depth=0,
+ workflow_execution_id="run-1",
+ )
+ runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
+ runtime_state.register_paused_node("node-1")
+ runtime_state.outputs = {"result": "value"}
+ wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity)
+ return WorkflowResumptionContext(
+ generate_entity=wrapper,
+ serialized_graph_runtime_state=runtime_state.dumps(),
+ )
+
+
+def test_build_snapshot_events_includes_pause_event() -> None:
+ workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED)
+ snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED)
+ resumption_context = _build_resumption_context("task-ctx")
+ pause_entity = _FakePauseEntity(
+ pause_id="pause-1",
+ workflow_run_id="run-1",
+ paused_at_value=datetime(2024, 1, 1, tzinfo=UTC),
+ pause_reasons=[
+ HumanInputRequired(
+ form_id="form-1",
+ form_content="content",
+ node_id="node-1",
+ node_title="Human Input",
+ )
+ ],
+ )
+
+ events = _build_snapshot_events(
+ workflow_run=workflow_run,
+ node_snapshots=[snapshot],
+ task_id="task-ctx",
+ message_context=None,
+ pause_entity=pause_entity,
+ resumption_context=resumption_context,
+ )
+
+ assert [event["event"] for event in events] == [
+ "workflow_started",
+ "node_started",
+ "node_finished",
+ "workflow_paused",
+ ]
+ assert events[2]["data"]["status"] == WorkflowNodeExecutionStatus.PAUSED.value
+ pause_data = events[-1]["data"]
+ assert pause_data["paused_nodes"] == ["node-1"]
+ assert pause_data["outputs"] == {"result": "value"}
+ assert pause_data["status"] == WorkflowExecutionStatus.PAUSED.value
+ assert pause_data["created_at"] == int(workflow_run.created_at.timestamp())
+ assert pause_data["elapsed_time"] == workflow_run.elapsed_time
+ assert pause_data["total_tokens"] == workflow_run.total_tokens
+ assert pause_data["total_steps"] == workflow_run.total_steps
+
+
+def test_build_snapshot_events_applies_message_context() -> None:
+ workflow_run = _build_workflow_run(WorkflowExecutionStatus.RUNNING)
+ snapshot = _build_snapshot(WorkflowNodeExecutionStatus.SUCCEEDED)
+ message_context = MessageContext(
+ conversation_id="conv-1",
+ message_id="msg-1",
+ created_at=1700000000,
+ answer="snapshot message",
+ )
+
+ events = _build_snapshot_events(
+ workflow_run=workflow_run,
+ node_snapshots=[snapshot],
+ task_id="task-1",
+ message_context=message_context,
+ pause_entity=None,
+ resumption_context=None,
+ )
+
+ assert [event["event"] for event in events] == [
+ "workflow_started",
+ "message_replace",
+ "node_started",
+ "node_finished",
+ ]
+ assert events[1]["answer"] == "snapshot message"
+ for event in events:
+ assert event["conversation_id"] == "conv-1"
+ assert event["message_id"] == "msg-1"
+ assert event["created_at"] == 1700000000
+
+
+@pytest.mark.parametrize(
+ ("context_task_id", "buffered_task_id", "expected"),
+ [
+ ("task-ctx", "task-buffer", "task-ctx"),
+ (None, "task-buffer", "task-buffer"),
+ (None, None, "run-1"),
+ ],
+)
+def test_resolve_task_id_priority(context_task_id, buffered_task_id, expected) -> None:
+ resumption_context = _build_resumption_context(context_task_id) if context_task_id else None
+ buffer_state = BufferState(
+ queue=queue.Queue(),
+ stop_event=Event(),
+ done_event=Event(),
+ task_id_ready=Event(),
+ task_id_hint=buffered_task_id,
+ )
+ if buffered_task_id:
+ buffer_state.task_id_ready.set()
+ task_id = _resolve_task_id(resumption_context, buffer_state, "run-1", wait_timeout=0.0)
+ assert task_id == expected
diff --git a/api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py b/api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py
new file mode 100644
index 0000000000..5ac5ac8ad2
--- /dev/null
+++ b/api/tests/unit_tests/services/workflow/test_workflow_human_input_delivery.py
@@ -0,0 +1,184 @@
+import uuid
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+import pytest
+from sqlalchemy.orm import sessionmaker
+
+from core.workflow.enums import NodeType
+from core.workflow.nodes.human_input.entities import (
+ EmailDeliveryConfig,
+ EmailDeliveryMethod,
+ EmailRecipients,
+ ExternalRecipient,
+ HumanInputNodeData,
+ MemberRecipient,
+)
+from services import workflow_service as workflow_service_module
+from services.workflow_service import WorkflowService
+
+
+def _make_service() -> WorkflowService:
+ return WorkflowService(session_maker=sessionmaker())
+
+
+def _build_node_config(delivery_methods):
+ node_data = HumanInputNodeData(
+ title="Human Input",
+ delivery_methods=delivery_methods,
+ form_content="Test content",
+ inputs=[],
+ user_actions=[],
+ ).model_dump(mode="json")
+ node_data["type"] = NodeType.HUMAN_INPUT.value
+ return {"id": "node-1", "data": node_data}
+
+
+def _make_email_method(enabled: bool = True, debug_mode: bool = False) -> EmailDeliveryMethod:
+ return EmailDeliveryMethod(
+ id=uuid.uuid4(),
+ enabled=enabled,
+ config=EmailDeliveryConfig(
+ recipients=EmailRecipients(
+ whole_workspace=False,
+ items=[ExternalRecipient(email="tester@example.com")],
+ ),
+ subject="Test subject",
+ body="Test body",
+ debug_mode=debug_mode,
+ ),
+ )
+
+
+def test_human_input_delivery_requires_draft_workflow():
+ service = _make_service()
+ service.get_draft_workflow = MagicMock(return_value=None) # type: ignore[method-assign]
+ app_model = SimpleNamespace(tenant_id="tenant-1", id="app-1")
+ account = SimpleNamespace(id="account-1")
+
+ with pytest.raises(ValueError, match="Workflow not initialized"):
+ service.test_human_input_delivery(
+ app_model=app_model,
+ account=account,
+ node_id="node-1",
+ delivery_method_id="delivery-1",
+ )
+
+
+def test_human_input_delivery_allows_disabled_method(monkeypatch: pytest.MonkeyPatch):
+ service = _make_service()
+ delivery_method = _make_email_method(enabled=False)
+ node_config = _build_node_config([delivery_method])
+ workflow = MagicMock()
+ workflow.get_node_config_by_id.return_value = node_config
+ service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign]
+ service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[attr-defined]
+ node_stub = MagicMock()
+ node_stub._render_form_content_before_submission.return_value = "rendered"
+ node_stub._resolve_default_values.return_value = {}
+ service._build_human_input_node = MagicMock(return_value=node_stub) # type: ignore[attr-defined]
+ service._create_human_input_delivery_test_form = MagicMock( # type: ignore[attr-defined]
+ return_value=("form-1", {})
+ )
+
+ test_service_instance = MagicMock()
+ monkeypatch.setattr(
+ workflow_service_module,
+ "HumanInputDeliveryTestService",
+ MagicMock(return_value=test_service_instance),
+ )
+
+ app_model = SimpleNamespace(tenant_id="tenant-1", id="app-1")
+ account = SimpleNamespace(id="account-1")
+
+ service.test_human_input_delivery(
+ app_model=app_model,
+ account=account,
+ node_id="node-1",
+ delivery_method_id=str(delivery_method.id),
+ )
+
+ test_service_instance.send_test.assert_called_once()
+
+
+def test_human_input_delivery_dispatches_to_test_service(monkeypatch: pytest.MonkeyPatch):
+ service = _make_service()
+ delivery_method = _make_email_method(enabled=True)
+ node_config = _build_node_config([delivery_method])
+ workflow = MagicMock()
+ workflow.get_node_config_by_id.return_value = node_config
+ service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign]
+ service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[attr-defined]
+ node_stub = MagicMock()
+ node_stub._render_form_content_before_submission.return_value = "rendered"
+ node_stub._resolve_default_values.return_value = {}
+ service._build_human_input_node = MagicMock(return_value=node_stub) # type: ignore[attr-defined]
+ service._create_human_input_delivery_test_form = MagicMock( # type: ignore[attr-defined]
+ return_value=("form-1", {})
+ )
+
+ test_service_instance = MagicMock()
+ monkeypatch.setattr(
+ workflow_service_module,
+ "HumanInputDeliveryTestService",
+ MagicMock(return_value=test_service_instance),
+ )
+
+ app_model = SimpleNamespace(tenant_id="tenant-1", id="app-1")
+ account = SimpleNamespace(id="account-1")
+
+ service.test_human_input_delivery(
+ app_model=app_model,
+ account=account,
+ node_id="node-1",
+ delivery_method_id=str(delivery_method.id),
+ inputs={"#node-1.output#": "value"},
+ )
+
+ pool_args = service._build_human_input_variable_pool.call_args.kwargs
+ assert pool_args["manual_inputs"] == {"#node-1.output#": "value"}
+ test_service_instance.send_test.assert_called_once()
+
+
+def test_human_input_delivery_debug_mode_overrides_recipients(monkeypatch: pytest.MonkeyPatch):
+ service = _make_service()
+ delivery_method = _make_email_method(enabled=True, debug_mode=True)
+ node_config = _build_node_config([delivery_method])
+ workflow = MagicMock()
+ workflow.get_node_config_by_id.return_value = node_config
+ service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign]
+ service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[attr-defined]
+ node_stub = MagicMock()
+ node_stub._render_form_content_before_submission.return_value = "rendered"
+ node_stub._resolve_default_values.return_value = {}
+ service._build_human_input_node = MagicMock(return_value=node_stub) # type: ignore[attr-defined]
+ service._create_human_input_delivery_test_form = MagicMock( # type: ignore[attr-defined]
+ return_value=("form-1", {})
+ )
+
+ test_service_instance = MagicMock()
+ monkeypatch.setattr(
+ workflow_service_module,
+ "HumanInputDeliveryTestService",
+ MagicMock(return_value=test_service_instance),
+ )
+
+ app_model = SimpleNamespace(tenant_id="tenant-1", id="app-1")
+ account = SimpleNamespace(id="account-1")
+
+ service.test_human_input_delivery(
+ app_model=app_model,
+ account=account,
+ node_id="node-1",
+ delivery_method_id=str(delivery_method.id),
+ )
+
+ test_service_instance.send_test.assert_called_once()
+ sent_method = test_service_instance.send_test.call_args.kwargs["method"]
+ assert isinstance(sent_method, EmailDeliveryMethod)
+ assert sent_method.config.debug_mode is True
+ assert sent_method.config.recipients.whole_workspace is False
+ assert len(sent_method.config.recipients.items) == 1
+ recipient = sent_method.config.recipients.items[0]
+ assert isinstance(recipient, MemberRecipient)
+ assert recipient.user_id == account.id
diff --git a/api/tests/unit_tests/services/workflow/test_workflow_node_execution_service_repository.py b/api/tests/unit_tests/services/workflow/test_workflow_node_execution_service_repository.py
index 32d2f8b7e0..70d7bde870 100644
--- a/api/tests/unit_tests/services/workflow/test_workflow_node_execution_service_repository.py
+++ b/api/tests/unit_tests/services/workflow/test_workflow_node_execution_service_repository.py
@@ -5,6 +5,7 @@ from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
+from core.workflow.enums import WorkflowNodeExecutionStatus
from models.workflow import WorkflowNodeExecutionModel
from repositories.sqlalchemy_api_workflow_node_execution_repository import (
DifyAPISQLAlchemyWorkflowNodeExecutionRepository,
@@ -52,6 +53,9 @@ class TestSQLAlchemyWorkflowNodeExecutionServiceRepository:
call_args = mock_session.scalar.call_args[0][0]
assert hasattr(call_args, "compile") # It's a SQLAlchemy statement
+ compiled = call_args.compile()
+ assert WorkflowNodeExecutionStatus.PAUSED in compiled.params.values()
+
def test_get_node_last_execution_not_found(self, repository):
"""Test getting the last execution for a node when it doesn't exist."""
# Arrange
@@ -71,28 +75,6 @@ class TestSQLAlchemyWorkflowNodeExecutionServiceRepository:
assert result is None
mock_session.scalar.assert_called_once()
- def test_get_executions_by_workflow_run(self, repository, mock_execution):
- """Test getting all executions for a workflow run."""
- # Arrange
- mock_session = MagicMock(spec=Session)
- repository._session_maker.return_value.__enter__.return_value = mock_session
- executions = [mock_execution]
- mock_session.execute.return_value.scalars.return_value.all.return_value = executions
-
- # Act
- result = repository.get_executions_by_workflow_run(
- tenant_id="tenant-123",
- app_id="app-456",
- workflow_run_id="run-101",
- )
-
- # Assert
- assert result == executions
- mock_session.execute.assert_called_once()
- # Verify the query was constructed correctly
- call_args = mock_session.execute.call_args[0][0]
- assert hasattr(call_args, "compile") # It's a SQLAlchemy statement
-
def test_get_executions_by_workflow_run_empty(self, repository):
"""Test getting executions for a workflow run when none exist."""
# Arrange
diff --git a/api/tests/unit_tests/services/workflow/test_workflow_service.py b/api/tests/unit_tests/services/workflow/test_workflow_service.py
index 9700cbaf0e..015dac257e 100644
--- a/api/tests/unit_tests/services/workflow/test_workflow_service.py
+++ b/api/tests/unit_tests/services/workflow/test_workflow_service.py
@@ -1,9 +1,15 @@
+from contextlib import nullcontext
+from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
+from core.workflow.enums import NodeType
+from core.workflow.nodes.human_input.entities import FormInput, HumanInputNodeData, UserAction
+from core.workflow.nodes.human_input.enums import FormInputType
from models.model import App
from models.workflow import Workflow
+from services import workflow_service as workflow_service_module
from services.workflow_service import WorkflowService
@@ -161,3 +167,120 @@ class TestWorkflowService:
assert workflows == []
assert has_more is False
mock_session.scalars.assert_called_once()
+
+ def test_submit_human_input_form_preview_uses_rendered_content(
+ self, workflow_service: WorkflowService, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ service = workflow_service
+ node_data = HumanInputNodeData(
+ title="Human Input",
+ form_content="{{#$output.name#}}
",
+ inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")],
+ user_actions=[UserAction(id="approve", title="Approve")],
+ )
+ node = MagicMock()
+ node.node_data = node_data
+ node.render_form_content_before_submission.return_value = "preview
"
+ node.render_form_content_with_outputs.return_value = "rendered
"
+
+ service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[method-assign]
+ service._build_human_input_node = MagicMock(return_value=node) # type: ignore[method-assign]
+
+ workflow = MagicMock()
+ workflow.get_node_config_by_id.return_value = {"id": "node-1", "data": {"type": NodeType.HUMAN_INPUT.value}}
+ workflow.get_enclosing_node_type_and_id.return_value = None
+ service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign]
+
+ saved_outputs: dict[str, object] = {}
+
+ class DummySession:
+ def __init__(self, *args, **kwargs):
+ self.commit = MagicMock()
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, tb):
+ return False
+
+ def begin(self):
+ return nullcontext()
+
+ class DummySaver:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ def save(self, outputs, process_data):
+ saved_outputs.update(outputs)
+
+ monkeypatch.setattr(workflow_service_module, "Session", DummySession)
+ monkeypatch.setattr(workflow_service_module, "DraftVariableSaver", DummySaver)
+ monkeypatch.setattr(workflow_service_module, "db", SimpleNamespace(engine=MagicMock()))
+
+ app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
+ account = SimpleNamespace(id="account-1")
+
+ result = service.submit_human_input_form_preview(
+ app_model=app_model,
+ account=account,
+ node_id="node-1",
+ form_inputs={"name": "Ada", "extra": "ignored"},
+ inputs={"#node-0.result#": "LLM output"},
+ action="approve",
+ )
+
+ service._build_human_input_variable_pool.assert_called_once_with(
+ app_model=app_model,
+ workflow=workflow,
+ node_config={"id": "node-1", "data": {"type": NodeType.HUMAN_INPUT.value}},
+ manual_inputs={"#node-0.result#": "LLM output"},
+ )
+
+ node.render_form_content_with_outputs.assert_called_once()
+ called_args = node.render_form_content_with_outputs.call_args.args
+ assert called_args[0] == "preview
"
+ assert called_args[2] == node_data.outputs_field_names()
+ rendered_outputs = called_args[1]
+ assert rendered_outputs["name"] == "Ada"
+ assert rendered_outputs["extra"] == "ignored"
+ assert "extra" in saved_outputs
+ assert "extra" in result
+ assert saved_outputs["name"] == "Ada"
+ assert result["name"] == "Ada"
+ assert result["__action_id"] == "approve"
+ assert "__rendered_content" in result
+
+ def test_submit_human_input_form_preview_missing_inputs_message(self, workflow_service: WorkflowService) -> None:
+ service = workflow_service
+ node_data = HumanInputNodeData(
+ title="Human Input",
+ form_content="{{#$output.name#}}
",
+ inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")],
+ user_actions=[UserAction(id="approve", title="Approve")],
+ )
+ node = MagicMock()
+ node.node_data = node_data
+ node._render_form_content_before_submission.return_value = "preview
"
+ node._render_form_content_with_outputs.return_value = "rendered
"
+
+ service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[method-assign]
+ service._build_human_input_node = MagicMock(return_value=node) # type: ignore[method-assign]
+
+ workflow = MagicMock()
+ workflow.get_node_config_by_id.return_value = {"id": "node-1", "data": {"type": NodeType.HUMAN_INPUT.value}}
+ service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign]
+
+ app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
+ account = SimpleNamespace(id="account-1")
+
+ with pytest.raises(ValueError) as exc_info:
+ service.submit_human_input_form_preview(
+ app_model=app_model,
+ account=account,
+ node_id="node-1",
+ form_inputs={},
+ inputs={},
+ action="approve",
+ )
+
+ assert "Missing required inputs" in str(exc_info.value)
diff --git a/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py b/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py
new file mode 100644
index 0000000000..051eefa60a
--- /dev/null
+++ b/api/tests/unit_tests/tasks/test_human_input_timeout_tasks.py
@@ -0,0 +1,210 @@
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+
+from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
+from tasks import human_input_timeout_tasks as task_module
+
+
+class _FakeScalarResult:
+ def __init__(self, items: list[Any]):
+ self._items = items
+
+ def all(self) -> list[Any]:
+ return self._items
+
+
+class _FakeSession:
+ def __init__(self, items: list[Any], capture: dict[str, Any]):
+ self._items = items
+ self._capture = capture
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, tb):
+ return False
+
+ def scalars(self, stmt):
+ self._capture["stmt"] = stmt
+ return _FakeScalarResult(self._items)
+
+
+class _FakeSessionFactory:
+ def __init__(self, items: list[Any], capture: dict[str, Any]):
+ self._items = items
+ self._capture = capture
+ self._capture["session_factory"] = self
+
+ def __call__(self):
+ session = _FakeSession(self._items, self._capture)
+ self._capture["session"] = session
+ return session
+
+
+class _FakeFormRepo:
+ def __init__(self, _session_factory, form_map: dict[str, Any] | None = None):
+ self.calls: list[dict[str, Any]] = []
+ self._form_map = form_map or {}
+
+ def mark_timeout(self, *, form_id: str, timeout_status: HumanInputFormStatus, reason: str | None = None):
+ self.calls.append(
+ {
+ "form_id": form_id,
+ "timeout_status": timeout_status,
+ "reason": reason,
+ }
+ )
+ form = self._form_map.get(form_id)
+ return SimpleNamespace(
+ form_id=form_id,
+ workflow_run_id=getattr(form, "workflow_run_id", None),
+ node_id=getattr(form, "node_id", None),
+ )
+
+
+class _FakeService:
+ def __init__(self, _session_factory, form_repository=None):
+ self.enqueued: list[str] = []
+
+ def enqueue_resume(self, workflow_run_id: str | None) -> None:
+ if workflow_run_id is not None:
+ self.enqueued.append(workflow_run_id)
+
+
+def _build_form(
+ *,
+ form_id: str,
+ form_kind: HumanInputFormKind,
+ created_at: datetime,
+ expiration_time: datetime,
+ workflow_run_id: str | None,
+ node_id: str,
+) -> SimpleNamespace:
+ return SimpleNamespace(
+ id=form_id,
+ form_kind=form_kind,
+ created_at=created_at,
+ expiration_time=expiration_time,
+ workflow_run_id=workflow_run_id,
+ node_id=node_id,
+ status=HumanInputFormStatus.WAITING,
+ )
+
+
+def test_is_global_timeout_uses_created_at():
+ now = datetime(2025, 1, 1, 12, 0, 0)
+ form = SimpleNamespace(created_at=now - timedelta(seconds=61), workflow_run_id="run-1")
+
+ assert task_module._is_global_timeout(form, 60, now=now) is True
+
+ form.workflow_run_id = None
+ assert task_module._is_global_timeout(form, 60, now=now) is False
+
+ form.workflow_run_id = "run-1"
+ form.created_at = now - timedelta(seconds=59)
+ assert task_module._is_global_timeout(form, 60, now=now) is False
+
+ assert task_module._is_global_timeout(form, 0, now=now) is False
+
+
+def test_check_and_handle_human_input_timeouts_marks_and_routes(monkeypatch: pytest.MonkeyPatch):
+ now = datetime(2025, 1, 1, 12, 0, 0)
+ monkeypatch.setattr(task_module, "naive_utc_now", lambda: now)
+ monkeypatch.setattr(task_module.dify_config, "HITL_GLOBAL_TIMEOUT_SECONDS", 3600)
+ monkeypatch.setattr(task_module, "db", SimpleNamespace(engine=object()))
+
+ forms = [
+ _build_form(
+ form_id="form-global",
+ form_kind=HumanInputFormKind.RUNTIME,
+ created_at=now - timedelta(hours=2),
+ expiration_time=now + timedelta(hours=1),
+ workflow_run_id="run-global",
+ node_id="node-global",
+ ),
+ _build_form(
+ form_id="form-node",
+ form_kind=HumanInputFormKind.RUNTIME,
+ created_at=now - timedelta(minutes=5),
+ expiration_time=now - timedelta(seconds=1),
+ workflow_run_id="run-node",
+ node_id="node-node",
+ ),
+ _build_form(
+ form_id="form-delivery",
+ form_kind=HumanInputFormKind.DELIVERY_TEST,
+ created_at=now - timedelta(minutes=1),
+ expiration_time=now - timedelta(seconds=1),
+ workflow_run_id=None,
+ node_id="node-delivery",
+ ),
+ ]
+
+ capture: dict[str, Any] = {}
+ monkeypatch.setattr(task_module, "sessionmaker", lambda *args, **kwargs: _FakeSessionFactory(forms, capture))
+
+ form_map = {form.id: form for form in forms}
+ repo = _FakeFormRepo(None, form_map=form_map)
+
+ def _repo_factory(_session_factory):
+ return repo
+
+ service = _FakeService(None)
+
+ def _service_factory(_session_factory, form_repository=None):
+ return service
+
+ global_calls: list[dict[str, Any]] = []
+
+ monkeypatch.setattr(task_module, "HumanInputFormSubmissionRepository", _repo_factory)
+ monkeypatch.setattr(task_module, "HumanInputService", _service_factory)
+ monkeypatch.setattr(task_module, "_handle_global_timeout", lambda **kwargs: global_calls.append(kwargs))
+
+ task_module.check_and_handle_human_input_timeouts(limit=100)
+
+ assert {(call["form_id"], call["timeout_status"], call["reason"]) for call in repo.calls} == {
+ ("form-global", HumanInputFormStatus.EXPIRED, "global_timeout"),
+ ("form-node", HumanInputFormStatus.TIMEOUT, "node_timeout"),
+ ("form-delivery", HumanInputFormStatus.TIMEOUT, "delivery_test_timeout"),
+ }
+ assert service.enqueued == ["run-node"]
+ assert global_calls == [
+ {
+ "form_id": "form-global",
+ "workflow_run_id": "run-global",
+ "node_id": "node-global",
+ "session_factory": capture.get("session_factory"),
+ }
+ ]
+
+ stmt = capture.get("stmt")
+ assert stmt is not None
+ stmt_text = str(stmt)
+ assert "created_at <=" in stmt_text
+ assert "expiration_time <=" in stmt_text
+ assert "ORDER BY human_input_forms.id" in stmt_text
+
+
+def test_check_and_handle_human_input_timeouts_omits_global_filter_when_disabled(monkeypatch: pytest.MonkeyPatch):
+ now = datetime(2025, 1, 1, 12, 0, 0)
+ monkeypatch.setattr(task_module, "naive_utc_now", lambda: now)
+ monkeypatch.setattr(task_module.dify_config, "HITL_GLOBAL_TIMEOUT_SECONDS", 0)
+ monkeypatch.setattr(task_module, "db", SimpleNamespace(engine=object()))
+
+ capture: dict[str, Any] = {}
+ monkeypatch.setattr(task_module, "sessionmaker", lambda *args, **kwargs: _FakeSessionFactory([], capture))
+ monkeypatch.setattr(task_module, "HumanInputFormSubmissionRepository", _FakeFormRepo)
+ monkeypatch.setattr(task_module, "HumanInputService", _FakeService)
+ monkeypatch.setattr(task_module, "_handle_global_timeout", lambda **_kwargs: None)
+
+ task_module.check_and_handle_human_input_timeouts(limit=1)
+
+ stmt = capture.get("stmt")
+ assert stmt is not None
+ stmt_text = str(stmt)
+ assert "created_at <=" not in stmt_text
diff --git a/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py b/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py
new file mode 100644
index 0000000000..20cb7a211e
--- /dev/null
+++ b/api/tests/unit_tests/tasks/test_mail_human_input_delivery_task.py
@@ -0,0 +1,123 @@
+from collections.abc import Sequence
+from types import SimpleNamespace
+
+import pytest
+
+from tasks import mail_human_input_delivery_task as task_module
+
+
+class _DummyMail:
+ def __init__(self):
+ self.sent: list[dict[str, str]] = []
+ self._inited = True
+
+ def is_inited(self) -> bool:
+ return self._inited
+
+ def send(self, *, to: str, subject: str, html: str):
+ self.sent.append({"to": to, "subject": subject, "html": html})
+
+
+class _DummySession:
+ def __init__(self, form):
+ self._form = form
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ return False
+
+ def get(self, _model, _form_id):
+ return self._form
+
+
+def _build_job(recipient_count: int = 1) -> task_module._EmailDeliveryJob:
+ recipients: list[task_module._EmailRecipient] = []
+ for idx in range(recipient_count):
+ recipients.append(task_module._EmailRecipient(email=f"user{idx}@example.com", token=f"token-{idx}"))
+
+ return task_module._EmailDeliveryJob(
+ form_id="form-1",
+ subject="Subject",
+ body="Body for {{#url}}",
+ form_content="content",
+ recipients=recipients,
+ )
+
+
+def test_dispatch_human_input_email_task_sends_to_each_recipient(monkeypatch: pytest.MonkeyPatch):
+ mail = _DummyMail()
+ form = SimpleNamespace(id="form-1", tenant_id="tenant-1", workflow_run_id=None)
+
+ monkeypatch.setattr(task_module, "mail", mail)
+ monkeypatch.setattr(
+ task_module.FeatureService,
+ "get_features",
+ lambda _tenant_id: SimpleNamespace(human_input_email_delivery_enabled=True),
+ )
+ jobs: Sequence[task_module._EmailDeliveryJob] = [_build_job(recipient_count=2)]
+ monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: jobs)
+
+ task_module.dispatch_human_input_email_task(
+ form_id="form-1",
+ node_title="Approve",
+ session_factory=lambda: _DummySession(form),
+ )
+
+ assert len(mail.sent) == 2
+ assert all(payload["subject"] == "Subject" for payload in mail.sent)
+ assert all("Body for" in payload["html"] for payload in mail.sent)
+
+
+def test_dispatch_human_input_email_task_skips_when_feature_disabled(monkeypatch: pytest.MonkeyPatch):
+ mail = _DummyMail()
+ form = SimpleNamespace(id="form-1", tenant_id="tenant-1", workflow_run_id=None)
+
+ monkeypatch.setattr(task_module, "mail", mail)
+ monkeypatch.setattr(
+ task_module.FeatureService,
+ "get_features",
+ lambda _tenant_id: SimpleNamespace(human_input_email_delivery_enabled=False),
+ )
+ monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: [])
+
+ task_module.dispatch_human_input_email_task(
+ form_id="form-1",
+ node_title="Approve",
+ session_factory=lambda: _DummySession(form),
+ )
+
+ assert mail.sent == []
+
+
+def test_dispatch_human_input_email_task_replaces_body_variables(monkeypatch: pytest.MonkeyPatch):
+ mail = _DummyMail()
+ form = SimpleNamespace(id="form-1", tenant_id="tenant-1", workflow_run_id="run-1")
+ job = task_module._EmailDeliveryJob(
+ form_id="form-1",
+ subject="Subject",
+ body="Body {{#node1.value#}}",
+ form_content="content",
+ recipients=[task_module._EmailRecipient(email="user@example.com", token="token-1")],
+ )
+
+ variable_pool = task_module.VariablePool()
+ variable_pool.add(["node1", "value"], "OK")
+
+ monkeypatch.setattr(task_module, "mail", mail)
+ monkeypatch.setattr(
+ task_module.FeatureService,
+ "get_features",
+ lambda _tenant_id: SimpleNamespace(human_input_email_delivery_enabled=True),
+ )
+ monkeypatch.setattr(task_module, "_load_email_jobs", lambda _session, _form: [job])
+ monkeypatch.setattr(task_module, "_load_variable_pool", lambda _workflow_run_id: variable_pool)
+
+ task_module.dispatch_human_input_email_task(
+ form_id="form-1",
+ node_title="Approve",
+ session_factory=lambda: _DummySession(form),
+ )
+
+ assert mail.sent[0]["html"] == "Body OK"
diff --git a/api/tests/unit_tests/tasks/test_workflow_execute_task.py b/api/tests/unit_tests/tasks/test_workflow_execute_task.py
new file mode 100644
index 0000000000..161151305d
--- /dev/null
+++ b/api/tests/unit_tests/tasks/test_workflow_execute_task.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+import json
+import uuid
+from unittest.mock import MagicMock
+
+import pytest
+
+from models.model import AppMode
+from tasks.app_generate.workflow_execute_task import _publish_streaming_response
+
+
+@pytest.fixture
+def mock_topic(mocker) -> MagicMock:
+ topic = MagicMock()
+ mocker.patch(
+ "tasks.app_generate.workflow_execute_task.MessageBasedAppGenerator.get_response_topic",
+ return_value=topic,
+ )
+ return topic
+
+
+def test_publish_streaming_response_with_uuid(mock_topic: MagicMock):
+ workflow_run_id = uuid.uuid4()
+ response_stream = iter([{"event": "foo"}, "ping"])
+
+ _publish_streaming_response(response_stream, workflow_run_id, app_mode=AppMode.ADVANCED_CHAT)
+
+ payloads = [call.args[0] for call in mock_topic.publish.call_args_list]
+ assert payloads == [json.dumps({"event": "foo"}).encode(), json.dumps("ping").encode()]
+
+
+def test_publish_streaming_response_coerces_string_uuid(mock_topic: MagicMock):
+ workflow_run_id = uuid.uuid4()
+ response_stream = iter([{"event": "bar"}])
+
+ _publish_streaming_response(response_stream, str(workflow_run_id), app_mode=AppMode.ADVANCED_CHAT)
+
+ mock_topic.publish.assert_called_once_with(json.dumps({"event": "bar"}).encode())
diff --git a/api/tests/unit_tests/tasks/test_workflow_node_execution_tasks.py b/api/tests/unit_tests/tasks/test_workflow_node_execution_tasks.py
new file mode 100644
index 0000000000..fd5f0713a4
--- /dev/null
+++ b/api/tests/unit_tests/tasks/test_workflow_node_execution_tasks.py
@@ -0,0 +1,488 @@
+# """
+# Unit tests for workflow node execution Celery tasks.
+
+# These tests verify the asynchronous storage functionality for workflow node execution data,
+# including truncation and offloading logic.
+# """
+
+# import json
+# from unittest.mock import MagicMock, Mock, patch
+# from uuid import uuid4
+
+# import pytest
+
+# from core.workflow.entities.workflow_node_execution import (
+# WorkflowNodeExecution,
+# WorkflowNodeExecutionStatus,
+# )
+# from core.workflow.enums import NodeType
+# from libs.datetime_utils import naive_utc_now
+# from models import WorkflowNodeExecutionModel
+# from models.enums import ExecutionOffLoadType
+# from models.model import UploadFile
+# from models.workflow import WorkflowNodeExecutionOffload, WorkflowNodeExecutionTriggeredFrom
+# from tasks.workflow_node_execution_tasks import (
+# _create_truncator,
+# _json_encode,
+# _replace_or_append_offload,
+# _truncate_and_upload_async,
+# save_workflow_node_execution_data_task,
+# save_workflow_node_execution_task,
+# )
+
+
+# @pytest.fixture
+# def sample_execution_data():
+# """Sample execution data for testing."""
+# execution = WorkflowNodeExecution(
+# id=str(uuid4()),
+# node_execution_id=str(uuid4()),
+# workflow_id=str(uuid4()),
+# workflow_execution_id=str(uuid4()),
+# index=1,
+# node_id="test_node",
+# node_type=NodeType.LLM,
+# title="Test Node",
+# inputs={"input_key": "input_value"},
+# outputs={"output_key": "output_value"},
+# process_data={"process_key": "process_value"},
+# status=WorkflowNodeExecutionStatus.RUNNING,
+# created_at=naive_utc_now(),
+# )
+# return execution.model_dump()
+
+
+# @pytest.fixture
+# def mock_db_model():
+# """Mock database model for testing."""
+# db_model = Mock(spec=WorkflowNodeExecutionModel)
+# db_model.id = "test-execution-id"
+# db_model.offload_data = []
+# return db_model
+
+
+# @pytest.fixture
+# def mock_file_service():
+# """Mock file service for testing."""
+# file_service = Mock()
+# mock_upload_file = Mock(spec=UploadFile)
+# mock_upload_file.id = "mock-file-id"
+# file_service.upload_file.return_value = mock_upload_file
+# return file_service
+
+
+# class TestSaveWorkflowNodeExecutionDataTask:
+# """Test cases for save_workflow_node_execution_data_task."""
+
+# @patch("tasks.workflow_node_execution_tasks.sessionmaker")
+# @patch("tasks.workflow_node_execution_tasks.select")
+# def test_save_execution_data_task_success(
+# self, mock_select, mock_sessionmaker, sample_execution_data, mock_db_model
+# ):
+# """Test successful execution of save_workflow_node_execution_data_task."""
+# # Setup mocks
+# mock_session = MagicMock()
+# mock_sessionmaker.return_value.return_value.__enter__.return_value = mock_session
+# mock_session.execute.return_value.scalars.return_value.first.return_value = mock_db_model
+
+# # Execute task
+# result = save_workflow_node_execution_data_task(
+# execution_data=sample_execution_data,
+# tenant_id="test-tenant-id",
+# app_id="test-app-id",
+# user_data={"user_id": "test-user-id", "user_type": "account"},
+# )
+
+# # Verify success
+# assert result is True
+# mock_session.merge.assert_called_once_with(mock_db_model)
+# mock_session.commit.assert_called_once()
+
+# @patch("tasks.workflow_node_execution_tasks.sessionmaker")
+# @patch("tasks.workflow_node_execution_tasks.select")
+# def test_save_execution_data_task_execution_not_found(self, mock_select, mock_sessionmaker,
+# sample_execution_data):
+# """Test task when execution is not found in database."""
+# # Setup mocks
+# mock_session = MagicMock()
+# mock_sessionmaker.return_value.return_value.__enter__.return_value = mock_session
+# mock_session.execute.return_value.scalars.return_value.first.return_value = None
+
+# # Execute task
+# result = save_workflow_node_execution_data_task(
+# execution_data=sample_execution_data,
+# tenant_id="test-tenant-id",
+# app_id="test-app-id",
+# user_data={"user_id": "test-user-id", "user_type": "account"},
+# )
+
+# # Verify failure
+# assert result is False
+# mock_session.merge.assert_not_called()
+# mock_session.commit.assert_not_called()
+
+# @patch("tasks.workflow_node_execution_tasks.sessionmaker")
+# @patch("tasks.workflow_node_execution_tasks.select")
+# def test_save_execution_data_task_with_truncation(self, mock_select, mock_sessionmaker, mock_db_model):
+# """Test task with data that requires truncation."""
+# # Create execution with large data
+# large_data = {"large_field": "x" * 10000}
+# execution = WorkflowNodeExecution(
+# id=str(uuid4()),
+# node_execution_id=str(uuid4()),
+# workflow_id=str(uuid4()),
+# workflow_execution_id=str(uuid4()),
+# index=1,
+# node_id="test_node",
+# node_type=NodeType.LLM,
+# title="Test Node",
+# inputs=large_data,
+# outputs=large_data,
+# process_data=large_data,
+# status=WorkflowNodeExecutionStatus.RUNNING,
+# created_at=naive_utc_now(),
+# )
+# execution_data = execution.model_dump()
+
+# # Setup mocks
+# mock_session = MagicMock()
+# mock_sessionmaker.return_value.return_value.__enter__.return_value = mock_session
+# mock_session.execute.return_value.scalars.return_value.first.return_value = mock_db_model
+
+# # Create mock upload file
+# mock_upload_file = Mock(spec=UploadFile)
+# mock_upload_file.id = "mock-file-id"
+
+# # Execute task
+# with patch("tasks.workflow_node_execution_tasks._truncate_and_upload_async") as mock_truncate:
+# # Mock truncation results
+# mock_truncate.return_value = {
+# "truncated_value": {"large_field": "[TRUNCATED]"},
+# "file": mock_upload_file,
+# "offload": WorkflowNodeExecutionOffload(
+# id=str(uuid4()),
+# tenant_id="test-tenant-id",
+# app_id="test-app-id",
+# node_execution_id=execution.id,
+# type_=ExecutionOffLoadType.INPUTS,
+# file_id=mock_upload_file.id,
+# ),
+# }
+
+# result = save_workflow_node_execution_data_task(
+# execution_data=execution_data,
+# tenant_id="test-tenant-id",
+# app_id="test-app-id",
+# user_data={"user_id": "test-user-id", "user_type": "account"},
+# )
+
+# # Verify success and truncation was called
+# assert result is True
+# assert mock_truncate.call_count == 3 # inputs, outputs, process_data
+# mock_session.merge.assert_called_once_with(mock_db_model)
+# mock_session.commit.assert_called_once()
+
+# @patch("tasks.workflow_node_execution_tasks.sessionmaker")
+# def test_save_execution_data_task_retry_on_exception(self, mock_sessionmaker, sample_execution_data):
+# """Test task retry mechanism on exception."""
+# # Setup mock to raise exception
+# mock_sessionmaker.side_effect = Exception("Database error")
+
+# # Create a mock task instance with proper retry behavior
+# with patch.object(save_workflow_node_execution_data_task, "retry") as mock_retry:
+# mock_retry.side_effect = Exception("Retry called")
+
+# # Execute task and expect retry
+# with pytest.raises(Exception, match="Retry called"):
+# save_workflow_node_execution_data_task(
+# execution_data=sample_execution_data,
+# tenant_id="test-tenant-id",
+# app_id="test-app-id",
+# user_data={"user_id": "test-user-id", "user_type": "account"},
+# )
+
+# # Verify retry was called
+# mock_retry.assert_called_once()
+
+
+# class TestTruncateAndUploadAsync:
+# """Test cases for _truncate_and_upload_async function."""
+
+# def test_truncate_and_upload_with_none_values(self, mock_file_service):
+# """Test _truncate_and_upload_async with None values."""
+# # The function handles None values internally, so we test with empty dict instead
+# result = _truncate_and_upload_async(
+# values={},
+# execution_id="test-id",
+# type_=ExecutionOffLoadType.INPUTS,
+# tenant_id="test-tenant",
+# app_id="test-app",
+# user_data={"user_id": "test-user", "user_type": "account"},
+# file_service=mock_file_service,
+# )
+
+# # Empty dict should not require truncation
+# assert result is None
+# mock_file_service.upload_file.assert_not_called()
+
+# @patch("tasks.workflow_node_execution_tasks._create_truncator")
+# def test_truncate_and_upload_no_truncation_needed(self, mock_create_truncator, mock_file_service):
+# """Test _truncate_and_upload_async when no truncation is needed."""
+# # Mock truncator to return no truncation
+# mock_truncator = Mock()
+# mock_truncator.truncate_variable_mapping.return_value = ({"small": "data"}, False)
+# mock_create_truncator.return_value = mock_truncator
+
+# small_values = {"small": "data"}
+# result = _truncate_and_upload_async(
+# values=small_values,
+# execution_id="test-id",
+# type_=ExecutionOffLoadType.INPUTS,
+# tenant_id="test-tenant",
+# app_id="test-app",
+# user_data={"user_id": "test-user", "user_type": "account"},
+# file_service=mock_file_service,
+# )
+
+# assert result is None
+# mock_file_service.upload_file.assert_not_called()
+
+# @patch("tasks.workflow_node_execution_tasks._create_truncator")
+# @patch("models.Account")
+# @patch("models.Tenant")
+# def test_truncate_and_upload_with_account_user(
+# self, mock_tenant_class, mock_account_class, mock_create_truncator, mock_file_service
+# ):
+# """Test _truncate_and_upload_async with account user."""
+# # Mock truncator to return truncation needed
+# mock_truncator = Mock()
+# mock_truncator.truncate_variable_mapping.return_value = ({"truncated": "data"}, True)
+# mock_create_truncator.return_value = mock_truncator
+
+# # Mock user and tenant creation
+# mock_account = Mock()
+# mock_account.id = "test-user"
+# mock_account_class.return_value = mock_account
+
+# mock_tenant = Mock()
+# mock_tenant.id = "test-tenant"
+# mock_tenant_class.return_value = mock_tenant
+
+# large_values = {"large": "x" * 10000}
+# result = _truncate_and_upload_async(
+# values=large_values,
+# execution_id="test-id",
+# type_=ExecutionOffLoadType.INPUTS,
+# tenant_id="test-tenant",
+# app_id="test-app",
+# user_data={"user_id": "test-user", "user_type": "account"},
+# file_service=mock_file_service,
+# )
+
+# # Verify result structure
+# assert result is not None
+# assert "truncated_value" in result
+# assert "file" in result
+# assert "offload" in result
+# assert result["truncated_value"] == {"truncated": "data"}
+
+# # Verify file upload was called
+# mock_file_service.upload_file.assert_called_once()
+# upload_call = mock_file_service.upload_file.call_args
+# assert upload_call[1]["filename"] == "node_execution_test-id_inputs.json"
+# assert upload_call[1]["mimetype"] == "application/json"
+# assert upload_call[1]["user"] == mock_account
+
+# @patch("tasks.workflow_node_execution_tasks._create_truncator")
+# @patch("models.EndUser")
+# def test_truncate_and_upload_with_end_user(self, mock_end_user_class, mock_create_truncator, mock_file_service):
+# """Test _truncate_and_upload_async with end user."""
+# # Mock truncator to return truncation needed
+# mock_truncator = Mock()
+# mock_truncator.truncate_variable_mapping.return_value = ({"truncated": "data"}, True)
+# mock_create_truncator.return_value = mock_truncator
+
+# # Mock end user creation
+# mock_end_user = Mock()
+# mock_end_user.id = "test-user"
+# mock_end_user.tenant_id = "test-tenant"
+# mock_end_user_class.return_value = mock_end_user
+
+# large_values = {"large": "x" * 10000}
+# result = _truncate_and_upload_async(
+# values=large_values,
+# execution_id="test-id",
+# type_=ExecutionOffLoadType.OUTPUTS,
+# tenant_id="test-tenant",
+# app_id="test-app",
+# user_data={"user_id": "test-user", "user_type": "end_user"},
+# file_service=mock_file_service,
+# )
+
+# # Verify result structure
+# assert result is not None
+# assert result["truncated_value"] == {"truncated": "data"}
+
+# # Verify file upload was called with end user
+# mock_file_service.upload_file.assert_called_once()
+# upload_call = mock_file_service.upload_file.call_args
+# assert upload_call[1]["filename"] == "node_execution_test-id_outputs.json"
+# assert upload_call[1]["user"] == mock_end_user
+
+
+# class TestHelperFunctions:
+# """Test cases for helper functions."""
+
+# @patch("tasks.workflow_node_execution_tasks.dify_config")
+# def test_create_truncator(self, mock_config):
+# """Test _create_truncator function."""
+# mock_config.WORKFLOW_VARIABLE_TRUNCATION_MAX_SIZE = 1000
+# mock_config.WORKFLOW_VARIABLE_TRUNCATION_ARRAY_LENGTH = 100
+# mock_config.WORKFLOW_VARIABLE_TRUNCATION_STRING_LENGTH = 500
+
+# truncator = _create_truncator()
+
+# # Verify truncator was created with correct config
+# assert truncator is not None
+
+# def test_json_encode(self):
+# """Test _json_encode function."""
+# test_data = {"key": "value", "number": 42}
+# result = _json_encode(test_data)
+
+# assert isinstance(result, str)
+# decoded = json.loads(result)
+# assert decoded == test_data
+
+# def test_replace_or_append_offload_replace_existing(self):
+# """Test _replace_or_append_offload replaces existing offload of same type."""
+# existing_offload = WorkflowNodeExecutionOffload(
+# id=str(uuid4()),
+# tenant_id="test-tenant",
+# app_id="test-app",
+# node_execution_id="test-execution",
+# type_=ExecutionOffLoadType.INPUTS,
+# file_id="old-file-id",
+# )
+
+# new_offload = WorkflowNodeExecutionOffload(
+# id=str(uuid4()),
+# tenant_id="test-tenant",
+# app_id="test-app",
+# node_execution_id="test-execution",
+# type_=ExecutionOffLoadType.INPUTS,
+# file_id="new-file-id",
+# )
+
+# result = _replace_or_append_offload([existing_offload], new_offload)
+
+# assert len(result) == 1
+# assert result[0].file_id == "new-file-id"
+
+# def test_replace_or_append_offload_append_new_type(self):
+# """Test _replace_or_append_offload appends new offload of different type."""
+# existing_offload = WorkflowNodeExecutionOffload(
+# id=str(uuid4()),
+# tenant_id="test-tenant",
+# app_id="test-app",
+# node_execution_id="test-execution",
+# type_=ExecutionOffLoadType.INPUTS,
+# file_id="inputs-file-id",
+# )
+
+# new_offload = WorkflowNodeExecutionOffload(
+# id=str(uuid4()),
+# tenant_id="test-tenant",
+# app_id="test-app",
+# node_execution_id="test-execution",
+# type_=ExecutionOffLoadType.OUTPUTS,
+# file_id="outputs-file-id",
+# )
+
+# result = _replace_or_append_offload([existing_offload], new_offload)
+
+# assert len(result) == 2
+# file_ids = [offload.file_id for offload in result]
+# assert "inputs-file-id" in file_ids
+# assert "outputs-file-id" in file_ids
+
+
+# class TestSaveWorkflowNodeExecutionTask:
+# """Test cases for save_workflow_node_execution_task."""
+
+# @patch("tasks.workflow_node_execution_tasks.sessionmaker")
+# @patch("tasks.workflow_node_execution_tasks.select")
+# def test_save_workflow_node_execution_task_create_new(self, mock_select, mock_sessionmaker,
+# sample_execution_data):
+# """Test creating a new workflow node execution."""
+# # Setup mocks
+# mock_session = MagicMock()
+# mock_sessionmaker.return_value.return_value.__enter__.return_value = mock_session
+# mock_session.scalar.return_value = None # No existing execution
+
+# # Execute task
+# result = save_workflow_node_execution_task(
+# execution_data=sample_execution_data,
+# tenant_id="test-tenant-id",
+# app_id="test-app-id",
+# triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN.value,
+# creator_user_id="test-user-id",
+# creator_user_role="account",
+# )
+
+# # Verify success
+# assert result is True
+# mock_session.add.assert_called_once()
+# mock_session.commit.assert_called_once()
+
+# @patch("tasks.workflow_node_execution_tasks.sessionmaker")
+# @patch("tasks.workflow_node_execution_tasks.select")
+# def test_save_workflow_node_execution_task_update_existing(
+# self, mock_select, mock_sessionmaker, sample_execution_data
+# ):
+# """Test updating an existing workflow node execution."""
+# # Setup mocks
+# mock_session = MagicMock()
+# mock_sessionmaker.return_value.return_value.__enter__.return_value = mock_session
+
+# existing_execution = Mock(spec=WorkflowNodeExecutionModel)
+# mock_session.scalar.return_value = existing_execution
+
+# # Execute task
+# result = save_workflow_node_execution_task(
+# execution_data=sample_execution_data,
+# tenant_id="test-tenant-id",
+# app_id="test-app-id",
+# triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN.value,
+# creator_user_id="test-user-id",
+# creator_user_role="account",
+# )
+
+# # Verify success
+# assert result is True
+# mock_session.add.assert_not_called() # Should not add new, just update existing
+# mock_session.commit.assert_called_once()
+
+# @patch("tasks.workflow_node_execution_tasks.sessionmaker")
+# def test_save_workflow_node_execution_task_retry_on_exception(self, mock_sessionmaker, sample_execution_data):
+# """Test task retry mechanism on exception."""
+# # Setup mock to raise exception
+# mock_sessionmaker.side_effect = Exception("Database error")
+
+# # Create a mock task instance with proper retry behavior
+# with patch.object(save_workflow_node_execution_task, "retry") as mock_retry:
+# mock_retry.side_effect = Exception("Retry called")
+
+# # Execute task and expect retry
+# with pytest.raises(Exception, match="Retry called"):
+# save_workflow_node_execution_task(
+# execution_data=sample_execution_data,
+# tenant_id="test-tenant-id",
+# app_id="test-app-id",
+# triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN.value,
+# creator_user_id="test-user-id",
+# creator_user_role="account",
+# )
+
+# # Verify retry was called
+# mock_retry.assert_called_once()
diff --git a/api/ty.toml b/api/ty.toml
index afdd37897e..3d3dda4595 100644
--- a/api/ty.toml
+++ b/api/ty.toml
@@ -26,5 +26,21 @@ exclude = [
# non-producition or generated code
"migrations",
"tests",
+ # targeted ignores for current type-check errors
+ # TODO(QuantumGhost): suppress type errors in HITL related code.
+ # fix the type error later
+ "configs/middleware/cache/redis_pubsub_config.py",
+ "extensions/ext_redis.py",
+ "models/execution_extra_content.py",
+ "tasks/workflow_execution_tasks.py",
+ "core/workflow/nodes/base/node.py",
+ "services/human_input_delivery_test_service.py",
+ "core/app/apps/advanced_chat/app_generator.py",
+ "controllers/console/human_input_form.py",
+ "controllers/console/app/workflow_run.py",
+ "repositories/sqlalchemy_api_workflow_node_execution_repository.py",
+ "extensions/logstore/repositories/logstore_api_workflow_run_repository.py",
+ "controllers/web/workflow_events.py",
+ "tasks/app_generate/workflow_execute_task.py",
]
diff --git a/docker/.env.example b/docker/.env.example
index 41a0205bf5..93099347bd 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -1399,9 +1399,9 @@ PLUGIN_STDIO_BUFFER_SIZE=1024
PLUGIN_STDIO_MAX_BUFFER_SIZE=5242880
PLUGIN_PYTHON_ENV_INIT_TIMEOUT=120
-# Plugin Daemon side timeout (configure to match the API side below)
+# Plugin Daemon side timeout (configure to match the API side below)
PLUGIN_MAX_EXECUTION_TIMEOUT=600
-# API side timeout (configure to match the Plugin Daemon side above)
+# API side timeout (configure to match the Plugin Daemon side above)
PLUGIN_DAEMON_TIMEOUT=600.0
# PIP_MIRROR_URL=https://pypi.tuna.tsinghua.edu.cn/simple
PIP_MIRROR_URL=
@@ -1519,4 +1519,31 @@ AMPLITUDE_API_KEY=
SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30
+
+
+# Redis URL used for PubSub between API and
+# celery worker
+# defaults to url constructed from `REDIS_*`
+# configurations
+PUBSUB_REDIS_URL=
+# Pub/sub channel type for streaming events.
+# valid options are:
+#
+# - pubsub: for normal Pub/Sub
+# - sharded: for sharded Pub/Sub
+#
+# It's highly recommended to use sharded Pub/Sub AND redis cluster
+# for large deployments.
+PUBSUB_REDIS_CHANNEL_TYPE=pubsub
+# Whether to use Redis cluster mode while running
+# PubSub.
+# It's highly recommended to enable this for large deployments.
+PUBSUB_REDIS_USE_CLUSTERS=false
+
+# Whether to Enable human input timeout check task
+ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
+# Human input timeout check interval in minutes
+HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1
+
+
SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL=90000
diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml
index 2e97891a60..f9a254c1a6 100644
--- a/docker/docker-compose.yaml
+++ b/docker/docker-compose.yaml
@@ -683,6 +683,11 @@ x-shared-env: &shared-api-worker-env
SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD: ${SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD:-21}
SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE: ${SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE:-1000}
SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS: ${SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS:-30}
+ PUBSUB_REDIS_URL: ${PUBSUB_REDIS_URL:-}
+ PUBSUB_REDIS_CHANNEL_TYPE: ${PUBSUB_REDIS_CHANNEL_TYPE:-pubsub}
+ PUBSUB_REDIS_USE_CLUSTERS: ${PUBSUB_REDIS_USE_CLUSTERS:-false}
+ ENABLE_HUMAN_INPUT_TIMEOUT_TASK: ${ENABLE_HUMAN_INPUT_TIMEOUT_TASK:-true}
+ HUMAN_INPUT_TIMEOUT_TASK_INTERVAL: ${HUMAN_INPUT_TIMEOUT_TASK_INTERVAL:-1}
SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL: ${SANDBOX_EXPIRED_RECORDS_CLEAN_TASK_LOCK_TTL:-90000}
services:
From 4e7226dc3939e9f2ea8d2fbf074dcc1e5d06aa2e Mon Sep 17 00:00:00 2001
From: QuantumGhost
Date: Fri, 30 Jan 2026 11:07:44 +0800
Subject: [PATCH 09/14] chore: update version to 1.12.0 (#31726)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
---
api/pyproject.toml | 2 +-
api/uv.lock | 2 +-
docker/docker-compose-template.yaml | 8 ++++----
docker/docker-compose.yaml | 8 ++++----
web/package.json | 2 +-
5 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/api/pyproject.toml b/api/pyproject.toml
index af2dba6fac..482dd4c8ad 100644
--- a/api/pyproject.toml
+++ b/api/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "dify-api"
-version = "1.11.4"
+version = "1.12.0"
requires-python = ">=3.11,<3.13"
dependencies = [
diff --git a/api/uv.lock b/api/uv.lock
index a3ad292168..7bb43fbb12 100644
--- a/api/uv.lock
+++ b/api/uv.lock
@@ -1368,7 +1368,7 @@ wheels = [
[[package]]
name = "dify-api"
-version = "1.11.4"
+version = "1.12.0"
source = { virtual = "." }
dependencies = [
{ name = "aliyun-log-python-sdk" },
diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml
index 9659990383..860e728023 100644
--- a/docker/docker-compose-template.yaml
+++ b/docker/docker-compose-template.yaml
@@ -21,7 +21,7 @@ services:
# API service
api:
- image: langgenius/dify-api:1.11.4
+ image: langgenius/dify-api:1.12.0
restart: always
environment:
# Use the shared environment variables.
@@ -63,7 +63,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
- image: langgenius/dify-api:1.11.4
+ image: langgenius/dify-api:1.12.0
restart: always
environment:
# Use the shared environment variables.
@@ -102,7 +102,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
- image: langgenius/dify-api:1.11.4
+ image: langgenius/dify-api:1.12.0
restart: always
environment:
# Use the shared environment variables.
@@ -132,7 +132,7 @@ services:
# Frontend web application.
web:
- image: langgenius/dify-web:1.11.4
+ image: langgenius/dify-web:1.12.0
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml
index f9a254c1a6..023fdf4a9d 100644
--- a/docker/docker-compose.yaml
+++ b/docker/docker-compose.yaml
@@ -712,7 +712,7 @@ services:
# API service
api:
- image: langgenius/dify-api:1.11.4
+ image: langgenius/dify-api:1.12.0
restart: always
environment:
# Use the shared environment variables.
@@ -754,7 +754,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
- image: langgenius/dify-api:1.11.4
+ image: langgenius/dify-api:1.12.0
restart: always
environment:
# Use the shared environment variables.
@@ -793,7 +793,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
- image: langgenius/dify-api:1.11.4
+ image: langgenius/dify-api:1.12.0
restart: always
environment:
# Use the shared environment variables.
@@ -823,7 +823,7 @@ services:
# Frontend web application.
web:
- image: langgenius/dify-web:1.11.4
+ image: langgenius/dify-web:1.12.0
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
diff --git a/web/package.json b/web/package.json
index 47a46ed6fc..f66e4ceb5b 100644
--- a/web/package.json
+++ b/web/package.json
@@ -1,7 +1,7 @@
{
"name": "dify-web",
"type": "module",
- "version": "1.11.4",
+ "version": "1.12.0",
"private": true,
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a",
"imports": {
From 5a3ceb240e7e407c4f83c705f5b315ede9cd7950 Mon Sep 17 00:00:00 2001
From: FFXN <31929997+FFXN@users.noreply.github.com>
Date: Fri, 30 Jan 2026 11:08:09 +0800
Subject: [PATCH 10/14] feat: Summary index for knowledge. (#31719)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jyong <76649700+JohnJyong@users.noreply.github.com>
Co-authored-by: zxhlyh
Co-authored-by: Yansong Zhang <916125788@qq.com>
Co-authored-by: hj24
Co-authored-by: CodingOnStar
Co-authored-by: CodingOnStar
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
---
.../2026_01_27_1815-788d3099ae3a_add_summary_index_feature.py | 4 ++--
api/models/dataset.py | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/api/migrations/versions/2026_01_27_1815-788d3099ae3a_add_summary_index_feature.py b/api/migrations/versions/2026_01_27_1815-788d3099ae3a_add_summary_index_feature.py
index 3c2e0822e1..c6c72859dc 100644
--- a/api/migrations/versions/2026_01_27_1815-788d3099ae3a_add_summary_index_feature.py
+++ b/api/migrations/versions/2026_01_27_1815-788d3099ae3a_add_summary_index_feature.py
@@ -51,7 +51,7 @@ def upgrade():
batch_op.add_column(sa.Column('summary_index_setting', models.types.AdjustedJSON(), nullable=True))
with op.batch_alter_table('documents', schema=None) as batch_op:
- batch_op.add_column(sa.Column('need_summary', sa.Boolean(), server_default=sa.text('false'), nullable=True))
+ batch_op.add_column(sa.Column('need_summary', sa.Boolean(), server_default=sa.text('false'), nullable=False))
else:
# MySQL: Use compatible syntax
op.create_table(
@@ -83,7 +83,7 @@ def upgrade():
batch_op.add_column(sa.Column('summary_index_setting', models.types.AdjustedJSON(), nullable=True))
with op.batch_alter_table('documents', schema=None) as batch_op:
- batch_op.add_column(sa.Column('need_summary', sa.Boolean(), server_default=sa.text('false'), nullable=True))
+ batch_op.add_column(sa.Column('need_summary', sa.Boolean(), server_default=sa.text('false'), nullable=False))
# ### end Alembic commands ###
diff --git a/api/models/dataset.py b/api/models/dataset.py
index 6ab8f372bf..e7da2961bc 100644
--- a/api/models/dataset.py
+++ b/api/models/dataset.py
@@ -420,7 +420,7 @@ class Document(Base):
doc_metadata = mapped_column(AdjustedJSON, nullable=True)
doc_form = mapped_column(String(255), nullable=False, server_default=sa.text("'text_model'"))
doc_language = mapped_column(String(255), nullable=True)
- need_summary: Mapped[bool | None] = mapped_column(sa.Boolean, nullable=True, server_default=sa.text("false"))
+ need_summary: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"))
DATA_SOURCES = ["upload_file", "notion_import", "website_crawl"]
From 5c0df4a3ef3a54576d1dc9d68e3492215943a686 Mon Sep 17 00:00:00 2001
From: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
Date: Fri, 30 Jan 2026 12:26:07 +0800
Subject: [PATCH 11/14] chore: Revert "refactor: prefer css icon" (#31733)
---
web/eslint-rules/index.js | 4 +-
.../rules/prefer-tailwind-icon.js | 384 ------------------
web/eslint.config.mjs | 44 +-
web/package.json | 8 +-
web/pnpm-lock.yaml | 269 ++----------
web/tailwind-common-config.ts | 139 +------
6 files changed, 65 insertions(+), 783 deletions(-)
delete mode 100644 web/eslint-rules/rules/prefer-tailwind-icon.js
diff --git a/web/eslint-rules/index.js b/web/eslint-rules/index.js
index 1559590328..8eda0caaa6 100644
--- a/web/eslint-rules/index.js
+++ b/web/eslint-rules/index.js
@@ -3,14 +3,13 @@ import noAsAnyInT from './rules/no-as-any-in-t.js'
import noExtraKeys from './rules/no-extra-keys.js'
import noLegacyNamespacePrefix from './rules/no-legacy-namespace-prefix.js'
import noVersionPrefix from './rules/no-version-prefix.js'
-import preferTailwindIcon from './rules/prefer-tailwind-icon.js'
import requireNsOption from './rules/require-ns-option.js'
import validI18nKeys from './rules/valid-i18n-keys.js'
/** @type {import('eslint').ESLint.Plugin} */
const plugin = {
meta: {
- name: 'dify',
+ name: 'dify-i18n',
version: '1.0.0',
},
rules: {
@@ -19,7 +18,6 @@ const plugin = {
'no-extra-keys': noExtraKeys,
'no-legacy-namespace-prefix': noLegacyNamespacePrefix,
'no-version-prefix': noVersionPrefix,
- 'prefer-tailwind-icon': preferTailwindIcon,
'require-ns-option': requireNsOption,
'valid-i18n-keys': validI18nKeys,
},
diff --git a/web/eslint-rules/rules/prefer-tailwind-icon.js b/web/eslint-rules/rules/prefer-tailwind-icon.js
deleted file mode 100644
index ed5e111316..0000000000
--- a/web/eslint-rules/rules/prefer-tailwind-icon.js
+++ /dev/null
@@ -1,384 +0,0 @@
-/**
- * Default prop-to-class mappings
- * Maps component props to Tailwind class prefixes
- */
-const DEFAULT_PROP_MAPPINGS = {
- size: 'size',
- width: 'w',
- height: 'h',
-}
-
-/**
- * Convert PascalCase/camelCase to kebab-case
- * @param {string} name
- * @returns {string} The kebab-case string
- */
-function camelToKebab(name) {
- return name
- .replace(/([a-z])(\d)/g, '$1-$2')
- .replace(/(\d)([a-z])/gi, '$1-$2')
- .replace(/([a-z])([A-Z])/g, '$1-$2')
- .toLowerCase()
-}
-
-/**
- * Default icon library configurations
- *
- * Config options:
- * - pattern: string | RegExp - Pattern to match import source
- * - prefix: string | ((match: RegExpMatchArray) => string) - Icon class prefix
- * - suffix: string | ((match: RegExpMatchArray) => string) - Icon class suffix
- * - extractSubPath: boolean - Extract subdirectory path and add to prefix
- * - iconFilter: (name: string) => boolean - Filter which imports to process
- * - stripPrefix: string - Prefix to remove from icon name before transform
- * - stripSuffix: string - Suffix to remove from icon name before transform
- */
-const DEFAULT_ICON_CONFIGS = [
- {
- // @/app/components/base/icons/src/public/* and vender/*
- pattern: /^@\/app\/components\/base\/icons\/src\/(public|vender)/,
- prefix: match => `i-custom-${match[1]}-`,
- extractSubPath: true,
- },
- {
- // @remixicon/react
- pattern: '@remixicon/react',
- prefix: 'i-ri-',
- iconFilter: name => name.startsWith('Ri'),
- stripPrefix: 'Ri',
- },
- {
- // @heroicons/react/{size}/{variant}
- pattern: /^@heroicons\/react\/(\d+)\/(solid|outline)$/,
- prefix: 'i-heroicons-',
- suffix: match => `-${match[1]}-${match[2]}`,
- iconFilter: name => name.endsWith('Icon'),
- stripSuffix: 'Icon',
- },
-]
-
-/**
- * Convert pixel value to Tailwind class
- * @param {number} pixels
- * @param {string} classPrefix - e.g., 'size', 'w', 'h'
- * @returns {string} The Tailwind class string
- */
-function pixelToClass(pixels, classPrefix) {
- if (pixels % 4 === 0) {
- const units = pixels / 4
- return `${classPrefix}-${units}`
- }
- // For non-standard sizes, use Tailwind arbitrary value syntax
- return `${classPrefix}-[${pixels}px]`
-}
-
-/**
- * Match source against config pattern
- * @param {string} source - The import source path
- * @param {object} config - The icon config
- * @returns {{ matched: boolean, match: RegExpMatchArray | null, basePath: string }} Match result
- */
-function matchPattern(source, config) {
- const { pattern } = config
- if (pattern instanceof RegExp) {
- const match = source.match(pattern)
- if (match) {
- return { matched: true, match, basePath: match[0] }
- }
- return { matched: false, match: null, basePath: '' }
- }
- // String pattern: exact match or prefix match
- if (source === pattern || source.startsWith(`${pattern}/`)) {
- return { matched: true, match: null, basePath: pattern }
- }
- return { matched: false, match: null, basePath: '' }
-}
-
-/**
- * Get icon class from config
- * @param {string} iconName
- * @param {object} config
- * @param {string} source - The import source path
- * @param {RegExpMatchArray | null} match - The regex match result
- * @returns {string} The full Tailwind icon class string
- */
-function getIconClass(iconName, config, source, match) {
- // Strip prefix/suffix from icon name if configured
- let name = iconName
- if (config.stripPrefix && name.startsWith(config.stripPrefix)) {
- name = name.slice(config.stripPrefix.length)
- }
- if (config.stripSuffix && name.endsWith(config.stripSuffix)) {
- name = name.slice(0, -config.stripSuffix.length)
- }
-
- // Transform name (use custom or default camelToKebab)
- const transformed = config.transformName ? config.transformName(name, source) : camelToKebab(name)
-
- // Get prefix (can be string or function)
- const prefix = typeof config.prefix === 'function' ? config.prefix(match) : config.prefix
-
- // Get suffix (can be string or function)
- const suffix = typeof config.suffix === 'function' ? config.suffix(match) : (config.suffix || '')
-
- // Extract subdirectory path after the pattern to include in prefix (only if extractSubPath is enabled)
- let subPrefix = ''
- if (config.extractSubPath) {
- const basePath = match ? match[0] : config.pattern
- if (source.startsWith(`${basePath}/`)) {
- const subPath = source.slice(basePath.length + 1)
- if (subPath) {
- subPrefix = `${subPath.replace(/\//g, '-')}-`
- }
- }
- }
-
- return `${prefix}${subPrefix}${transformed}${suffix}`
-}
-
-/** @type {import('eslint').Rule.RuleModule} */
-export default {
- meta: {
- type: 'suggestion',
- docs: {
- description: 'Prefer Tailwind CSS icon classes over icon library components',
- },
- hasSuggestions: true,
- schema: [
- {
- type: 'object',
- properties: {
- libraries: {
- type: 'array',
- items: {
- type: 'object',
- properties: {
- pattern: { type: 'string' },
- prefix: { type: 'string' },
- suffix: { type: 'string' },
- extractSubPath: { type: 'boolean' },
- },
- required: ['pattern', 'prefix'],
- },
- },
- propMappings: {
- type: 'object',
- additionalProperties: { type: 'string' },
- description: 'Maps component props to Tailwind class prefixes, e.g., { size: "size", width: "w", height: "h" }',
- },
- },
- additionalProperties: false,
- },
- ],
- messages: {
- preferTailwindIcon:
- 'Prefer using Tailwind CSS icon class "{{iconClass}}" over "{{componentName}}" from "{{source}}"',
- preferTailwindIconImport:
- 'Icon "{{importedName}}" from "{{source}}" can be replaced with Tailwind CSS class "{{iconClass}}"',
- },
- },
- create(context) {
- const options = context.options[0] || {}
- const iconConfigs = options.libraries || DEFAULT_ICON_CONFIGS
- const propMappings = options.propMappings || DEFAULT_PROP_MAPPINGS
-
- // Track imports: localName -> { node, importedName, config, source, match, used }
- const iconImports = new Map()
-
- return {
- ImportDeclaration(node) {
- const source = node.source.value
-
- // Find matching config
- let matchedConfig = null
- let matchResult = null
- for (const config of iconConfigs) {
- const result = matchPattern(source, config)
- if (result.matched) {
- matchedConfig = config
- matchResult = result.match
- break
- }
- }
- if (!matchedConfig)
- return
-
- // Use default filter if not provided (for user-configured libraries)
- const iconFilter = matchedConfig.iconFilter || (() => true)
-
- for (const specifier of node.specifiers) {
- if (specifier.type === 'ImportSpecifier') {
- const importedName = specifier.imported.name
- const localName = specifier.local.name
-
- if (iconFilter(importedName)) {
- iconImports.set(localName, {
- node: specifier,
- importedName,
- localName,
- config: matchedConfig,
- source,
- match: matchResult,
- used: false,
- })
- }
- }
- }
- },
-
- JSXOpeningElement(node) {
- if (node.name.type !== 'JSXIdentifier')
- return
-
- const componentName = node.name.name
- const iconInfo = iconImports.get(componentName)
-
- if (!iconInfo)
- return
-
- iconInfo.used = true
-
- const iconClass = getIconClass(iconInfo.importedName, iconInfo.config, iconInfo.source, iconInfo.match)
-
- // Find className attribute
- const classNameAttr = node.attributes.find(
- attr => attr.type === 'JSXAttribute' && attr.name.name === 'className',
- )
-
- // Process prop mappings (size, width, height, etc.)
- const mappedClasses = []
- const mappedPropNames = Object.keys(propMappings)
-
- for (const propName of mappedPropNames) {
- const attr = node.attributes.find(
- a => a.type === 'JSXAttribute' && a.name.name === propName,
- )
-
- if (attr && attr.value) {
- let pixelValue = null
-
- if (attr.value.type === 'JSXExpressionContainer'
- && attr.value.expression.type === 'Literal'
- && typeof attr.value.expression.value === 'number') {
- pixelValue = attr.value.expression.value
- }
- else if (attr.value.type === 'Literal'
- && typeof attr.value.value === 'number') {
- pixelValue = attr.value.value
- }
-
- if (pixelValue !== null) {
- mappedClasses.push(pixelToClass(pixelValue, propMappings[propName]))
- }
- }
- }
-
- // Build new className
- const sourceCode = context.sourceCode
- let newClassName
- const classesToAdd = [iconClass, ...mappedClasses].filter(Boolean).join(' ')
-
- if (classNameAttr && classNameAttr.value) {
- if (classNameAttr.value.type === 'Literal') {
- newClassName = `${classesToAdd} ${classNameAttr.value.value}`
- }
- else if (classNameAttr.value.type === 'JSXExpressionContainer') {
- const expression = sourceCode.getText(classNameAttr.value.expression)
- newClassName = `\`${classesToAdd} \${${expression}}\``
- }
- }
- else {
- newClassName = classesToAdd
- }
-
- const parent = node.parent
- const isSelfClosing = node.selfClosing
- const excludedAttrs = ['className', ...mappedPropNames]
-
- context.report({
- node,
- messageId: 'preferTailwindIcon',
- data: {
- iconClass,
- componentName,
- source: iconInfo.source,
- },
- suggest: [
- {
- messageId: 'preferTailwindIcon',
- data: {
- iconClass,
- componentName,
- source: iconInfo.source,
- },
- fix(fixer) {
- const fixes = []
-
- const classValue = newClassName.startsWith('`')
- ? `{${newClassName}}`
- : `"${newClassName}"`
-
- const otherAttrs = node.attributes
- .filter(attr => !(attr.type === 'JSXAttribute' && excludedAttrs.includes(attr.name.name)))
- .map(attr => sourceCode.getText(attr))
- .join(' ')
-
- const attrsStr = otherAttrs
- ? `className=${classValue} ${otherAttrs}`
- : `className=${classValue}`
-
- if (isSelfClosing) {
- fixes.push(fixer.replaceText(parent, ` `))
- }
- else {
- const closingElement = parent.closingElement
- fixes.push(fixer.replaceText(node, ``))
- if (closingElement) {
- fixes.push(fixer.replaceText(closingElement, ' '))
- }
- }
-
- return fixes
- },
- },
- ],
- })
- },
-
- 'Program:exit': function () {
- const sourceCode = context.sourceCode
-
- // Report icons that were imported but not found in JSX
- for (const [, iconInfo] of iconImports) {
- if (!iconInfo.used) {
- // Verify the import is still referenced somewhere in the file (besides the import itself)
- try {
- const variables = sourceCode.getDeclaredVariables(iconInfo.node)
- const variable = variables[0]
- // Check if there are any references besides the import declaration
- const hasReferences = variable && variable.references.some(
- ref => ref.identifier !== iconInfo.node.local,
- )
- if (!hasReferences)
- continue
- }
- catch {
- continue
- }
-
- const iconClass = getIconClass(iconInfo.importedName, iconInfo.config, iconInfo.source, iconInfo.match)
- context.report({
- node: iconInfo.node,
- messageId: 'preferTailwindIconImport',
- data: {
- importedName: iconInfo.importedName,
- source: iconInfo.source,
- iconClass,
- },
- })
- }
- }
- },
- }
- },
-}
diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs
index 9d582828fd..9ef3f8d04f 100644
--- a/web/eslint.config.mjs
+++ b/web/eslint.config.mjs
@@ -4,7 +4,7 @@ import pluginQuery from '@tanstack/eslint-plugin-query'
import sonar from 'eslint-plugin-sonarjs'
import storybook from 'eslint-plugin-storybook'
import tailwind from 'eslint-plugin-tailwindcss'
-import dify from './eslint-rules/index.js'
+import difyI18n from './eslint-rules/index.js'
export default antfu(
{
@@ -104,34 +104,44 @@ export default antfu(
'tailwindcss/migration-from-tailwind-2': 'warn',
},
},
- // Dify custom rules
- {
- plugins: {
- dify,
- },
- },
- {
- files: ['**/*.tsx'],
- rules: {
- 'dify/prefer-tailwind-icon': 'warn',
- },
- },
+ // dify i18n namespace migration
+ // {
+ // files: ['**/*.ts', '**/*.tsx'],
+ // ignores: ['eslint-rules/**', 'i18n/**', 'i18n-config/**'],
+ // plugins: {
+ // 'dify-i18n': difyI18n,
+ // },
+ // rules: {
+ // // 'dify-i18n/no-as-any-in-t': ['error', { mode: 'all' }],
+ // 'dify-i18n/no-as-any-in-t': 'error',
+ // // 'dify-i18n/no-legacy-namespace-prefix': 'error',
+ // // 'dify-i18n/require-ns-option': 'error',
+ // },
+ // },
+ // i18n JSON validation rules
{
files: ['i18n/**/*.json'],
+ plugins: {
+ 'dify-i18n': difyI18n,
+ },
rules: {
'sonarjs/max-lines': 'off',
'max-lines': 'off',
'jsonc/sort-keys': 'error',
- 'dify/valid-i18n-keys': 'error',
- 'dify/no-extra-keys': 'error',
- 'dify/consistent-placeholders': 'error',
+ 'dify-i18n/valid-i18n-keys': 'error',
+ 'dify-i18n/no-extra-keys': 'error',
+ 'dify-i18n/consistent-placeholders': 'error',
},
},
+ // package.json version prefix validation
{
files: ['**/package.json'],
+ plugins: {
+ 'dify-i18n': difyI18n,
+ },
rules: {
- 'dify/no-version-prefix': 'error',
+ 'dify-i18n/no-version-prefix': 'error',
},
},
)
diff --git a/web/package.json b/web/package.json
index f66e4ceb5b..0096c6b58a 100644
--- a/web/package.json
+++ b/web/package.json
@@ -162,13 +162,7 @@
"devDependencies": {
"@antfu/eslint-config": "7.0.1",
"@chromatic-com/storybook": "5.0.0",
- "@egoist/tailwindcss-icons": "1.9.0",
"@eslint-react/eslint-plugin": "2.7.0",
- "@iconify-json/heroicons": "1.2.3",
- "@iconify-json/ri": "1.2.7",
- "@iconify/tools": "5.0.2",
- "@iconify/types": "2.0.0",
- "@iconify/utils": "3.1.0",
"@mdx-js/loader": "3.1.1",
"@mdx-js/react": "3.1.1",
"@next/bundle-analyzer": "16.1.5",
@@ -211,7 +205,7 @@
"@vitejs/plugin-react": "5.1.2",
"@vitest/coverage-v8": "4.0.17",
"autoprefixer": "10.4.21",
- "code-inspector-plugin": "1.4.1",
+ "code-inspector-plugin": "1.3.6",
"cross-env": "10.1.0",
"esbuild-wasm": "0.27.2",
"eslint": "9.39.2",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index da5ec2b627..e79dee6936 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -372,27 +372,9 @@ importers:
'@chromatic-com/storybook':
specifier: 5.0.0
version: 5.0.0(storybook@10.2.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
- '@egoist/tailwindcss-icons':
- specifier: 1.9.0
- version: 1.9.0(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2))
'@eslint-react/eslint-plugin':
specifier: 2.7.0
version: 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@iconify-json/heroicons':
- specifier: 1.2.3
- version: 1.2.3
- '@iconify-json/ri':
- specifier: 1.2.7
- version: 1.2.7
- '@iconify/tools':
- specifier: 5.0.2
- version: 5.0.2
- '@iconify/types':
- specifier: 2.0.0
- version: 2.0.0
- '@iconify/utils':
- specifier: 3.1.0
- version: 3.1.0
'@mdx-js/loader':
specifier: 3.1.1
version: 3.1.1(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
@@ -520,8 +502,8 @@ importers:
specifier: 10.4.21
version: 10.4.21(postcss@8.5.6)
code-inspector-plugin:
- specifier: 1.4.1
- version: 1.4.1
+ specifier: 1.3.6
+ version: 1.3.6
cross-env:
specifier: 10.1.0
version: 10.1.0
@@ -748,9 +730,6 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
- '@antfu/utils@8.1.1':
- resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==}
-
'@asamuzakjp/css-color@4.1.1':
resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==}
@@ -887,23 +866,23 @@ packages:
'@clack/prompts@0.8.2':
resolution: {integrity: sha512-6b9Ab2UiZwJYA9iMyboYyW9yJvAO9V753ZhS+DHKEjZRKAxPPOb7MXXu84lsPFG+vZt6FRFniZ8rXi+zCIw4yQ==}
- '@code-inspector/core@1.4.1':
- resolution: {integrity: sha512-k5iLYvrBBPBPODcwuzgEcAZnXU4XTnEO1jOmNQBHCehN6nrMO1m5Efjz35KPkSX+8T4IWvXvLoXR5XPfhDlxug==}
+ '@code-inspector/core@1.3.6':
+ resolution: {integrity: sha512-bSxf/PWDPY6rv9EFf0mJvTnLnz3927PPrpX6BmQcRKQab+Ez95yRqrVZY8IcBUpaqA/k3etA5rZ1qkN0V4ERtw==}
- '@code-inspector/esbuild@1.4.1':
- resolution: {integrity: sha512-0tf73j0wgsu1Rl5CNe5o5L/GB/lGvQQVjuLTbAB/but+Bw//nHRnlrA29lBzNM6cyBDZzwofa71Q+TH8Fu4aZQ==}
+ '@code-inspector/esbuild@1.3.6':
+ resolution: {integrity: sha512-s35dseBXI2yqfX6ZK29Ix941jaE/4KPlZZeMk6B5vDahj75FDUfVxQ7ORy4cX2hyz8CmlOycsY/au5mIvFpAFg==}
- '@code-inspector/mako@1.4.1':
- resolution: {integrity: sha512-inpiJbc8J+qaEYcMgzyAFusuyryZ9i0wUQhLJRbWl1WrUdWTE8xNHDjhPeTVaMav42NTGDnVKJhhKD6tNaxyFA==}
+ '@code-inspector/mako@1.3.6':
+ resolution: {integrity: sha512-FJvuTElOi3TUCWTIaYTFYk2iTUD6MlO51SC8SYfwmelhuvnOvTMa2TkylInX16OGb4f7sGNLRj2r+7NNx/gqpw==}
- '@code-inspector/turbopack@1.4.1':
- resolution: {integrity: sha512-xVefk907E39U/oywR9YiEqJn1VlNBHIcIsYkjNnFp0U3qBb3A40VqivlCqkWaP9xHAwEH8/UT3Sfh3aoUPC9/Q==}
+ '@code-inspector/turbopack@1.3.6':
+ resolution: {integrity: sha512-pfXgvZCn4/brpTvqy8E0HTe6V/ksVKEPQo697Nt5k22kBnlEM61UT3rI2Art+fDDEMPQTxVOFpdbwCKSLwMnmQ==}
- '@code-inspector/vite@1.4.1':
- resolution: {integrity: sha512-ptbGkmtw5mvuFse6Kjmd6bCgm+isHrBq+HumWlAMBH//Qb2frHkEV7kWjO6/AkBXfm/ccNJy+jNwWq0632ChDg==}
+ '@code-inspector/vite@1.3.6':
+ resolution: {integrity: sha512-vXYvzGc0S1NR4p3BeD1Xx2170OnyecZD0GtebLlTiHw/cetzlrBHVpbkIwIEzzzpTYYshwwDt8ZbuvdjmqhHgw==}
- '@code-inspector/webpack@1.4.1':
- resolution: {integrity: sha512-UkqC5MsWRVJT2y10GM7tIZdQmFuGAlArJSfq2hq727eXMDV3otY5d1UCQopYvUIEC90QQNHJDeK4e+UQipF6AQ==}
+ '@code-inspector/webpack@1.3.6':
+ resolution: {integrity: sha512-bi/+vsym9d6NXQQ++Phk74VLMiVoGKjgPHr445j/D43URG8AN8yYa+gRDBEDcZx4B128dihrVMxEO8+OgWGjTw==}
'@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
@@ -936,18 +915,10 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'}
- '@cyberalien/svg-utils@1.0.11':
- resolution: {integrity: sha512-qEE9mnyI+avfGT3emKuRs3ucYkITeaV0Xi7VlYN41f+uGnZBecQP3jwz/AF437H9J4Q7qPClHKm4NiTYpNE6hA==}
-
'@discoveryjs/json-ext@0.5.7':
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
engines: {node: '>=10.0.0'}
- '@egoist/tailwindcss-icons@1.9.0':
- resolution: {integrity: sha512-xWA9cUy6hzlK7Y6TaoRIcwmilSXiTJ8rbXcEdf9uht7yzDgw/yIgF4rThIQMrpD2Y2v4od51+r2y6Z7GStanDQ==}
- peerDependencies:
- tailwindcss: '*'
-
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
@@ -1321,21 +1292,9 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
- '@iconify-json/heroicons@1.2.3':
- resolution: {integrity: sha512-n+vmCEgTesRsOpp5AB5ILB6srsgsYK+bieoQBNlafvoEhjVXLq8nIGN4B0v/s4DUfa0dOrjwE/cKJgIKdJXOEg==}
-
- '@iconify-json/ri@1.2.7':
- resolution: {integrity: sha512-j/Fkb8GlWY5y/zLj1BGxWRtDzuJFrI7562zLw+iQVEykieBgew43+r8qAvtSajvb75MfUIHjsNOYQPRD8FfLfw==}
-
- '@iconify/tools@5.0.2':
- resolution: {integrity: sha512-esoFiH0LYpiqqVAO+RTenh6qqGKf0V8T0T6IG7dFLCw26cjcYGG34UMHjkbuq+MMl23U39FtkzhWZsCDDtOhew==}
-
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
- '@iconify/utils@2.3.0':
- resolution: {integrity: sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA==}
-
'@iconify/utils@3.1.0':
resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==}
@@ -3920,8 +3879,8 @@ packages:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
- code-inspector-plugin@1.4.1:
- resolution: {integrity: sha512-DuOEoOWtkz3Mq6JTogJjSfXkVnXuGy6Gjfi+eBYtgRFlZmQ5sw1/LacsPnTK89O4Oz6gZj+zjxpwNfpWg3htpA==}
+ code-inspector-plugin@1.3.6:
+ resolution: {integrity: sha512-ddTg8embDqLZxKEdSNOm+/0YnVVgWKr10+Bu2qFqQDObj/3twGh0Z23TIz+5/URxfRhTPbp2sUSpWlw78piJbQ==}
collapse-white-space@2.1.0:
resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==}
@@ -3949,10 +3908,6 @@ packages:
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
- commander@11.1.0:
- resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
- engines: {node: '>=16'}
-
commander@13.1.0:
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
engines: {node: '>=18'}
@@ -4024,21 +3979,10 @@ packages:
css-mediaquery@0.1.2:
resolution: {integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==}
- css-select@5.2.2:
- resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
-
- css-tree@2.2.1:
- resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
- engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
-
css-tree@3.1.0:
resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
- css-what@6.2.2:
- resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
- engines: {node: '>= 6'}
-
css.escape@1.5.1:
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
@@ -4047,10 +3991,6 @@ packages:
engines: {node: '>=4'}
hasBin: true
- csso@5.0.5:
- resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==}
- engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
-
cssstyle@5.3.7:
resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==}
engines: {node: '>=20'}
@@ -4309,25 +4249,12 @@ packages:
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
- dom-serializer@2.0.0:
- resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
-
- domelementtype@2.3.0:
- resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
-
- domhandler@5.0.3:
- resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
- engines: {node: '>= 4'}
-
dompurify@3.2.7:
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
dompurify@3.3.0:
resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==}
- domutils@3.2.2:
- resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
-
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
@@ -4385,10 +4312,6 @@ packages:
resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
engines: {node: '>=10.13.0'}
- entities@4.5.0:
- resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
- engines: {node: '>=0.12'}
-
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
@@ -4822,9 +4745,6 @@ packages:
fflate@0.4.8:
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
- fflate@0.8.2:
- resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
-
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
@@ -5626,9 +5546,6 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
- mdn-data@2.0.28:
- resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
-
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
@@ -5816,10 +5733,6 @@ packages:
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
- modern-tar@0.7.3:
- resolution: {integrity: sha512-4W79zekKGyYU4JXVmB78DOscMFaJth2gGhgfTl2alWE4rNe3nf4N2pqenQ0rEtIewrnD79M687Ouba3YGTLOvg==}
- engines: {node: '>=18.0.0'}
-
module-alias@2.2.3:
resolution: {integrity: sha512-23g5BFj4zdQL/b6tor7Ji+QY4pEfNH784BMslY9Qb0UnJWRAt+lQGLYmRaM0KDBwIG23ffEBELhZDP2rhi9f/Q==}
@@ -6620,10 +6533,6 @@ packages:
engines: {node: '>=14.0.0'}
hasBin: true
- sax@1.4.4:
- resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==}
- engines: {node: '>=11.0.0'}
-
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
@@ -6893,11 +6802,6 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
- svgo@4.0.0:
- resolution: {integrity: sha512-VvrHQ+9uniE+Mvx3+C9IEe/lWasXCU0nXMY2kZeLrHNICuRiC8uMPyM14UEaMOFA5mhyQqEkB02VoQ16n3DLaw==}
- engines: {node: '>=16'}
- hasBin: true
-
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@@ -7808,8 +7712,6 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.0.2
- '@antfu/utils@8.1.1': {}
-
'@asamuzakjp/css-color@4.1.1':
dependencies:
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
@@ -7997,7 +7899,7 @@ snapshots:
picocolors: 1.1.1
sisteransi: 1.0.5
- '@code-inspector/core@1.4.1':
+ '@code-inspector/core@1.3.6':
dependencies:
'@vue/compiler-dom': 3.5.27
chalk: 4.1.2
@@ -8007,35 +7909,35 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@code-inspector/esbuild@1.4.1':
+ '@code-inspector/esbuild@1.3.6':
dependencies:
- '@code-inspector/core': 1.4.1
+ '@code-inspector/core': 1.3.6
transitivePeerDependencies:
- supports-color
- '@code-inspector/mako@1.4.1':
+ '@code-inspector/mako@1.3.6':
dependencies:
- '@code-inspector/core': 1.4.1
+ '@code-inspector/core': 1.3.6
transitivePeerDependencies:
- supports-color
- '@code-inspector/turbopack@1.4.1':
+ '@code-inspector/turbopack@1.3.6':
dependencies:
- '@code-inspector/core': 1.4.1
- '@code-inspector/webpack': 1.4.1
+ '@code-inspector/core': 1.3.6
+ '@code-inspector/webpack': 1.3.6
transitivePeerDependencies:
- supports-color
- '@code-inspector/vite@1.4.1':
+ '@code-inspector/vite@1.3.6':
dependencies:
- '@code-inspector/core': 1.4.1
+ '@code-inspector/core': 1.3.6
chalk: 4.1.1
transitivePeerDependencies:
- supports-color
- '@code-inspector/webpack@1.4.1':
+ '@code-inspector/webpack@1.3.6':
dependencies:
- '@code-inspector/core': 1.4.1
+ '@code-inspector/core': 1.3.6
transitivePeerDependencies:
- supports-color
@@ -8061,19 +7963,8 @@ snapshots:
'@csstools/css-tokenizer@3.0.4': {}
- '@cyberalien/svg-utils@1.0.11':
- dependencies:
- '@iconify/types': 2.0.0
-
'@discoveryjs/json-ext@0.5.7': {}
- '@egoist/tailwindcss-icons@1.9.0(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2))':
- dependencies:
- '@iconify/utils': 2.3.0
- tailwindcss: 3.4.18(tsx@4.21.0)(yaml@2.8.2)
- transitivePeerDependencies:
- - supports-color
-
'@emnapi/core@1.8.1':
dependencies:
'@emnapi/wasi-threads': 1.1.0
@@ -8437,39 +8328,8 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
- '@iconify-json/heroicons@1.2.3':
- dependencies:
- '@iconify/types': 2.0.0
-
- '@iconify-json/ri@1.2.7':
- dependencies:
- '@iconify/types': 2.0.0
-
- '@iconify/tools@5.0.2':
- dependencies:
- '@cyberalien/svg-utils': 1.0.11
- '@iconify/types': 2.0.0
- '@iconify/utils': 3.1.0
- fflate: 0.8.2
- modern-tar: 0.7.3
- pathe: 2.0.3
- svgo: 4.0.0
-
'@iconify/types@2.0.0': {}
- '@iconify/utils@2.3.0':
- dependencies:
- '@antfu/install-pkg': 1.1.0
- '@antfu/utils': 8.1.1
- '@iconify/types': 2.0.0
- debug: 4.4.3
- globals: 15.15.0
- kolorist: 1.8.0
- local-pkg: 1.1.2
- mlly: 1.8.0
- transitivePeerDependencies:
- - supports-color
-
'@iconify/utils@3.1.0':
dependencies:
'@antfu/install-pkg': 1.1.0
@@ -11283,14 +11143,14 @@ snapshots:
- '@types/react'
- '@types/react-dom'
- code-inspector-plugin@1.4.1:
+ code-inspector-plugin@1.3.6:
dependencies:
- '@code-inspector/core': 1.4.1
- '@code-inspector/esbuild': 1.4.1
- '@code-inspector/mako': 1.4.1
- '@code-inspector/turbopack': 1.4.1
- '@code-inspector/vite': 1.4.1
- '@code-inspector/webpack': 1.4.1
+ '@code-inspector/core': 1.3.6
+ '@code-inspector/esbuild': 1.3.6
+ '@code-inspector/mako': 1.3.6
+ '@code-inspector/turbopack': 1.3.6
+ '@code-inspector/vite': 1.3.6
+ '@code-inspector/webpack': 1.3.6
chalk: 4.1.1
transitivePeerDependencies:
- supports-color
@@ -11319,8 +11179,6 @@ snapshots:
comma-separated-tokens@2.0.3: {}
- commander@11.1.0: {}
-
commander@13.1.0: {}
commander@2.20.3:
@@ -11379,34 +11237,15 @@ snapshots:
css-mediaquery@0.1.2: {}
- css-select@5.2.2:
- dependencies:
- boolbase: 1.0.0
- css-what: 6.2.2
- domhandler: 5.0.3
- domutils: 3.2.2
- nth-check: 2.1.1
-
- css-tree@2.2.1:
- dependencies:
- mdn-data: 2.0.28
- source-map-js: 1.2.1
-
css-tree@3.1.0:
dependencies:
mdn-data: 2.12.2
source-map-js: 1.2.1
- css-what@6.2.2: {}
-
css.escape@1.5.1: {}
cssesc@3.0.0: {}
- csso@5.0.5:
- dependencies:
- css-tree: 2.2.1
-
cssstyle@5.3.7:
dependencies:
'@asamuzakjp/css-color': 4.1.1
@@ -11672,18 +11511,6 @@ snapshots:
dom-accessibility-api@0.6.3: {}
- dom-serializer@2.0.0:
- dependencies:
- domelementtype: 2.3.0
- domhandler: 5.0.3
- entities: 4.5.0
-
- domelementtype@2.3.0: {}
-
- domhandler@5.0.3:
- dependencies:
- domelementtype: 2.3.0
-
dompurify@3.2.7:
optionalDependencies:
'@types/trusted-types': 2.0.7
@@ -11692,12 +11519,6 @@ snapshots:
optionalDependencies:
'@types/trusted-types': 2.0.7
- domutils@3.2.2:
- dependencies:
- dom-serializer: 2.0.0
- domelementtype: 2.3.0
- domhandler: 5.0.3
-
dotenv@16.6.1: {}
duplexer@0.1.2: {}
@@ -11750,8 +11571,6 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.0
- entities@4.5.0: {}
-
entities@6.0.1: {}
entities@7.0.1: {}
@@ -12401,8 +12220,6 @@ snapshots:
fflate@0.4.8: {}
- fflate@0.8.2: {}
-
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
@@ -13354,8 +13171,6 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
- mdn-data@2.0.28: {}
-
mdn-data@2.12.2: {}
memoize-one@5.2.1: {}
@@ -13720,8 +13535,6 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.3
- modern-tar@0.7.3: {}
-
module-alias@2.2.3: {}
monaco-editor@0.55.1:
@@ -14651,8 +14464,6 @@ snapshots:
optionalDependencies:
'@parcel/watcher': 2.5.6
- sax@1.4.4: {}
-
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
@@ -14961,16 +14772,6 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
- svgo@4.0.0:
- dependencies:
- commander: 11.1.0
- css-select: 5.2.2
- css-tree: 3.1.0
- css-what: 6.2.2
- csso: 5.0.5
- picocolors: 1.1.1
- sax: 1.4.4
-
symbol-tree@3.2.4: {}
synckit@0.11.12:
diff --git a/web/tailwind-common-config.ts b/web/tailwind-common-config.ts
index 59ae5e730f..2fd568edd1 100644
--- a/web/tailwind-common-config.ts
+++ b/web/tailwind-common-config.ts
@@ -1,131 +1,8 @@
-import type { IconifyJSON } from '@iconify/types'
-import fs from 'node:fs'
-import path from 'node:path'
-import { fileURLToPath } from 'node:url'
-import { getIconCollections, iconsPlugin } from '@egoist/tailwindcss-icons'
-import { cleanupSVG, deOptimisePaths, importDirectorySync, isEmptyColor, parseColors, runSVGO } from '@iconify/tools'
-import { compareColors, stringToColor } from '@iconify/utils/lib/colors'
import tailwindTypography from '@tailwindcss/typography'
// @ts-expect-error workaround for turbopack issue
import tailwindThemeVarDefine from './themes/tailwind-theme-var-define.ts'
import typography from './typography.js'
-const _dirname = typeof __dirname !== 'undefined'
- ? __dirname
- : path.dirname(fileURLToPath(import.meta.url))
-
-// https://iconify.design/docs/articles/cleaning-up-icons/
-function getIconSetFromDir(dir: string, prefix: string) {
- // Import icons
- const iconSet = importDirectorySync(dir, {
- prefix,
- ignoreImportErrors: 'warn',
- })
-
- // Validate, clean up, fix palette and optimise
- iconSet.forEachSync((name, type) => {
- if (type !== 'icon')
- return
-
- const svg = iconSet.toSVG(name)
- if (!svg) {
- // Invalid icon
- iconSet.remove(name)
- return
- }
-
- // Clean up and optimise icons
- try {
- // Clean up icon code
- cleanupSVG(svg)
-
- // Change color to `currentColor`
- // Skip this step if icon has hardcoded palette
- const blackColor = stringToColor('black')!
- const whiteColor = stringToColor('white')!
- parseColors(svg, {
- defaultColor: 'currentColor',
- callback: (attr, colorStr, color) => {
- if (!color) {
- // Color cannot be parsed!
- throw new Error(`Invalid color: "${colorStr}" in attribute ${attr}`)
- }
-
- if (isEmptyColor(color)) {
- // Color is empty: 'none' or 'transparent'. Return as is
- return color
- }
-
- // Change black to 'currentColor'
- if (compareColors(color, blackColor))
- return 'currentColor'
-
- // Remove shapes with white color
- if (compareColors(color, whiteColor))
- return 'remove'
-
- // Icon is not monotone
- return color
- },
- })
-
- // Optimise
- runSVGO(svg)
-
- // Update paths for compatibility with old software
- deOptimisePaths(svg)
- }
- catch (err) {
- // Invalid icon
- console.error(`Error parsing ${name}:`, err)
- iconSet.remove(name)
- return
- }
-
- // Update icon
- iconSet.fromSVG(name, svg)
- })
-
- // Export
- return iconSet.export()
-}
-
-function getCollectionsFromSubDirs(baseDir: string, prefixBase: string): Record {
- const collections: Record = {}
-
- function processDir(dir: string, prefix: string): void {
- const entries = fs.readdirSync(dir, { withFileTypes: true })
- const subDirs = entries.filter(e => e.isDirectory())
- const svgFiles = entries.filter(e => e.isFile() && e.name.endsWith('.svg'))
-
- // Process SVG files in current directory if any
- if (svgFiles.length > 0) {
- collections[prefix] = getIconSetFromDir(dir, prefix)
- }
-
- // Recurse into subdirectories if any
- if (subDirs.length > 0) {
- for (const subDir of subDirs) {
- const subDirPath = path.join(dir, subDir.name)
- const subPrefix = `${prefix}-${subDir.name}`
- processDir(subDirPath, subPrefix)
- }
- }
- }
-
- // Read top-level subdirectories and process each
- const entries = fs.readdirSync(baseDir, { withFileTypes: true })
- for (const entry of entries) {
- if (entry.isDirectory()) {
- const subDirPath = path.join(baseDir, entry.name)
- const prefix = `${prefixBase}-${entry.name}`
- processDir(subDirPath, prefix)
- }
- }
-
- return collections
-}
-
const config = {
theme: {
typography,
@@ -271,21 +148,7 @@ const config = {
},
},
},
- plugins: [
- tailwindTypography,
- iconsPlugin({
- collections: {
- ...getCollectionsFromSubDirs(path.resolve(_dirname, 'app/components/base/icons/assets/public'), 'custom-public'),
- ...getCollectionsFromSubDirs(path.resolve(_dirname, 'app/components/base/icons/assets/vender'), 'custom-vender'),
- ...getIconCollections(['heroicons', 'ri']),
- },
- extraProperties: {
- width: '1rem',
- height: '1rem',
- display: 'block',
- },
- }),
- ],
+ plugins: [tailwindTypography],
// https://github.com/tailwindlabs/tailwindcss/discussions/5969
corePlugins: {
preflight: false,
From cf7fae393ccff421e01c196ec25d78928da1931f Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 30 Jan 2026 12:27:01 +0800
Subject: [PATCH 12/14] chore(i18n): sync translations with en-US (#31730)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
---
web/i18n/ar-TN/common.json | 2 +
web/i18n/ar-TN/share.json | 10 ++++
web/i18n/ar-TN/workflow.json | 103 +++++++++++++++++++++++++++++++++
web/i18n/de-DE/common.json | 2 +
web/i18n/de-DE/share.json | 10 ++++
web/i18n/de-DE/workflow.json | 103 +++++++++++++++++++++++++++++++++
web/i18n/es-ES/common.json | 2 +
web/i18n/es-ES/share.json | 10 ++++
web/i18n/es-ES/workflow.json | 103 +++++++++++++++++++++++++++++++++
web/i18n/fa-IR/common.json | 2 +
web/i18n/fa-IR/share.json | 10 ++++
web/i18n/fa-IR/workflow.json | 103 +++++++++++++++++++++++++++++++++
web/i18n/fr-FR/common.json | 2 +
web/i18n/fr-FR/share.json | 10 ++++
web/i18n/fr-FR/workflow.json | 103 +++++++++++++++++++++++++++++++++
web/i18n/hi-IN/common.json | 2 +
web/i18n/hi-IN/share.json | 10 ++++
web/i18n/hi-IN/workflow.json | 103 +++++++++++++++++++++++++++++++++
web/i18n/id-ID/common.json | 2 +
web/i18n/id-ID/share.json | 10 ++++
web/i18n/id-ID/workflow.json | 103 +++++++++++++++++++++++++++++++++
web/i18n/it-IT/common.json | 2 +
web/i18n/it-IT/share.json | 10 ++++
web/i18n/it-IT/workflow.json | 103 +++++++++++++++++++++++++++++++++
web/i18n/ja-JP/common.json | 2 +
web/i18n/ja-JP/share.json | 10 ++++
web/i18n/ja-JP/workflow.json | 103 +++++++++++++++++++++++++++++++++
web/i18n/ko-KR/common.json | 2 +
web/i18n/ko-KR/share.json | 10 ++++
web/i18n/ko-KR/workflow.json | 103 +++++++++++++++++++++++++++++++++
web/i18n/pl-PL/common.json | 2 +
web/i18n/pl-PL/share.json | 10 ++++
web/i18n/pl-PL/workflow.json | 103 +++++++++++++++++++++++++++++++++
web/i18n/pt-BR/common.json | 2 +
web/i18n/pt-BR/share.json | 10 ++++
web/i18n/pt-BR/workflow.json | 103 +++++++++++++++++++++++++++++++++
web/i18n/ro-RO/common.json | 2 +
web/i18n/ro-RO/share.json | 10 ++++
web/i18n/ro-RO/workflow.json | 103 +++++++++++++++++++++++++++++++++
web/i18n/ru-RU/common.json | 2 +
web/i18n/ru-RU/share.json | 10 ++++
web/i18n/ru-RU/workflow.json | 103 +++++++++++++++++++++++++++++++++
web/i18n/sl-SI/common.json | 2 +
web/i18n/sl-SI/share.json | 10 ++++
web/i18n/sl-SI/workflow.json | 103 +++++++++++++++++++++++++++++++++
web/i18n/th-TH/common.json | 2 +
web/i18n/th-TH/share.json | 10 ++++
web/i18n/th-TH/workflow.json | 103 +++++++++++++++++++++++++++++++++
web/i18n/tr-TR/common.json | 2 +
web/i18n/tr-TR/share.json | 10 ++++
web/i18n/tr-TR/workflow.json | 103 +++++++++++++++++++++++++++++++++
web/i18n/uk-UA/common.json | 2 +
web/i18n/uk-UA/share.json | 10 ++++
web/i18n/uk-UA/workflow.json | 103 +++++++++++++++++++++++++++++++++
web/i18n/vi-VN/common.json | 2 +
web/i18n/vi-VN/share.json | 10 ++++
web/i18n/vi-VN/workflow.json | 103 +++++++++++++++++++++++++++++++++
web/i18n/zh-Hans/share.json | 17 +++---
web/i18n/zh-Hant/common.json | 2 +
web/i18n/zh-Hant/share.json | 10 ++++
web/i18n/zh-Hant/workflow.json | 103 +++++++++++++++++++++++++++++++++
61 files changed, 2309 insertions(+), 8 deletions(-)
diff --git a/web/i18n/ar-TN/common.json b/web/i18n/ar-TN/common.json
index ca9bbc0c8d..59dc4fad0a 100644
--- a/web/i18n/ar-TN/common.json
+++ b/web/i18n/ar-TN/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "اكتب كلمة المطالبة هنا، أدخل '{' لإدراج متغير، أدخل '/' لإدراج كتلة محتوى مطالبة",
"promptEditor.query.item.desc": "إدراج قالب استعلام المستخدم",
"promptEditor.query.item.title": "استعلام",
+ "promptEditor.requestURL.item.desc": "إدراج عنوان URL للطلب",
+ "promptEditor.requestURL.item.title": "عنوان URL للطلب",
"promptEditor.variable.item.desc": "إدراج المتغيرات والأدوات الخارجية",
"promptEditor.variable.item.title": "المتغيرات والأدوات الخارجية",
"promptEditor.variable.modal.add": "متغير جديد",
diff --git a/web/i18n/ar-TN/share.json b/web/i18n/ar-TN/share.json
index 2b4698685a..5d348d86a7 100644
--- a/web/i18n/ar-TN/share.json
+++ b/web/i18n/ar-TN/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "تشغيل مرة واحدة",
"generation.tabs.saved": "محفوظ",
"generation.title": "إكمال الذكاء الاصطناعي",
+ "humanInput.completed": "يبدو أن هذا الطلب تمت معالجته في مكان آخر.",
+ "humanInput.expirationTimeNowOrFuture": "سينتهي هذا الإجراء {{relativeTime}}.",
+ "humanInput.expired": "يبدو أن هذا الطلب قد انتهت صلاحيته.",
+ "humanInput.expiredTip": "انتهت صلاحية هذا الإجراء.",
+ "humanInput.formNotFound": "لم يتم العثور على النموذج.",
+ "humanInput.rateLimitExceeded": "طلبات كثيرة جدًا، يرجى المحاولة مرة أخرى لاحقًا.",
+ "humanInput.recorded": "تم تسجيل مدخلاتك.",
+ "humanInput.sorry": "عذراً!",
+ "humanInput.submissionID": "submission_id: {{id}}",
+ "humanInput.thanks": "شكراً!",
"login.backToHome": "العودة إلى الصفحة الرئيسية"
}
diff --git a/web/i18n/ar-TN/workflow.json b/web/i18n/ar-TN/workflow.json
index 533caff5f8..ab221c869c 100644
--- a/web/i18n/ar-TN/workflow.json
+++ b/web/i18n/ar-TN/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "مستخرج المستندات",
"blocks.end": "الإخراج",
"blocks.http-request": "طلب HTTP",
+ "blocks.human-input": "إدخال بشري",
"blocks.if-else": "IF/ELSE",
"blocks.iteration": "تكرار",
"blocks.iteration-start": "بداية التكرار",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "تستخدم لتحليل المستندات التي تم تحميلها إلى محتوى نصي يسهل فهمه بواسطة LLM.",
"blocksAbout.end": "تحديد الإخراج ونوع النتيجة لسير العمل",
"blocksAbout.http-request": "السماح بإرسال طلبات الخادم عبر بروتوكول HTTP",
+ "blocksAbout.human-input": "اطلب تأكيداً بشرياً قبل إنشاء الخطوة التالية",
"blocksAbout.if-else": "يسمح لك بتقسيم سير العمل إلى فرعين بناءً على شروط if/else",
"blocksAbout.iteration": "تنفيذ خطوات متعددة على كائن قائمة حتى يتم إخراج جميع النتائج.",
"blocksAbout.iteration-start": "نقطة بدء التكرار",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "تم ترقية ميزات تحميل الصور إلى تحميل الملفات. ",
"common.goBackToEdit": "العودة إلى المحرر",
"common.handMode": "وضع اليد",
+ "common.humanInputEmailTip": "البريد الإلكتروني (طريقة التسليم) المرسل إلى المستلمين المهيئين",
+ "common.humanInputEmailTipInDebugMode": "البريد الإلكتروني (طريقة التسليم) المرسل إلى {{email}} ",
+ "common.humanInputWebappTip": "معاينة التصحيح فقط، لن يرى المستخدم هذا في تطبيق الويب.",
"common.importDSL": "استيراد DSL",
"common.importDSLTip": "سيتم استبدال المسودة الحالية.\nقم بتصدير سير العمل كنسخة احتياطية قبل الاستيراد.",
"common.importFailure": "فشل الاستيراد",
@@ -500,6 +505,104 @@
"nodes.http.value": "القيمة",
"nodes.http.verifySSL.title": "التحقق من شهادة SSL",
"nodes.http.verifySSL.warningTooltip": "لا يوصى بتعطيل التحقق من SSL لبيئات الإنتاج. يجب استخدامه فقط في التطوير أو الاختبار، حيث إنه يجعل الاتصال عرضة لتهديدات الأمان مثل هجمات الوسيط.",
+ "nodes.humanInput.deliveryMethod.added": "تمت الإضافة",
+ "nodes.humanInput.deliveryMethod.contactTip1": "هل تفتقد طريقة تسليم تحتاجها؟",
+ "nodes.humanInput.deliveryMethod.contactTip2": "أخبرنا على support@dify.ai .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "جميع الأعضاء ({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "المحتوى",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "أدخل محتوى البريد الإلكتروني",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "وضع التصحيح",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "في وضع التصحيح، سيتم إرسال البريد الإلكتروني فقط إلى بريدك الإلكتروني {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "بيئة الإنتاج غير متأثرة.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "إرسال طلب الإدخال عبر البريد الإلكتروني",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ إضافة",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "تمت الإضافة",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "البريد الإلكتروني، مفصول بفاصلة",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "إضافة أعضاء مساحة العمل أو المستلمين الخارجيين",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "تحديد",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "المستلم",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "متغير عنوان URL للطلب هو نقطة الدخول للإدخال البشري.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "الموضوع",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "أدخل موضوع البريد الإلكتروني",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "تكوين البريد الإلكتروني",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "تم إرسال بريد إلكتروني تجريبي إلى {{email}} . يرجى التحقق من صندوق الوارد الخاص بك.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "وضع التصحيح مفعّل.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "سيتم إرسال البريد الإلكتروني إلى {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "تم إرسال البريد الإلكتروني",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(اختياري)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "إرسال البريد الإلكتروني",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "إرسال رسائل بريد إلكتروني تجريبية إلى المستلمين المكونين",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "إرسال بريد إلكتروني تجريبي إلى {{email}}",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "يُوصى بتفعيل وضع التصحيح لاختبار تسليم البريد الإلكتروني.",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "مرسل البريد الإلكتروني التجريبي",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "المتغيرات في محتوى النموذج",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "املأ متغيرات النموذج لمحاكاة ما يراه المستلمون فعلياً.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "تم إرسال البريد الإلكتروني إلى أعضاء {{team}} وعناوين البريد الإلكتروني التالية:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "تم إرسال البريد الإلكتروني إلى أعضاء {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "تم إرسال البريد الإلكتروني إلى عناوين البريد الإلكتروني التالية:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "سيتم إرسال البريد الإلكتروني إلى أعضاء {{team}} وعناوين البريد الإلكتروني التالية:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "سيتم إرسال البريد الإلكتروني إلى أعضاء {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "سيتم إرسال البريد الإلكتروني إلى عناوين البريد الإلكتروني التالية:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "لم يتم إضافة طريقة تسليم، لا يمكن تشغيل العملية.",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "غير متاح",
+ "nodes.humanInput.deliveryMethod.notConfigured": "غير مُكوّن",
+ "nodes.humanInput.deliveryMethod.title": "طريقة التسليم",
+ "nodes.humanInput.deliveryMethod.tooltip": "كيفية تسليم نموذج الإدخال البشري للمستخدم.",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "إرسال طلب الإدخال عبر Discord",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "إرسال طلب الإدخال عبر البريد الإلكتروني",
+ "nodes.humanInput.deliveryMethod.types.email.title": "البريد الإلكتروني",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "إرسال طلب الإدخال عبر Slack",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "إرسال طلب الإدخال عبر Teams",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "عرض للمستخدم النهائي في تطبيق الويب",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "تطبيق الويب",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "فتح قفل تسليم البريد الإلكتروني للإدخال البشري",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "إرسال طلبات التأكيد عبر البريد الإلكتروني قبل أن يتخذ الوكلاء إجراءً — مفيد لسير عمل النشر والموافقة.",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "تجاهل",
+ "nodes.humanInput.editor.previewTip": "في وضع المعاينة، أزرار الإجراء غير وظيفية.",
+ "nodes.humanInput.errorMsg.duplicateActionId": "تم العثور على معرف إجراء مكرر في إجراءات المستخدم",
+ "nodes.humanInput.errorMsg.emptyActionId": "لا يمكن أن يكون معرف الإجراء فارغاً",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "لا يمكن أن يكون عنوان الإجراء فارغاً",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "الرجاء تحديد طريقة تسليم واحدة على الأقل",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "الرجاء تفعيل طريقة تسليم واحدة على الأقل",
+ "nodes.humanInput.errorMsg.noUserActions": "الرجاء إضافة إجراء مستخدم واحد على الأقل",
+ "nodes.humanInput.formContent.hotkeyTip": "اضغط لإدراج متغير، لإدراج حقل إدخال",
+ "nodes.humanInput.formContent.placeholder": "اكتب المحتوى هنا",
+ "nodes.humanInput.formContent.preview": "معاينة",
+ "nodes.humanInput.formContent.title": "محتوى النموذج",
+ "nodes.humanInput.formContent.tooltip": "ما سيراه المستخدمون بعد فتح النموذج. يدعم تنسيق Markdown.",
+ "nodes.humanInput.insertInputField.insert": "إدراج",
+ "nodes.humanInput.insertInputField.prePopulateField": "ملء الحقل مسبقاً",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": "أضف أو . سيرى المستخدمون هذا المحتوى في البداية، أو اتركه فارغاً.",
+ "nodes.humanInput.insertInputField.saveResponseAs": "حفظ الاستجابة باسم",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "قم بتسمية هذا المتغير للإشارة لاحقاً",
+ "nodes.humanInput.insertInputField.staticContent": "محتوى ثابت",
+ "nodes.humanInput.insertInputField.title": "إدراج حقل إدخال",
+ "nodes.humanInput.insertInputField.useConstantInstead": "استخدام ثابت بدلاً من ذلك",
+ "nodes.humanInput.insertInputField.useVarInstead": "استخدام متغير بدلاً من ذلك",
+ "nodes.humanInput.insertInputField.variable": "متغير",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "يمكن أن يحتوي اسم المتغير على حروف وأرقام وشرطات سفلية فقط، ولا يمكن أن يبدأ برقم",
+ "nodes.humanInput.log.backstageInputURL": "عنوان URL للإدخال خلف الكواليس:",
+ "nodes.humanInput.log.reason": "السبب:",
+ "nodes.humanInput.log.reasonContent": "الإدخال البشري مطلوب للمتابعة",
+ "nodes.humanInput.singleRun.back": "رجوع",
+ "nodes.humanInput.singleRun.button": "إنشاء النموذج",
+ "nodes.humanInput.singleRun.label": "متغيرات النموذج",
+ "nodes.humanInput.timeout.days": "أيام",
+ "nodes.humanInput.timeout.hours": "ساعات",
+ "nodes.humanInput.timeout.title": "المهلة الزمنية",
+ "nodes.humanInput.userActions.actionIdFormatTip": "يجب أن يبدأ معرف الإجراء بحرف أو شرطة سفلية، متبوعاً بأحرف أو أرقام أو شرطات سفلية",
+ "nodes.humanInput.userActions.actionIdTooLong": "يجب أن يكون معرف الإجراء {{maxLength}} حرفاً أو أقل",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "اسم الإجراء",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "نص عرض الزر",
+ "nodes.humanInput.userActions.buttonTextTooLong": "يجب أن يكون نص الزر {{maxLength}} حرفاً أو أقل",
+ "nodes.humanInput.userActions.chooseStyle": "اختر نمط الزر",
+ "nodes.humanInput.userActions.emptyTip": "انقر فوق الزر '+' لإضافة إجراءات المستخدم",
+ "nodes.humanInput.userActions.title": "إجراءات المستخدم",
+ "nodes.humanInput.userActions.tooltip": "حدد الأزرار التي يمكن للمستخدمين النقر عليها للرد على هذا النموذج. يمكن لكل زر تشغيل مسارات سير عمل مختلفة. يجب أن يبدأ معرف الإجراء بحرف أو شرطة سفلية، متبوعاً بأحرف أو أرقام أو شرطات سفلية.",
+ "nodes.humanInput.userActions.triggered": "تم تشغيل {{actionName}} ",
"nodes.ifElse.addCondition": "إضافة شرط",
"nodes.ifElse.addSubVariable": "متغير فرعي",
"nodes.ifElse.and": "و",
diff --git a/web/i18n/de-DE/common.json b/web/i18n/de-DE/common.json
index b1f11e5026..7c4cd7e244 100644
--- a/web/i18n/de-DE/common.json
+++ b/web/i18n/de-DE/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "Schreiben Sie hier Ihr Aufforderungswort, geben Sie '{' ein, um eine Variable einzufügen, geben Sie '/' ein, um einen Aufforderungs-Inhaltsblock einzufügen",
"promptEditor.query.item.desc": "Benutzerabfragevorlage einfügen",
"promptEditor.query.item.title": "Abfrage",
+ "promptEditor.requestURL.item.desc": "Anfrage-URL einfügen",
+ "promptEditor.requestURL.item.title": "Anfrage-URL",
"promptEditor.variable.item.desc": "Variablen & Externe Werkzeuge einfügen",
"promptEditor.variable.item.title": "Variablen & Externe Werkzeuge",
"promptEditor.variable.modal.add": "Neue Variable",
diff --git a/web/i18n/de-DE/share.json b/web/i18n/de-DE/share.json
index 8268f837ba..0d7997b848 100644
--- a/web/i18n/de-DE/share.json
+++ b/web/i18n/de-DE/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "Einmal ausführen",
"generation.tabs.saved": "Gespeichert",
"generation.title": "KI-Vervollständigung",
+ "humanInput.completed": "Es scheint, dass diese Anfrage woanders bearbeitet wurde.",
+ "humanInput.expirationTimeNowOrFuture": "Diese Aktion läuft {{relativeTime}} ab.",
+ "humanInput.expired": "Es scheint, dass diese Anfrage abgelaufen ist.",
+ "humanInput.expiredTip": "Diese Aktion ist abgelaufen.",
+ "humanInput.formNotFound": "Formular nicht gefunden.",
+ "humanInput.rateLimitExceeded": "Zu viele Anfragen, bitte versuchen Sie es später erneut.",
+ "humanInput.recorded": "Ihre Eingabe wurde aufgezeichnet.",
+ "humanInput.sorry": "Entschuldigung!",
+ "humanInput.submissionID": "submission_id: {{id}}",
+ "humanInput.thanks": "Danke!",
"login.backToHome": "Zurück zur Startseite"
}
diff --git a/web/i18n/de-DE/workflow.json b/web/i18n/de-DE/workflow.json
index 870b06752c..5f53c485b2 100644
--- a/web/i18n/de-DE/workflow.json
+++ b/web/i18n/de-DE/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "Doc Extraktor",
"blocks.end": "Ausgabe",
"blocks.http-request": "HTTP-Anfrage",
+ "blocks.human-input": "Menschliche Eingabe",
"blocks.if-else": "WENN/SONST",
"blocks.iteration": "Iteration",
"blocks.iteration-start": "Iterationsstart",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "Wird verwendet, um hochgeladene Dokumente in Textinhalte zu analysieren, die für LLM leicht verständlich sind.",
"blocksAbout.end": "Definieren Sie die Ausgabe und den Ergebnistyp eines Workflows",
"blocksAbout.http-request": "Ermöglichen, dass Serveranforderungen über das HTTP-Protokoll gesendet werden",
+ "blocksAbout.human-input": "Um menschliche Bestätigung bitten, bevor der nächste Schritt generiert wird",
"blocksAbout.if-else": "Ermöglicht das Aufteilen des Workflows in zwei Zweige basierend auf if/else-Bedingungen",
"blocksAbout.iteration": "Mehrere Schritte an einem Listenobjekt ausführen, bis alle Ergebnisse ausgegeben wurden.",
"blocksAbout.iteration-start": "Startknoten der Iteration",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "Die Funktionen zum Hochladen von Bildern wurden auf das Hochladen von Dateien aktualisiert.",
"common.goBackToEdit": "Zurück zum Editor",
"common.handMode": "Handmodus",
+ "common.humanInputEmailTip": "E-Mail (Zustellmethode) an Ihre konfigurierten Empfänger gesendet",
+ "common.humanInputEmailTipInDebugMode": "E-Mail (Zustellmethode) gesendet an {{email}} ",
+ "common.humanInputWebappTip": "Nur Debug-Vorschau, der Benutzer wird dies nicht in der Web-App sehen.",
"common.importDSL": "DSL importieren",
"common.importDSLTip": "Der aktuelle Entwurf wird überschrieben. Exportieren Sie den Workflow vor dem Import als Backup.",
"common.importFailure": "Fehler beim Import",
@@ -500,6 +505,104 @@
"nodes.http.value": "Wert",
"nodes.http.verifySSL.title": "SSL-Zertifikat überprüfen",
"nodes.http.verifySSL.warningTooltip": "Das Deaktivieren der SSL-Überprüfung wird für Produktionsumgebungen nicht empfohlen. Dies sollte nur in der Entwicklung oder im Test verwendet werden, da es die Verbindung anfällig für Sicherheitsbedrohungen wie Man-in-the-Middle-Angriffe macht.",
+ "nodes.humanInput.deliveryMethod.added": "Hinzugefügt",
+ "nodes.humanInput.deliveryMethod.contactTip1": "Fehlt Ihnen eine Zustellmethode, die Sie benötigen?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "Teilen Sie es uns unter support@dify.ai mit.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "Alle Mitglieder ({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "Inhalt",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "E-Mail-Inhalt eingeben",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "Debug-Modus",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "Im Debug-Modus wird die E-Mail nur an Ihre Konto-E-Mail-Adresse {{email}} gesendet.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "Die Produktionsumgebung ist nicht betroffen.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "Anfrage zur Eingabe per E-Mail senden",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ Hinzufügen",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "Hinzugefügt",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "E-Mail, durch Komma getrennt",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "Workspace-Mitglieder oder externe Empfänger hinzufügen",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "Auswählen",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "Empfänger",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "Die Anfrage-URL-Variable ist der Auslöseeingang für menschliche Eingabe.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "Betreff",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "E-Mail-Betreff eingeben",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "E-Mail-Konfiguration",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "Eine Test-E-Mail wurde an {{email}} gesendet. Bitte überprüfen Sie Ihren Posteingang.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "Debug-Modus ist aktiviert.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "E-Mail wird an {{email}} gesendet.",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "E-Mail gesendet",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(optional)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "E-Mail senden",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "Test-E-Mails an Ihre konfigurierten Empfänger senden",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "Test-E-Mail an {{email}} senden",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "Es wird empfohlen, den Debug-Modus zu aktivieren , um die E-Mail-Zustellung zu testen.",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "Test-E-Mail-Sender",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "Variablen im Formularinhalt",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "Füllen Sie Formularvariablen aus, um zu emulieren, was Empfänger tatsächlich sehen.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "E-Mail wurde an {{team}} -Mitglieder und die folgenden E-Mail-Adressen gesendet:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "E-Mail wurde an {{team}} -Mitglieder gesendet.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "E-Mail wurde an die folgenden E-Mail-Adressen gesendet:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "E-Mail wird an {{team}} -Mitglieder und die folgenden E-Mail-Adressen gesendet:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "E-Mail wird an {{team}} -Mitglieder gesendet.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "E-Mail wird an die folgenden E-Mail-Adressen gesendet:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "Keine Zustellmethode hinzugefügt, die Operation kann nicht ausgelöst werden.",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "Nicht verfügbar",
+ "nodes.humanInput.deliveryMethod.notConfigured": "Nicht konfiguriert",
+ "nodes.humanInput.deliveryMethod.title": "Zustellmethode",
+ "nodes.humanInput.deliveryMethod.tooltip": "Wie das Formular für menschliche Eingabe an den Benutzer zugestellt wird.",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "Anfrage zur Eingabe per Discord senden",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "Anfrage zur Eingabe per E-Mail senden",
+ "nodes.humanInput.deliveryMethod.types.email.title": "E-Mail",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "Anfrage zur Eingabe per Slack senden",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "Anfrage zur Eingabe per Teams senden",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "Endbenutzer in der Web-App anzeigen",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "Web-App",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "E-Mail-Zustellung für menschliche Eingabe freischalten",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "Senden Sie Bestätigungsanfragen per E-Mail, bevor Agenten Maßnahmen ergreifen – nützlich für Veröffentlichungs- und Genehmigungsworkflows.",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "Verwerfen",
+ "nodes.humanInput.editor.previewTip": "Im Vorschaumodus sind Aktionsschaltflächen nicht funktionsfähig.",
+ "nodes.humanInput.errorMsg.duplicateActionId": "Doppelte Aktions-ID in Benutzeraktionen gefunden",
+ "nodes.humanInput.errorMsg.emptyActionId": "Aktions-ID darf nicht leer sein",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "Aktionstitel darf nicht leer sein",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "Bitte wählen Sie mindestens eine Zustellmethode aus",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "Bitte aktivieren Sie mindestens eine Zustellmethode",
+ "nodes.humanInput.errorMsg.noUserActions": "Bitte fügen Sie mindestens eine Benutzeraktion hinzu",
+ "nodes.humanInput.formContent.hotkeyTip": "Drücken Sie , um Variable einzufügen, , um Eingabefeld einzufügen",
+ "nodes.humanInput.formContent.placeholder": "Inhalt hier eingeben",
+ "nodes.humanInput.formContent.preview": "Vorschau",
+ "nodes.humanInput.formContent.title": "Formularinhalt",
+ "nodes.humanInput.formContent.tooltip": "Was Benutzer nach dem Öffnen des Formulars sehen. Unterstützt Markdown-Formatierung.",
+ "nodes.humanInput.insertInputField.insert": "Einfügen",
+ "nodes.humanInput.insertInputField.prePopulateField": "Feld vorab ausfüllen",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": " oder hinzufügen. Benutzer sehen diesen Inhalt zunächst, oder leer lassen.",
+ "nodes.humanInput.insertInputField.saveResponseAs": "Antwort speichern als",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "Benennen Sie diese Variable für spätere Referenz",
+ "nodes.humanInput.insertInputField.staticContent": "Statischer Inhalt",
+ "nodes.humanInput.insertInputField.title": "Eingabefeld einfügen",
+ "nodes.humanInput.insertInputField.useConstantInstead": "Stattdessen Konstante verwenden",
+ "nodes.humanInput.insertInputField.useVarInstead": "Stattdessen Variable verwenden",
+ "nodes.humanInput.insertInputField.variable": "Variable",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "Variablenname darf nur Buchstaben, Zahlen und Unterstriche enthalten und darf nicht mit einer Zahl beginnen",
+ "nodes.humanInput.log.backstageInputURL": "Backstage-Eingabe-URL:",
+ "nodes.humanInput.log.reason": "Grund:",
+ "nodes.humanInput.log.reasonContent": "Menschliche Eingabe erforderlich, um fortzufahren",
+ "nodes.humanInput.singleRun.back": "Zurück",
+ "nodes.humanInput.singleRun.button": "Formular generieren",
+ "nodes.humanInput.singleRun.label": "Formularvariablen",
+ "nodes.humanInput.timeout.days": "Tage",
+ "nodes.humanInput.timeout.hours": "Stunden",
+ "nodes.humanInput.timeout.title": "Zeitüberschreitung",
+ "nodes.humanInput.userActions.actionIdFormatTip": "Aktions-ID muss mit einem Buchstaben oder Unterstrich beginnen, gefolgt von Buchstaben, Zahlen oder Unterstrichen",
+ "nodes.humanInput.userActions.actionIdTooLong": "Aktions-ID darf höchstens {{maxLength}} Zeichen lang sein",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "Aktionsname",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "Schaltflächen-Anzeigetext",
+ "nodes.humanInput.userActions.buttonTextTooLong": "Schaltflächentext darf höchstens {{maxLength}} Zeichen lang sein",
+ "nodes.humanInput.userActions.chooseStyle": "Wählen Sie einen Schaltflächenstil",
+ "nodes.humanInput.userActions.emptyTip": "Klicken Sie auf die '+'-Schaltfläche, um Benutzeraktionen hinzuzufügen",
+ "nodes.humanInput.userActions.title": "Benutzeraktionen",
+ "nodes.humanInput.userActions.tooltip": "Definieren Sie Schaltflächen, auf die Benutzer klicken können, um auf dieses Formular zu reagieren. Jede Schaltfläche kann unterschiedliche Workflow-Pfade auslösen. Aktions-ID muss mit einem Buchstaben oder Unterstrich beginnen, gefolgt von Buchstaben, Zahlen oder Unterstrichen.",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} wurde ausgelöst",
"nodes.ifElse.addCondition": "Bedingung hinzufügen",
"nodes.ifElse.addSubVariable": "Untervariable",
"nodes.ifElse.and": "und",
diff --git a/web/i18n/es-ES/common.json b/web/i18n/es-ES/common.json
index 2a57d940a3..4c62793c5b 100644
--- a/web/i18n/es-ES/common.json
+++ b/web/i18n/es-ES/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "Escribe tu palabra de indicación aquí, ingresa '{' para insertar una variable, ingresa '/' para insertar un bloque de contenido de indicación",
"promptEditor.query.item.desc": "Insertar plantilla de consulta del usuario",
"promptEditor.query.item.title": "Consulta",
+ "promptEditor.requestURL.item.desc": "Insertar URL de solicitud",
+ "promptEditor.requestURL.item.title": "URL de Solicitud",
"promptEditor.variable.item.desc": "Insertar Variables y Herramientas Externas",
"promptEditor.variable.item.title": "Variables y Herramientas Externas",
"promptEditor.variable.modal.add": "Nueva variable",
diff --git a/web/i18n/es-ES/share.json b/web/i18n/es-ES/share.json
index a191fceae5..5fa50a0055 100644
--- a/web/i18n/es-ES/share.json
+++ b/web/i18n/es-ES/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "Ejecutar una vez",
"generation.tabs.saved": "Guardado",
"generation.title": "Completado por IA",
+ "humanInput.completed": "Parece que esta solicitud fue tratada en otro lugar.",
+ "humanInput.expirationTimeNowOrFuture": "Esta acción expirará {{relativeTime}}.",
+ "humanInput.expired": "Parece que esta solicitud ha expirado.",
+ "humanInput.expiredTip": "Esta acción ha expirado.",
+ "humanInput.formNotFound": "Formulario no encontrado.",
+ "humanInput.rateLimitExceeded": "Demasiadas solicitudes, inténtelo de nuevo más tarde.",
+ "humanInput.recorded": "Su entrada ha sido registrada.",
+ "humanInput.sorry": "¡Lo sentimos!",
+ "humanInput.submissionID": "submission_id: {{id}}",
+ "humanInput.thanks": "¡Gracias!",
"login.backToHome": "Volver a Inicio"
}
diff --git a/web/i18n/es-ES/workflow.json b/web/i18n/es-ES/workflow.json
index d4e1b09ed1..6eef1da198 100644
--- a/web/i18n/es-ES/workflow.json
+++ b/web/i18n/es-ES/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "Extractor de documentos",
"blocks.end": "Salida",
"blocks.http-request": "Solicitud HTTP",
+ "blocks.human-input": "Entrada Humana",
"blocks.if-else": "SI/SINO",
"blocks.iteration": "Iteración",
"blocks.iteration-start": "Inicio de iteración",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "Se utiliza para analizar documentos cargados en contenido de texto que es fácilmente comprensible por LLM.",
"blocksAbout.end": "Define la salida y el tipo de resultado de un flujo de trabajo",
"blocksAbout.http-request": "Permite enviar solicitudes al servidor a través del protocolo HTTP",
+ "blocksAbout.human-input": "Solicitar confirmación humana antes de generar el siguiente paso",
"blocksAbout.if-else": "Te permite dividir el flujo de trabajo en dos ramas basadas en condiciones SI/SINO",
"blocksAbout.iteration": "Realiza múltiples pasos en un objeto de lista hasta que se generen todos los resultados.",
"blocksAbout.iteration-start": "Nodo de inicio de iteración",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "Las funciones de carga de imágenes se han actualizado a la carga de archivos.",
"common.goBackToEdit": "Volver al editor",
"common.handMode": "Modo mano",
+ "common.humanInputEmailTip": "Correo electrónico (Método de Entrega) enviado a los destinatarios configurados",
+ "common.humanInputEmailTipInDebugMode": "Correo electrónico (Método de Entrega) enviado a {{email}} ",
+ "common.humanInputWebappTip": "Solo vista previa de depuración, el usuario no verá esto en la aplicación web.",
"common.importDSL": "Importar DSL",
"common.importDSLTip": "El borrador actual se sobrescribirá. Exporta el flujo de trabajo como respaldo antes de importar.",
"common.importFailure": "Error al importar",
@@ -500,6 +505,104 @@
"nodes.http.value": "Valor",
"nodes.http.verifySSL.title": "Verificar el certificado SSL",
"nodes.http.verifySSL.warningTooltip": "Deshabilitar la verificación SSL no se recomienda para entornos de producción. Esto solo debe utilizarse en desarrollo o pruebas, ya que hace que la conexión sea vulnerable a amenazas de seguridad como ataques de intermediario.",
+ "nodes.humanInput.deliveryMethod.added": "Agregado",
+ "nodes.humanInput.deliveryMethod.contactTip1": "¿Falta un método de entrega que necesita?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "Cuéntenoslo en support@dify.ai .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "Todos los miembros ({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "Cuerpo",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "Ingrese el cuerpo del correo electrónico",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "Modo de Depuración",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "En modo de depuración, el correo electrónico solo se enviará a su correo electrónico de cuenta {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "El entorno de producción no se ve afectado.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "Enviar solicitud de entrada por correo electrónico",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ Agregar",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "Agregado",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "Correo electrónico, separado por comas",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "Agregar miembros del espacio de trabajo o destinatarios externos",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "Seleccionar",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "Destinatario",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "La variable URL de solicitud es la entrada de activación para la entrada humana.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "Asunto",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "Ingrese el asunto del correo electrónico",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "Configuración de Correo Electrónico",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "Se ha enviado un correo electrónico de prueba a {{email}} . Por favor, revise su bandeja de entrada.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "El modo de depuración está activado.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "El correo electrónico se enviará a {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "Correo Electrónico Enviado",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(opcional)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "Enviar Correo Electrónico",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "Enviar correos electrónicos de prueba a los destinatarios configurados",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "Enviar un correo electrónico de prueba a {{email}}",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "Se recomienda activar el Modo de Depuración para probar la entrega de correo electrónico.",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "Remitente de Correo Electrónico de Prueba",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "Variables en el Contenido del Formulario",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "Complete las variables del formulario para emular lo que los destinatarios realmente ven.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "El correo electrónico se ha enviado a los miembros de {{team}} y las siguientes direcciones de correo electrónico:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "El correo electrónico se ha enviado a los miembros de {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "El correo electrónico se ha enviado a las siguientes direcciones de correo electrónico:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "El correo electrónico se enviará a los miembros de {{team}} y las siguientes direcciones de correo electrónico:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "El correo electrónico se enviará a los miembros de {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "El correo electrónico se enviará a las siguientes direcciones de correo electrónico:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "No se ha agregado ningún método de entrega, la operación no se puede activar.",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "No disponible",
+ "nodes.humanInput.deliveryMethod.notConfigured": "No configurado",
+ "nodes.humanInput.deliveryMethod.title": "Método de Entrega",
+ "nodes.humanInput.deliveryMethod.tooltip": "Cómo se entrega el formulario de entrada humana al usuario.",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "Enviar solicitud de entrada a través de Discord",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "Enviar solicitud de entrada por correo electrónico",
+ "nodes.humanInput.deliveryMethod.types.email.title": "Correo Electrónico",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "Enviar solicitud de entrada a través de Slack",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "Enviar solicitud de entrada a través de Teams",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "Mostrar al usuario final en la webapp",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "Webapp",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "Desbloquear entrega de correo electrónico para Entrada Humana",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "Enviar solicitudes de confirmación por correo electrónico antes de que los agentes tomen acción — útil para flujos de trabajo de publicación y aprobación.",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "Descartar",
+ "nodes.humanInput.editor.previewTip": "En modo de vista previa, los botones de acción no son funcionales.",
+ "nodes.humanInput.errorMsg.duplicateActionId": "Se encontró un ID de acción duplicado en las acciones del usuario",
+ "nodes.humanInput.errorMsg.emptyActionId": "El ID de acción no puede estar vacío",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "El título de acción no puede estar vacío",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "Seleccione al menos un método de entrega",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "Active al menos un método de entrega",
+ "nodes.humanInput.errorMsg.noUserActions": "Agregue al menos una acción del usuario",
+ "nodes.humanInput.formContent.hotkeyTip": "Presione para insertar variable, para insertar campo de entrada",
+ "nodes.humanInput.formContent.placeholder": "Escriba el contenido aquí",
+ "nodes.humanInput.formContent.preview": "Vista previa",
+ "nodes.humanInput.formContent.title": "Contenido del Formulario",
+ "nodes.humanInput.formContent.tooltip": "Lo que los usuarios verán después de abrir el formulario. Admite formato Markdown.",
+ "nodes.humanInput.insertInputField.insert": "Insertar",
+ "nodes.humanInput.insertInputField.prePopulateField": "Rellenar Campo Previamente",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": "Agregue o los usuarios verán este contenido inicialmente, o déjelo vacío.",
+ "nodes.humanInput.insertInputField.saveResponseAs": "Guardar Respuesta Como",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "Nombre esta variable para referencia posterior",
+ "nodes.humanInput.insertInputField.staticContent": "Contenido Estático",
+ "nodes.humanInput.insertInputField.title": "Insertar Campo de Entrada",
+ "nodes.humanInput.insertInputField.useConstantInstead": "Usar Constante En Su Lugar",
+ "nodes.humanInput.insertInputField.useVarInstead": "Usar Variable En Su Lugar",
+ "nodes.humanInput.insertInputField.variable": "variable",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "El nombre de la variable solo puede contener letras, números y guiones bajos, y no puede comenzar con un número",
+ "nodes.humanInput.log.backstageInputURL": "URL de entrada entre bastidores:",
+ "nodes.humanInput.log.reason": "Razón:",
+ "nodes.humanInput.log.reasonContent": "Se requiere entrada humana para continuar",
+ "nodes.humanInput.singleRun.back": "Atrás",
+ "nodes.humanInput.singleRun.button": "Generar Formulario",
+ "nodes.humanInput.singleRun.label": "Variables del formulario",
+ "nodes.humanInput.timeout.days": "Días",
+ "nodes.humanInput.timeout.hours": "Horas",
+ "nodes.humanInput.timeout.title": "Tiempo de Espera",
+ "nodes.humanInput.userActions.actionIdFormatTip": "El ID de acción debe comenzar con una letra o guiones bajos, seguido de letras, números o guiones bajos",
+ "nodes.humanInput.userActions.actionIdTooLong": "El ID de acción debe tener {{maxLength}} caracteres o menos",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "Nombre de Acción",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "Texto de visualización del botón",
+ "nodes.humanInput.userActions.buttonTextTooLong": "El texto del botón debe tener {{maxLength}} caracteres o menos",
+ "nodes.humanInput.userActions.chooseStyle": "Elija un estilo de botón",
+ "nodes.humanInput.userActions.emptyTip": "Haga clic en el botón '+' para agregar acciones del usuario",
+ "nodes.humanInput.userActions.title": "Acciones del Usuario",
+ "nodes.humanInput.userActions.tooltip": "Defina botones en los que los usuarios puedan hacer clic para responder a este formulario. Cada botón puede activar diferentes rutas de flujo de trabajo. El ID de acción debe comenzar con una letra o guiones bajos, seguido de letras, números o guiones bajos.",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} ha sido activado",
"nodes.ifElse.addCondition": "Agregar condición",
"nodes.ifElse.addSubVariable": "Sub Variable",
"nodes.ifElse.and": "y",
diff --git a/web/i18n/fa-IR/common.json b/web/i18n/fa-IR/common.json
index 2a288f219e..5574605046 100644
--- a/web/i18n/fa-IR/common.json
+++ b/web/i18n/fa-IR/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "دستور خود را اینجا بنویسید، «{» را وارد کنید تا یک متغیر درج کنید، «/» را وارد کنید تا یک بلوک محتوای دستور درج کنید",
"promptEditor.query.item.desc": "درج الگوی پرسوجوی کاربر",
"promptEditor.query.item.title": "پرسوجو",
+ "promptEditor.requestURL.item.desc": "درج URL درخواست",
+ "promptEditor.requestURL.item.title": "URL درخواست",
"promptEditor.variable.item.desc": "درج متغیرها و ابزارهای خارجی",
"promptEditor.variable.item.title": "متغیرها و ابزارهای خارجی",
"promptEditor.variable.modal.add": "متغیر جدید",
diff --git a/web/i18n/fa-IR/share.json b/web/i18n/fa-IR/share.json
index 137440c5c3..7aa2d0e577 100644
--- a/web/i18n/fa-IR/share.json
+++ b/web/i18n/fa-IR/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "یکبار اجرا کن",
"generation.tabs.saved": "ذخیره شده",
"generation.title": "تکمیل هوش مصنوعی",
+ "humanInput.completed": "به نظر میرسد این درخواست در جای دیگری پردازش شده است.",
+ "humanInput.expirationTimeNowOrFuture": "این اقدام {{relativeTime}} منقضی خواهد شد.",
+ "humanInput.expired": "به نظر میرسد این درخواست منقضی شده است.",
+ "humanInput.expiredTip": "این اقدام منقضی شده است.",
+ "humanInput.formNotFound": "فرم یافت نشد.",
+ "humanInput.rateLimitExceeded": "درخواستهای زیاد، لطفاً بعداً دوباره امتحان کنید.",
+ "humanInput.recorded": "ورودی شما ثبت شد.",
+ "humanInput.sorry": "متأسفیم!",
+ "humanInput.submissionID": "submission_id: {{id}}",
+ "humanInput.thanks": "متشکریم!",
"login.backToHome": "بازگشت به خانه"
}
diff --git a/web/i18n/fa-IR/workflow.json b/web/i18n/fa-IR/workflow.json
index 45558bf76b..8a77189a8b 100644
--- a/web/i18n/fa-IR/workflow.json
+++ b/web/i18n/fa-IR/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "استخراج کننده سند",
"blocks.end": "خروجی",
"blocks.http-request": "درخواست HTTP",
+ "blocks.human-input": "ورودی انسان",
"blocks.if-else": "IF/ELSE",
"blocks.iteration": "تکرار",
"blocks.iteration-start": "شروع تکرار",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "برای تجزیه اسناد آپلود شده به محتوای متنی استفاده می شود که به راحتی توسط LLM قابل درک است.",
"blocksAbout.end": "خروجی و نوع نتیجه یک جریان کار را تعریف کنید",
"blocksAbout.http-request": "اجازه میدهد تا درخواستهای سرور از طریق پروتکل HTTP ارسال شوند",
+ "blocksAbout.human-input": "درخواست تأیید انسان قبل از تولید مرحله بعدی",
"blocksAbout.if-else": "اجازه میدهد تا جریان کار به دو شاخه بر اساس شرایط if/else تقسیم شود",
"blocksAbout.iteration": "اجرای چندین مرحله روی یک شیء لیست تا همه نتایج خروجی داده شوند.",
"blocksAbout.iteration-start": "گره شروع تکرار",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "ویژگی های آپلود تصویر برای آپلود فایل ارتقا یافته است.",
"common.goBackToEdit": "بازگشت به ویرایشگر",
"common.handMode": "حالت دست",
+ "common.humanInputEmailTip": "ایمیل (روش تحویل) به گیرندگان پیکربندی شده شما ارسال شد",
+ "common.humanInputEmailTipInDebugMode": "ایمیل (روش تحویل) به {{email}} ارسال شد",
+ "common.humanInputWebappTip": "فقط پیشنمایش اشکالزدایی، کاربر این را در برنامه وب نخواهد دید.",
"common.importDSL": "وارد کردن DSL",
"common.importDSLTip": "پیشنویس فعلی بر روی هم نوشته خواهد شد. قبل از وارد کردن، جریان کار را به عنوان نسخه پشتیبان صادر کنید.",
"common.importFailure": "خطا در وارد کردن",
@@ -500,6 +505,104 @@
"nodes.http.value": "مقدار",
"nodes.http.verifySSL.title": "گواهی SSL را تأیید کنید",
"nodes.http.verifySSL.warningTooltip": "غیرفعال کردن تأیید SSL برای محیطهای تولید توصیه نمیشود. این فقط باید در توسعه یا آزمایش استفاده شود، زیرا این کار اتصال را در معرض تهدیدات امنیتی مانند حملات میانی قرار میدهد.",
+ "nodes.humanInput.deliveryMethod.added": "اضافه شد",
+ "nodes.humanInput.deliveryMethod.contactTip1": "روش تحویلی که نیاز دارید وجود ندارد؟",
+ "nodes.humanInput.deliveryMethod.contactTip2": "به ما در support@dify.ai اطلاع دهید.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "همه اعضا ({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "محتوا",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "محتوای ایمیل را وارد کنید",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "حالت اشکالزدایی",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "در حالت اشکالزدایی، ایمیل فقط به حساب ایمیل شما {{email}} ارسال میشود.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "محیط تولید تحت تأثیر قرار نمیگیرد.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "ارسال درخواست ورودی از طریق ایمیل",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ اضافه کردن",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "اضافه شد",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "ایمیل، با کاما جدا شده",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "اضافه کردن اعضای فضای کاری یا گیرندگان خارجی",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "انتخاب",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "گیرنده",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "متغیر URL درخواست، نقطه ورودی برای ورودی انسان است.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "موضوع",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "موضوع ایمیل را وارد کنید",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "پیکربندی ایمیل",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "یک ایمیل آزمایشی به {{email}} ارسال شد. لطفاً صندوق ورودی خود را بررسی کنید.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "حالت اشکالزدایی فعال است.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "ایمیل به {{email}} ارسال خواهد شد.",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "ایمیل ارسال شد",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(اختیاری)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "ارسال ایمیل",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "ارسال ایمیلهای آزمایشی به گیرندگان پیکربندی شده",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "ارسال ایمیل آزمایشی به {{email}}",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "توصیه میشود حالت اشکالزدایی را فعال کنید برای آزمایش تحویل ایمیل.",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "ارسالکننده ایمیل آزمایشی",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "متغیرها در محتوای فرم",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "متغیرهای فرم را پر کنید تا شبیهسازی کنید آنچه گیرندگان واقعاً میبینند.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "ایمیل به اعضای {{team}} و آدرسهای ایمیل زیر ارسال شد:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "ایمیل به اعضای {{team}} ارسال شد.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "ایمیل به آدرسهای ایمیل زیر ارسال شد:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "ایمیل به اعضای {{team}} و آدرسهای ایمیل زیر ارسال خواهد شد:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "ایمیل به اعضای {{team}} ارسال خواهد شد.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "ایمیل به آدرسهای ایمیل زیر ارسال خواهد شد:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "هیچ روش تحویلی اضافه نشده، عملیات قابل اجرا نیست.",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "در دسترس نیست",
+ "nodes.humanInput.deliveryMethod.notConfigured": "پیکربندی نشده",
+ "nodes.humanInput.deliveryMethod.title": "روش تحویل",
+ "nodes.humanInput.deliveryMethod.tooltip": "نحوه تحویل فرم ورودی انسان به کاربر.",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "ارسال درخواست ورودی از طریق Discord",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "ارسال درخواست ورودی از طریق ایمیل",
+ "nodes.humanInput.deliveryMethod.types.email.title": "ایمیل",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "ارسال درخواست ورودی از طریق Slack",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "ارسال درخواست ورودی از طریق Teams",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "نمایش به کاربر نهایی در وباپلیکیشن",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "وباپلیکیشن",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "باز کردن قفل تحویل ایمیل برای ورودی انسان",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "ارسال درخواستهای تأیید از طریق ایمیل قبل از اقدام عوامل — مفید برای گردشکارهای انتشار و تأیید.",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "رد کردن",
+ "nodes.humanInput.editor.previewTip": "در حالت پیشنمایش، دکمههای اقدام کاربردی ندارند.",
+ "nodes.humanInput.errorMsg.duplicateActionId": "شناسه اقدام تکراری در اقدامات کاربر یافت شد",
+ "nodes.humanInput.errorMsg.emptyActionId": "شناسه اقدام نمیتواند خالی باشد",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "عنوان اقدام نمیتواند خالی باشد",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "لطفاً حداقل یک روش تحویل انتخاب کنید",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "لطفاً حداقل یک روش تحویل را فعال کنید",
+ "nodes.humanInput.errorMsg.noUserActions": "لطفاً حداقل یک اقدام کاربر اضافه کنید",
+ "nodes.humanInput.formContent.hotkeyTip": " را برای درج متغیر، را برای درج فیلد ورودی فشار دهید",
+ "nodes.humanInput.formContent.placeholder": "محتوا را اینجا تایپ کنید",
+ "nodes.humanInput.formContent.preview": "پیشنمایش",
+ "nodes.humanInput.formContent.title": "محتوای فرم",
+ "nodes.humanInput.formContent.tooltip": "آنچه کاربران پس از باز کردن فرم خواهند دید. از قالببندی Markdown پشتیبانی میکند.",
+ "nodes.humanInput.insertInputField.insert": "درج",
+ "nodes.humanInput.insertInputField.prePopulateField": "پیشپر کردن فیلد",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": " یا اضافه کنید. کاربران در ابتدا این محتوا را خواهند دید، یا خالی بگذارید.",
+ "nodes.humanInput.insertInputField.saveResponseAs": "ذخیره پاسخ به عنوان",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "این متغیر را برای ارجاع بعدی نامگذاری کنید",
+ "nodes.humanInput.insertInputField.staticContent": "محتوای ثابت",
+ "nodes.humanInput.insertInputField.title": "درج فیلد ورودی",
+ "nodes.humanInput.insertInputField.useConstantInstead": "به جای آن از ثابت استفاده کنید",
+ "nodes.humanInput.insertInputField.useVarInstead": "به جای آن از متغیر استفاده کنید",
+ "nodes.humanInput.insertInputField.variable": "متغیر",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "نام متغیر فقط میتواند شامل حروف، اعداد و زیرخط باشد و نمیتواند با عدد شروع شود",
+ "nodes.humanInput.log.backstageInputURL": "URL ورودی پشت صحنه:",
+ "nodes.humanInput.log.reason": "دلیل:",
+ "nodes.humanInput.log.reasonContent": "ورودی انسان برای ادامه لازم است",
+ "nodes.humanInput.singleRun.back": "بازگشت",
+ "nodes.humanInput.singleRun.button": "تولید فرم",
+ "nodes.humanInput.singleRun.label": "متغیرهای فرم",
+ "nodes.humanInput.timeout.days": "روز",
+ "nodes.humanInput.timeout.hours": "ساعت",
+ "nodes.humanInput.timeout.title": "تایماوت",
+ "nodes.humanInput.userActions.actionIdFormatTip": "شناسه اقدام باید با حرف یا زیرخط شروع شود و به دنبال آن حروف، اعداد یا زیرخط بیاید",
+ "nodes.humanInput.userActions.actionIdTooLong": "شناسه اقدام باید {{maxLength}} کاراکتر یا کمتر باشد",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "نام اقدام",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "متن نمایش دکمه",
+ "nodes.humanInput.userActions.buttonTextTooLong": "متن دکمه باید {{maxLength}} کاراکتر یا کمتر باشد",
+ "nodes.humanInput.userActions.chooseStyle": "یک سبک دکمه انتخاب کنید",
+ "nodes.humanInput.userActions.emptyTip": "روی دکمه '+' کلیک کنید تا اقدامات کاربر اضافه شود",
+ "nodes.humanInput.userActions.title": "اقدامات کاربر",
+ "nodes.humanInput.userActions.tooltip": "دکمههایی را تعریف کنید که کاربران میتوانند برای پاسخ به این فرم کلیک کنند. هر دکمه میتواند مسیرهای گردش کار مختلفی را فعال کند. شناسه اقدام باید با حرف یا زیرخط شروع شود و به دنبال آن حروف، اعداد یا زیرخط بیاید.",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} فعال شد",
"nodes.ifElse.addCondition": "افزودن شرط",
"nodes.ifElse.addSubVariable": "متغیر فرعی",
"nodes.ifElse.and": "و",
diff --git a/web/i18n/fr-FR/common.json b/web/i18n/fr-FR/common.json
index 97cc1ffb5c..db3bca4f34 100644
--- a/web/i18n/fr-FR/common.json
+++ b/web/i18n/fr-FR/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "Écrivez votre mot d'invite ici, entrez '{' pour insérer une variable, entrez '/' pour insérer un bloc de contenu d'invite",
"promptEditor.query.item.desc": "Insérez le modèle de requête utilisateur",
"promptEditor.query.item.title": "Requête",
+ "promptEditor.requestURL.item.desc": "Insérer l'URL de la requête",
+ "promptEditor.requestURL.item.title": "URL de la Requête",
"promptEditor.variable.item.desc": "Insérer des Variables & Outils Externes",
"promptEditor.variable.item.title": "Variables & Outils Externes",
"promptEditor.variable.modal.add": "Nouvelle variable",
diff --git a/web/i18n/fr-FR/share.json b/web/i18n/fr-FR/share.json
index f4de0a15e0..5127985731 100644
--- a/web/i18n/fr-FR/share.json
+++ b/web/i18n/fr-FR/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "Exécuter une fois",
"generation.tabs.saved": "Enregistré",
"generation.title": "Complétion IA",
+ "humanInput.completed": "Il semble que cette demande ait été traitée ailleurs.",
+ "humanInput.expirationTimeNowOrFuture": "Cette action expirera {{relativeTime}}.",
+ "humanInput.expired": "Il semble que cette demande ait expiré.",
+ "humanInput.expiredTip": "Cette action a expiré.",
+ "humanInput.formNotFound": "Formulaire introuvable.",
+ "humanInput.rateLimitExceeded": "Trop de demandes, veuillez réessayer plus tard.",
+ "humanInput.recorded": "Votre saisie a été enregistrée.",
+ "humanInput.sorry": "Désolé !",
+ "humanInput.submissionID": "submission_id : {{id}}",
+ "humanInput.thanks": "Merci !",
"login.backToHome": "Retour à l'accueil"
}
diff --git a/web/i18n/fr-FR/workflow.json b/web/i18n/fr-FR/workflow.json
index 95781ce262..b5f13ca3b1 100644
--- a/web/i18n/fr-FR/workflow.json
+++ b/web/i18n/fr-FR/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "Extracteur de documents",
"blocks.end": "Sortie",
"blocks.http-request": "Requête HTTP",
+ "blocks.human-input": "Saisie Humaine",
"blocks.if-else": "SI/SINON",
"blocks.iteration": "Itération",
"blocks.iteration-start": "Début d'itération",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "Utilisé pour analyser les documents téléchargés en contenu texte facilement compréhensible par LLM.",
"blocksAbout.end": "Définir la sortie et le type de résultat d'un flux de travail",
"blocksAbout.http-request": "Permettre l'envoi de requêtes serveur via le protocole HTTP",
+ "blocksAbout.human-input": "Demander une confirmation humaine avant de générer l'étape suivante",
"blocksAbout.if-else": "Permet de diviser le flux de travail en deux branches basées sur des conditions if/else",
"blocksAbout.iteration": "Effectuer plusieurs étapes sur un objet de liste jusqu'à ce que tous les résultats soient produits.",
"blocksAbout.iteration-start": "Nœud de début d'itération",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "Les fonctionnalités de téléchargement d’images ont été mises à niveau vers le téléchargement de fichiers.",
"common.goBackToEdit": "Retour à l'éditeur",
"common.handMode": "Mode main",
+ "common.humanInputEmailTip": "E-mail (Méthode de Livraison) envoyé à vos destinataires configurés",
+ "common.humanInputEmailTipInDebugMode": "E-mail (Méthode de Livraison) envoyé à {{email}} ",
+ "common.humanInputWebappTip": "Aperçu de débogage uniquement, l'utilisateur ne verra pas cela dans l'application web.",
"common.importDSL": "Importe DSL",
"common.importDSLTip": "Le projet actuel sera écrasé. Exporter le flux de travail en tant que sauvegarde avant d'importer.",
"common.importFailure": "Echec de l'importation",
@@ -500,6 +505,104 @@
"nodes.http.value": "Valeur",
"nodes.http.verifySSL.title": "Vérifier le certificat SSL",
"nodes.http.verifySSL.warningTooltip": "Désactiver la vérification SSL n'est pas recommandé pour les environnements de production. Cela ne devrait être utilisé que dans le développement ou les tests, car cela rend la connexion vulnérable aux menaces de sécurité telles que les attaques de type 'man-in-the-middle'.",
+ "nodes.humanInput.deliveryMethod.added": "Ajouté",
+ "nodes.humanInput.deliveryMethod.contactTip1": "Vous manque-t-il une méthode de livraison dont vous avez besoin ?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "Dites-le nous à support@dify.ai .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "Tous les membres ({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "Corps",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "Entrez le corps de l'e-mail",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "Mode Débogage",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "En mode débogage, l'e-mail ne sera envoyé qu'à votre adresse e-mail de compte {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "L'environnement de production n'est pas affecté.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "Envoyer une demande de saisie par e-mail",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ Ajouter",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "Ajouté",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "E-mail, séparé par des virgules",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "Ajouter des membres de l'espace de travail ou des destinataires externes",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "Sélectionner",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "Destinataire",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "La variable URL de demande est le point d'entrée du déclencheur pour la saisie humaine.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "Objet",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "Entrez l'objet de l'e-mail",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "Configuration de l'E-mail",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "Un e-mail de test a été envoyé à {{email}} . Veuillez vérifier votre boîte de réception.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "Le mode débogage est activé.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "L'e-mail sera envoyé à {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "E-mail Envoyé",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(facultatif)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "Envoyer l'E-mail",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "Envoyer des e-mails de test à vos destinataires configurés",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "Envoyer un e-mail de test à {{email}}",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "Il est recommandé d'activer le Mode Débogage pour tester la livraison d'e-mails.",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "Expéditeur d'E-mail de Test",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "Variables dans le Contenu du Formulaire",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "Remplissez les variables du formulaire pour émuler ce que les destinataires voient réellement.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "L'e-mail a été envoyé aux membres de {{team}} et aux adresses e-mail suivantes :",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "L'e-mail a été envoyé aux membres de {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "L'e-mail a été envoyé aux adresses e-mail suivantes :",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "L'e-mail sera envoyé aux membres de {{team}} et aux adresses e-mail suivantes :",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "L'e-mail sera envoyé aux membres de {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "L'e-mail sera envoyé aux adresses e-mail suivantes :",
+ "nodes.humanInput.deliveryMethod.emptyTip": "Aucune méthode de livraison ajoutée, l'opération ne peut pas être déclenchée.",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "Non disponible",
+ "nodes.humanInput.deliveryMethod.notConfigured": "Non configuré",
+ "nodes.humanInput.deliveryMethod.title": "Méthode de Livraison",
+ "nodes.humanInput.deliveryMethod.tooltip": "Comment le formulaire de saisie humaine est livré à l'utilisateur.",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "Envoyer une demande de saisie via Discord",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "Envoyer une demande de saisie par e-mail",
+ "nodes.humanInput.deliveryMethod.types.email.title": "E-mail",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "Envoyer une demande de saisie via Slack",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "Envoyer une demande de saisie via Teams",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "Afficher à l'utilisateur final dans l'application web",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "Application Web",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "Débloquer la livraison par e-mail pour la Saisie Humaine",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "Envoyer des demandes de confirmation par e-mail avant que les agents n'agissent — utile pour les flux de travail de publication et d'approbation.",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "Ignorer",
+ "nodes.humanInput.editor.previewTip": "En mode aperçu, les boutons d'action ne sont pas fonctionnels.",
+ "nodes.humanInput.errorMsg.duplicateActionId": "ID d'action en double trouvé dans les actions utilisateur",
+ "nodes.humanInput.errorMsg.emptyActionId": "L'ID d'action ne peut pas être vide",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "Le titre de l'action ne peut pas être vide",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "Veuillez sélectionner au moins une méthode de livraison",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "Veuillez activer au moins une méthode de livraison",
+ "nodes.humanInput.errorMsg.noUserActions": "Veuillez ajouter au moins une action utilisateur",
+ "nodes.humanInput.formContent.hotkeyTip": "Appuyez sur pour insérer une variable, pour insérer un champ de saisie",
+ "nodes.humanInput.formContent.placeholder": "Tapez le contenu ici",
+ "nodes.humanInput.formContent.preview": "Aperçu",
+ "nodes.humanInput.formContent.title": "Contenu du Formulaire",
+ "nodes.humanInput.formContent.tooltip": "Ce que les utilisateurs verront après avoir ouvert le formulaire. Prend en charge le formatage Markdown.",
+ "nodes.humanInput.insertInputField.insert": "Insérer",
+ "nodes.humanInput.insertInputField.prePopulateField": "Pré-remplir le Champ",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": "Ajoutez ou les utilisateurs verront ce contenu initialement, ou laissez vide.",
+ "nodes.humanInput.insertInputField.saveResponseAs": "Enregistrer la Réponse Sous",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "Nommez cette variable pour une référence ultérieure",
+ "nodes.humanInput.insertInputField.staticContent": "Contenu Statique",
+ "nodes.humanInput.insertInputField.title": "Insérer un Champ de Saisie",
+ "nodes.humanInput.insertInputField.useConstantInstead": "Utiliser une Constante à la Place",
+ "nodes.humanInput.insertInputField.useVarInstead": "Utiliser une Variable à la Place",
+ "nodes.humanInput.insertInputField.variable": "variable",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "Le nom de la variable ne peut contenir que des lettres, des chiffres et des traits de soulignement, et ne peut pas commencer par un chiffre",
+ "nodes.humanInput.log.backstageInputURL": "URL de saisie en coulisses :",
+ "nodes.humanInput.log.reason": "Raison :",
+ "nodes.humanInput.log.reasonContent": "Saisie humaine requise pour continuer",
+ "nodes.humanInput.singleRun.back": "Retour",
+ "nodes.humanInput.singleRun.button": "Générer le Formulaire",
+ "nodes.humanInput.singleRun.label": "Variables du formulaire",
+ "nodes.humanInput.timeout.days": "Jours",
+ "nodes.humanInput.timeout.hours": "Heures",
+ "nodes.humanInput.timeout.title": "Délai d'expiration",
+ "nodes.humanInput.userActions.actionIdFormatTip": "L'ID d'action doit commencer par une lettre ou des traits de soulignement, suivi de lettres, de chiffres ou de traits de soulignement",
+ "nodes.humanInput.userActions.actionIdTooLong": "L'ID d'action doit comporter {{maxLength}} caractères ou moins",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "Nom de l'Action",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "Texte d'affichage du bouton",
+ "nodes.humanInput.userActions.buttonTextTooLong": "Le texte du bouton doit comporter {{maxLength}} caractères ou moins",
+ "nodes.humanInput.userActions.chooseStyle": "Choisissez un style de bouton",
+ "nodes.humanInput.userActions.emptyTip": "Cliquez sur le bouton '+' pour ajouter des actions utilisateur",
+ "nodes.humanInput.userActions.title": "Actions Utilisateur",
+ "nodes.humanInput.userActions.tooltip": "Définissez les boutons sur lesquels les utilisateurs peuvent cliquer pour répondre à ce formulaire. Chaque bouton peut déclencher différents chemins de flux de travail. L'ID d'action doit commencer par une lettre ou des traits de soulignement, suivi de lettres, de chiffres ou de traits de soulignement.",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} a été déclenché",
"nodes.ifElse.addCondition": "Ajouter une condition",
"nodes.ifElse.addSubVariable": "Sous-variable",
"nodes.ifElse.and": "et",
diff --git a/web/i18n/hi-IN/common.json b/web/i18n/hi-IN/common.json
index cfae4abec7..527fe696de 100644
--- a/web/i18n/hi-IN/common.json
+++ b/web/i18n/hi-IN/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "अपना प्रॉम्प्ट शब्द यहां लिखें, वेरिएबल डालने के लिए '{' दर्ज करें, प्रॉम्प्ट सामग्री ब्लॉक डालने के लिए '/' दर्ज करें",
"promptEditor.query.item.desc": "उपयोगकर्ता क्वेरी टेम्पलेट डालें",
"promptEditor.query.item.title": "क्वेरी",
+ "promptEditor.requestURL.item.desc": "अनुरोध URL डालें",
+ "promptEditor.requestURL.item.title": "अनुरोध URL",
"promptEditor.variable.item.desc": "वेरिएबल और बाहरी उपकरण डालें",
"promptEditor.variable.item.title": "वेरिएबल और बाहरी उपकरण",
"promptEditor.variable.modal.add": "नया वेरिएबल",
diff --git a/web/i18n/hi-IN/share.json b/web/i18n/hi-IN/share.json
index fb9679c6ad..61a0158299 100644
--- a/web/i18n/hi-IN/share.json
+++ b/web/i18n/hi-IN/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "एक बार चलाएं",
"generation.tabs.saved": "सहेजा गया",
"generation.title": "एआई पूर्णता",
+ "humanInput.completed": "ऐसा लगता है कि इस अनुरोध को कहीं और संभाला गया था।",
+ "humanInput.expirationTimeNowOrFuture": "यह कार्रवाई {{relativeTime}} समाप्त हो जाएगी।",
+ "humanInput.expired": "ऐसा लगता है कि इस अनुरोध की समय सीमा समाप्त हो गई है।",
+ "humanInput.expiredTip": "यह कार्रवाई समाप्त हो गई है।",
+ "humanInput.formNotFound": "फॉर्म नहीं मिला।",
+ "humanInput.rateLimitExceeded": "बहुत सारे अनुरोध, कृपया बाद में फिर से प्रयास करें।",
+ "humanInput.recorded": "आपका इनपुट रिकॉर्ड कर लिया गया है।",
+ "humanInput.sorry": "क्षमा करें!",
+ "humanInput.submissionID": "submission_id: {{id}}",
+ "humanInput.thanks": "धन्यवाद!",
"login.backToHome": "होम पर वापस"
}
diff --git a/web/i18n/hi-IN/workflow.json b/web/i18n/hi-IN/workflow.json
index 23b9d3cad2..944b6506bc 100644
--- a/web/i18n/hi-IN/workflow.json
+++ b/web/i18n/hi-IN/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "डॉक्टर एक्सट्रैक्टर",
"blocks.end": "आउटपुट",
"blocks.http-request": "एचटीटीपी अनुरोध",
+ "blocks.human-input": "मानव इनपुट",
"blocks.if-else": "यदि/अन्यथा",
"blocks.iteration": "पुनरावृत्ति",
"blocks.iteration-start": "पुनरावृत्ति प्रारंभ",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "अपलोड किए गए दस्तावेज़ों को पाठ सामग्री में पार्स करने के लिए उपयोग किया जाता है जो एलएलएम द्वारा आसानी से समझा जा सकता है।",
"blocksAbout.end": "वर्कफ़्लो का आउटपुट और परिणाम प्रकार परिभाषित करें",
"blocksAbout.http-request": "HTTP प्रोटोकॉल पर सर्वर अनुरोधों को भेजने की अनुमति दें",
+ "blocksAbout.human-input": "अगला कदम उत्पन्न करने से पहले मानव से पुष्टि मांगें",
"blocksAbout.if-else": "if/else शर्तों के आधार पर वर्कफ़्लो को दो शाखाओं में विभाजित करने की अनुमति देता है",
"blocksAbout.iteration": "एक सूची वस्तु पर तब तक कई कदम करें जब तक सभी परिणाम आउटपुट न हो जाएं।",
"blocksAbout.iteration-start": "पुनरावृत्ति प्रारंभ नोड",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "छवि अपलोड सुविधाओं को फ़ाइल अपलोड में अपग्रेड किया गया है।",
"common.goBackToEdit": "संपादक पर वापस जाएं",
"common.handMode": "हैंड मोड",
+ "common.humanInputEmailTip": "ईमेल (वितरण विधि) आपके कॉन्फ़िगर किए गए प्राप्तकर्ताओं को भेजा गया",
+ "common.humanInputEmailTipInDebugMode": "ईमेल (वितरण विधि) {{email}} को भेजा गया",
+ "common.humanInputWebappTip": "केवल डीबग पूर्वावलोकन, उपयोगकर्ता इसे वेब ऐप में नहीं देखेगा।",
"common.importDSL": "DSL आयात करें",
"common.importDSLTip": "वर्तमान ड्राफ्ट ओवरराइट हो जाएगा। आयात करने से पहले वर्कफ़्लो को बैकअप के रूप में निर्यात करें.",
"common.importFailure": "आयात विफलता",
@@ -500,6 +505,104 @@
"nodes.http.value": "मान",
"nodes.http.verifySSL.title": "SSL प्रमाणपत्र की पुष्टि करें",
"nodes.http.verifySSL.warningTooltip": "SSL सत्यापन को अक्षम करना उत्पादन वातावरण के लिए अनुशंसित नहीं है। इसका उपयोग केवल विकास या परीक्षण में किया जाना चाहिए, क्योंकि यह कनेक्शन को मिडल-मैन हमलों जैसे सुरक्षा खतरों के लिए कमजोर बना देता है।",
+ "nodes.humanInput.deliveryMethod.added": "जोड़ा गया",
+ "nodes.humanInput.deliveryMethod.contactTip1": "आपको जिस डिलीवरी विधि की आवश्यकता है वह गायब है?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "हमें support@dify.ai पर बताएं।",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "सभी सदस्य ({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "सामग्री",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "ईमेल सामग्री दर्ज करें",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "डीबग मोड",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "डीबग मोड में, ईमेल केवल आपके खाते के ईमेल {{email}} पर भेजा जाएगा।",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "उत्पादन वातावरण प्रभावित नहीं होता।",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "ईमेल के माध्यम से इनपुट के लिए अनुरोध भेजें",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ जोड़ें",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "जोड़ा गया",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "ईमेल, अल्पविराम से अलग",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "कार्यक्षेत्र सदस्यों या बाहरी प्राप्तकर्ताओं को जोड़ें",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "चुनें",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "प्राप्तकर्ता",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "अनुरोध URL वेरिएबल मानव इनपुट के लिए ट्रिगर एंट्री है।",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "विषय",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "ईमेल विषय दर्ज करें",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "ईमेल कॉन्फ़िगरेशन",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "{{email}} पर एक परीक्षण ईमेल भेजा गया है। कृपया अपना इनबॉक्स जांचें।",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "डीबग मोड सक्षम है।",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "ईमेल {{email}} पर भेजा जाएगा।",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "ईमेल भेजा गया",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(वैकल्पिक)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "ईमेल भेजें",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "अपने कॉन्फ़िगर किए गए प्राप्तकर्ताओं को परीक्षण ईमेल भेजें",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "{{email}} पर परीक्षण ईमेल भेजें",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "ईमेल डिलीवरी का परीक्षण करने के लिए डीबग मोड सक्षम करना अनुशंसित है।",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "परीक्षण ईमेल भेजने वाला",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "फॉर्म सामग्री में वेरिएबल",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "प्राप्तकर्ता वास्तव में क्या देखते हैं, इसका अनुकरण करने के लिए फॉर्म वेरिएबल भरें।",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "ईमेल {{team}} सदस्यों और निम्नलिखित ईमेल पतों पर भेजा गया है:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "ईमेल {{team}} सदस्यों को भेजा गया है।",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "ईमेल निम्नलिखित ईमेल पतों पर भेजा गया है:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "ईमेल {{team}} सदस्यों और निम्नलिखित ईमेल पतों पर भेजा जाएगा:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "ईमेल {{team}} सदस्यों को भेजा जाएगा।",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "ईमेल निम्नलिखित ईमेल पतों पर भेजा जाएगा:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "कोई डिलीवरी विधि नहीं जोड़ी गई, ऑपरेशन ट्रिगर नहीं किया जा सकता।",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "उपलब्ध नहीं",
+ "nodes.humanInput.deliveryMethod.notConfigured": "कॉन्फ़िगर नहीं किया गया",
+ "nodes.humanInput.deliveryMethod.title": "डिलीवरी विधि",
+ "nodes.humanInput.deliveryMethod.tooltip": "मानव इनपुट फॉर्म उपयोगकर्ता को कैसे दिया जाता है।",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "Discord के माध्यम से इनपुट के लिए अनुरोध भेजें",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "ईमेल के माध्यम से इनपुट के लिए अनुरोध भेजें",
+ "nodes.humanInput.deliveryMethod.types.email.title": "ईमेल",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "Slack के माध्यम से इनपुट के लिए अनुरोध भेजें",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "Teams के माध्यम से इनपुट के लिए अनुरोध भेजें",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "वेबऐप में अंतिम उपयोगकर्ता को प्रदर्शित करें",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "वेबऐप",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "मानव इनपुट के लिए ईमेल डिलीवरी अनलॉक करें",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "एजेंट कार्रवाई करने से पहले ईमेल के माध्यम से पुष्टि अनुरोध भेजें — प्रकाशन और अनुमोदन वर्कफ़्लो के लिए उपयोगी।",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "खारिज करें",
+ "nodes.humanInput.editor.previewTip": "पूर्वावलोकन मोड में, कार्रवाई बटन कार्यात्मक नहीं हैं।",
+ "nodes.humanInput.errorMsg.duplicateActionId": "उपयोगकर्ता कार्रवाइयों में डुप्लिकेट कार्रवाई आईडी पाई गई",
+ "nodes.humanInput.errorMsg.emptyActionId": "कार्रवाई आईडी खाली नहीं हो सकती",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "कार्रवाई शीर्षक खाली नहीं हो सकता",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "कृपया कम से कम एक डिलीवरी विधि चुनें",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "कृपया कम से कम एक डिलीवरी विधि सक्षम करें",
+ "nodes.humanInput.errorMsg.noUserActions": "कृपया कम से कम एक उपयोगकर्ता कार्रवाई जोड़ें",
+ "nodes.humanInput.formContent.hotkeyTip": "वेरिएबल सम्मिलित करने के लिए दबाएं, इनपुट फ़ील्ड सम्मिलित करने के लिए दबाएं",
+ "nodes.humanInput.formContent.placeholder": "यहां सामग्री टाइप करें",
+ "nodes.humanInput.formContent.preview": "पूर्वावलोकन",
+ "nodes.humanInput.formContent.title": "फॉर्म सामग्री",
+ "nodes.humanInput.formContent.tooltip": "फॉर्म खोलने के बाद उपयोगकर्ता क्या देखेंगे। Markdown फ़ॉर्मेटिंग का समर्थन करता है।",
+ "nodes.humanInput.insertInputField.insert": "सम्मिलित करें",
+ "nodes.humanInput.insertInputField.prePopulateField": "फ़ील्ड पूर्व-भरें",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": " या जोड़ें। उपयोगकर्ता शुरू में यह सामग्री देखेंगे, या खाली छोड़ दें।",
+ "nodes.humanInput.insertInputField.saveResponseAs": "प्रतिक्रिया इस रूप में सहेजें",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "बाद में संदर्भ के लिए इस वेरिएबल का नाम दें",
+ "nodes.humanInput.insertInputField.staticContent": "स्थिर सामग्री",
+ "nodes.humanInput.insertInputField.title": "इनपुट फ़ील्ड सम्मिलित करें",
+ "nodes.humanInput.insertInputField.useConstantInstead": "इसके बजाय स्थिरांक का उपयोग करें",
+ "nodes.humanInput.insertInputField.useVarInstead": "इसके बजाय वेरिएबल का उपयोग करें",
+ "nodes.humanInput.insertInputField.variable": "वेरिएबल",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "वेरिएबल नाम में केवल अक्षर, संख्याएं और अंडरस्कोर हो सकते हैं, और यह संख्या से शुरू नहीं हो सकता",
+ "nodes.humanInput.log.backstageInputURL": "बैकस्टेज इनपुट URL:",
+ "nodes.humanInput.log.reason": "कारण:",
+ "nodes.humanInput.log.reasonContent": "जारी रखने के लिए मानव इनपुट आवश्यक है",
+ "nodes.humanInput.singleRun.back": "वापस",
+ "nodes.humanInput.singleRun.button": "फॉर्म जेनरेट करें",
+ "nodes.humanInput.singleRun.label": "फॉर्म वेरिएबल",
+ "nodes.humanInput.timeout.days": "दिन",
+ "nodes.humanInput.timeout.hours": "घंटे",
+ "nodes.humanInput.timeout.title": "टाइमआउट",
+ "nodes.humanInput.userActions.actionIdFormatTip": "कार्रवाई आईडी एक अक्षर या अंडरस्कोर से शुरू होनी चाहिए, उसके बाद अक्षर, संख्याएं या अंडरस्कोर हो सकते हैं",
+ "nodes.humanInput.userActions.actionIdTooLong": "कार्रवाई आईडी {{maxLength}} वर्ण या उससे कम होनी चाहिए",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "कार्रवाई नाम",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "बटन प्रदर्शित करने के लिए पाठ",
+ "nodes.humanInput.userActions.buttonTextTooLong": "बटन पाठ {{maxLength}} वर्ण या उससे कम होना चाहिए",
+ "nodes.humanInput.userActions.chooseStyle": "बटन शैली चुनें",
+ "nodes.humanInput.userActions.emptyTip": "उपयोगकर्ता कार्रवाइयां जोड़ने के लिए '+' बटन पर क्लिक करें",
+ "nodes.humanInput.userActions.title": "उपयोगकर्ता कार्रवाइयां",
+ "nodes.humanInput.userActions.tooltip": "उन बटन को परिभाषित करें जिन पर उपयोगकर्ता इस फॉर्म का जवाब देने के लिए क्लिक कर सकते हैं। प्रत्येक बटन विभिन्न वर्कफ़्लो पथों को ट्रिगर कर सकता है। कार्रवाई आईडी एक अक्षर या अंडरस्कोर से शुरू होनी चाहिए, उसके बाद अक्षर, संख्याएं या अंडरस्कोर हो सकते हैं।",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} ट्रिगर किया गया है",
"nodes.ifElse.addCondition": "शर्त जोड़ें",
"nodes.ifElse.addSubVariable": "उप चर",
"nodes.ifElse.and": "और",
diff --git a/web/i18n/id-ID/common.json b/web/i18n/id-ID/common.json
index 7135a67974..61a8b9c0dd 100644
--- a/web/i18n/id-ID/common.json
+++ b/web/i18n/id-ID/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "Tulis kata prompt Anda di sini, masukkan '{' untuk menyisipkan variabel, masukkan '/' untuk menyisipkan blok konten prompt",
"promptEditor.query.item.desc": "Menyisipkan templat kueri pengguna",
"promptEditor.query.item.title": "Kueri",
+ "promptEditor.requestURL.item.desc": "Sisipkan URL permintaan",
+ "promptEditor.requestURL.item.title": "URL Permintaan",
"promptEditor.variable.item.desc": "Sisipkan Variabel & Alat Eksternal",
"promptEditor.variable.item.title": "Variabel & Alat Eksternal",
"promptEditor.variable.modal.add": "Variabel baru",
diff --git a/web/i18n/id-ID/share.json b/web/i18n/id-ID/share.json
index f9f9b5aaa0..d494489d54 100644
--- a/web/i18n/id-ID/share.json
+++ b/web/i18n/id-ID/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "Jalankan Sekali",
"generation.tabs.saved": "Disimpan",
"generation.title": "Penyelesaian AI",
+ "humanInput.completed": "Sepertinya permintaan ini telah ditangani di tempat lain.",
+ "humanInput.expirationTimeNowOrFuture": "Tindakan ini akan kedaluwarsa {{relativeTime}}.",
+ "humanInput.expired": "Sepertinya permintaan ini telah kedaluwarsa.",
+ "humanInput.expiredTip": "Tindakan ini telah kedaluwarsa.",
+ "humanInput.formNotFound": "Formulir tidak ditemukan.",
+ "humanInput.rateLimitExceeded": "Terlalu banyak permintaan, silakan coba lagi nanti.",
+ "humanInput.recorded": "Input Anda telah dicatat.",
+ "humanInput.sorry": "Maaf!",
+ "humanInput.submissionID": "submission_id: {{id}}",
+ "humanInput.thanks": "Terima kasih!",
"login.backToHome": "Kembali ke Beranda"
}
diff --git a/web/i18n/id-ID/workflow.json b/web/i18n/id-ID/workflow.json
index c16f5346ac..8bd900e163 100644
--- a/web/i18n/id-ID/workflow.json
+++ b/web/i18n/id-ID/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "Ekstraktor Dokumen",
"blocks.end": "Keluaran",
"blocks.http-request": "Permintaan HTTP",
+ "blocks.human-input": "Input Manusia",
"blocks.if-else": "JIKA/LAIN",
"blocks.iteration": "Iterasi",
"blocks.iteration-start": "Iterasi Mulai",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "Digunakan untuk mengurai dokumen yang diunggah menjadi konten teks yang mudah dipahami oleh LLM.",
"blocksAbout.end": "Menentukan output dan jenis hasil alur kerja",
"blocksAbout.http-request": "Izinkan permintaan server dikirim melalui protokol HTTP",
+ "blocksAbout.human-input": "Minta konfirmasi manusia sebelum menghasilkan langkah berikutnya",
"blocksAbout.if-else": "Memungkinkan Anda membagi alur kerja menjadi dua cabang berdasarkan kondisi if/else",
"blocksAbout.iteration": "Lakukan beberapa langkah pada objek daftar hingga semua hasil dikeluarkan.",
"blocksAbout.iteration-start": "Node Mulai Iterasi",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "Fitur unggahan gambar telah ditingkatkan menjadi unggah file.",
"common.goBackToEdit": "Kembali ke editor",
"common.handMode": "Mode Tangan",
+ "common.humanInputEmailTip": "Email (Metode Pengiriman) dikirim ke penerima yang dikonfigurasi",
+ "common.humanInputEmailTipInDebugMode": "Email (Metode Pengiriman) dikirim ke {{email}} ",
+ "common.humanInputWebappTip": "Hanya pratinjau debug, pengguna tidak akan melihat ini di aplikasi web.",
"common.importDSL": "Impor DSL",
"common.importDSLTip": "Draf saat ini akan ditimpa.\nEkspor alur kerja sebagai cadangan sebelum mengimpor.",
"common.importFailure": "Impor Gagal",
@@ -500,6 +505,104 @@
"nodes.http.value": "Nilai",
"nodes.http.verifySSL.title": "Verifikasi Sertifikat SSL",
"nodes.http.verifySSL.warningTooltip": "Menonaktifkan verifikasi SSL tidak disarankan untuk lingkungan produksi. Ini hanya boleh digunakan dalam pengembangan atau pengujian, karena membuat koneksi rentan terhadap ancaman keamanan seperti serangan man-in-the-middle.",
+ "nodes.humanInput.deliveryMethod.added": "Ditambahkan",
+ "nodes.humanInput.deliveryMethod.contactTip1": "Tidak ada metode pengiriman yang Anda butuhkan?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "Beritahu kami di support@dify.ai .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "Semua anggota ({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "Isi",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "Masukkan isi email",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "Mode Debug",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "Dalam mode debug, email hanya akan dikirim ke akun email Anda {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "Lingkungan produksi tidak terpengaruh.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "Kirim permintaan input melalui email",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ Tambah",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "Ditambahkan",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "Email, dipisahkan koma",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "Tambah anggota workspace atau penerima eksternal",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "Pilih",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "Penerima",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "Variabel URL permintaan adalah titik masuk pemicu untuk input manusia.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "Subjek",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "Masukkan subjek email",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "Konfigurasi Email",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "Email uji telah dikirim ke {{email}} . Silakan periksa kotak masuk Anda.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "Mode debug diaktifkan.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "Email akan dikirim ke {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "Email Terkirim",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(opsional)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "Kirim Email",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "Kirim email uji ke penerima yang dikonfigurasi",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "Kirim email uji ke {{email}}",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "Disarankan untuk mengaktifkan Mode Debug untuk menguji pengiriman email.",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "Pengirim Email Uji",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "Variabel dalam Konten Formulir",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "Isi variabel formulir untuk meniru apa yang sebenarnya dilihat penerima.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "Email telah dikirim ke anggota {{team}} dan alamat email berikut:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "Email telah dikirim ke anggota {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "Email telah dikirim ke alamat email berikut:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "Email akan dikirim ke anggota {{team}} dan alamat email berikut:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "Email akan dikirim ke anggota {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "Email akan dikirim ke alamat email berikut:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "Tidak ada metode pengiriman yang ditambahkan, operasi tidak dapat dipicu.",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "Tidak tersedia",
+ "nodes.humanInput.deliveryMethod.notConfigured": "Belum dikonfigurasi",
+ "nodes.humanInput.deliveryMethod.title": "Metode Pengiriman",
+ "nodes.humanInput.deliveryMethod.tooltip": "Bagaimana formulir input manusia dikirimkan kepada pengguna.",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "Kirim permintaan input melalui Discord",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "Kirim permintaan input melalui email",
+ "nodes.humanInput.deliveryMethod.types.email.title": "Email",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "Kirim permintaan input melalui Slack",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "Kirim permintaan input melalui Teams",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "Tampilkan kepada pengguna akhir di webapp",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "Webapp",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "Buka kunci pengiriman Email untuk Input Manusia",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "Kirim permintaan konfirmasi melalui email sebelum agen mengambil tindakan — berguna untuk alur kerja publikasi dan persetujuan.",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "Abaikan",
+ "nodes.humanInput.editor.previewTip": "Dalam mode pratinjau, tombol tindakan tidak berfungsi.",
+ "nodes.humanInput.errorMsg.duplicateActionId": "ID tindakan duplikat ditemukan dalam tindakan pengguna",
+ "nodes.humanInput.errorMsg.emptyActionId": "ID tindakan tidak boleh kosong",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "Judul tindakan tidak boleh kosong",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "Silakan pilih setidaknya satu metode pengiriman",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "Silakan aktifkan setidaknya satu metode pengiriman",
+ "nodes.humanInput.errorMsg.noUserActions": "Silakan tambahkan setidaknya satu tindakan pengguna",
+ "nodes.humanInput.formContent.hotkeyTip": "Tekan untuk menyisipkan variabel, untuk menyisipkan bidang input",
+ "nodes.humanInput.formContent.placeholder": "Ketik konten di sini",
+ "nodes.humanInput.formContent.preview": "Pratinjau",
+ "nodes.humanInput.formContent.title": "Konten Formulir",
+ "nodes.humanInput.formContent.tooltip": "Apa yang akan dilihat pengguna setelah membuka formulir. Mendukung pemformatan Markdown.",
+ "nodes.humanInput.insertInputField.insert": "Sisipkan",
+ "nodes.humanInput.insertInputField.prePopulateField": "Isi Bidang Sebelumnya",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": "Tambahkan atau . Pengguna akan melihat konten ini pada awalnya, atau biarkan kosong.",
+ "nodes.humanInput.insertInputField.saveResponseAs": "Simpan Respons Sebagai",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "Beri nama variabel ini untuk referensi nanti",
+ "nodes.humanInput.insertInputField.staticContent": "Konten Statis",
+ "nodes.humanInput.insertInputField.title": "Sisipkan Bidang Input",
+ "nodes.humanInput.insertInputField.useConstantInstead": "Gunakan Konstanta Sebagai Gantinya",
+ "nodes.humanInput.insertInputField.useVarInstead": "Gunakan Variabel Sebagai Gantinya",
+ "nodes.humanInput.insertInputField.variable": "variabel",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "Nama variabel hanya dapat berisi huruf, angka, dan garis bawah, dan tidak boleh dimulai dengan angka",
+ "nodes.humanInput.log.backstageInputURL": "URL input backstage:",
+ "nodes.humanInput.log.reason": "Alasan:",
+ "nodes.humanInput.log.reasonContent": "Input manusia diperlukan untuk melanjutkan",
+ "nodes.humanInput.singleRun.back": "Kembali",
+ "nodes.humanInput.singleRun.button": "Buat Formulir",
+ "nodes.humanInput.singleRun.label": "Variabel formulir",
+ "nodes.humanInput.timeout.days": "Hari",
+ "nodes.humanInput.timeout.hours": "Jam",
+ "nodes.humanInput.timeout.title": "Batas waktu",
+ "nodes.humanInput.userActions.actionIdFormatTip": "ID tindakan harus dimulai dengan huruf atau garis bawah, diikuti dengan huruf, angka, atau garis bawah",
+ "nodes.humanInput.userActions.actionIdTooLong": "ID tindakan harus {{maxLength}} karakter atau kurang",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "Nama Tindakan",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "Teks Tampilan Tombol",
+ "nodes.humanInput.userActions.buttonTextTooLong": "Teks tombol harus {{maxLength}} karakter atau kurang",
+ "nodes.humanInput.userActions.chooseStyle": "Pilih gaya tombol",
+ "nodes.humanInput.userActions.emptyTip": "Klik tombol '+' untuk menambahkan tindakan pengguna",
+ "nodes.humanInput.userActions.title": "Tindakan Pengguna",
+ "nodes.humanInput.userActions.tooltip": "Tentukan tombol yang dapat diklik pengguna untuk merespons formulir ini. Setiap tombol dapat memicu jalur alur kerja yang berbeda. ID tindakan harus dimulai dengan huruf atau garis bawah, diikuti dengan huruf, angka, atau garis bawah.",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} telah dipicu",
"nodes.ifElse.addCondition": "Tambahkan Kondisi",
"nodes.ifElse.addSubVariable": "Sub Variabel",
"nodes.ifElse.and": "dan",
diff --git a/web/i18n/it-IT/common.json b/web/i18n/it-IT/common.json
index b707ddce7d..a894e1e1b5 100644
--- a/web/i18n/it-IT/common.json
+++ b/web/i18n/it-IT/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "Scrivi qui il tuo prompt, inserisci '{' per inserire una variabile, inserisci '/' per inserire un blocco di contenuto del prompt",
"promptEditor.query.item.desc": "Inserisci modello di query dell'utente",
"promptEditor.query.item.title": "Query",
+ "promptEditor.requestURL.item.desc": "Inserisci URL richiesta",
+ "promptEditor.requestURL.item.title": "URL richiesta",
"promptEditor.variable.item.desc": "Inserisci Variabili & Strumenti Esterni",
"promptEditor.variable.item.title": "Variabili & Strumenti Esterni",
"promptEditor.variable.modal.add": "Nuova variabile",
diff --git a/web/i18n/it-IT/share.json b/web/i18n/it-IT/share.json
index 502eac044c..cc23974113 100644
--- a/web/i18n/it-IT/share.json
+++ b/web/i18n/it-IT/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "Esegui una volta",
"generation.tabs.saved": "Salvato",
"generation.title": "Completamento AI",
+ "humanInput.completed": "Sembra che questa richiesta sia stata gestita altrove.",
+ "humanInput.expirationTimeNowOrFuture": "Questa azione scadrà {{relativeTime}}.",
+ "humanInput.expired": "Sembra che questa richiesta sia scaduta.",
+ "humanInput.expiredTip": "Questa azione è scaduta.",
+ "humanInput.formNotFound": "Modulo non trovato.",
+ "humanInput.rateLimitExceeded": "Troppe richieste, riprova più tardi.",
+ "humanInput.recorded": "Il tuo input è stato registrato.",
+ "humanInput.sorry": "Spiacente!",
+ "humanInput.submissionID": "submission_id: {{id}}",
+ "humanInput.thanks": "Grazie!",
"login.backToHome": "Torna alla home"
}
diff --git a/web/i18n/it-IT/workflow.json b/web/i18n/it-IT/workflow.json
index fdf0d6e517..cb1fcab53b 100644
--- a/web/i18n/it-IT/workflow.json
+++ b/web/i18n/it-IT/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "Estrattore di documenti",
"blocks.end": "Uscita",
"blocks.http-request": "Richiesta HTTP",
+ "blocks.human-input": "Input Umano",
"blocks.if-else": "SE/ALTRIMENTI",
"blocks.iteration": "Iterazione",
"blocks.iteration-start": "Inizio Iterazione",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "Utilizzato per analizzare i documenti caricati in contenuti di testo facilmente comprensibili da LLM.",
"blocksAbout.end": "Definisci l'uscita e il tipo di risultato di un flusso di lavoro",
"blocksAbout.http-request": "Consenti l'invio di richieste server tramite il protocollo HTTP",
+ "blocksAbout.human-input": "Chiedi conferma umana prima di generare il prossimo passo",
"blocksAbout.if-else": "Ti consente di dividere il flusso di lavoro in due rami basati su condizioni se/altrimenti",
"blocksAbout.iteration": "Esegui più passaggi su un oggetto lista fino a quando tutti i risultati non sono stati prodotti.",
"blocksAbout.iteration-start": "Nodo iniziale dell'iterazione",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "Le funzioni di caricamento delle immagini sono state aggiornate al caricamento dei file.",
"common.goBackToEdit": "Torna all'editor",
"common.handMode": "Modalità Mano",
+ "common.humanInputEmailTip": "Email (metodo di consegna) inviata ai destinatari configurati",
+ "common.humanInputEmailTipInDebugMode": "Email (metodo di consegna) inviata a {{email}} ",
+ "common.humanInputWebappTip": "Solo anteprima di debug, l'utente non vedrà questo nell'app web.",
"common.importDSL": "Importa DSL",
"common.importDSLTip": "La bozza corrente verrà sovrascritta. Esporta il flusso di lavoro come backup prima di importare.",
"common.importFailure": "Importazione fallita",
@@ -500,6 +505,104 @@
"nodes.http.value": "Valore",
"nodes.http.verifySSL.title": "Verifica il certificato SSL",
"nodes.http.verifySSL.warningTooltip": "Disabilitare la verifica SSL non è raccomandato per gli ambienti di produzione. Questo dovrebbe essere utilizzato solo in sviluppo o test, poiché rende la connessione vulnerabile a minacce alla sicurezza come gli attacchi man-in-the-middle.",
+ "nodes.humanInput.deliveryMethod.added": "Aggiunto",
+ "nodes.humanInput.deliveryMethod.contactTip1": "Manca un metodo di consegna di cui hai bisogno?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "Faccelo sapere a support@dify.ai .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "Tutti i membri ({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "Corpo",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "Inserisci il corpo dell'email",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "Modalità Debug",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "In modalità debug, l'email verrà inviata solo al tuo account email {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "L'ambiente di produzione non è interessato.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "Invia richiesta di input via email",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ Aggiungi",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "Aggiunto",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "Email, separate da virgola",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "Aggiungi membri del workspace o destinatari esterni",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "Seleziona",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "Destinatario",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "La variabile URL richiesta è il punto di ingresso per l'input umano.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "Oggetto",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "Inserisci l'oggetto dell'email",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "Configurazione Email",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "Un'email di test è stata inviata a {{email}} . Controlla la tua casella di posta.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "La modalità debug è abilitata.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "L'email verrà inviata a {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "Email Inviata",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(opzionale)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "Invia Email",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "Invia email di test ai tuoi destinatari configurati",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "Invia un'email di test a {{email}}",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "Si consiglia di abilitare la Modalità Debug per testare la consegna delle email.",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "Mittente Email di Test",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "Variabili nel Contenuto del Modulo",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "Compila le variabili del modulo per emulare ciò che i destinatari vedono effettivamente.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "L'email è stata inviata ai membri di {{team}} e ai seguenti indirizzi email:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "L'email è stata inviata ai membri di {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "L'email è stata inviata ai seguenti indirizzi email:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "L'email verrà inviata ai membri di {{team}} e ai seguenti indirizzi email:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "L'email verrà inviata ai membri di {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "L'email verrà inviata ai seguenti indirizzi email:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "Nessun metodo di consegna aggiunto, l'operazione non può essere attivata.",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "Non disponibile",
+ "nodes.humanInput.deliveryMethod.notConfigured": "Non configurato",
+ "nodes.humanInput.deliveryMethod.title": "Metodo di Consegna",
+ "nodes.humanInput.deliveryMethod.tooltip": "Come il modulo di input umano viene consegnato all'utente.",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "Invia richiesta di input via Discord",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "Invia richiesta di input via email",
+ "nodes.humanInput.deliveryMethod.types.email.title": "Email",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "Invia richiesta di input via Slack",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "Invia richiesta di input via Teams",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "Mostra all'utente finale nella webapp",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "Webapp",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "Sblocca la consegna via Email per l'Input Umano",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "Invia richieste di conferma via email prima che gli agenti agiscano — utile per flussi di lavoro di pubblicazione e approvazione.",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "Ignora",
+ "nodes.humanInput.editor.previewTip": "In modalità anteprima, i pulsanti di azione non sono funzionali.",
+ "nodes.humanInput.errorMsg.duplicateActionId": "ID azione duplicato trovato nelle azioni utente",
+ "nodes.humanInput.errorMsg.emptyActionId": "L'ID azione non può essere vuoto",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "Il titolo dell'azione non può essere vuoto",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "Seleziona almeno un metodo di consegna",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "Abilita almeno un metodo di consegna",
+ "nodes.humanInput.errorMsg.noUserActions": "Aggiungi almeno un'azione utente",
+ "nodes.humanInput.formContent.hotkeyTip": "Premi per inserire variabile, per inserire campo di input",
+ "nodes.humanInput.formContent.placeholder": "Digita qui il contenuto",
+ "nodes.humanInput.formContent.preview": "Anteprima",
+ "nodes.humanInput.formContent.title": "Contenuto del Modulo",
+ "nodes.humanInput.formContent.tooltip": "Cosa vedranno gli utenti dopo aver aperto il modulo. Supporta la formattazione Markdown.",
+ "nodes.humanInput.insertInputField.insert": "Inserisci",
+ "nodes.humanInput.insertInputField.prePopulateField": "Pre-compila Campo",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": "Aggiungi o . Gli utenti vedranno inizialmente questo contenuto, o lascia vuoto.",
+ "nodes.humanInput.insertInputField.saveResponseAs": "Salva Risposta Come",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "Denomina questa variabile per riferimento successivo",
+ "nodes.humanInput.insertInputField.staticContent": "Contenuto Statico",
+ "nodes.humanInput.insertInputField.title": "Inserisci Campo di Input",
+ "nodes.humanInput.insertInputField.useConstantInstead": "Usa Costante Invece",
+ "nodes.humanInput.insertInputField.useVarInstead": "Usa Variabile Invece",
+ "nodes.humanInput.insertInputField.variable": "variabile",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "Il nome della variabile può contenere solo lettere, numeri e underscore, e non può iniziare con un numero",
+ "nodes.humanInput.log.backstageInputURL": "URL input backstage:",
+ "nodes.humanInput.log.reason": "Motivo:",
+ "nodes.humanInput.log.reasonContent": "Input umano richiesto per procedere",
+ "nodes.humanInput.singleRun.back": "Indietro",
+ "nodes.humanInput.singleRun.button": "Genera Modulo",
+ "nodes.humanInput.singleRun.label": "Variabili del modulo",
+ "nodes.humanInput.timeout.days": "Giorni",
+ "nodes.humanInput.timeout.hours": "Ore",
+ "nodes.humanInput.timeout.title": "Timeout",
+ "nodes.humanInput.userActions.actionIdFormatTip": "L'ID azione deve iniziare con una lettera o underscore, seguito da lettere, numeri o underscore",
+ "nodes.humanInput.userActions.actionIdTooLong": "L'ID azione deve essere di {{maxLength}} caratteri o meno",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "Nome Azione",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "Testo del Pulsante da Visualizzare",
+ "nodes.humanInput.userActions.buttonTextTooLong": "Il testo del pulsante deve essere di {{maxLength}} caratteri o meno",
+ "nodes.humanInput.userActions.chooseStyle": "Scegli uno stile del pulsante",
+ "nodes.humanInput.userActions.emptyTip": "Clicca il pulsante '+' per aggiungere azioni utente",
+ "nodes.humanInput.userActions.title": "Azioni Utente",
+ "nodes.humanInput.userActions.tooltip": "Definisci i pulsanti su cui gli utenti possono cliccare per rispondere a questo modulo. Ogni pulsante può attivare percorsi di workflow diversi. L'ID azione deve iniziare con una lettera o underscore, seguito da lettere, numeri o underscore.",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} è stato attivato",
"nodes.ifElse.addCondition": "Aggiungi Condizione",
"nodes.ifElse.addSubVariable": "Variabile secondaria",
"nodes.ifElse.and": "e",
diff --git a/web/i18n/ja-JP/common.json b/web/i18n/ja-JP/common.json
index afb4daa45f..29f523fd72 100644
--- a/web/i18n/ja-JP/common.json
+++ b/web/i18n/ja-JP/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "ここにプロンプトワードを入力してください。変数を挿入するには「{」を、プロンプトコンテンツブロックを挿入するには「/」を入力します。",
"promptEditor.query.item.desc": "ユーザークエリテンプレートを挿入",
"promptEditor.query.item.title": "クエリ",
+ "promptEditor.requestURL.item.desc": "リクエストURLを挿入",
+ "promptEditor.requestURL.item.title": "リクエストURL",
"promptEditor.variable.item.desc": "変数&外部ツールを挿入",
"promptEditor.variable.item.title": "変数&外部ツール",
"promptEditor.variable.modal.add": "新しい変数",
diff --git a/web/i18n/ja-JP/share.json b/web/i18n/ja-JP/share.json
index 69bf69b1f2..7c5adbdab8 100644
--- a/web/i18n/ja-JP/share.json
+++ b/web/i18n/ja-JP/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "1 回実行",
"generation.tabs.saved": "保存済み",
"generation.title": "AI 文章作成",
+ "humanInput.completed": "このリクエストは他の場所で処理されたようです。",
+ "humanInput.expirationTimeNowOrFuture": "このアクションは{{relativeTime}}に期限切れになります。",
+ "humanInput.expired": "このリクエストは期限切れのようです。",
+ "humanInput.expiredTip": "このアクションは期限切れです。",
+ "humanInput.formNotFound": "フォームが見つかりません。",
+ "humanInput.rateLimitExceeded": "リクエストが多すぎます。しばらくしてからもう一度お試しください。",
+ "humanInput.recorded": "入力が記録されました。",
+ "humanInput.sorry": "申し訳ございません!",
+ "humanInput.submissionID": "submission_id: {{id}}",
+ "humanInput.thanks": "ありがとうございます!",
"login.backToHome": "ホームに戻る"
}
diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json
index aee11d69c9..6dd914e9bd 100644
--- a/web/i18n/ja-JP/workflow.json
+++ b/web/i18n/ja-JP/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "テキスト抽出",
"blocks.end": "出力",
"blocks.http-request": "HTTP リクエスト",
+ "blocks.human-input": "人間の入力",
"blocks.if-else": "IF/ELSE",
"blocks.iteration": "イテレーション",
"blocks.iteration-start": "イテレーション開始",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "アップロード文書を LLM 処理用に最適化されたテキストに変換します。",
"blocksAbout.end": "ワークフローの出力と結果のタイプを定義します",
"blocksAbout.http-request": "HTTP リクエストを送信できます。",
+ "blocksAbout.human-input": "次のステップを生成する前に人間の確認を求める",
"blocksAbout.if-else": "if/else 条件でワークフローを 2 つの分岐に分割します。",
"blocksAbout.iteration": "リスト要素に対して反復処理を実行し全結果を出力します。",
"blocksAbout.iteration-start": "反復開始ノード",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "画像アップロード機能がファイルアップロードに拡張されました",
"common.goBackToEdit": "編集に戻る",
"common.handMode": "ハンドモード",
+ "common.humanInputEmailTip": "設定された受信者にメール(配信方法)が送信されました",
+ "common.humanInputEmailTipInDebugMode": "{{email}} にメール(配信方法)が送信されました",
+ "common.humanInputWebappTip": "デバッグプレビューのみ、ユーザーはWebアプリでこれを見ることができません。",
"common.importDSL": "DSL をインポート",
"common.importDSLTip": "現在の下書きは上書きされます。インポート前にワークフローをエクスポートしてバックアップしてください",
"common.importFailure": "インポート失敗",
@@ -500,6 +505,104 @@
"nodes.http.value": "値",
"nodes.http.verifySSL.title": "SSL証明書を確認する",
"nodes.http.verifySSL.warningTooltip": "SSL検証を無効にすることは、本番環境では推奨されません。これは開発またはテストのみに使用すべきであり、中間者攻撃などのセキュリティ脅威に対して接続を脆弱にするためです。",
+ "nodes.humanInput.deliveryMethod.added": "追加済み",
+ "nodes.humanInput.deliveryMethod.contactTip1": "必要な配信方法が見つかりませんか?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "support@dify.ai までお知らせください。",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "全メンバー({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "本文",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "メール本文を入力",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "デバッグモード",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "デバッグモードでは、メールはアカウントのメールアドレス{{email}} にのみ送信されます。",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "本番環境には影響しません。",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "メールで入力リクエストを送信",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ 追加",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "追加済み",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "メールアドレス、カンマ区切り",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "ワークスペースメンバーまたは外部受信者を追加",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "選択",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "受信者",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "リクエストURL変数は人間の入力のトリガーエントリーです。",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "件名",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "メール件名を入力",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "メール設定",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "テストメールが{{email}} に送信されました。受信箱を確認してください。",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "デバッグモードが有効です。",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "メールは{{email}} に送信されます。",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "メール送信完了",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(オプション)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "メールを送信",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "設定された受信者にテストメールを送信",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "{{email}}にテストメールを送信",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "メール配信をテストするにはデバッグモードを有効にする ことをお勧めします。",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "テストメール送信者",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "フォームコンテンツの変数",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "フォーム変数を入力して、受信者が実際に見る内容をエミュレートします。",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "{{team}} メンバーと以下のメールアドレスにメールが送信されました:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "{{team}} メンバーにメールが送信されました。",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "以下のメールアドレスにメールが送信されました:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "{{team}} メンバーと以下のメールアドレスにメールが送信されます:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "{{team}} メンバーにメールが送信されます。",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "以下のメールアドレスにメールが送信されます:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "配信方法が追加されていないため、操作をトリガーできません。",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "利用不可",
+ "nodes.humanInput.deliveryMethod.notConfigured": "未設定",
+ "nodes.humanInput.deliveryMethod.title": "配信方法",
+ "nodes.humanInput.deliveryMethod.tooltip": "人間の入力フォームがユーザーに配信される方法。",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "Discordで入力リクエストを送信",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "メールで入力リクエストを送信",
+ "nodes.humanInput.deliveryMethod.types.email.title": "メール",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "Slackで入力リクエストを送信",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "Teamsで入力リクエストを送信",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "Webアプリでエンドユーザーに表示",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "Webアプリ",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "人間の入力のメール配信をアンロック",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "エージェントがアクションを実行する前にメールで確認リクエストを送信 — 公開および承認ワークフローに便利です。",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "非表示",
+ "nodes.humanInput.editor.previewTip": "プレビューモードでは、アクションボタンは機能しません。",
+ "nodes.humanInput.errorMsg.duplicateActionId": "ユーザーアクションに重複するアクションIDが見つかりました",
+ "nodes.humanInput.errorMsg.emptyActionId": "アクションIDは空にできません",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "アクションタイトルは空にできません",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "少なくとも1つの配信方法を選択してください",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "少なくとも1つの配信方法を有効にしてください",
+ "nodes.humanInput.errorMsg.noUserActions": "少なくとも1つのユーザーアクションを追加してください",
+ "nodes.humanInput.formContent.hotkeyTip": " を押して変数を挿入、 を押して入力フィールドを挿入",
+ "nodes.humanInput.formContent.placeholder": "ここにコンテンツを入力",
+ "nodes.humanInput.formContent.preview": "プレビュー",
+ "nodes.humanInput.formContent.title": "フォームコンテンツ",
+ "nodes.humanInput.formContent.tooltip": "ユーザーがフォームを開いた後に表示される内容。Markdown形式をサポート。",
+ "nodes.humanInput.insertInputField.insert": "挿入",
+ "nodes.humanInput.insertInputField.prePopulateField": "フィールドを事前入力",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": " または を追加すると、ユーザーは最初にこの内容を見ます。または空のままにします。",
+ "nodes.humanInput.insertInputField.saveResponseAs": "レスポンスを次の名前で保存",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "後で参照するためにこの変数に名前を付ける",
+ "nodes.humanInput.insertInputField.staticContent": "静的コンテンツ",
+ "nodes.humanInput.insertInputField.title": "入力フィールドを挿入",
+ "nodes.humanInput.insertInputField.useConstantInstead": "代わりに定数を使用",
+ "nodes.humanInput.insertInputField.useVarInstead": "代わりに変数を使用",
+ "nodes.humanInput.insertInputField.variable": "変数",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "変数名は文字、数字、アンダースコアのみを含み、数字で始めることはできません",
+ "nodes.humanInput.log.backstageInputURL": "バックステージ入力URL:",
+ "nodes.humanInput.log.reason": "理由:",
+ "nodes.humanInput.log.reasonContent": "続行するには人間の入力が必要です",
+ "nodes.humanInput.singleRun.back": "戻る",
+ "nodes.humanInput.singleRun.button": "フォームを生成",
+ "nodes.humanInput.singleRun.label": "フォーム変数",
+ "nodes.humanInput.timeout.days": "日",
+ "nodes.humanInput.timeout.hours": "時間",
+ "nodes.humanInput.timeout.title": "タイムアウト",
+ "nodes.humanInput.userActions.actionIdFormatTip": "アクションIDは文字またはアンダースコアで始まり、その後に文字、数字、またはアンダースコアが続く必要があります",
+ "nodes.humanInput.userActions.actionIdTooLong": "アクションIDは{{maxLength}}文字以下である必要があります",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "アクション名",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "ボタン表示テキスト",
+ "nodes.humanInput.userActions.buttonTextTooLong": "ボタンテキストは{{maxLength}}文字以下である必要があります",
+ "nodes.humanInput.userActions.chooseStyle": "ボタンスタイルを選択",
+ "nodes.humanInput.userActions.emptyTip": "'+'ボタンをクリックしてユーザーアクションを追加",
+ "nodes.humanInput.userActions.title": "ユーザーアクション",
+ "nodes.humanInput.userActions.tooltip": "ユーザーがこのフォームに応答するためにクリックできるボタンを定義します。各ボタンは異なるワークフローパスをトリガーできます。アクションIDは文字またはアンダースコアで始まり、その後に文字、数字、またはアンダースコアが続く必要があります。",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} がトリガーされました",
"nodes.ifElse.addCondition": "条件を追加",
"nodes.ifElse.addSubVariable": "サブ変数",
"nodes.ifElse.and": "かつ",
diff --git a/web/i18n/ko-KR/common.json b/web/i18n/ko-KR/common.json
index e5d64cd97d..141026143f 100644
--- a/web/i18n/ko-KR/common.json
+++ b/web/i18n/ko-KR/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "여기에 프롬프트 단어를 입력하세요. 변수를 삽입하려면 \"{{\"를 입력하고, 프롬프트 컨텐츠 블록을 삽입하려면 \"/\"를 입력하세요.",
"promptEditor.query.item.desc": "사용자 쿼리 템플릿을 삽입합니다.",
"promptEditor.query.item.title": "쿼리",
+ "promptEditor.requestURL.item.desc": "요청 URL 삽입",
+ "promptEditor.requestURL.item.title": "요청 URL",
"promptEditor.variable.item.desc": "변수 및 외부 도구를 삽입합니다.",
"promptEditor.variable.item.title": "변수 및 외부 도구",
"promptEditor.variable.modal.add": "새로운 변수",
diff --git a/web/i18n/ko-KR/share.json b/web/i18n/ko-KR/share.json
index 0069046033..1c911b252b 100644
--- a/web/i18n/ko-KR/share.json
+++ b/web/i18n/ko-KR/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "일회용 실행",
"generation.tabs.saved": "저장된 결과",
"generation.title": "AI 생성",
+ "humanInput.completed": "이 요청은 다른 곳에서 처리된 것 같습니다.",
+ "humanInput.expirationTimeNowOrFuture": "이 작업은 {{relativeTime}}에 만료됩니다.",
+ "humanInput.expired": "이 요청이 만료된 것 같습니다.",
+ "humanInput.expiredTip": "이 작업이 만료되었습니다.",
+ "humanInput.formNotFound": "양식을 찾을 수 없습니다.",
+ "humanInput.rateLimitExceeded": "요청이 너무 많습니다. 나중에 다시 시도하세요.",
+ "humanInput.recorded": "입력이 기록되었습니다.",
+ "humanInput.sorry": "죄송합니다!",
+ "humanInput.submissionID": "submission_id: {{id}}",
+ "humanInput.thanks": "감사합니다!",
"login.backToHome": "홈으로 돌아가기"
}
diff --git a/web/i18n/ko-KR/workflow.json b/web/i18n/ko-KR/workflow.json
index ea2963d052..106869f00b 100644
--- a/web/i18n/ko-KR/workflow.json
+++ b/web/i18n/ko-KR/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "Doc 추출기",
"blocks.end": "출력",
"blocks.http-request": "HTTP 요청",
+ "blocks.human-input": "사람 입력",
"blocks.if-else": "IF/ELSE",
"blocks.iteration": "반복",
"blocks.iteration-start": "반복 시작",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "업로드된 문서를 LLM 에서 쉽게 이해할 수 있는 텍스트 콘텐츠로 구문 분석하는 데 사용됩니다.",
"blocksAbout.end": "워크플로의 출력 및 결과 유형을 정의합니다",
"blocksAbout.http-request": "HTTP 프로토콜을 통해 서버 요청을 보낼 수 있습니다",
+ "blocksAbout.human-input": "다음 단계를 생성하기 전에 사람의 확인 요청",
"blocksAbout.if-else": "if/else 조건을 기반으로 워크플로우를 두 가지 분기로 나눌 수 있습니다",
"blocksAbout.iteration": "목록 객체에서 여러 단계를 수행하여 모든 결과가 출력될 때까지 반복합니다.",
"blocksAbout.iteration-start": "반복 시작 노드",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "이미지 업로드 기능이 파일 업로드로 업그레이드되었습니다.",
"common.goBackToEdit": "편집기로 돌아가기",
"common.handMode": "드래그 모드",
+ "common.humanInputEmailTip": "구성된 수신자에게 이메일(전달 방법)이 전송되었습니다",
+ "common.humanInputEmailTipInDebugMode": "{{email}} 로 이메일(전달 방법)이 전송되었습니다",
+ "common.humanInputWebappTip": "디버그 미리보기만, 사용자는 웹 앱에서 이것을 볼 수 없습니다.",
"common.importDSL": "DSL 가져오기",
"common.importDSLTip": "현재 초안을 덮어씁니다. 가져오기 전에 워크플로우를 백업으로 내보냅니다.",
"common.importFailure": "가져오기 실패",
@@ -500,6 +505,104 @@
"nodes.http.value": "값",
"nodes.http.verifySSL.title": "SSL 인증서 확인",
"nodes.http.verifySSL.warningTooltip": "SSL 검증을 비활성화하는 것은 프로덕션 환경에서는 권장되지 않습니다. 이는 연결이 중간자 공격과 같은 보안 위협에 취약하게 만들므로 개발 또는 테스트에서만 사용해야 합니다.",
+ "nodes.humanInput.deliveryMethod.added": "추가됨",
+ "nodes.humanInput.deliveryMethod.contactTip1": "필요한 전달 방법이 없으신가요?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "support@dify.ai 로 알려주세요.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "모든 멤버({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "본문",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "이메일 본문 입력",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "디버그 모드",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "디버그 모드에서는 이메일이 계정 이메일 {{email}} 로만 전송됩니다.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "프로덕션 환경은 영향을 받지 않습니다.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "이메일을 통해 입력 요청 전송",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ 추가",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "추가됨",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "이메일, 쉼표로 구분",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "워크스페이스 멤버 또는 외부 수신자 추가",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "선택",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "수신자",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "요청 URL 변수는 사람 입력의 트리거 진입점입니다.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "제목",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "이메일 제목 입력",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "이메일 구성",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "테스트 이메일이 {{email}} 로 전송되었습니다. 받은 편지함을 확인하세요.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "디버그 모드가 활성화되었습니다.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "이메일이 {{email}} 로 전송됩니다.",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "이메일 전송됨",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(선택 사항)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "이메일 보내기",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "구성된 수신자에게 테스트 이메일 보내기",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "{{email}}로 테스트 이메일 보내기",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "이메일 전달을 테스트하려면 디버그 모드를 활성화 하는 것이 좋습니다.",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "테스트 이메일 발신자",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "양식 콘텐츠의 변수",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "수신자가 실제로 보는 내용을 에뮬레이트하려면 양식 변수를 입력하세요.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "{{team}} 멤버 및 다음 이메일 주소로 이메일이 전송되었습니다:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "{{team}} 멤버에게 이메일이 전송되었습니다.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "다음 이메일 주소로 이메일이 전송되었습니다:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "{{team}} 멤버 및 다음 이메일 주소로 이메일이 전송됩니다:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "{{team}} 멤버에게 이메일이 전송됩니다.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "다음 이메일 주소로 이메일이 전송됩니다:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "전달 방법이 추가되지 않아 작업을 트리거할 수 없습니다.",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "사용 불가",
+ "nodes.humanInput.deliveryMethod.notConfigured": "구성되지 않음",
+ "nodes.humanInput.deliveryMethod.title": "전달 방법",
+ "nodes.humanInput.deliveryMethod.tooltip": "사람 입력 양식이 사용자에게 전달되는 방법.",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "Discord를 통해 입력 요청 전송",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "이메일을 통해 입력 요청 전송",
+ "nodes.humanInput.deliveryMethod.types.email.title": "이메일",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "Slack을 통해 입력 요청 전송",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "Teams를 통해 입력 요청 전송",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "웹앱에서 최종 사용자에게 표시",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "웹앱",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "사람 입력을 위한 이메일 전달 잠금 해제",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "에이전트가 작업을 수행하기 전에 이메일을 통해 확인 요청 전송 — 게시 및 승인 워크플로에 유용합니다.",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "무시",
+ "nodes.humanInput.editor.previewTip": "미리보기 모드에서는 작업 버튼이 작동하지 않습니다.",
+ "nodes.humanInput.errorMsg.duplicateActionId": "사용자 작업에서 중복 작업 ID가 발견되었습니다",
+ "nodes.humanInput.errorMsg.emptyActionId": "작업 ID는 비워 둘 수 없습니다",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "작업 제목은 비워 둘 수 없습니다",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "최소 하나의 전달 방법을 선택하세요",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "최소 하나의 전달 방법을 활성화하세요",
+ "nodes.humanInput.errorMsg.noUserActions": "최소 하나의 사용자 작업을 추가하세요",
+ "nodes.humanInput.formContent.hotkeyTip": " 를 눌러 변수 삽입, 를 눌러 입력 필드 삽입",
+ "nodes.humanInput.formContent.placeholder": "여기에 콘텐츠 입력",
+ "nodes.humanInput.formContent.preview": "미리보기",
+ "nodes.humanInput.formContent.title": "양식 콘텐츠",
+ "nodes.humanInput.formContent.tooltip": "양식을 연 후 사용자에게 표시될 내용입니다. Markdown 형식을 지원합니다.",
+ "nodes.humanInput.insertInputField.insert": "삽입",
+ "nodes.humanInput.insertInputField.prePopulateField": "필드 미리 채우기",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": " 또는 를 추가하면 사용자가 처음에 이 콘텐츠를 보거나 비워 둡니다.",
+ "nodes.humanInput.insertInputField.saveResponseAs": "응답을 다음으로 저장",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "나중에 참조할 수 있도록 이 변수의 이름 지정",
+ "nodes.humanInput.insertInputField.staticContent": "정적 콘텐츠",
+ "nodes.humanInput.insertInputField.title": "입력 필드 삽입",
+ "nodes.humanInput.insertInputField.useConstantInstead": "대신 상수 사용",
+ "nodes.humanInput.insertInputField.useVarInstead": "대신 변수 사용",
+ "nodes.humanInput.insertInputField.variable": "변수",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "변수 이름은 문자, 숫자 및 밑줄만 포함할 수 있으며 숫자로 시작할 수 없습니다",
+ "nodes.humanInput.log.backstageInputURL": "백스테이지 입력 URL:",
+ "nodes.humanInput.log.reason": "이유:",
+ "nodes.humanInput.log.reasonContent": "계속하려면 사람 입력이 필요합니다",
+ "nodes.humanInput.singleRun.back": "뒤로",
+ "nodes.humanInput.singleRun.button": "양식 생성",
+ "nodes.humanInput.singleRun.label": "양식 변수",
+ "nodes.humanInput.timeout.days": "일",
+ "nodes.humanInput.timeout.hours": "시간",
+ "nodes.humanInput.timeout.title": "시간 초과",
+ "nodes.humanInput.userActions.actionIdFormatTip": "작업 ID는 문자 또는 밑줄로 시작하고 그 뒤에 문자, 숫자 또는 밑줄이 와야 합니다",
+ "nodes.humanInput.userActions.actionIdTooLong": "작업 ID는 {{maxLength}}자 이하여야 합니다",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "작업 이름",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "버튼 표시 텍스트",
+ "nodes.humanInput.userActions.buttonTextTooLong": "버튼 텍스트는 {{maxLength}}자 이하여야 합니다",
+ "nodes.humanInput.userActions.chooseStyle": "버튼 스타일 선택",
+ "nodes.humanInput.userActions.emptyTip": "'+' 버튼을 클릭하여 사용자 작업 추가",
+ "nodes.humanInput.userActions.title": "사용자 작업",
+ "nodes.humanInput.userActions.tooltip": "사용자가 이 양식에 응답하기 위해 클릭할 수 있는 버튼을 정의합니다. 각 버튼은 다른 워크플로 경로를 트리거할 수 있습니다. 작업 ID는 문자 또는 밑줄로 시작하고 그 뒤에 문자, 숫자 또는 밑줄이 와야 합니다.",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} 이(가) 트리거되었습니다",
"nodes.ifElse.addCondition": "조건 추가",
"nodes.ifElse.addSubVariable": "하위 변수",
"nodes.ifElse.and": "그리고",
diff --git a/web/i18n/pl-PL/common.json b/web/i18n/pl-PL/common.json
index 5263f010e9..8d5939b4e8 100644
--- a/web/i18n/pl-PL/common.json
+++ b/web/i18n/pl-PL/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "Wpisz swoje słowo kluczowe tutaj, wprowadź '{' aby wstawić zmienną, wprowadź '/' aby wstawić blok treści słownika",
"promptEditor.query.item.desc": "Wstaw szablon zapytania użytkownika",
"promptEditor.query.item.title": "Zapytanie",
+ "promptEditor.requestURL.item.desc": "Wstaw URL żądania",
+ "promptEditor.requestURL.item.title": "URL żądania",
"promptEditor.variable.item.desc": "Wstaw Zmienne i Narzędzia Zewnętrzne",
"promptEditor.variable.item.title": "Zmienne i Narzędzia Zewnętrzne",
"promptEditor.variable.modal.add": "Nowa zmienna",
diff --git a/web/i18n/pl-PL/share.json b/web/i18n/pl-PL/share.json
index 525b3724a2..ad4395d0b3 100644
--- a/web/i18n/pl-PL/share.json
+++ b/web/i18n/pl-PL/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "Uruchom raz",
"generation.tabs.saved": "Zapisane",
"generation.title": "Uzupełnianie AI",
+ "humanInput.completed": "Wygląda na to, że to żądanie zostało obsłużone gdzie indziej.",
+ "humanInput.expirationTimeNowOrFuture": "Ta akcja wygaśnie {{relativeTime}}.",
+ "humanInput.expired": "Wygląda na to, że to żądanie wygasło.",
+ "humanInput.expiredTip": "Ta akcja wygasła.",
+ "humanInput.formNotFound": "Formularz nie został znaleziony.",
+ "humanInput.rateLimitExceeded": "Zbyt wiele żądań, spróbuj ponownie później.",
+ "humanInput.recorded": "Twój wpis został zapisany.",
+ "humanInput.sorry": "Przepraszamy!",
+ "humanInput.submissionID": "submission_id: {{id}}",
+ "humanInput.thanks": "Dziękujemy!",
"login.backToHome": "Powrót do strony głównej"
}
diff --git a/web/i18n/pl-PL/workflow.json b/web/i18n/pl-PL/workflow.json
index 8f0fc1c96b..091b1f6ca2 100644
--- a/web/i18n/pl-PL/workflow.json
+++ b/web/i18n/pl-PL/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "Ekstraktor dokumentów",
"blocks.end": "Wyjście",
"blocks.http-request": "Żądanie HTTP",
+ "blocks.human-input": "Dane wprowadzone przez człowieka",
"blocks.if-else": "JEŚLI/W PRZECIWNYM WYPADKU",
"blocks.iteration": "Iteracja",
"blocks.iteration-start": "Początek iteracji",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "Służy do analizowania przesłanych dokumentów w treści tekstowej, która jest łatwo zrozumiała dla LLM.",
"blocksAbout.end": "Zdefiniuj wyjście i typ wyniku przepływu pracy",
"blocksAbout.http-request": "Pozwala na wysyłanie żądań serwera za pomocą protokołu HTTP",
+ "blocksAbout.human-input": "Poproś o potwierdzenie przez człowieka przed wygenerowaniem kolejnego kroku",
"blocksAbout.if-else": "Pozwala na podział przepływu pracy na dwie gałęzie na podstawie warunków if/else",
"blocksAbout.iteration": "Wykonuj wielokrotne kroki na liście obiektów, aż wszystkie wyniki zostaną wypisane.",
"blocksAbout.iteration-start": "Węzeł początkowy iteracji",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "Funkcje przesyłania obrazów zostały zaktualizowane do przesyłania plików.",
"common.goBackToEdit": "Wróć do edytora",
"common.handMode": "Tryb ręczny",
+ "common.humanInputEmailTip": "E-mail (Metoda dostawy) wysłany do skonfigurowanych odbiorców",
+ "common.humanInputEmailTipInDebugMode": "E-mail (Metoda dostawy) wysłany do {{email}} ",
+ "common.humanInputWebappTip": "Tylko podgląd debugowania, użytkownik nie zobaczy tego w aplikacji internetowej.",
"common.importDSL": "Importowanie DSL",
"common.importDSLTip": "Bieżąca wersja robocza zostanie nadpisana. Eksportuj przepływ pracy jako kopię zapasową przed zaimportowaniem.",
"common.importFailure": "Niepowodzenie importu",
@@ -500,6 +505,104 @@
"nodes.http.value": "Wartość",
"nodes.http.verifySSL.title": "Zweryfikuj certyfikat SSL",
"nodes.http.verifySSL.warningTooltip": "Wyłączenie weryfikacji SSL nie jest zalecane w środowiskach produkcyjnych. Powinno to być używane tylko w rozwoju lub testowaniu, ponieważ naraża połączenie na zagrożenia bezpieczeństwa, takie jak ataki typu man-in-the-middle.",
+ "nodes.humanInput.deliveryMethod.added": "Dodano",
+ "nodes.humanInput.deliveryMethod.contactTip1": "Brakuje potrzebnej metody dostarczania?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "Daj nam znać na support@dify.ai .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "Wszyscy członkowie ({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "Treść",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "Wprowadź treść wiadomości e-mail",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "Tryb debugowania",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "W trybie debugowania wiadomość e-mail zostanie wysłana tylko na Twoje konto {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "Środowisko produkcyjne nie jest dotknięte.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "Wyślij żądanie danych wejściowych przez e-mail",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ Dodaj",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "Dodano",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "E-mail, oddzielone przecinkami",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "Dodaj członków obszaru roboczego lub odbiorców zewnętrznych",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "Wybierz",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "Odbiorca",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "Zmienna URL żądania jest punktem wejścia dla danych wprowadzanych przez człowieka.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "Temat",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "Wprowadź temat wiadomości e-mail",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "Konfiguracja e-mail",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "Testowy e-mail został wysłany na {{email}} . Sprawdź swoją skrzynkę odbiorczą.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "Tryb debugowania jest włączony.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "E-mail zostanie wysłany na {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "E-mail wysłany",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(opcjonalne)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "Wyślij e-mail",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "Wyślij testowe e-maile do skonfigurowanych odbiorców",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "Wyślij testowy e-mail na {{email}}",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "Zaleca się włączenie trybu debugowania do testowania dostarczania e-maili.",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "Nadawca testowych e-maili",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "Zmienne w treści formularza",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "Wypełnij zmienne formularza, aby emulować to, co faktycznie widzą odbiorcy.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "E-mail został wysłany do członków {{team}} i na następujące adresy e-mail:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "E-mail został wysłany do członków {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "E-mail został wysłany na następujące adresy e-mail:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "E-mail zostanie wysłany do członków {{team}} i na następujące adresy e-mail:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "E-mail zostanie wysłany do członków {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "E-mail zostanie wysłany na następujące adresy e-mail:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "Nie dodano metody dostarczania, operacja nie może zostać uruchomiona.",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "Niedostępne",
+ "nodes.humanInput.deliveryMethod.notConfigured": "Nieskonfigurowane",
+ "nodes.humanInput.deliveryMethod.title": "Metoda dostarczania",
+ "nodes.humanInput.deliveryMethod.tooltip": "Jak formularz danych wprowadzanych przez człowieka jest dostarczany użytkownikowi.",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "Wyślij żądanie danych wejściowych przez Discord",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "Wyślij żądanie danych wejściowych przez e-mail",
+ "nodes.humanInput.deliveryMethod.types.email.title": "E-mail",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "Wyślij żądanie danych wejściowych przez Slack",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "Wyślij żądanie danych wejściowych przez Teams",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "Wyświetl użytkownikowi końcowemu w aplikacji webowej",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "Aplikacja webowa",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "Odblokuj dostarczanie e-mailowe dla danych wprowadzanych przez człowieka",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "Wysyłaj żądania potwierdzenia e-mailem przed działaniem agentów — przydatne w procesach publikacji i zatwierdzania.",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "Odrzuć",
+ "nodes.humanInput.editor.previewTip": "W trybie podglądu przyciski akcji nie są funkcjonalne.",
+ "nodes.humanInput.errorMsg.duplicateActionId": "Znaleziono zduplikowany identyfikator akcji w akcjach użytkownika",
+ "nodes.humanInput.errorMsg.emptyActionId": "Identyfikator akcji nie może być pusty",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "Tytuł akcji nie może być pusty",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "Wybierz co najmniej jedną metodę dostarczania",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "Włącz co najmniej jedną metodę dostarczania",
+ "nodes.humanInput.errorMsg.noUserActions": "Dodaj co najmniej jedną akcję użytkownika",
+ "nodes.humanInput.formContent.hotkeyTip": "Naciśnij , aby wstawić zmienną, , aby wstawić pole wprowadzania",
+ "nodes.humanInput.formContent.placeholder": "Wpisz treść tutaj",
+ "nodes.humanInput.formContent.preview": "Podgląd",
+ "nodes.humanInput.formContent.title": "Treść formularza",
+ "nodes.humanInput.formContent.tooltip": "Co użytkownicy zobaczą po otwarciu formularza. Obsługuje formatowanie Markdown.",
+ "nodes.humanInput.insertInputField.insert": "Wstaw",
+ "nodes.humanInput.insertInputField.prePopulateField": "Wstępnie wypełnij pole",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": "Dodaj lub . Użytkownicy zobaczą tę treść początkowo lub pozostaw puste.",
+ "nodes.humanInput.insertInputField.saveResponseAs": "Zapisz odpowiedź jako",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "Nazwij tę zmienną do późniejszego odniesienia",
+ "nodes.humanInput.insertInputField.staticContent": "Treść statyczna",
+ "nodes.humanInput.insertInputField.title": "Wstaw pole wprowadzania",
+ "nodes.humanInput.insertInputField.useConstantInstead": "Użyj stałej zamiast tego",
+ "nodes.humanInput.insertInputField.useVarInstead": "Użyj zmiennej zamiast tego",
+ "nodes.humanInput.insertInputField.variable": "zmienna",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "Nazwa zmiennej może zawierać tylko litery, cyfry i podkreślenia i nie może zaczynać się od cyfry",
+ "nodes.humanInput.log.backstageInputURL": "URL wprowadzania w tle:",
+ "nodes.humanInput.log.reason": "Powód:",
+ "nodes.humanInput.log.reasonContent": "Wymagane dane wprowadzone przez człowieka, aby kontynuować",
+ "nodes.humanInput.singleRun.back": "Wstecz",
+ "nodes.humanInput.singleRun.button": "Generuj formularz",
+ "nodes.humanInput.singleRun.label": "Zmienne formularza",
+ "nodes.humanInput.timeout.days": "Dni",
+ "nodes.humanInput.timeout.hours": "Godziny",
+ "nodes.humanInput.timeout.title": "Limit czasu",
+ "nodes.humanInput.userActions.actionIdFormatTip": "Identyfikator akcji musi zaczynać się od litery lub podkreślenia, po którym następują litery, cyfry lub podkreślenia",
+ "nodes.humanInput.userActions.actionIdTooLong": "Identyfikator akcji musi mieć {{maxLength}} znaków lub mniej",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "Nazwa akcji",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "Tekst wyświetlany na przycisku",
+ "nodes.humanInput.userActions.buttonTextTooLong": "Tekst przycisku musi mieć {{maxLength}} znaków lub mniej",
+ "nodes.humanInput.userActions.chooseStyle": "Wybierz styl przycisku",
+ "nodes.humanInput.userActions.emptyTip": "Kliknij przycisk '+', aby dodać akcje użytkownika",
+ "nodes.humanInput.userActions.title": "Akcje użytkownika",
+ "nodes.humanInput.userActions.tooltip": "Zdefiniuj przyciski, które użytkownicy mogą klikać, aby odpowiedzieć na ten formularz. Każdy przycisk może uruchomić różne ścieżki przepływu pracy. Identyfikator akcji musi zaczynać się od litery lub podkreślenia, po którym następują litery, cyfry lub podkreślenia.",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} został uruchomiony",
"nodes.ifElse.addCondition": "Dodaj warunek",
"nodes.ifElse.addSubVariable": "Zmienna podrzędna",
"nodes.ifElse.and": "i",
diff --git a/web/i18n/pt-BR/common.json b/web/i18n/pt-BR/common.json
index 52f4eeb874..f708748a99 100644
--- a/web/i18n/pt-BR/common.json
+++ b/web/i18n/pt-BR/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "Escreva sua palavra de incentivo aqui, digite '{' para inserir uma variável, digite '/' para inserir um bloco de conteúdo de incentivo",
"promptEditor.query.item.desc": "Inserir modelo de consulta do usuário",
"promptEditor.query.item.title": "Consulta",
+ "promptEditor.requestURL.item.desc": "Inserir URL de solicitação",
+ "promptEditor.requestURL.item.title": "URL de Solicitação",
"promptEditor.variable.item.desc": "Inserir Variáveis e Ferramentas Externas",
"promptEditor.variable.item.title": "Variáveis e Ferramentas Externas",
"promptEditor.variable.modal.add": "Nova variável",
diff --git a/web/i18n/pt-BR/share.json b/web/i18n/pt-BR/share.json
index 59e2002fd7..e783317056 100644
--- a/web/i18n/pt-BR/share.json
+++ b/web/i18n/pt-BR/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "Executar uma vez",
"generation.tabs.saved": "Salvo",
"generation.title": "Completar com IA",
+ "humanInput.completed": "Parece que esta solicitação foi tratada em outro lugar.",
+ "humanInput.expirationTimeNowOrFuture": "Esta ação expirará {{relativeTime}}.",
+ "humanInput.expired": "Parece que esta solicitação expirou.",
+ "humanInput.expiredTip": "Esta ação expirou.",
+ "humanInput.formNotFound": "Formulário não encontrado.",
+ "humanInput.rateLimitExceeded": "Muitas solicitações, tente novamente mais tarde.",
+ "humanInput.recorded": "Sua entrada foi registrada.",
+ "humanInput.sorry": "Desculpe!",
+ "humanInput.submissionID": "submission_id: {{id}}",
+ "humanInput.thanks": "Obrigado!",
"login.backToHome": "Voltar para a página inicial"
}
diff --git a/web/i18n/pt-BR/workflow.json b/web/i18n/pt-BR/workflow.json
index a914ad5031..4ddf62d523 100644
--- a/web/i18n/pt-BR/workflow.json
+++ b/web/i18n/pt-BR/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "Extrator de documentos",
"blocks.end": "Saída",
"blocks.http-request": "Requisição HTTP",
+ "blocks.human-input": "Entrada Humana",
"blocks.if-else": "SE/SENÃO",
"blocks.iteration": "Iteração",
"blocks.iteration-start": "Início de iteração",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "Usado para analisar documentos carregados em conteúdo de texto que é facilmente compreensível pelo LLM.",
"blocksAbout.end": "Definir a saída e o tipo de resultado de um fluxo de trabalho",
"blocksAbout.http-request": "Permitir que solicitações de servidor sejam enviadas pelo protocolo HTTP",
+ "blocksAbout.human-input": "Solicitar confirmação humana antes de gerar a próxima etapa",
"blocksAbout.if-else": "Permite dividir o fluxo de trabalho em dois ramos com base nas condições if/else",
"blocksAbout.iteration": "Execute múltiplos passos em um objeto lista até que todos os resultados sejam produzidos.",
"blocksAbout.iteration-start": "Nó de Início da Iteração",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "Os recursos de upload de imagens foram atualizados para upload de arquivos.",
"common.goBackToEdit": "Voltar para o editor",
"common.handMode": "Modo mão",
+ "common.humanInputEmailTip": "E-mail (Método de Entrega) enviado para seus destinatários configurados",
+ "common.humanInputEmailTipInDebugMode": "E-mail (Método de Entrega) enviado para {{email}} ",
+ "common.humanInputWebappTip": "Somente visualização de depuração, o usuário não verá isso no aplicativo web.",
"common.importDSL": "Importar DSL",
"common.importDSLTip": "O rascunho atual será substituído. Exporte o fluxo de trabalho como backup antes de importar.",
"common.importFailure": "Falha na importação",
@@ -500,6 +505,104 @@
"nodes.http.value": "Valor",
"nodes.http.verifySSL.title": "Verificar o certificado SSL",
"nodes.http.verifySSL.warningTooltip": "Desabilitar a verificação SSL não é recomendado para ambientes de produção. Isso deve ser usado apenas em desenvolvimento ou teste, pois torna a conexão vulnerável a ameaças de segurança, como ataques man-in-the-middle.",
+ "nodes.humanInput.deliveryMethod.added": "Adicionado",
+ "nodes.humanInput.deliveryMethod.contactTip1": "Faltando um método de entrega que você precisa?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "Conte-nos em support@dify.ai .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "Todos os membros ({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "Corpo",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "Insira o corpo do e-mail",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "Modo de Depuração",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "No modo de depuração, o e-mail será enviado apenas para o e-mail da sua conta {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "O ambiente de produção não é afetado.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "Enviar solicitação de entrada por e-mail",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ Adicionar",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "Adicionado",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "E-mail, separado por vírgula",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "Adicionar membros do workspace ou destinatários externos",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "Selecionar",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "Destinatário",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "A variável URL de solicitação é a entrada de gatilho para entrada humana.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "Assunto",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "Insira o assunto do e-mail",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "Configuração de E-mail",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "Um e-mail de teste foi enviado para {{email}} . Verifique sua caixa de entrada.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "O modo de depuração está ativado.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "O e-mail será enviado para {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "E-mail Enviado",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(opcional)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "Enviar E-mail",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "Enviar e-mails de teste para seus destinatários configurados",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "Enviar um e-mail de teste para {{email}}",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "É recomendado ativar o Modo de Depuração para testar a entrega de e-mail.",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "Remetente de E-mail de Teste",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "Variáveis no Conteúdo do Formulário",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "Preencha as variáveis do formulário para emular o que os destinatários realmente veem.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "O e-mail foi enviado para os membros de {{team}} e os seguintes endereços de e-mail:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "O e-mail foi enviado para os membros de {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "O e-mail foi enviado para os seguintes endereços de e-mail:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "O e-mail será enviado para os membros de {{team}} e os seguintes endereços de e-mail:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "O e-mail será enviado para os membros de {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "O e-mail será enviado para os seguintes endereços de e-mail:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "Nenhum método de entrega adicionado, a operação não pode ser acionada.",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "Não disponível",
+ "nodes.humanInput.deliveryMethod.notConfigured": "Não configurado",
+ "nodes.humanInput.deliveryMethod.title": "Método de Entrega",
+ "nodes.humanInput.deliveryMethod.tooltip": "Como o formulário de entrada humana é entregue ao usuário.",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "Enviar solicitação de entrada via Discord",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "Enviar solicitação de entrada por e-mail",
+ "nodes.humanInput.deliveryMethod.types.email.title": "E-mail",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "Enviar solicitação de entrada via Slack",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "Enviar solicitação de entrada via Teams",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "Exibir para o usuário final no webapp",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "Webapp",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "Desbloquear entrega de e-mail para Entrada Humana",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "Enviar solicitações de confirmação por e-mail antes que os agentes tomem ações — útil para fluxos de trabalho de publicação e aprovação.",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "Dispensar",
+ "nodes.humanInput.editor.previewTip": "No modo de visualização, os botões de ação não são funcionais.",
+ "nodes.humanInput.errorMsg.duplicateActionId": "ID de ação duplicado encontrado nas ações do usuário",
+ "nodes.humanInput.errorMsg.emptyActionId": "O ID da ação não pode estar vazio",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "O título da ação não pode estar vazio",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "Selecione pelo menos um método de entrega",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "Ative pelo menos um método de entrega",
+ "nodes.humanInput.errorMsg.noUserActions": "Adicione pelo menos uma ação do usuário",
+ "nodes.humanInput.formContent.hotkeyTip": "Pressione para inserir variável, para inserir campo de entrada",
+ "nodes.humanInput.formContent.placeholder": "Digite o conteúdo aqui",
+ "nodes.humanInput.formContent.preview": "Visualizar",
+ "nodes.humanInput.formContent.title": "Conteúdo do Formulário",
+ "nodes.humanInput.formContent.tooltip": "O que os usuários verão após abrir o formulário. Suporta formatação Markdown.",
+ "nodes.humanInput.insertInputField.insert": "Inserir",
+ "nodes.humanInput.insertInputField.prePopulateField": "Pré-preencher Campo",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": "Adicione ou os usuários verão este conteúdo inicialmente, ou deixe vazio.",
+ "nodes.humanInput.insertInputField.saveResponseAs": "Salvar Resposta Como",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "Nomeie esta variável para referência posterior",
+ "nodes.humanInput.insertInputField.staticContent": "Conteúdo Estático",
+ "nodes.humanInput.insertInputField.title": "Inserir Campo de Entrada",
+ "nodes.humanInput.insertInputField.useConstantInstead": "Usar Constante Em Vez Disso",
+ "nodes.humanInput.insertInputField.useVarInstead": "Usar Variável Em Vez Disso",
+ "nodes.humanInput.insertInputField.variable": "variável",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "O nome da variável pode conter apenas letras, números e sublinhados, e não pode começar com um número",
+ "nodes.humanInput.log.backstageInputURL": "URL de entrada nos bastidores:",
+ "nodes.humanInput.log.reason": "Motivo:",
+ "nodes.humanInput.log.reasonContent": "Entrada humana necessária para prosseguir",
+ "nodes.humanInput.singleRun.back": "Voltar",
+ "nodes.humanInput.singleRun.button": "Gerar Formulário",
+ "nodes.humanInput.singleRun.label": "Variáveis do formulário",
+ "nodes.humanInput.timeout.days": "Dias",
+ "nodes.humanInput.timeout.hours": "Horas",
+ "nodes.humanInput.timeout.title": "Tempo Limite",
+ "nodes.humanInput.userActions.actionIdFormatTip": "O ID da ação deve começar com uma letra ou sublinhados, seguido de letras, números ou sublinhados",
+ "nodes.humanInput.userActions.actionIdTooLong": "O ID da ação deve ter {{maxLength}} caracteres ou menos",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "Nome da Ação",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "Texto de exibição do botão",
+ "nodes.humanInput.userActions.buttonTextTooLong": "O texto do botão deve ter {{maxLength}} caracteres ou menos",
+ "nodes.humanInput.userActions.chooseStyle": "Escolha um estilo de botão",
+ "nodes.humanInput.userActions.emptyTip": "Clique no botão '+' para adicionar ações do usuário",
+ "nodes.humanInput.userActions.title": "Ações do Usuário",
+ "nodes.humanInput.userActions.tooltip": "Defina botões em que os usuários podem clicar para responder a este formulário. Cada botão pode acionar diferentes caminhos de fluxo de trabalho. O ID da ação deve começar com uma letra ou sublinhados, seguido de letras, números ou sublinhados.",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} foi acionado",
"nodes.ifElse.addCondition": "Adicionar condição",
"nodes.ifElse.addSubVariable": "Subvariável",
"nodes.ifElse.and": "e",
diff --git a/web/i18n/ro-RO/common.json b/web/i18n/ro-RO/common.json
index 27cb3d4481..aa4c2d372b 100644
--- a/web/i18n/ro-RO/common.json
+++ b/web/i18n/ro-RO/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "Scrieți aici prompt-ul, introduceți '{}' pentru a insera o variabilă, introduceți '/' pentru a insera un bloc de conținut prompt",
"promptEditor.query.item.desc": "Inserați șablon de interogare utilizator",
"promptEditor.query.item.title": "Interogare",
+ "promptEditor.requestURL.item.desc": "Inserați URL-ul cererii",
+ "promptEditor.requestURL.item.title": "URL cerere",
"promptEditor.variable.item.desc": "Inserați variabile și instrumente externe",
"promptEditor.variable.item.title": "Variabile și instrumente externe",
"promptEditor.variable.modal.add": "Nouă variabilă",
diff --git a/web/i18n/ro-RO/share.json b/web/i18n/ro-RO/share.json
index 1410b2e24e..eb8b7ccf4b 100644
--- a/web/i18n/ro-RO/share.json
+++ b/web/i18n/ro-RO/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "Rulează o singură dată",
"generation.tabs.saved": "Salvat",
"generation.title": "Completare AI",
+ "humanInput.completed": "Se pare că această cerere a fost tratată în altă parte.",
+ "humanInput.expirationTimeNowOrFuture": "Această acțiune va expira {{relativeTime}}.",
+ "humanInput.expired": "Se pare că această cerere a expirat.",
+ "humanInput.expiredTip": "Această acțiune a expirat.",
+ "humanInput.formNotFound": "Formular negăsit.",
+ "humanInput.rateLimitExceeded": "Prea multe cereri, vă rugăm încercați din nou mai târziu.",
+ "humanInput.recorded": "Datele dumneavoastră au fost înregistrate.",
+ "humanInput.sorry": "Ne pare rău!",
+ "humanInput.submissionID": "submission_id: {{id}}",
+ "humanInput.thanks": "Mulțumim!",
"login.backToHome": "Înapoi la Acasă"
}
diff --git a/web/i18n/ro-RO/workflow.json b/web/i18n/ro-RO/workflow.json
index 55d56f54fc..507895f9ce 100644
--- a/web/i18n/ro-RO/workflow.json
+++ b/web/i18n/ro-RO/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "Extractor de documente",
"blocks.end": "Ieșire",
"blocks.http-request": "Cerere HTTP",
+ "blocks.human-input": "Input uman",
"blocks.if-else": "Dacă/Altminteri",
"blocks.iteration": "Iterație",
"blocks.iteration-start": "Început de iterație",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "Folosit pentru a analiza documentele încărcate în conținut text care este ușor de înțeles de LLM.",
"blocksAbout.end": "Definiți ieșirea și tipul rezultatului unui flux de lucru",
"blocksAbout.http-request": "Permite trimiterea cererilor de server prin protocolul HTTP",
+ "blocksAbout.human-input": "Cere confirmarea umană înainte de a genera următorul pas",
"blocksAbout.if-else": "Permite împărțirea fluxului de lucru în două ramuri pe baza condițiilor if/else",
"blocksAbout.iteration": "Efectuați mai mulți pași pe un obiect listă până când toate rezultatele sunt produse.",
"blocksAbout.iteration-start": "Nod de început al iterației",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "Funcțiile de încărcare a imaginilor au fost actualizate la încărcarea fișierelor.",
"common.goBackToEdit": "Înapoi la editor",
"common.handMode": "Modul mână",
+ "common.humanInputEmailTip": "Email (Metodă de livrare) trimis către destinatarii dvs. configurați",
+ "common.humanInputEmailTipInDebugMode": "Email (Metodă de livrare) trimis către {{email}} ",
+ "common.humanInputWebappTip": "Doar previzualizare de depanare, utilizatorul nu va vedea acest lucru în aplicația web.",
"common.importDSL": "Importați DSL",
"common.importDSLTip": "Proiectul curent va fi suprascris. Exportați fluxul de lucru ca backup înainte de import.",
"common.importFailure": "Eșecul importului",
@@ -500,6 +505,104 @@
"nodes.http.value": "Valoare",
"nodes.http.verifySSL.title": "Verifică certificatul SSL",
"nodes.http.verifySSL.warningTooltip": "Dezactivarea verificării SSL nu este recomandată pentru medii de producție. Acest lucru ar trebui să fie folosit doar în dezvoltare sau testare, deoarece face conexiunea vulnerabilă la amenințări de securitate, cum ar fi atacurile man-in-the-middle.",
+ "nodes.humanInput.deliveryMethod.added": "Adăugat",
+ "nodes.humanInput.deliveryMethod.contactTip1": "Lipsește o metodă de livrare de care aveți nevoie?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "Spuneți-ne la support@dify.ai .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "Toți membrii ({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "Conținut",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "Introduceți conținutul emailului",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "Mod debug",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "În modul debug, emailul va fi trimis doar la adresa contului dvs. {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "Mediul de producție nu este afectat.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "Trimiteți cererea de input prin email",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ Adăugați",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "Adăugat",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "Email, separate prin virgulă",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "Adăugați membri ai spațiului de lucru sau destinatari externi",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "Selectați",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "Destinatar",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "Variabila URL cerere este punctul de intrare pentru input-ul uman.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "Subiect",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "Introduceți subiectul emailului",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "Configurare Email",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "Un email de testare a fost trimis la {{email}} . Vă rugăm verificați inbox-ul.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "Modul debug este activat.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "Emailul va fi trimis la {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "Email trimis",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(opțional)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "Trimiteți Email",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "Trimiteți emailuri de testare către destinatarii dvs. configurați",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "Trimiteți un email de testare la {{email}}",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "Este recomandat să activați modul debug pentru testarea livrării emailurilor.",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "Expeditor Email de testare",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "Variabile în conținutul formularului",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "Completați variabilele formularului pentru a emula ceea ce văd destinatarii.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "Emailul a fost trimis membrilor {{team}} și la următoarele adrese:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "Emailul a fost trimis membrilor {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "Emailul a fost trimis la următoarele adrese:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "Emailul va fi trimis membrilor {{team}} și la următoarele adrese:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "Emailul va fi trimis membrilor {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "Emailul va fi trimis la următoarele adrese:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "Nicio metodă de livrare adăugată, operația nu poate fi declanșată.",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "Indisponibil",
+ "nodes.humanInput.deliveryMethod.notConfigured": "Neconfigurat",
+ "nodes.humanInput.deliveryMethod.title": "Metodă de livrare",
+ "nodes.humanInput.deliveryMethod.tooltip": "Cum este livrat formularul de input uman utilizatorului.",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "Trimiteți cererea de input prin Discord",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "Trimiteți cererea de input prin email",
+ "nodes.humanInput.deliveryMethod.types.email.title": "Email",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "Trimiteți cererea de input prin Slack",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "Trimiteți cererea de input prin Teams",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "Afișați utilizatorului final în webapp",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "Webapp",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "Deblocați livrarea prin Email pentru Input Uman",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "Trimiteți cereri de confirmare prin email înainte ca agenții să acționeze — util pentru fluxuri de publicare și aprobare.",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "Respingeți",
+ "nodes.humanInput.editor.previewTip": "În modul previzualizare, butoanele de acțiune nu sunt funcționale.",
+ "nodes.humanInput.errorMsg.duplicateActionId": "ID acțiune duplicat găsit în acțiunile utilizatorului",
+ "nodes.humanInput.errorMsg.emptyActionId": "ID-ul acțiunii nu poate fi gol",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "Titlul acțiunii nu poate fi gol",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "Vă rugăm selectați cel puțin o metodă de livrare",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "Vă rugăm activați cel puțin o metodă de livrare",
+ "nodes.humanInput.errorMsg.noUserActions": "Vă rugăm adăugați cel puțin o acțiune utilizator",
+ "nodes.humanInput.formContent.hotkeyTip": "Apăsați pentru a insera variabilă, pentru a insera câmp de input",
+ "nodes.humanInput.formContent.placeholder": "Tastați conținut aici",
+ "nodes.humanInput.formContent.preview": "Previzualizare",
+ "nodes.humanInput.formContent.title": "Conținut formular",
+ "nodes.humanInput.formContent.tooltip": "Ce vor vedea utilizatorii după deschiderea formularului. Suportă formatare Markdown.",
+ "nodes.humanInput.insertInputField.insert": "Inserați",
+ "nodes.humanInput.insertInputField.prePopulateField": "Pre-completați câmpul",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": "Adăugați sau . Utilizatorii vor vedea inițial acest conținut, sau lăsați gol.",
+ "nodes.humanInput.insertInputField.saveResponseAs": "Salvați răspunsul ca",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "Denumiți această variabilă pentru referință ulterioară",
+ "nodes.humanInput.insertInputField.staticContent": "Conținut static",
+ "nodes.humanInput.insertInputField.title": "Inserați câmp de input",
+ "nodes.humanInput.insertInputField.useConstantInstead": "Utilizați constantă în schimb",
+ "nodes.humanInput.insertInputField.useVarInstead": "Utilizați variabilă în schimb",
+ "nodes.humanInput.insertInputField.variable": "variabilă",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "Numele variabilei poate conține doar litere, cifre și underscore și nu poate începe cu o cifră",
+ "nodes.humanInput.log.backstageInputURL": "URL input backstage:",
+ "nodes.humanInput.log.reason": "Motiv:",
+ "nodes.humanInput.log.reasonContent": "Input uman necesar pentru a continua",
+ "nodes.humanInput.singleRun.back": "Înapoi",
+ "nodes.humanInput.singleRun.button": "Generați formular",
+ "nodes.humanInput.singleRun.label": "Variabile formular",
+ "nodes.humanInput.timeout.days": "Zile",
+ "nodes.humanInput.timeout.hours": "Ore",
+ "nodes.humanInput.timeout.title": "Timeout",
+ "nodes.humanInput.userActions.actionIdFormatTip": "ID-ul acțiunii trebuie să înceapă cu o literă sau underscore, urmat de litere, cifre sau underscore",
+ "nodes.humanInput.userActions.actionIdTooLong": "ID-ul acțiunii trebuie să fie de {{maxLength}} caractere sau mai puțin",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "Nume acțiune",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "Text afișat pe buton",
+ "nodes.humanInput.userActions.buttonTextTooLong": "Textul butonului trebuie să fie de {{maxLength}} caractere sau mai puțin",
+ "nodes.humanInput.userActions.chooseStyle": "Alegeți un stil de buton",
+ "nodes.humanInput.userActions.emptyTip": "Faceți clic pe butonul '+' pentru a adăuga acțiuni utilizator",
+ "nodes.humanInput.userActions.title": "Acțiuni utilizator",
+ "nodes.humanInput.userActions.tooltip": "Definiți butoane pe care utilizatorii le pot apăsa pentru a răspunde la acest formular. Fiecare buton poate declanșa căi de flux diferite. ID-ul acțiunii trebuie să înceapă cu o literă sau underscore, urmat de litere, cifre sau underscore.",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} a fost declanșat",
"nodes.ifElse.addCondition": "Adăugați condiție",
"nodes.ifElse.addSubVariable": "Subvariabilă",
"nodes.ifElse.and": "și",
diff --git a/web/i18n/ru-RU/common.json b/web/i18n/ru-RU/common.json
index 52eb905a9d..bbec54c5ed 100644
--- a/web/i18n/ru-RU/common.json
+++ b/web/i18n/ru-RU/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "Напишите здесь свое ключевое слово подсказки, введите '{', чтобы вставить переменную, введите '/', чтобы вставить блок содержимого подсказки",
"promptEditor.query.item.desc": "Вставить шаблон запроса пользователя",
"promptEditor.query.item.title": "Запрос",
+ "promptEditor.requestURL.item.desc": "Вставить URL запроса",
+ "promptEditor.requestURL.item.title": "URL запроса",
"promptEditor.variable.item.desc": "Вставить переменные и внешние инструменты",
"promptEditor.variable.item.title": "Переменные и внешние инструменты",
"promptEditor.variable.modal.add": "Новая переменная",
diff --git a/web/i18n/ru-RU/share.json b/web/i18n/ru-RU/share.json
index a091958dec..4b883ca7da 100644
--- a/web/i18n/ru-RU/share.json
+++ b/web/i18n/ru-RU/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "Запустить один раз",
"generation.tabs.saved": "Сохраненные",
"generation.title": "Завершение ИИ",
+ "humanInput.completed": "Похоже, этот запрос был обработан в другом месте.",
+ "humanInput.expirationTimeNowOrFuture": "Это действие истечет {{relativeTime}}.",
+ "humanInput.expired": "Похоже, срок действия этого запроса истёк.",
+ "humanInput.expiredTip": "Срок действия этого действия истёк.",
+ "humanInput.formNotFound": "Форма не найдена.",
+ "humanInput.rateLimitExceeded": "Слишком много запросов, пожалуйста, попробуйте позже.",
+ "humanInput.recorded": "Ваш ввод был записан.",
+ "humanInput.sorry": "Извините!",
+ "humanInput.submissionID": "submission_id: {{id}}",
+ "humanInput.thanks": "Спасибо!",
"login.backToHome": "Назад на главную"
}
diff --git a/web/i18n/ru-RU/workflow.json b/web/i18n/ru-RU/workflow.json
index 3856032cad..a00be27f7b 100644
--- a/web/i18n/ru-RU/workflow.json
+++ b/web/i18n/ru-RU/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "Экстрактор документов",
"blocks.end": "Вывод",
"blocks.http-request": "HTTP-запрос",
+ "blocks.human-input": "Ввод человека",
"blocks.if-else": "ЕСЛИ/ИНАЧЕ",
"blocks.iteration": "Итерация",
"blocks.iteration-start": "Начало итерации",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "Используется для разбора загруженных документов в текстовый контент, который легко воспринимается LLM.",
"blocksAbout.end": "Определите вывод и тип результата рабочего процесса",
"blocksAbout.http-request": "Разрешить отправку запросов на сервер по протоколу HTTP",
+ "blocksAbout.human-input": "Запросить подтверждение человека перед генерацией следующего шага",
"blocksAbout.if-else": "Позволяет разделить рабочий процесс на две ветки на основе условий if/else",
"blocksAbout.iteration": "Выполнение нескольких шагов над объектом списка до тех пор, пока не будут выведены все результаты.",
"blocksAbout.iteration-start": "Начальный узел итерации",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "Функции загрузки изображений были обновлены до загрузки файлов.",
"common.goBackToEdit": "Вернуться к редактору",
"common.handMode": "Режим руки",
+ "common.humanInputEmailTip": "Электронное письмо (метод доставки) отправлено настроенным получателям",
+ "common.humanInputEmailTipInDebugMode": "Электронное письмо (метод доставки) отправлено на {{email}} ",
+ "common.humanInputWebappTip": "Только предварительный просмотр отладки, пользователь не увидит это в веб-приложении.",
"common.importDSL": "Импортировать DSL",
"common.importDSLTip": "Текущий черновик будет перезаписан. Экспортируйте рабочий процесс в качестве резервной копии перед импортом.",
"common.importFailure": "Ошибка импорта",
@@ -500,6 +505,104 @@
"nodes.http.value": "Значение",
"nodes.http.verifySSL.title": "Проверить SSL-сертификат",
"nodes.http.verifySSL.warningTooltip": "Отключение проверки SSL не рекомендуется для производственных сред. Это следует использовать только в разработке или тестировании, так как это делает соединение уязвимым для угроз безопасности, таких как атаки «человек посередине».",
+ "nodes.humanInput.deliveryMethod.added": "Добавлено",
+ "nodes.humanInput.deliveryMethod.contactTip1": "Не хватает нужного вам способа доставки?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "Сообщите нам по адресу support@dify.ai .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "Все участники ({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "Содержание",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "Введите текст письма",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "Режим отладки",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "В режиме отладки письмо будет отправлено только на ваш аккаунт {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "Производственная среда не затронута.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "Отправить запрос на ввод по электронной почте",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ Добавить",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "Добавлено",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "Email, через запятую",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "Добавить участников рабочего пространства или внешних получателей",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "Выбрать",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "Получатель",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "Переменная URL запроса является точкой входа для ввода человека.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "Тема",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "Введите тему письма",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "Настройка электронной почты",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "Тестовое письмо отправлено на {{email}} . Пожалуйста, проверьте ваш почтовый ящик.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "Режим отладки включён.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "Письмо будет отправлено на {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "Письмо отправлено",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(необязательно)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "Отправить письмо",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "Отправить тестовые письма вашим настроенным получателям",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "Отправить тестовое письмо на {{email}}",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "Рекомендуется включить режим отладки для тестирования доставки писем.",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "Тестовый отправитель писем",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "Переменные в содержимом формы",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "Заполните переменные формы, чтобы эмулировать то, что на самом деле видят получатели.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "Письмо отправлено участникам {{team}} и следующим адресам электронной почты:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "Письмо отправлено участникам {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "Письмо отправлено на следующие адреса электронной почты:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "Письмо будет отправлено участникам {{team}} и следующим адресам электронной почты:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "Письмо будет отправлено участникам {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "Письмо будет отправлено на следующие адреса электронной почты:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "Способ доставки не добавлен, операция не может быть запущена.",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "Недоступно",
+ "nodes.humanInput.deliveryMethod.notConfigured": "Не настроено",
+ "nodes.humanInput.deliveryMethod.title": "Способ доставки",
+ "nodes.humanInput.deliveryMethod.tooltip": "Как форма ввода человека доставляется пользователю.",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "Отправить запрос на ввод через Discord",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "Отправить запрос на ввод по электронной почте",
+ "nodes.humanInput.deliveryMethod.types.email.title": "Электронная почта",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "Отправить запрос на ввод через Slack",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "Отправить запрос на ввод через Teams",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "Показать конечному пользователю в веб-приложении",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "Веб-приложение",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "Разблокировать доставку по электронной почте для ввода человека",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "Отправляйте запросы на подтверждение по электронной почте до того, как агенты предпримут действия — полезно для публикации и рабочих процессов утверждения.",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "Отклонить",
+ "nodes.humanInput.editor.previewTip": "В режиме предварительного просмотра кнопки действий не функционируют.",
+ "nodes.humanInput.errorMsg.duplicateActionId": "Обнаружен дублирующийся идентификатор действия в действиях пользователя",
+ "nodes.humanInput.errorMsg.emptyActionId": "Идентификатор действия не может быть пустым",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "Название действия не может быть пустым",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "Пожалуйста, выберите хотя бы один способ доставки",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "Пожалуйста, включите хотя бы один способ доставки",
+ "nodes.humanInput.errorMsg.noUserActions": "Пожалуйста, добавьте хотя бы одно действие пользователя",
+ "nodes.humanInput.formContent.hotkeyTip": "Нажмите для вставки переменной, для вставки поля ввода",
+ "nodes.humanInput.formContent.placeholder": "Введите содержимое здесь",
+ "nodes.humanInput.formContent.preview": "Предварительный просмотр",
+ "nodes.humanInput.formContent.title": "Содержимое формы",
+ "nodes.humanInput.formContent.tooltip": "Что увидят пользователи после открытия формы. Поддерживает форматирование Markdown.",
+ "nodes.humanInput.insertInputField.insert": "Вставить",
+ "nodes.humanInput.insertInputField.prePopulateField": "Предварительно заполнить поле",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": "Добавьте или . Пользователи изначально увидят это содержимое, или оставьте пустым.",
+ "nodes.humanInput.insertInputField.saveResponseAs": "Сохранить ответ как",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "Назовите эту переменную для последующей ссылки",
+ "nodes.humanInput.insertInputField.staticContent": "Статическое содержимое",
+ "nodes.humanInput.insertInputField.title": "Вставить поле ввода",
+ "nodes.humanInput.insertInputField.useConstantInstead": "Использовать константу вместо этого",
+ "nodes.humanInput.insertInputField.useVarInstead": "Использовать переменную вместо этого",
+ "nodes.humanInput.insertInputField.variable": "переменная",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "Имя переменной может содержать только буквы, цифры и подчеркивания и не может начинаться с цифры",
+ "nodes.humanInput.log.backstageInputURL": "URL ввода за кулисами:",
+ "nodes.humanInput.log.reason": "Причина:",
+ "nodes.humanInput.log.reasonContent": "Требуется ввод человека для продолжения",
+ "nodes.humanInput.singleRun.back": "Назад",
+ "nodes.humanInput.singleRun.button": "Сгенерировать форму",
+ "nodes.humanInput.singleRun.label": "Переменные формы",
+ "nodes.humanInput.timeout.days": "Дни",
+ "nodes.humanInput.timeout.hours": "Часы",
+ "nodes.humanInput.timeout.title": "Тайм-аут",
+ "nodes.humanInput.userActions.actionIdFormatTip": "Идентификатор действия должен начинаться с буквы или подчеркивания, за которым следуют буквы, цифры или подчеркивания",
+ "nodes.humanInput.userActions.actionIdTooLong": "Идентификатор действия должен содержать не более {{maxLength}} символов",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "Название действия",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "Текст кнопки для отображения",
+ "nodes.humanInput.userActions.buttonTextTooLong": "Текст кнопки должен содержать не более {{maxLength}} символов",
+ "nodes.humanInput.userActions.chooseStyle": "Выберите стиль кнопки",
+ "nodes.humanInput.userActions.emptyTip": "Нажмите кнопку '+' для добавления действий пользователя",
+ "nodes.humanInput.userActions.title": "Действия пользователя",
+ "nodes.humanInput.userActions.tooltip": "Определите кнопки, на которые пользователи могут нажимать, чтобы ответить на эту форму. Каждая кнопка может запускать разные пути рабочего процесса. Идентификатор действия должен начинаться с буквы или подчеркивания, за которым следуют буквы, цифры или подчеркивания.",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} был запущен",
"nodes.ifElse.addCondition": "Добавить условие",
"nodes.ifElse.addSubVariable": "Подпеременная",
"nodes.ifElse.and": "и",
diff --git a/web/i18n/sl-SI/common.json b/web/i18n/sl-SI/common.json
index 1c822cb4e3..df09fe767d 100644
--- a/web/i18n/sl-SI/common.json
+++ b/web/i18n/sl-SI/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "Tukaj vnesite svojo pozivno besedo, vnesite '{' za vstavljanje spremenljivke, vnesite '/', da vstavite blok vsebine",
"promptEditor.query.item.desc": "Vstavljanje predloge uporabniške poizvedbe",
"promptEditor.query.item.title": "Poizvedba",
+ "promptEditor.requestURL.item.desc": "Vstavi URL zahteve",
+ "promptEditor.requestURL.item.title": "URL zahteve",
"promptEditor.variable.item.desc": "Vstavljanje spremenljivk in zunanjih orodij",
"promptEditor.variable.item.title": "Spremenljivke in zunanja orodja",
"promptEditor.variable.modal.add": "Nova spremenljivka",
diff --git a/web/i18n/sl-SI/share.json b/web/i18n/sl-SI/share.json
index 37fe88eb67..4b745a342a 100644
--- a/web/i18n/sl-SI/share.json
+++ b/web/i18n/sl-SI/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "Zaženi enkrat",
"generation.tabs.saved": "Shranjeno",
"generation.title": "AI Zaključek",
+ "humanInput.completed": "Videti je, da je bila ta zahteva obravnavana drugje.",
+ "humanInput.expirationTimeNowOrFuture": "To dejanje bo poteklo {{relativeTime}}.",
+ "humanInput.expired": "Videti je, da je ta zahteva potekla.",
+ "humanInput.expiredTip": "To dejanje je poteklo.",
+ "humanInput.formNotFound": "Obrazec ni bil najden.",
+ "humanInput.rateLimitExceeded": "Preveč zahtev, poskusite znova kasneje.",
+ "humanInput.recorded": "Vaš vnos je bil zabeležen.",
+ "humanInput.sorry": "Oprostite!",
+ "humanInput.submissionID": "submission_id: {{id}}",
+ "humanInput.thanks": "Hvala!",
"login.backToHome": "Nazaj na začetno stran"
}
diff --git a/web/i18n/sl-SI/workflow.json b/web/i18n/sl-SI/workflow.json
index 5bb5f89467..381d7f866c 100644
--- a/web/i18n/sl-SI/workflow.json
+++ b/web/i18n/sl-SI/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "Ekstraktor dokumentov",
"blocks.end": "Izhod",
"blocks.http-request": "HTTP zahteva",
+ "blocks.human-input": "Človeški vnos",
"blocks.if-else": "Če/Drugače",
"blocks.iteration": "Iteracija",
"blocks.iteration-start": "Začetek iteracije",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "Uporabljeno za razčlenitev prenesenih dokumentov v besedilno vsebino, ki jo je enostavno razumeti za LLM.",
"blocksAbout.end": "Določite izhod in tip rezultata delovnega toka",
"blocksAbout.http-request": "Dovoli pošiljanje zahtevkov strežniku prek protokola HTTP",
+ "blocksAbout.human-input": "Prosite za človeško potrditev pred ustvarjanjem naslednjega koraka",
"blocksAbout.if-else": "Omogoča vam, da razdelite delovni tok na dve veji na podlagi pogojev if/else.",
"blocksAbout.iteration": "Izvedite več korakov na seznamu objektov, dokler niso vsi rezultati izpisani.",
"blocksAbout.iteration-start": "Začetni vozel iteracije",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "Funkcije nalaganja slik so bile nadgrajene na nalaganje datotek.",
"common.goBackToEdit": "Pojdi nazaj k uredniku",
"common.handMode": "Ročni način",
+ "common.humanInputEmailTip": "E-pošta (način dostave) poslana vašim konfiguriranim prejemnikom",
+ "common.humanInputEmailTipInDebugMode": "E-pošta (način dostave) poslana na {{email}} ",
+ "common.humanInputWebappTip": "Samo predogled za odpravljanje napak, uporabnik tega ne bo videl v spletni aplikaciji.",
"common.importDSL": "Uvozi DSL",
"common.importDSLTip": "Trenutni osnutek bo prepisan. Izvozite delovni postopek kot varnostno kopijo pred uvozom.",
"common.importFailure": "Uvoz ni uspel",
@@ -500,6 +505,104 @@
"nodes.http.value": "Vrednost",
"nodes.http.verifySSL.title": "Preverite SSL certifikat",
"nodes.http.verifySSL.warningTooltip": "Onemogočanje preverjanja SSL ni priporočljivo za proizvodna okolja. To bi se moralo uporabljati le pri razvoju ali testiranju, saj povezavo izpostavi varnostnim grožnjam, kot so napadi človek-v-sredini.",
+ "nodes.humanInput.deliveryMethod.added": "Dodano",
+ "nodes.humanInput.deliveryMethod.contactTip1": "Manjka vam način dostave, ki ga potrebujete?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "Povejte nam na support@dify.ai .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "Vsi člani ({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "Vsebina",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "Vnesite vsebino e-pošte",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "Način odpravljanja napak",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "V načinu odpravljanja napak bo e-pošta poslana samo na vaš e-poštni naslov {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "Produkcijsko okolje ni prizadeto.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "Pošlji zahtevo za vnos po e-pošti",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ Dodaj",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "Dodano",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "E-pošta, ločena z vejico",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "Dodaj člane delovnega prostora ali zunanje prejemnike",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "Izberi",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "Prejemnik",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "Spremenljivka URL zahteve je vstopna točka za človeški vnos.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "Zadeva",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "Vnesite zadevo e-pošte",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "Konfiguracija e-pošte",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "Testna e-pošta je bila poslana na {{email}} . Preverite svoj nabiralnik.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "Način odpravljanja napak je omogočen.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "E-pošta bo poslana na {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "E-pošta poslana",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(neobvezno)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "Pošlji e-pošto",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "Pošljite testne e-pošte vašim konfiguriranim prejemnikom",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "Pošljite testno e-pošto na {{email}}",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "Priporočljivo je omogočiti način odpravljanja napak za testiranje dostave e-pošte.",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "Testni pošiljatelj e-pošte",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "Spremenljivke v vsebini obrazca",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "Izpolnite spremenljivke obrazca, da posnemete, kaj prejemniki dejansko vidijo.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "E-pošta je bila poslana članom {{team}} in naslednjim e-poštnim naslovom:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "E-pošta je bila poslana članom {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "E-pošta je bila poslana naslednjim e-poštnim naslovom:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "E-pošta bo poslana članom {{team}} in naslednjim e-poštnim naslovom:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "E-pošta bo poslana članom {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "E-pošta bo poslana naslednjim e-poštnim naslovom:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "Dodan ni bil noben način dostave, operacije ni mogoče sprožiti.",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "Ni na voljo",
+ "nodes.humanInput.deliveryMethod.notConfigured": "Ni konfigurirano",
+ "nodes.humanInput.deliveryMethod.title": "Način dostave",
+ "nodes.humanInput.deliveryMethod.tooltip": "Kako se obrazec za človeški vnos dostavi uporabniku.",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "Pošlji zahtevo za vnos prek Discord",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "Pošlji zahtevo za vnos po e-pošti",
+ "nodes.humanInput.deliveryMethod.types.email.title": "E-pošta",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "Pošlji zahtevo za vnos prek Slack",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "Pošlji zahtevo za vnos prek Teams",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "Prikaži končnemu uporabniku v spletni aplikaciji",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "Spletna aplikacija",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "Odklenite dostavo po e-pošti za človeški vnos",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "Pošiljajte zahteve za potrditev po e-pošti, preden agenti ukrepajo — koristno za objavo in potrditev delovnih tokov.",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "Opusti",
+ "nodes.humanInput.editor.previewTip": "V načinu predogleda gumbi za dejanja niso funkcionalni.",
+ "nodes.humanInput.errorMsg.duplicateActionId": "V uporabniških dejanjih je bil najden podvojen ID dejanja",
+ "nodes.humanInput.errorMsg.emptyActionId": "ID dejanja ne sme biti prazen",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "Naslov dejanja ne sme biti prazen",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "Izberite vsaj en način dostave",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "Omogočite vsaj en način dostave",
+ "nodes.humanInput.errorMsg.noUserActions": "Dodajte vsaj eno uporabniško dejanje",
+ "nodes.humanInput.formContent.hotkeyTip": "Pritisnite za vstavljanje spremenljivke, za vstavljanje vnosnega polja",
+ "nodes.humanInput.formContent.placeholder": "Vnesite vsebino tukaj",
+ "nodes.humanInput.formContent.preview": "Predogled",
+ "nodes.humanInput.formContent.title": "Vsebina obrazca",
+ "nodes.humanInput.formContent.tooltip": "Kaj bodo uporabniki videli po odprtju obrazca. Podpira oblikovanje Markdown.",
+ "nodes.humanInput.insertInputField.insert": "Vstavi",
+ "nodes.humanInput.insertInputField.prePopulateField": "Vnaprej izpolni polje",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": "Dodajte ali . Uporabniki bodo na začetku videli to vsebino ali pustite prazno.",
+ "nodes.humanInput.insertInputField.saveResponseAs": "Shrani odgovor kot",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "Poimenujte to spremenljivko za poznejšo sklicevanje",
+ "nodes.humanInput.insertInputField.staticContent": "Statična vsebina",
+ "nodes.humanInput.insertInputField.title": "Vstavi vnosno polje",
+ "nodes.humanInput.insertInputField.useConstantInstead": "Namesto tega uporabi konstanto",
+ "nodes.humanInput.insertInputField.useVarInstead": "Namesto tega uporabi spremenljivko",
+ "nodes.humanInput.insertInputField.variable": "spremenljivka",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "Ime spremenljivke lahko vsebuje samo črke, številke in podčrtaje ter se ne sme začeti s številko",
+ "nodes.humanInput.log.backstageInputURL": "URL vnosa v ozadju:",
+ "nodes.humanInput.log.reason": "Razlog:",
+ "nodes.humanInput.log.reasonContent": "Za nadaljevanje je potreben človeški vnos",
+ "nodes.humanInput.singleRun.back": "Nazaj",
+ "nodes.humanInput.singleRun.button": "Ustvari obrazec",
+ "nodes.humanInput.singleRun.label": "Spremenljivke obrazca",
+ "nodes.humanInput.timeout.days": "Dnevi",
+ "nodes.humanInput.timeout.hours": "Ure",
+ "nodes.humanInput.timeout.title": "Časovna omejitev",
+ "nodes.humanInput.userActions.actionIdFormatTip": "ID dejanja se mora začeti s črko ali podčrtajem, ki mu sledijo črke, številke ali podčrtaji",
+ "nodes.humanInput.userActions.actionIdTooLong": "ID dejanja mora biti {{maxLength}} znakov ali manj",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "Ime dejanja",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "Besedilo za prikaz na gumbu",
+ "nodes.humanInput.userActions.buttonTextTooLong": "Besedilo gumba mora biti {{maxLength}} znakov ali manj",
+ "nodes.humanInput.userActions.chooseStyle": "Izberite slog gumba",
+ "nodes.humanInput.userActions.emptyTip": "Kliknite gumb '+' za dodajanje uporabniških dejanj",
+ "nodes.humanInput.userActions.title": "Uporabniška dejanja",
+ "nodes.humanInput.userActions.tooltip": "Definirajte gumbe, ki jih lahko uporabniki kliknejo, da se odzovejo na ta obrazec. Vsak gumb lahko sproži različne poti delovnega toka. ID dejanja se mora začeti s črko ali podčrtajem, ki mu sledijo črke, številke ali podčrtaji.",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} je bil sprožen",
"nodes.ifElse.addCondition": "Dodaj pogoj",
"nodes.ifElse.addSubVariable": "Podspremenljivka",
"nodes.ifElse.and": "in",
diff --git a/web/i18n/th-TH/common.json b/web/i18n/th-TH/common.json
index 494d32f12d..d49da77931 100644
--- a/web/i18n/th-TH/common.json
+++ b/web/i18n/th-TH/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "เขียนคําพร้อมท์ของคุณที่นี่ ป้อน '{' เพื่อแทรกตัวแปร ป้อน '/' เพื่อแทรกบล็อกเนื้อหาพร้อมท์",
"promptEditor.query.item.desc": "แทรกเทมเพลตแบบสอบถามของผู้ใช้",
"promptEditor.query.item.title": "สอบถาม",
+ "promptEditor.requestURL.item.desc": "แทรก URL คำขอ",
+ "promptEditor.requestURL.item.title": "URL คำขอ",
"promptEditor.variable.item.desc": "แทรกตัวแปรและเครื่องมือภายนอก",
"promptEditor.variable.item.title": "ตัวแปรและเครื่องมือภายนอก",
"promptEditor.variable.modal.add": "ตัวแปรใหม่",
diff --git a/web/i18n/th-TH/share.json b/web/i18n/th-TH/share.json
index 511c45a15c..10b4536f44 100644
--- a/web/i18n/th-TH/share.json
+++ b/web/i18n/th-TH/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "เรียกใช้ครั้งเดียว",
"generation.tabs.saved": "บันทึก",
"generation.title": "ความสมบูรณ์ของ AI",
+ "humanInput.completed": "ดูเหมือนว่าคำขอนี้ได้รับการจัดการในที่อื่นแล้ว",
+ "humanInput.expirationTimeNowOrFuture": "การดำเนินการนี้จะหมดอายุ {{relativeTime}}",
+ "humanInput.expired": "ดูเหมือนว่าคำขอนี้หมดอายุแล้ว",
+ "humanInput.expiredTip": "การดำเนินการนี้หมดอายุแล้ว",
+ "humanInput.formNotFound": "ไม่พบแบบฟอร์ม",
+ "humanInput.rateLimitExceeded": "คำขอมากเกินไป โปรดลองอีกครั้งในภายหลัง",
+ "humanInput.recorded": "ข้อมูลของคุณได้รับการบันทึกแล้ว",
+ "humanInput.sorry": "ขออภัย!",
+ "humanInput.submissionID": "submission_id: {{id}}",
+ "humanInput.thanks": "ขอบคุณ!",
"login.backToHome": "กลับไปที่หน้าแรก"
}
diff --git a/web/i18n/th-TH/workflow.json b/web/i18n/th-TH/workflow.json
index 6cf7f37b7a..d1c767061c 100644
--- a/web/i18n/th-TH/workflow.json
+++ b/web/i18n/th-TH/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "ตัวแยกเอกสาร",
"blocks.end": "เอาต์พุต",
"blocks.http-request": "คําขอ HTTP",
+ "blocks.human-input": "ข้อมูลจากมนุษย์",
"blocks.if-else": "ถ้า/อื่น",
"blocks.iteration": "เกิด ซ้ำ",
"blocks.iteration-start": "เริ่มการทําซ้ํา",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "ใช้เพื่อแยกวิเคราะห์เอกสารที่อัปโหลดเป็นเนื้อหาข้อความที่ LLM เข้าใจได้ง่าย",
"blocksAbout.end": "กำหนดเอาต์พุตและประเภทผลลัพธ์ของเวิร์กโฟลว์",
"blocksAbout.http-request": "อนุญาตให้ส่งคําขอเซิร์ฟเวอร์ผ่านโปรโตคอล HTTP",
+ "blocksAbout.human-input": "ขอให้มนุษย์ยืนยันก่อนที่จะสร้างขั้นตอนถัดไป",
"blocksAbout.if-else": "ช่วยให้คุณสามารถแบ่งเวิร์กโฟลว์ออกเป็นสองสาขาตามเงื่อนไข if/else",
"blocksAbout.iteration": "ดําเนินการหลายขั้นตอนกับวัตถุรายการจนกว่าจะส่งออกผลลัพธ์ทั้งหมด",
"blocksAbout.iteration-start": "จุดเริ่มต้นของการวนซ้ำ",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "ฟีเจอร์การอัปโหลดรูปภาพได้รับการอัปเกรดเป็นการอัปโหลดไฟล์",
"common.goBackToEdit": "กลับไปที่ตัวแก้ไข",
"common.handMode": "โหมดมือ",
+ "common.humanInputEmailTip": "อีเมล (วิธีการจัดส่ง) ส่งถึงผู้รับที่กำหนดค่าไว้ของคุณ",
+ "common.humanInputEmailTipInDebugMode": "อีเมล (วิธีการจัดส่ง) ส่งไปที่ {{email}} ",
+ "common.humanInputWebappTip": "แสดงตัวอย่างดีบักเท่านั้น ผู้ใช้จะไม่เห็นสิ่งนี้ในเว็บแอป",
"common.importDSL": "นําเข้า DSL",
"common.importDSLTip": "ร่างปัจจุบันจะถูกเขียนทับ\nส่งออกเวิร์กโฟลว์เป็นข้อมูลสํารองก่อนนําเข้า",
"common.importFailure": "นําเข้าล้มเหลว",
@@ -500,6 +505,104 @@
"nodes.http.value": "ค่า",
"nodes.http.verifySSL.title": "ตรวจสอบใบรับรอง SSL",
"nodes.http.verifySSL.warningTooltip": "การปิดการตรวจสอบ SSL ไม่แนะนำให้ใช้ในสภาพแวดล้อมการผลิต ควรใช้เฉพาะในระหว่างการพัฒนาหรือการทดสอบเท่านั้น เนื่องจากจะทำให้การเชื่อมต่อมีความเสี่ยงต่อภัยคุกคามด้านความปลอดภัย เช่น การโจมตีแบบ Man-in-the-middle.",
+ "nodes.humanInput.deliveryMethod.added": "เพิ่มแล้ว",
+ "nodes.humanInput.deliveryMethod.contactTip1": "ไม่มีวิธีการส่งมอบที่คุณต้องการหรือไม่?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "แจ้งให้เราทราบที่ support@dify.ai ",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "สมาชิกทั้งหมด ({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "เนื้อหา",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "กรอกเนื้อหาอีเมล",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "โหมดดีบัก",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "ในโหมดดีบัก อีเมลจะถูกส่งไปที่อีเมลบัญชีของคุณ {{email}} เท่านั้น",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "สภาพแวดล้อมการผลิตไม่ได้รับผลกระทบ",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "ส่งคำขอข้อมูลทางอีเมล",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ เพิ่ม",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "เพิ่มแล้ว",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "อีเมล คั่นด้วยเครื่องหมายจุลภาค",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "เพิ่มสมาชิกพื้นที่ทำงานหรือผู้รับภายนอก",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "เลือก",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "ผู้รับ",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "ตัวแปร URL คำขอคือจุดเข้าใช้งานสำหรับข้อมูลจากมนุษย์",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "หัวเรื่อง",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "กรอกหัวเรื่องอีเมล",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "การกำหนดค่าอีเมล",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "ส่งอีเมลทดสอบไปที่ {{email}} แล้ว โปรดตรวจสอบกล่องจดหมายของคุณ",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "เปิดใช้งานโหมดดีบักแล้ว",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "อีเมลจะถูกส่งไปที่ {{email}} ",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "ส่งอีเมลแล้ว",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(ไม่จำเป็น)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "ส่งอีเมล",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "ส่งอีเมลทดสอบไปยังผู้รับที่กำหนดค่าไว้ของคุณ",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "ส่งอีเมลทดสอบไปที่ {{email}}",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "แนะนำให้ เปิดใช้งานโหมดดีบัก เพื่อทดสอบการส่งอีเมล",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "ผู้ส่งอีเมลทดสอบ",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "ตัวแปรในเนื้อหาแบบฟอร์ม",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "กรอกตัวแปรแบบฟอร์มเพื่อจำลองสิ่งที่ผู้รับเห็นจริง",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "ส่งอีเมลไปยังสมาชิก {{team}} และที่อยู่อีเมลต่อไปนี้แล้ว:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "ส่งอีเมลไปยังสมาชิก {{team}} แล้ว",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "ส่งอีเมลไปยังที่อยู่อีเมลต่อไปนี้แล้ว:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "อีเมลจะถูกส่งไปยังสมาชิก {{team}} และที่อยู่อีเมลต่อไปนี้:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "อีเมลจะถูกส่งไปยังสมาชิก {{team}} ",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "อีเมลจะถูกส่งไปยังที่อยู่อีเมลต่อไปนี้:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "ไม่มีวิธีการส่งมอบที่เพิ่มเข้ามา ไม่สามารถเรียกใช้การดำเนินการได้",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "ไม่พร้อมใช้งาน",
+ "nodes.humanInput.deliveryMethod.notConfigured": "ยังไม่ได้กำหนดค่า",
+ "nodes.humanInput.deliveryMethod.title": "วิธีการส่งมอบ",
+ "nodes.humanInput.deliveryMethod.tooltip": "วิธีที่แบบฟอร์มข้อมูลจากมนุษย์ถูกส่งมอบให้กับผู้ใช้",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "ส่งคำขอข้อมูลทาง Discord",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "ส่งคำขอข้อมูลทางอีเมล",
+ "nodes.humanInput.deliveryMethod.types.email.title": "อีเมล",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "ส่งคำขอข้อมูลทาง Slack",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "ส่งคำขอข้อมูลทาง Teams",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "แสดงให้ผู้ใช้ปลายทางเห็นในเว็บแอป",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "เว็บแอป",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "ปลดล็อกการส่งอีเมลสำหรับข้อมูลจากมนุษย์",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "ส่งคำขอยืนยันทางอีเมลก่อนที่ตัวแทนจะดำเนินการ — มีประโยชน์สำหรับการเผยแพร่และเวิร์กโฟลว์การอนุมัติ",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "ปิด",
+ "nodes.humanInput.editor.previewTip": "ในโหมดตัวอย่าง ปุ่มการดำเนินการไม่ทำงาน",
+ "nodes.humanInput.errorMsg.duplicateActionId": "พบ ID การดำเนินการซ้ำกันในการดำเนินการของผู้ใช้",
+ "nodes.humanInput.errorMsg.emptyActionId": "ID การดำเนินการต้องไม่ว่างเปล่า",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "ชื่อการดำเนินการต้องไม่ว่างเปล่า",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "โปรดเลือกวิธีการส่งมอบอย่างน้อยหนึ่งวิธี",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "โปรดเปิดใช้งานวิธีการส่งมอบอย่างน้อยหนึ่งวิธี",
+ "nodes.humanInput.errorMsg.noUserActions": "โปรดเพิ่มการดำเนินการของผู้ใช้อย่างน้อยหนึ่งรายการ",
+ "nodes.humanInput.formContent.hotkeyTip": "กด เพื่อแทรกตัวแปร เพื่อแทรกฟิลด์ข้อมูล",
+ "nodes.humanInput.formContent.placeholder": "พิมพ์เนื้อหาที่นี่",
+ "nodes.humanInput.formContent.preview": "ตัวอย่าง",
+ "nodes.humanInput.formContent.title": "เนื้อหาแบบฟอร์ม",
+ "nodes.humanInput.formContent.tooltip": "สิ่งที่ผู้ใช้จะเห็นหลังจากเปิดแบบฟอร์ม รองรับการจัดรูปแบบ Markdown",
+ "nodes.humanInput.insertInputField.insert": "แทรก",
+ "nodes.humanInput.insertInputField.prePopulateField": "กรอกล่วงหน้าในฟิลด์",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": "เพิ่ม หรือ ผู้ใช้จะเห็นเนื้อหานี้ในตอนแรก หรือเว้นว่างไว้",
+ "nodes.humanInput.insertInputField.saveResponseAs": "บันทึกการตอบสนองเป็น",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "ตั้งชื่อตัวแปรนี้สำหรับการอ้างอิงในภายหลัง",
+ "nodes.humanInput.insertInputField.staticContent": "เนื้อหาคงที่",
+ "nodes.humanInput.insertInputField.title": "แทรกฟิลด์ข้อมูล",
+ "nodes.humanInput.insertInputField.useConstantInstead": "ใช้ค่าคงที่แทน",
+ "nodes.humanInput.insertInputField.useVarInstead": "ใช้ตัวแปรแทน",
+ "nodes.humanInput.insertInputField.variable": "ตัวแปร",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "ชื่อตัวแปรสามารถมีได้เฉพาะตัวอักษร ตัวเลข และขีดล่าง และต้องไม่เริ่มต้นด้วยตัวเลข",
+ "nodes.humanInput.log.backstageInputURL": "URL ข้อมูลเบื้องหลัง:",
+ "nodes.humanInput.log.reason": "เหตุผล:",
+ "nodes.humanInput.log.reasonContent": "ต้องการข้อมูลจากมนุษย์เพื่อดำเนินการต่อ",
+ "nodes.humanInput.singleRun.back": "ย้อนกลับ",
+ "nodes.humanInput.singleRun.button": "สร้างแบบฟอร์ม",
+ "nodes.humanInput.singleRun.label": "ตัวแปรแบบฟอร์ม",
+ "nodes.humanInput.timeout.days": "วัน",
+ "nodes.humanInput.timeout.hours": "ชั่วโมง",
+ "nodes.humanInput.timeout.title": "หมดเวลา",
+ "nodes.humanInput.userActions.actionIdFormatTip": "ID การดำเนินการต้องเริ่มต้นด้วยตัวอักษรหรือขีดล่าง ตามด้วยตัวอักษร ตัวเลข หรือขีดล่าง",
+ "nodes.humanInput.userActions.actionIdTooLong": "ID การดำเนินการต้องมีความยาว {{maxLength}} ตัวอักษรหรือน้อยกว่า",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "ชื่อการดำเนินการ",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "ข้อความแสดงบนปุ่ม",
+ "nodes.humanInput.userActions.buttonTextTooLong": "ข้อความบนปุ่มต้องมีความยาว {{maxLength}} ตัวอักษรหรือน้อยกว่า",
+ "nodes.humanInput.userActions.chooseStyle": "เลือกสไตล์ปุ่ม",
+ "nodes.humanInput.userActions.emptyTip": "คลิกปุ่ม '+' เพื่อเพิ่มการดำเนินการของผู้ใช้",
+ "nodes.humanInput.userActions.title": "การดำเนินการของผู้ใช้",
+ "nodes.humanInput.userActions.tooltip": "กำหนดปุ่มที่ผู้ใช้สามารถคลิกเพื่อตอบสนองต่อแบบฟอร์มนี้ แต่ละปุ่มสามารถเรียกใช้เส้นทางเวิร์กโฟลว์ที่แตกต่างกัน ID การดำเนินการต้องเริ่มต้นด้วยตัวอักษรหรือขีดล่าง ตามด้วยตัวอักษร ตัวเลข หรือขีดล่าง",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} ถูกเรียกใช้แล้ว",
"nodes.ifElse.addCondition": "เพิ่มเงื่อนไข",
"nodes.ifElse.addSubVariable": "ตัวแปรย่อย",
"nodes.ifElse.and": "และ",
diff --git a/web/i18n/tr-TR/common.json b/web/i18n/tr-TR/common.json
index 3d662c1aff..0a5a447651 100644
--- a/web/i18n/tr-TR/common.json
+++ b/web/i18n/tr-TR/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "Prompt kelimenizi buraya yazın, değişken eklemek için '{' tuşuna, prompt içerik bloğu eklemek için '/' tuşuna basın",
"promptEditor.query.item.desc": "Kullanıcı sorgu şablonunu ekle",
"promptEditor.query.item.title": "Sorgu",
+ "promptEditor.requestURL.item.desc": "İstek URL'sini ekle",
+ "promptEditor.requestURL.item.title": "İstek URL'si",
"promptEditor.variable.item.desc": "Değişkenler & Harici Araçlar ekle",
"promptEditor.variable.item.title": "Değişkenler & Harici Araçlar",
"promptEditor.variable.modal.add": "Yeni değişken",
diff --git a/web/i18n/tr-TR/share.json b/web/i18n/tr-TR/share.json
index f0b25a6b96..8dc64335ea 100644
--- a/web/i18n/tr-TR/share.json
+++ b/web/i18n/tr-TR/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "Bir Kere Çalıştır",
"generation.tabs.saved": "Kaydedildi",
"generation.title": "AI Tamamlama",
+ "humanInput.completed": "Bu isteğin başka bir yerde işlendiği görülüyor.",
+ "humanInput.expirationTimeNowOrFuture": "Bu eylem {{relativeTime}} sona erecek.",
+ "humanInput.expired": "Bu isteğin süresi dolmuş gibi görünüyor.",
+ "humanInput.expiredTip": "Bu eylemin süresi doldu.",
+ "humanInput.formNotFound": "Form bulunamadı.",
+ "humanInput.rateLimitExceeded": "Çok fazla istek, lütfen daha sonra tekrar deneyin.",
+ "humanInput.recorded": "Girişiniz kaydedildi.",
+ "humanInput.sorry": "Üzgünüz!",
+ "humanInput.submissionID": "submission_id: {{id}}",
+ "humanInput.thanks": "Teşekkürler!",
"login.backToHome": "Ana Sayfaya Dön"
}
diff --git a/web/i18n/tr-TR/workflow.json b/web/i18n/tr-TR/workflow.json
index dd9ebe050b..51a957518d 100644
--- a/web/i18n/tr-TR/workflow.json
+++ b/web/i18n/tr-TR/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "Doküman Çıkarıcı",
"blocks.end": "Çıktı",
"blocks.http-request": "HTTP İsteği",
+ "blocks.human-input": "İnsan Girdisi",
"blocks.if-else": "IF/ELSE",
"blocks.iteration": "Yineleme",
"blocks.iteration-start": "Yineleme Başlat",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "Yüklenen belgeleri LLM tarafından kolayca anlaşılabilen metin içeriğine ayrıştırmak için kullanılır.",
"blocksAbout.end": "Bir iş akışının çıktısını ve sonuç türünü tanımlayın",
"blocksAbout.http-request": "HTTP protokolü üzerinden sunucu isteklerinin gönderilmesine izin verin",
+ "blocksAbout.human-input": "Sonraki adımı oluşturmadan önce insan onayı iste",
"blocksAbout.if-else": "İş akışını if/else koşullarına göre iki dala ayırmanızı sağlar",
"blocksAbout.iteration": "Bir liste nesnesinde birden fazla adım gerçekleştirir ve tüm sonuçlar çıkana kadar devam eder.",
"blocksAbout.iteration-start": "Yineleme Başlangıç düğümü",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "Resim yükleme özellikleri, dosya yüklemeye yükseltildi.",
"common.goBackToEdit": "Editöre geri dön",
"common.handMode": "El Modu",
+ "common.humanInputEmailTip": "E-posta (Teslimat Yöntemi) yapılandırılmış alıcılarınıza gönderildi",
+ "common.humanInputEmailTipInDebugMode": "E-posta (Teslimat Yöntemi) {{email}} adresine gönderildi",
+ "common.humanInputWebappTip": "Yalnızca hata ayıklama önizlemesi, kullanıcı bunu web uygulamasında görmeyecek.",
"common.importDSL": "DSL İçe Aktar",
"common.importDSLTip": "Geçerli taslak üzerine yazılacak. İçe aktarmadan önce workflow yedekleyin.",
"common.importFailure": "İçe Aktarma Başarısız",
@@ -500,6 +505,104 @@
"nodes.http.value": "Değer",
"nodes.http.verifySSL.title": "SSL Sertifikasını Doğrula",
"nodes.http.verifySSL.warningTooltip": "SSL doğrulamasını devre dışı bırakmak, üretim ortamları için önerilmez. Bu yalnızca geliştirme veya test aşamalarında kullanılmalıdır, çünkü bağlantıyı adam ortada saldırıları gibi güvenlik tehditlerine karşı savunmasız hale getirir.",
+ "nodes.humanInput.deliveryMethod.added": "Eklendi",
+ "nodes.humanInput.deliveryMethod.contactTip1": "İhtiyacınız olan bir teslimat yöntemi mi eksik?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "Bize support@dify.ai adresinden bildirin.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "Tüm üyeler ({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "İçerik",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "E-posta içeriğini girin",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "Hata Ayıklama Modu",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "Hata ayıklama modunda, e-posta yalnızca hesap e-postanız {{email}} adresine gönderilecektir.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "Üretim ortamı etkilenmez.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "E-posta yoluyla girdi talebi gönder",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ Ekle",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "Eklendi",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "E-posta, virgülle ayrılmış",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "Çalışma alanı üyeleri veya harici alıcılar ekle",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "Seç",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "Alıcı",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "İstek URL değişkeni, insan girdisi için tetikleyici giriş noktasıdır.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "Konu",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "E-posta konusunu girin",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "E-posta Yapılandırması",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "{{email}} adresine bir test e-postası gönderildi. Lütfen gelen kutunuzu kontrol edin.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "Hata ayıklama modu etkin.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "E-posta {{email}} adresine gönderilecek.",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "E-posta Gönderildi",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(isteğe bağlı)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "E-posta Gönder",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "Yapılandırılmış alıcılarınıza test e-postaları gönderin",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "{{email}} adresine test e-postası gönder",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "E-posta teslimatını test etmek için Hata Ayıklama Modunu etkinleştirmeniz önerilir.",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "Test E-posta Gönderici",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "Form İçeriğindeki Değişkenler",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "Alıcıların gerçekte ne gördüğünü taklit etmek için form değişkenlerini doldurun.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "E-posta {{team}} üyelerine ve şu e-posta adreslerine gönderildi:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "E-posta {{team}} üyelerine gönderildi.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "E-posta şu e-posta adreslerine gönderildi:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "E-posta {{team}} üyelerine ve şu e-posta adreslerine gönderilecek:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "E-posta {{team}} üyelerine gönderilecek.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "E-posta şu e-posta adreslerine gönderilecek:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "Teslimat yöntemi eklenmedi, işlem tetiklenemez.",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "Kullanılamaz",
+ "nodes.humanInput.deliveryMethod.notConfigured": "Yapılandırılmadı",
+ "nodes.humanInput.deliveryMethod.title": "Teslimat Yöntemi",
+ "nodes.humanInput.deliveryMethod.tooltip": "İnsan girdisi formunun kullanıcıya nasıl teslim edildiği.",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "Discord üzerinden girdi talebi gönder",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "E-posta yoluyla girdi talebi gönder",
+ "nodes.humanInput.deliveryMethod.types.email.title": "E-posta",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "Slack üzerinden girdi talebi gönder",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "Teams üzerinden girdi talebi gönder",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "Web uygulamasında son kullanıcıya göster",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "Web Uygulaması",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "İnsan Girdisi için E-posta Teslimatını Kilitle Aç",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "Aracılar harekete geçmeden önce e-posta yoluyla onay talepleri gönderin — yayınlama ve onay iş akışları için kullanışlıdır.",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "Kapat",
+ "nodes.humanInput.editor.previewTip": "Önizleme modunda, eylem düğmeleri işlevsel değildir.",
+ "nodes.humanInput.errorMsg.duplicateActionId": "Kullanıcı eylemlerinde yinelenen eylem kimliği bulundu",
+ "nodes.humanInput.errorMsg.emptyActionId": "Eylem kimliği boş olamaz",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "Eylem başlığı boş olamaz",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "Lütfen en az bir teslimat yöntemi seçin",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "Lütfen en az bir teslimat yöntemini etkinleştirin",
+ "nodes.humanInput.errorMsg.noUserActions": "Lütfen en az bir kullanıcı eylemi ekleyin",
+ "nodes.humanInput.formContent.hotkeyTip": "Değişken eklemek için , giriş alanı eklemek için tuşuna basın",
+ "nodes.humanInput.formContent.placeholder": "İçeriği buraya yazın",
+ "nodes.humanInput.formContent.preview": "Önizleme",
+ "nodes.humanInput.formContent.title": "Form İçeriği",
+ "nodes.humanInput.formContent.tooltip": "Kullanıcıların formu açtıktan sonra göreceği şey. Markdown biçimlendirmesini destekler.",
+ "nodes.humanInput.insertInputField.insert": "Ekle",
+ "nodes.humanInput.insertInputField.prePopulateField": "Alanı Önceden Doldur",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": " veya ekleyin. Kullanıcılar başlangıçta bu içeriği görecek veya boş bırakın.",
+ "nodes.humanInput.insertInputField.saveResponseAs": "Yanıtı Farklı Kaydet",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "Sonraki referans için bu değişkeni adlandırın",
+ "nodes.humanInput.insertInputField.staticContent": "Statik İçerik",
+ "nodes.humanInput.insertInputField.title": "Giriş Alanı Ekle",
+ "nodes.humanInput.insertInputField.useConstantInstead": "Bunun Yerine Sabit Kullan",
+ "nodes.humanInput.insertInputField.useVarInstead": "Bunun Yerine Değişken Kullan",
+ "nodes.humanInput.insertInputField.variable": "değişken",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "Değişken adı yalnızca harf, rakam ve alt çizgi içerebilir ve rakamla başlayamaz",
+ "nodes.humanInput.log.backstageInputURL": "Sahne arkası giriş URL'si:",
+ "nodes.humanInput.log.reason": "Neden:",
+ "nodes.humanInput.log.reasonContent": "Devam etmek için insan girdisi gerekli",
+ "nodes.humanInput.singleRun.back": "Geri",
+ "nodes.humanInput.singleRun.button": "Form Oluştur",
+ "nodes.humanInput.singleRun.label": "Form değişkenleri",
+ "nodes.humanInput.timeout.days": "Gün",
+ "nodes.humanInput.timeout.hours": "Saat",
+ "nodes.humanInput.timeout.title": "Zaman Aşımı",
+ "nodes.humanInput.userActions.actionIdFormatTip": "Eylem kimliği bir harf veya alt çizgi ile başlamalı, ardından harf, rakam veya alt çizgi gelmelidir",
+ "nodes.humanInput.userActions.actionIdTooLong": "Eylem kimliği {{maxLength}} karakter veya daha az olmalıdır",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "Eylem Adı",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "Düğme Görüntüleme Metni",
+ "nodes.humanInput.userActions.buttonTextTooLong": "Düğme metni {{maxLength}} karakter veya daha az olmalıdır",
+ "nodes.humanInput.userActions.chooseStyle": "Bir düğme stili seçin",
+ "nodes.humanInput.userActions.emptyTip": "Kullanıcı eylemleri eklemek için '+' düğmesine tıklayın",
+ "nodes.humanInput.userActions.title": "Kullanıcı Eylemleri",
+ "nodes.humanInput.userActions.tooltip": "Kullanıcıların bu forma yanıt vermek için tıklayabileceği düğmeleri tanımlayın. Her düğme farklı iş akışı yollarını tetikleyebilir. Eylem kimliği bir harf veya alt çizgi ile başlamalı, ardından harf, rakam veya alt çizgi gelmelidir.",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} tetiklendi",
"nodes.ifElse.addCondition": "Koşul Ekle",
"nodes.ifElse.addSubVariable": "Alt Değişken",
"nodes.ifElse.and": "ve",
diff --git a/web/i18n/uk-UA/common.json b/web/i18n/uk-UA/common.json
index 8da3860fb8..4afbfc26d9 100644
--- a/web/i18n/uk-UA/common.json
+++ b/web/i18n/uk-UA/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "Пишіть свої підказки тут, вводьте '{', щоб вставити змінну чи '/', щоб вставити блок-підказку",
"promptEditor.query.item.desc": "Вставити шаблон запиту користувача",
"promptEditor.query.item.title": "Запит",
+ "promptEditor.requestURL.item.desc": "Вставити URL запиту",
+ "promptEditor.requestURL.item.title": "URL запиту",
"promptEditor.variable.item.desc": "Вставити змінні та зовнішні інструменти",
"promptEditor.variable.item.title": "Змінні та зовнішні інструменти",
"promptEditor.variable.modal.add": "Нова змінна",
diff --git a/web/i18n/uk-UA/share.json b/web/i18n/uk-UA/share.json
index 09e2e153ba..4800e17dc5 100644
--- a/web/i18n/uk-UA/share.json
+++ b/web/i18n/uk-UA/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "Запустити один раз",
"generation.tabs.saved": "Збережено",
"generation.title": "Доповнення AI",
+ "humanInput.completed": "Схоже, цей запит було оброблено в іншому місці.",
+ "humanInput.expirationTimeNowOrFuture": "Ця дія закінчиться {{relativeTime}}.",
+ "humanInput.expired": "Схоже, термін дії цього запиту закінчився.",
+ "humanInput.expiredTip": "Термін дії цієї дії закінчився.",
+ "humanInput.formNotFound": "Форму не знайдено.",
+ "humanInput.rateLimitExceeded": "Занадто багато запитів, спробуйте пізніше.",
+ "humanInput.recorded": "Ваш ввід було записано.",
+ "humanInput.sorry": "Вибачте!",
+ "humanInput.submissionID": "submission_id: {{id}}",
+ "humanInput.thanks": "Дякуємо!",
"login.backToHome": "Повернутися на головну"
}
diff --git a/web/i18n/uk-UA/workflow.json b/web/i18n/uk-UA/workflow.json
index 22ee648f2d..9263a97ece 100644
--- a/web/i18n/uk-UA/workflow.json
+++ b/web/i18n/uk-UA/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "Екстрактор документів",
"blocks.end": "Вивід",
"blocks.http-request": "HTTP-запит",
+ "blocks.human-input": "Введення людини",
"blocks.if-else": "ЯКЩО/ІНАКШЕ",
"blocks.iteration": "Ітерація",
"blocks.iteration-start": "Початок ітерації",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "Використовується для аналізу завантажених документів у текстовий контент, який легко зрозумілий LLM.",
"blocksAbout.end": "Визначте вивід і тип результату робочого потоку",
"blocksAbout.http-request": "Дозволяє відправляти серверні запити через протокол HTTP",
+ "blocksAbout.human-input": "Попросити підтвердження людини перед генерацією наступного кроку",
"blocksAbout.if-else": "Дозволяє розділити робочий потік на дві гілки на основі умов if/else",
"blocksAbout.iteration": "Виконувати кілька кроків на об'єкті списку, поки не буде виведено всі результати.",
"blocksAbout.iteration-start": "Вузол початку ітерації",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "Функції завантаження зображень були оновлені для завантаження файлів.",
"common.goBackToEdit": "Повернутися до редактора",
"common.handMode": "Ручний режим",
+ "common.humanInputEmailTip": "Електронна пошта (метод доставки) надіслано налаштованим одержувачам",
+ "common.humanInputEmailTipInDebugMode": "Електронна пошта (метод доставки) надіслано на {{email}} ",
+ "common.humanInputWebappTip": "Лише попередній перегляд налагодження, користувач не побачить цього у веб-додатку.",
"common.importDSL": "Імпорт DSL",
"common.importDSLTip": "Поточна чернетка буде перезаписана. Експортуйте робочий процес як резервну копію перед імпортом.",
"common.importFailure": "Помилка імпорту",
@@ -500,6 +505,104 @@
"nodes.http.value": "Значення",
"nodes.http.verifySSL.title": "Перевірити SSL сертифікат",
"nodes.http.verifySSL.warningTooltip": "Вимкнення перевірки SSL не рекомендується для виробничих середовищ. Це слід використовувати лише в розробці або тестуванні, оскільки це робить з'єднання вразливим до загроз безпеці, таких як атаки «людина посередині».",
+ "nodes.humanInput.deliveryMethod.added": "Додано",
+ "nodes.humanInput.deliveryMethod.contactTip1": "Бракує потрібного вам способу доставки?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "Повідомте нам на support@dify.ai .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "Всі учасники ({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "Вміст",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "Введіть текст листа",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "Режим налагодження",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "У режимі налагодження лист буде надіслано лише на ваш обліковий запис {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "Виробниче середовище не постраждає.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "Надіслати запит на введення електронною поштою",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ Додати",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "Додано",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "Email, через кому",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "Додати учасників робочого простору або зовнішніх одержувачів",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "Вибрати",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "Одержувач",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "Змінна URL запиту є точкою входу для введення людини.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "Тема",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "Введіть тему листа",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "Налаштування електронної пошти",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "Тестовий лист надіслано на {{email}} . Будь ласка, перевірте свою поштову скриньку.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "Режим налагодження увімкнено.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "Лист буде надіслано на {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "Лист надіслано",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(необов'язково)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "Надіслати лист",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "Надіслати тестові листи вашим налаштованим одержувачам",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "Надіслати тестовий лист на {{email}}",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "Рекомендується увімкнути режим налагодження для тестування доставки листів.",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "Тестовий відправник листів",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "Змінні у вмісті форми",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "Заповніть змінні форми, щоб емулювати те, що насправді бачать одержувачі.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "Лист надіслано учасникам {{team}} та наступним адресам електронної пошти:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "Лист надіслано учасникам {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "Лист надіслано на наступні адреси електронної пошти:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "Лист буде надіслано учасникам {{team}} та наступним адресам електронної пошти:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "Лист буде надіслано учасникам {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "Лист буде надіслано на наступні адреси електронної пошти:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "Спосіб доставки не додано, операція не може бути запущена.",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "Недоступно",
+ "nodes.humanInput.deliveryMethod.notConfigured": "Не налаштовано",
+ "nodes.humanInput.deliveryMethod.title": "Спосіб доставки",
+ "nodes.humanInput.deliveryMethod.tooltip": "Як форма введення людини доставляється користувачу.",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "Надіслати запит на введення через Discord",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "Надіслати запит на введення електронною поштою",
+ "nodes.humanInput.deliveryMethod.types.email.title": "Електронна пошта",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "Надіслати запит на введення через Slack",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "Надіслати запит на введення через Teams",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "Показати кінцевому користувачу у веб-додатку",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "Веб-додаток",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "Розблокувати доставку електронною поштою для введення людини",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "Надсилайте запити на підтвердження електронною поштою до того, як агенти вживатимуть заходів — корисно для публікації та робочих процесів затвердження.",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "Відхилити",
+ "nodes.humanInput.editor.previewTip": "У режимі попереднього перегляду кнопки дій не функціонують.",
+ "nodes.humanInput.errorMsg.duplicateActionId": "Виявлено дублюючий ідентифікатор дії в діях користувача",
+ "nodes.humanInput.errorMsg.emptyActionId": "Ідентифікатор дії не може бути порожнім",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "Назва дії не може бути порожньою",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "Будь ласка, виберіть принаймні один спосіб доставки",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "Будь ласка, увімкніть принаймні один спосіб доставки",
+ "nodes.humanInput.errorMsg.noUserActions": "Будь ласка, додайте принаймні одну дію користувача",
+ "nodes.humanInput.formContent.hotkeyTip": "Натисніть для вставки змінної, для вставки поля введення",
+ "nodes.humanInput.formContent.placeholder": "Введіть вміст тут",
+ "nodes.humanInput.formContent.preview": "Попередній перегляд",
+ "nodes.humanInput.formContent.title": "Вміст форми",
+ "nodes.humanInput.formContent.tooltip": "Що побачать користувачі після відкриття форми. Підтримує форматування Markdown.",
+ "nodes.humanInput.insertInputField.insert": "Вставити",
+ "nodes.humanInput.insertInputField.prePopulateField": "Попередньо заповнити поле",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": "Додайте або . Користувачі спочатку побачать цей вміст, або залиште порожнім.",
+ "nodes.humanInput.insertInputField.saveResponseAs": "Зберегти відповідь як",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "Назвіть цю змінну для подальшого посилання",
+ "nodes.humanInput.insertInputField.staticContent": "Статичний вміст",
+ "nodes.humanInput.insertInputField.title": "Вставити поле введення",
+ "nodes.humanInput.insertInputField.useConstantInstead": "Використати константу замість цього",
+ "nodes.humanInput.insertInputField.useVarInstead": "Використати змінну замість цього",
+ "nodes.humanInput.insertInputField.variable": "змінна",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "Ім'я змінної може містити лише літери, цифри та підкреслення і не може починатися з цифри",
+ "nodes.humanInput.log.backstageInputURL": "URL введення за лаштунками:",
+ "nodes.humanInput.log.reason": "Причина:",
+ "nodes.humanInput.log.reasonContent": "Потрібне введення людини для продовження",
+ "nodes.humanInput.singleRun.back": "Назад",
+ "nodes.humanInput.singleRun.button": "Згенерувати форму",
+ "nodes.humanInput.singleRun.label": "Змінні форми",
+ "nodes.humanInput.timeout.days": "Дні",
+ "nodes.humanInput.timeout.hours": "Години",
+ "nodes.humanInput.timeout.title": "Тайм-аут",
+ "nodes.humanInput.userActions.actionIdFormatTip": "Ідентифікатор дії повинен починатися з літери або підкреслення, за яким слідують літери, цифри або підкреслення",
+ "nodes.humanInput.userActions.actionIdTooLong": "Ідентифікатор дії повинен містити не більше {{maxLength}} символів",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "Назва дії",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "Текст кнопки для відображення",
+ "nodes.humanInput.userActions.buttonTextTooLong": "Текст кнопки повинен містити не більше {{maxLength}} символів",
+ "nodes.humanInput.userActions.chooseStyle": "Виберіть стиль кнопки",
+ "nodes.humanInput.userActions.emptyTip": "Натисніть кнопку '+' для додавання дій користувача",
+ "nodes.humanInput.userActions.title": "Дії користувача",
+ "nodes.humanInput.userActions.tooltip": "Визначте кнопки, на які користувачі можуть натискати, щоб відповісти на цю форму. Кожна кнопка може запускати різні шляхи робочого процесу. Ідентифікатор дії повинен починатися з літери або підкреслення, за яким слідують літери, цифри або підкреслення.",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} було запущено",
"nodes.ifElse.addCondition": "Додати умову",
"nodes.ifElse.addSubVariable": "Підзмінна",
"nodes.ifElse.and": "і",
diff --git a/web/i18n/vi-VN/common.json b/web/i18n/vi-VN/common.json
index ae5fcf742f..443e8538a2 100644
--- a/web/i18n/vi-VN/common.json
+++ b/web/i18n/vi-VN/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "Viết từ khóa của bạn ở đây, nhập '{' để chèn một biến, nhập '/' để chèn một khối nội dung nhắc nhở",
"promptEditor.query.item.desc": "Chèn mẫu truy vấn người dùng",
"promptEditor.query.item.title": "Truy vấn",
+ "promptEditor.requestURL.item.desc": "Chèn URL yêu cầu",
+ "promptEditor.requestURL.item.title": "URL yêu cầu",
"promptEditor.variable.item.desc": "Chèn Biến & Công cụ Bên ngoài",
"promptEditor.variable.item.title": "Biến & Công cụ Bên ngoài",
"promptEditor.variable.modal.add": "Biến mới",
diff --git a/web/i18n/vi-VN/share.json b/web/i18n/vi-VN/share.json
index 69b478a183..790cb11885 100644
--- a/web/i18n/vi-VN/share.json
+++ b/web/i18n/vi-VN/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "Tạo đơn lẻ",
"generation.tabs.saved": "Đã lưu",
"generation.title": "Hoàn thiện AI",
+ "humanInput.completed": "Có vẻ như yêu cầu này đã được xử lý ở nơi khác.",
+ "humanInput.expirationTimeNowOrFuture": "Hành động này sẽ hết hạn {{relativeTime}}.",
+ "humanInput.expired": "Có vẻ như yêu cầu này đã hết hạn.",
+ "humanInput.expiredTip": "Hành động này đã hết hạn.",
+ "humanInput.formNotFound": "Không tìm thấy biểu mẫu.",
+ "humanInput.rateLimitExceeded": "Quá nhiều yêu cầu, vui lòng thử lại sau.",
+ "humanInput.recorded": "Đầu vào của bạn đã được ghi lại.",
+ "humanInput.sorry": "Xin lỗi!",
+ "humanInput.submissionID": "submission_id: {{id}}",
+ "humanInput.thanks": "Cảm ơn!",
"login.backToHome": "Trở về Trang Chủ"
}
diff --git a/web/i18n/vi-VN/workflow.json b/web/i18n/vi-VN/workflow.json
index 7972abde10..3186805548 100644
--- a/web/i18n/vi-VN/workflow.json
+++ b/web/i18n/vi-VN/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "Trình trích xuất tài liệu",
"blocks.end": "Đầu ra",
"blocks.http-request": "Yêu cầu HTTP",
+ "blocks.human-input": "Đầu vào của con người",
"blocks.if-else": "NẾU/NGƯỢC LẠI",
"blocks.iteration": "Lặp",
"blocks.iteration-start": "Bắt đầu lặp",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "Được sử dụng để phân tích cú pháp các tài liệu đã tải lên thành nội dung văn bản dễ hiểu bởi LLM.",
"blocksAbout.end": "Định nghĩa đầu ra và loại kết quả của quy trình làm việc",
"blocksAbout.http-request": "Cho phép gửi các yêu cầu máy chủ qua giao thức HTTP",
+ "blocksAbout.human-input": "Yêu cầu con người xác nhận trước khi tạo bước tiếp theo",
"blocksAbout.if-else": "Cho phép phân chia quy trình làm việc thành hai nhánh dựa trên điều kiện if/else",
"blocksAbout.iteration": "Thực hiện nhiều bước trên một đối tượng danh sách cho đến khi tất cả các kết quả được xuất ra.",
"blocksAbout.iteration-start": "Nút bắt đầu vòng lặp",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "Các tính năng tải lên hình ảnh đã được nâng cấp để tải tệp lên.",
"common.goBackToEdit": "Quay lại trình chỉnh sửa",
"common.handMode": "Chế độ tay",
+ "common.humanInputEmailTip": "Email (Phương thức giao hàng) đã được gửi đến người nhận đã cấu hình của bạn",
+ "common.humanInputEmailTipInDebugMode": "Email (Phương thức giao hàng) đã được gửi đến {{email}} ",
+ "common.humanInputWebappTip": "Chỉ xem trước gỡ lỗi, người dùng sẽ không thấy điều này trong ứng dụng web.",
"common.importDSL": "Nhập DSL",
"common.importDSLTip": "Dự thảo hiện tại sẽ bị ghi đè. Xuất quy trình làm việc dưới dạng bản sao lưu trước khi nhập.",
"common.importFailure": "Nhập không thành công",
@@ -500,6 +505,104 @@
"nodes.http.value": "Giá trị",
"nodes.http.verifySSL.title": "Xác thực chứng chỉ SSL",
"nodes.http.verifySSL.warningTooltip": "Việc vô hiệu hóa xác minh SSL không được khuyến khích cho các môi trường sản xuất. Điều này chỉ nên được sử dụng trong phát triển hoặc thử nghiệm, vì nó làm cho kết nối dễ bị tổn thương trước các mối đe dọa an ninh như cuộc tấn công man-in-the-middle.",
+ "nodes.humanInput.deliveryMethod.added": "Đã thêm",
+ "nodes.humanInput.deliveryMethod.contactTip1": "Thiếu phương thức giao hàng bạn cần?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "Hãy cho chúng tôi biết tại support@dify.ai .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "Tất cả thành viên ({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "Nội dung",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "Nhập nội dung email",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "Chế độ gỡ lỗi",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "Trong chế độ gỡ lỗi, email sẽ chỉ được gửi đến email tài khoản của bạn {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "Môi trường sản xuất không bị ảnh hưởng.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "Gửi yêu cầu đầu vào qua email",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ Thêm",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "Đã thêm",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "Email, phân cách bằng dấu phẩy",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "Thêm thành viên không gian làm việc hoặc người nhận bên ngoài",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "Chọn",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "Người nhận",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "Biến URL yêu cầu là điểm vào cho đầu vào của con người.",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "Chủ đề",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "Nhập chủ đề email",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "Cấu hình Email",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "Một email thử nghiệm đã được gửi đến {{email}} . Vui lòng kiểm tra hộp thư của bạn.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "Chế độ gỡ lỗi đã được bật.",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "Email sẽ được gửi đến {{email}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "Đã gửi Email",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(tùy chọn)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "Gửi Email",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "Gửi email thử nghiệm đến người nhận đã cấu hình của bạn",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "Gửi email thử nghiệm đến {{email}}",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "Khuyến nghị bật Chế độ Gỡ lỗi để kiểm tra giao hàng email.",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "Người gửi Email thử nghiệm",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "Biến trong Nội dung Biểu mẫu",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "Điền các biến biểu mẫu để mô phỏng những gì người nhận thực sự nhìn thấy.",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "Email đã được gửi đến các thành viên {{team}} và các địa chỉ email sau:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "Email đã được gửi đến các thành viên {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "Email đã được gửi đến các địa chỉ email sau:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "Email sẽ được gửi đến các thành viên {{team}} và các địa chỉ email sau:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "Email sẽ được gửi đến các thành viên {{team}} .",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "Email sẽ được gửi đến các địa chỉ email sau:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "Không có phương thức giao hàng nào được thêm vào, thao tác không thể được kích hoạt.",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "Không khả dụng",
+ "nodes.humanInput.deliveryMethod.notConfigured": "Chưa được cấu hình",
+ "nodes.humanInput.deliveryMethod.title": "Phương thức giao hàng",
+ "nodes.humanInput.deliveryMethod.tooltip": "Cách biểu mẫu đầu vào của con người được giao cho người dùng.",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "Gửi yêu cầu đầu vào qua Discord",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "Gửi yêu cầu đầu vào qua email",
+ "nodes.humanInput.deliveryMethod.types.email.title": "Email",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "Gửi yêu cầu đầu vào qua Slack",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "Gửi yêu cầu đầu vào qua Teams",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "Hiển thị cho người dùng cuối trong webapp",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "Webapp",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "Mở khóa giao hàng Email cho Đầu vào của Con người",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "Gửi yêu cầu xác nhận qua email trước khi các đại lý hành động — hữu ích cho quy trình phát hành và phê duyệt.",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "Bỏ qua",
+ "nodes.humanInput.editor.previewTip": "Ở chế độ xem trước, các nút hành động không hoạt động.",
+ "nodes.humanInput.errorMsg.duplicateActionId": "Tìm thấy ID hành động trùng lặp trong hành động người dùng",
+ "nodes.humanInput.errorMsg.emptyActionId": "ID hành động không được để trống",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "Tiêu đề hành động không được để trống",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "Vui lòng chọn ít nhất một phương thức giao hàng",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "Vui lòng bật ít nhất một phương thức giao hàng",
+ "nodes.humanInput.errorMsg.noUserActions": "Vui lòng thêm ít nhất một hành động người dùng",
+ "nodes.humanInput.formContent.hotkeyTip": "Nhấn để chèn biến, để chèn trường đầu vào",
+ "nodes.humanInput.formContent.placeholder": "Nhập nội dung tại đây",
+ "nodes.humanInput.formContent.preview": "Xem trước",
+ "nodes.humanInput.formContent.title": "Nội dung Biểu mẫu",
+ "nodes.humanInput.formContent.tooltip": "Những gì người dùng sẽ thấy sau khi mở biểu mẫu. Hỗ trợ định dạng Markdown.",
+ "nodes.humanInput.insertInputField.insert": "Chèn",
+ "nodes.humanInput.insertInputField.prePopulateField": "Điền trước Trường",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": "Thêm hoặc . Người dùng sẽ thấy nội dung này ban đầu, hoặc để trống.",
+ "nodes.humanInput.insertInputField.saveResponseAs": "Lưu Phản hồi Dưới dạng",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "Đặt tên cho biến này để tham chiếu sau",
+ "nodes.humanInput.insertInputField.staticContent": "Nội dung Tĩnh",
+ "nodes.humanInput.insertInputField.title": "Chèn Trường Đầu vào",
+ "nodes.humanInput.insertInputField.useConstantInstead": "Sử dụng Hằng số Thay thế",
+ "nodes.humanInput.insertInputField.useVarInstead": "Sử dụng Biến Thay thế",
+ "nodes.humanInput.insertInputField.variable": "biến",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "Tên biến chỉ có thể chứa chữ cái, số và dấu gạch dưới, và không được bắt đầu bằng số",
+ "nodes.humanInput.log.backstageInputURL": "URL đầu vào hậu trường:",
+ "nodes.humanInput.log.reason": "Lý do:",
+ "nodes.humanInput.log.reasonContent": "Yêu cầu đầu vào của con người để tiếp tục",
+ "nodes.humanInput.singleRun.back": "Quay lại",
+ "nodes.humanInput.singleRun.button": "Tạo Biểu mẫu",
+ "nodes.humanInput.singleRun.label": "Biến biểu mẫu",
+ "nodes.humanInput.timeout.days": "Ngày",
+ "nodes.humanInput.timeout.hours": "Giờ",
+ "nodes.humanInput.timeout.title": "Hết thời gian",
+ "nodes.humanInput.userActions.actionIdFormatTip": "ID hành động phải bắt đầu bằng chữ cái hoặc dấu gạch dưới, theo sau là chữ cái, số hoặc dấu gạch dưới",
+ "nodes.humanInput.userActions.actionIdTooLong": "ID hành động phải có {{maxLength}} ký tự trở xuống",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "Tên Hành động",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "Văn bản Hiển thị Nút",
+ "nodes.humanInput.userActions.buttonTextTooLong": "Văn bản nút phải có {{maxLength}} ký tự trở xuống",
+ "nodes.humanInput.userActions.chooseStyle": "Chọn kiểu nút",
+ "nodes.humanInput.userActions.emptyTip": "Nhấp vào nút '+' để thêm hành động người dùng",
+ "nodes.humanInput.userActions.title": "Hành động Người dùng",
+ "nodes.humanInput.userActions.tooltip": "Xác định các nút mà người dùng có thể nhấp để phản hồi biểu mẫu này. Mỗi nút có thể kích hoạt các đường dẫn quy trình làm việc khác nhau. ID hành động phải bắt đầu bằng chữ cái hoặc dấu gạch dưới, theo sau là chữ cái, số hoặc dấu gạch dưới.",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} đã được kích hoạt",
"nodes.ifElse.addCondition": "Thêm điều kiện",
"nodes.ifElse.addSubVariable": "Biến phụ",
"nodes.ifElse.and": "và",
diff --git a/web/i18n/zh-Hans/share.json b/web/i18n/zh-Hans/share.json
index 0a16583d22..3c80ff07d5 100644
--- a/web/i18n/zh-Hans/share.json
+++ b/web/i18n/zh-Hans/share.json
@@ -58,14 +58,15 @@
"generation.tabs.create": "运行一次",
"generation.tabs.saved": "已保存",
"generation.title": "AI 智能书写",
- "humanInput.completed": "此请求似乎在其他地方得到了处理。",
- "humanInput.expirationTimeNowOrFuture": "此操作将在 {{relativeTime}}过期。",
- "humanInput.expired": "此请求似乎已过期。",
+ "humanInput.completed": "似乎这个请求已在别处处理。",
+ "humanInput.expirationTimeNowOrFuture": "此操作将在{{relativeTime}}过期。",
+ "humanInput.expired": "似乎这个请求已过期。",
"humanInput.expiredTip": "此操作已过期。",
- "humanInput.formNotFound": "表单不存在。",
- "humanInput.rateLimitExceeded": "请求过于频繁,请稍后再试。",
- "humanInput.recorded": "您的输入已被记录。",
- "humanInput.sorry": "抱歉!",
- "humanInput.thanks": "谢谢!",
+ "humanInput.formNotFound": "未找到表单。",
+ "humanInput.rateLimitExceeded": "请求过多,请稍后再试。",
+ "humanInput.recorded": "您的输入已记录。",
+ "humanInput.sorry": "抱歉!",
+ "humanInput.submissionID": "提交 ID: {{id}}",
+ "humanInput.thanks": "谢谢!",
"login.backToHome": "返回首页"
}
diff --git a/web/i18n/zh-Hant/common.json b/web/i18n/zh-Hant/common.json
index 06bb7405a5..e9847f2514 100644
--- a/web/i18n/zh-Hant/common.json
+++ b/web/i18n/zh-Hant/common.json
@@ -511,6 +511,8 @@
"promptEditor.placeholder": "在這裡寫你的提示詞,輸入'{' 插入變數、輸入'/' 插入提示內容塊",
"promptEditor.query.item.desc": "插入使用者查詢模板",
"promptEditor.query.item.title": "查詢內容",
+ "promptEditor.requestURL.item.desc": "插入請求 URL",
+ "promptEditor.requestURL.item.title": "請求 URL",
"promptEditor.variable.item.desc": "插入變數和外部工具",
"promptEditor.variable.item.title": "變數 & 外部工具",
"promptEditor.variable.modal.add": "新增新變數",
diff --git a/web/i18n/zh-Hant/share.json b/web/i18n/zh-Hant/share.json
index f11dc9595f..543256dacb 100644
--- a/web/i18n/zh-Hant/share.json
+++ b/web/i18n/zh-Hant/share.json
@@ -58,5 +58,15 @@
"generation.tabs.create": "執行一次",
"generation.tabs.saved": "已儲存",
"generation.title": "AI 智慧書寫",
+ "humanInput.completed": "似乎這個請求已在別處處理。",
+ "humanInput.expirationTimeNowOrFuture": "此操作將在{{relativeTime}}過期。",
+ "humanInput.expired": "似乎這個請求已過期。",
+ "humanInput.expiredTip": "此操作已過期。",
+ "humanInput.formNotFound": "未找到表單。",
+ "humanInput.rateLimitExceeded": "請求過多,請稍後再試。",
+ "humanInput.recorded": "您的輸入已記錄。",
+ "humanInput.sorry": "抱歉!",
+ "humanInput.submissionID": "提交 ID: {{id}}",
+ "humanInput.thanks": "謝謝!",
"login.backToHome": "返回首頁"
}
diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json
index 50260b72b6..9d7e06d484 100644
--- a/web/i18n/zh-Hant/workflow.json
+++ b/web/i18n/zh-Hant/workflow.json
@@ -8,6 +8,7 @@
"blocks.document-extractor": "文件提取器",
"blocks.end": "輸出",
"blocks.http-request": "HTTP 請求",
+ "blocks.human-input": "人工輸入",
"blocks.if-else": "條件分支",
"blocks.iteration": "迭代",
"blocks.iteration-start": "迭代開始",
@@ -38,6 +39,7 @@
"blocksAbout.document-extractor": "用於將上傳的文件解析為 LLM 易於理解的文字內容。",
"blocksAbout.end": "定義一個 workflow 流程的輸出和結果類型",
"blocksAbout.http-request": "允許通過 HTTP 協議發送服務器請求",
+ "blocksAbout.human-input": "在生成下一步之前請求人工確認",
"blocksAbout.if-else": "允許你根據 if/else 條件將 workflow 拆分成兩個分支",
"blocksAbout.iteration": "對列表對象執行多次步驟直至輸出所有結果。",
"blocksAbout.iteration-start": "迭代起始節點",
@@ -145,6 +147,9 @@
"common.fileUploadTip": "圖片上傳功能已升級為檔上傳。",
"common.goBackToEdit": "返回編輯模式",
"common.handMode": "手模式",
+ "common.humanInputEmailTip": "電子郵件(發送方式)已發送給您配置的收件人",
+ "common.humanInputEmailTipInDebugMode": "電子郵件(發送方式)已發送至{{email}} ",
+ "common.humanInputWebappTip": "僅調試預覽,用戶在 Web 應用中看不到此內容。",
"common.importDSL": "導入 DSL",
"common.importDSLTip": "當前草稿將被覆蓋。在導入之前將工作流匯出為備份。",
"common.importFailure": "匯入失敗",
@@ -500,6 +505,104 @@
"nodes.http.value": "值",
"nodes.http.verifySSL.title": "驗證 SSL 證書",
"nodes.http.verifySSL.warningTooltip": "不建議在生產環境中禁用SSL驗證。這僅應用於開發或測試,因為這樣會使連接容易受到中間人攻擊等安全威脅的威脅。",
+ "nodes.humanInput.deliveryMethod.added": "已添加",
+ "nodes.humanInput.deliveryMethod.contactTip1": "缺少您需要的發送方式?",
+ "nodes.humanInput.deliveryMethod.contactTip2": "請通過support@dify.ai 告訴我們。",
+ "nodes.humanInput.deliveryMethod.emailConfigure.allMembers": "所有成員({{workspaceName}})",
+ "nodes.humanInput.deliveryMethod.emailConfigure.body": "正文",
+ "nodes.humanInput.deliveryMethod.emailConfigure.bodyPlaceholder": "輸入郵件正文",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugMode": "調試模式",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip1": "在調試模式下,郵件僅發送到您的帳號郵箱{{email}} 。",
+ "nodes.humanInput.deliveryMethod.emailConfigure.debugModeTip2": "生產環境不受影響。",
+ "nodes.humanInput.deliveryMethod.emailConfigure.description": "通過電子郵件發送輸入請求",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.add": "+ 添加",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.added": "已添加",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.placeholder": "郵箱,用逗號分隔",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.title": "添加工作區成員或外部收件人",
+ "nodes.humanInput.deliveryMethod.emailConfigure.memberSelector.trigger": "選擇",
+ "nodes.humanInput.deliveryMethod.emailConfigure.recipient": "收件人",
+ "nodes.humanInput.deliveryMethod.emailConfigure.requestURLTip": "請求 URL 變量是人工輸入的觸發入口。",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subject": "主題",
+ "nodes.humanInput.deliveryMethod.emailConfigure.subjectPlaceholder": "輸入郵件主題",
+ "nodes.humanInput.deliveryMethod.emailConfigure.title": "郵件配置",
+ "nodes.humanInput.deliveryMethod.emailSender.debugDone": "測試郵件已發送至{{email}} 。請檢查您的收件箱。",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip": "已啟用調試模式。",
+ "nodes.humanInput.deliveryMethod.emailSender.debugModeTip2": "郵件將發送至{{email}} 。",
+ "nodes.humanInput.deliveryMethod.emailSender.done": "郵件已發送",
+ "nodes.humanInput.deliveryMethod.emailSender.optional": "(可選)",
+ "nodes.humanInput.deliveryMethod.emailSender.send": "發送郵件",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTip": "向您配置的收件人發送測試郵件",
+ "nodes.humanInput.deliveryMethod.emailSender.testSendTipInDebugMode": "向{{email}}發送測試郵件",
+ "nodes.humanInput.deliveryMethod.emailSender.tip": "建議啟用調試模式 以測試郵件發送。",
+ "nodes.humanInput.deliveryMethod.emailSender.title": "測試郵件發送器",
+ "nodes.humanInput.deliveryMethod.emailSender.vars": "表單內容中的變量",
+ "nodes.humanInput.deliveryMethod.emailSender.varsTip": "填寫表單變量以模擬收件人實際看到的內容。",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone1": "郵件已發送至{{team}} 成員和以下郵箱地址:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone2": "郵件已發送至{{team}} 成員。",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamDone3": "郵件已發送至以下郵箱地址:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip1": "郵件將發送至{{team}} 成員和以下郵箱地址:",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip2": "郵件將發送至{{team}} 成員。",
+ "nodes.humanInput.deliveryMethod.emailSender.wholeTeamTip3": "郵件將發送至以下郵箱地址:",
+ "nodes.humanInput.deliveryMethod.emptyTip": "未添加發送方式,無法觸發操作。",
+ "nodes.humanInput.deliveryMethod.notAvailableInTriggerMode": "不可用",
+ "nodes.humanInput.deliveryMethod.notConfigured": "未配置",
+ "nodes.humanInput.deliveryMethod.title": "發送方式",
+ "nodes.humanInput.deliveryMethod.tooltip": "人工輸入表單如何發送給用戶。",
+ "nodes.humanInput.deliveryMethod.types.discord.description": "通過 Discord 發送輸入請求",
+ "nodes.humanInput.deliveryMethod.types.discord.title": "Discord",
+ "nodes.humanInput.deliveryMethod.types.email.description": "通過電子郵件發送輸入請求",
+ "nodes.humanInput.deliveryMethod.types.email.title": "電子郵件",
+ "nodes.humanInput.deliveryMethod.types.slack.description": "通過 Slack 發送輸入請求",
+ "nodes.humanInput.deliveryMethod.types.slack.title": "Slack",
+ "nodes.humanInput.deliveryMethod.types.teams.description": "通過 Teams 發送輸入請求",
+ "nodes.humanInput.deliveryMethod.types.teams.title": "Teams",
+ "nodes.humanInput.deliveryMethod.types.webapp.description": "在 Web 應用中顯示給最終用戶",
+ "nodes.humanInput.deliveryMethod.types.webapp.title": "Web 應用",
+ "nodes.humanInput.deliveryMethod.upgradeTip": "解鎖人工輸入的電子郵件發送",
+ "nodes.humanInput.deliveryMethod.upgradeTipContent": "在代理執行操作前通過電子郵件發送確認請求 — 對發布和審批工作流很有用。",
+ "nodes.humanInput.deliveryMethod.upgradeTipHide": "關閉",
+ "nodes.humanInput.editor.previewTip": "在預覽模式下,操作按鈕不可用。",
+ "nodes.humanInput.errorMsg.duplicateActionId": "用戶操作中發現重複的操作 ID",
+ "nodes.humanInput.errorMsg.emptyActionId": "操作 ID 不能為空",
+ "nodes.humanInput.errorMsg.emptyActionTitle": "操作標題不能為空",
+ "nodes.humanInput.errorMsg.noDeliveryMethod": "請至少選擇一種發送方式",
+ "nodes.humanInput.errorMsg.noDeliveryMethodEnabled": "請至少啟用一種發送方式",
+ "nodes.humanInput.errorMsg.noUserActions": "請至少添加一個用戶操作",
+ "nodes.humanInput.formContent.hotkeyTip": "按 插入變量,按 插入輸入字段",
+ "nodes.humanInput.formContent.placeholder": "在此輸入內容",
+ "nodes.humanInput.formContent.preview": "預覽",
+ "nodes.humanInput.formContent.title": "表單內容",
+ "nodes.humanInput.formContent.tooltip": "用戶打開表單後將看到的內容。支持 Markdown 格式。",
+ "nodes.humanInput.insertInputField.insert": "插入",
+ "nodes.humanInput.insertInputField.prePopulateField": "預填充字段",
+ "nodes.humanInput.insertInputField.prePopulateFieldPlaceholder": "添加 或 ,用戶將首先看到此內容,或留空。",
+ "nodes.humanInput.insertInputField.saveResponseAs": "保存響應為",
+ "nodes.humanInput.insertInputField.saveResponseAsPlaceholder": "命名此變量以便稍後引用",
+ "nodes.humanInput.insertInputField.staticContent": "靜態內容",
+ "nodes.humanInput.insertInputField.title": "插入輸入字段",
+ "nodes.humanInput.insertInputField.useConstantInstead": "改用常量",
+ "nodes.humanInput.insertInputField.useVarInstead": "改用變量",
+ "nodes.humanInput.insertInputField.variable": "變量",
+ "nodes.humanInput.insertInputField.variableNameInvalid": "變量名只能包含字母、數字和下劃線,且不能以數字開頭",
+ "nodes.humanInput.log.backstageInputURL": "後台輸入 URL:",
+ "nodes.humanInput.log.reason": "原因:",
+ "nodes.humanInput.log.reasonContent": "需要人工輸入才能繼續",
+ "nodes.humanInput.singleRun.back": "返回",
+ "nodes.humanInput.singleRun.button": "生成表單",
+ "nodes.humanInput.singleRun.label": "表單變量",
+ "nodes.humanInput.timeout.days": "天",
+ "nodes.humanInput.timeout.hours": "小時",
+ "nodes.humanInput.timeout.title": "超時",
+ "nodes.humanInput.userActions.actionIdFormatTip": "操作 ID 必須以字母或下劃線開頭,後跟字母、數字或下劃線",
+ "nodes.humanInput.userActions.actionIdTooLong": "操作 ID 不能超過{{maxLength}}個字符",
+ "nodes.humanInput.userActions.actionNamePlaceholder": "操作名稱",
+ "nodes.humanInput.userActions.buttonTextPlaceholder": "按鈕顯示文本",
+ "nodes.humanInput.userActions.buttonTextTooLong": "按鈕文本不能超過{{maxLength}}個字符",
+ "nodes.humanInput.userActions.chooseStyle": "選擇按鈕樣式",
+ "nodes.humanInput.userActions.emptyTip": "點擊 '+' 按鈕添加用戶操作",
+ "nodes.humanInput.userActions.title": "用戶操作",
+ "nodes.humanInput.userActions.tooltip": "定義用戶可以點擊以響應此表單的按鈕。每個按鈕可以觸發不同的工作流路徑。操作 ID 必須以字母或下劃線開頭,後跟字母、數字或下劃線。",
+ "nodes.humanInput.userActions.triggered": "{{actionName}} 已被觸發",
"nodes.ifElse.addCondition": "新增條件",
"nodes.ifElse.addSubVariable": "子變數",
"nodes.ifElse.and": "and",
From 0cf7827f2a579ac99a271707e7825a6a4f0b48c6 Mon Sep 17 00:00:00 2001
From: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
Date: Fri, 30 Jan 2026 14:10:09 +0800
Subject: [PATCH 13/14] chore: update lint config (#31735)
---
web/eslint-suppressions.json | 323 +++++++++++++++++++
web/eslint.config.mjs | 35 +--
web/package.json | 23 +-
web/pnpm-lock.yaml | 586 ++++++++++++++++++-----------------
4 files changed, 653 insertions(+), 314 deletions(-)
diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json
index 2662e979c1..ae82d79919 100644
--- a/web/eslint-suppressions.json
+++ b/web/eslint-suppressions.json
@@ -182,6 +182,11 @@
"count": 1
}
},
+ "app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/app/annotation/batch-add-annotation-modal/csv-downloader.spec.tsx": {
"ts/no-explicit-any": {
"count": 2
@@ -191,6 +196,9 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
+ "react-refresh/only-export-components": {
+ "count": 1
+ },
"ts/no-explicit-any": {
"count": 2
}
@@ -198,6 +206,9 @@
"app/components/app/annotation/edit-annotation-modal/edit-item/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
+ },
+ "react-refresh/only-export-components": {
+ "count": 1
}
},
"app/components/app/annotation/edit-annotation-modal/index.spec.tsx": {
@@ -254,6 +265,11 @@
"count": 6
}
},
+ "app/components/app/configuration/base/var-highlight/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/app/configuration/config-prompt/advanced-prompt-input.tsx": {
"ts/no-explicit-any": {
"count": 2
@@ -424,6 +440,11 @@
"count": 6
}
},
+ "app/components/app/configuration/debug/debug-with-multiple-model/context.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/app/configuration/debug/debug-with-multiple-model/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 5
@@ -506,6 +527,11 @@
"count": 1
}
},
+ "app/components/app/create-app-dialog/app-list/sidebar.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/app/create-app-modal/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 7
@@ -522,6 +548,14 @@
"app/components/app/create-from-dsl-modal/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
+ },
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
+ "app/components/app/log/filter.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
}
},
"app/components/app/log/index.tsx": {
@@ -590,6 +624,9 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
},
+ "react-refresh/only-export-components": {
+ "count": 1
+ },
"ts/no-explicit-any": {
"count": 4
}
@@ -599,6 +636,11 @@
"count": 2
}
},
+ "app/components/app/workflow-log/filter.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/app/workflow-log/list.spec.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -650,6 +692,11 @@
"count": 1
}
},
+ "app/components/base/action-button/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/base/agent-log-modal/detail.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -678,6 +725,11 @@
"count": 2
}
},
+ "app/components/base/amplitude/AmplitudeProvider.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/base/amplitude/utils.ts": {
"ts/no-explicit-any": {
"count": 2
@@ -726,6 +778,9 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
},
+ "react-refresh/only-export-components": {
+ "count": 1
+ },
"react/no-nested-component-definitions": {
"count": 1
}
@@ -735,11 +790,21 @@
"count": 1
}
},
+ "app/components/base/button/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/base/button/sync-button.stories.tsx": {
"no-console": {
"count": 1
}
},
+ "app/components/base/carousel/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/base/chat/chat-with-history/chat-wrapper.tsx": {
"ts/no-explicit-any": {
"count": 7
@@ -827,6 +892,11 @@
"count": 1
}
},
+ "app/components/base/chat/chat/context.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/base/chat/chat/hooks.ts": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
@@ -930,10 +1000,18 @@
}
},
"app/components/base/error-boundary/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 3
+ },
"ts/no-explicit-any": {
"count": 2
}
},
+ "app/components/base/features/context.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/base/features/new-feature-panel/annotation-reply/index.tsx": {
"ts/no-explicit-any": {
"count": 3
@@ -999,6 +1077,11 @@
"count": 3
}
},
+ "app/components/base/file-uploader/store.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 4
+ }
+ },
"app/components/base/file-uploader/utils.spec.ts": {
"test/no-identical-title": {
"count": 1
@@ -1095,6 +1178,11 @@
"count": 2
}
},
+ "app/components/base/ga/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/base/icons/utils.ts": {
"ts/no-explicit-any": {
"count": 3
@@ -1146,6 +1234,16 @@
"count": 1
}
},
+ "app/components/base/input/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
+ "app/components/base/logo/dify-logo.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 2
+ }
+ },
"app/components/base/markdown-blocks/audio-block.tsx": {
"ts/no-explicit-any": {
"count": 5
@@ -1296,6 +1394,11 @@
"count": 1
}
},
+ "app/components/base/node-status/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/base/notion-connector/index.stories.tsx": {
"no-console": {
"count": 1
@@ -1327,6 +1430,9 @@
}
},
"app/components/base/portal-to-follow-elem/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 2
+ },
"ts/no-explicit-any": {
"count": 1
}
@@ -1508,6 +1614,16 @@
"count": 1
}
},
+ "app/components/base/textarea/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
+ "app/components/base/toast/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 2
+ }
+ },
"app/components/base/video-gallery/VideoPlayer.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -1551,6 +1667,16 @@
"count": 2
}
},
+ "app/components/billing/pricing/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
+ "app/components/billing/pricing/plan-switcher/plan-range-switcher.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/billing/pricing/plans/cloud-plan-item/index.spec.tsx": {
"test/prefer-hooks-in-order": {
"count": 1
@@ -1601,6 +1727,11 @@
"count": 3
}
},
+ "app/components/datasets/common/image-uploader/store.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 4
+ }
+ },
"app/components/datasets/common/image-uploader/utils.ts": {
"ts/no-explicit-any": {
"count": 2
@@ -1611,6 +1742,16 @@
"count": 1
}
},
+ "app/components/datasets/common/retrieval-method-info/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
+ "app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/datasets/create/file-preview/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -1641,6 +1782,11 @@
"count": 3
}
},
+ "app/components/datasets/create/step-two/preview-item/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/datasets/create/stop-embedding-modal/index.spec.tsx": {
"test/prefer-hooks-in-order": {
"count": 1
@@ -1698,6 +1844,9 @@
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
},
+ "react-refresh/only-export-components": {
+ "count": 1
+ },
"ts/no-explicit-any": {
"count": 2
}
@@ -1762,6 +1911,11 @@
"count": 2
}
},
+ "app/components/datasets/documents/create-from-pipeline/data-source/store/provider.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/datasets/documents/create-from-pipeline/data-source/store/slices/online-drive.ts": {
"ts/no-explicit-any": {
"count": 4
@@ -1807,6 +1961,11 @@
"count": 1
}
},
+ "app/components/datasets/documents/detail/completed/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/datasets/documents/detail/completed/new-child-segment.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -1830,6 +1989,11 @@
"count": 1
}
},
+ "app/components/datasets/documents/detail/segment-add/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/datasets/documents/detail/settings/pipeline-settings/index.tsx": {
"ts/no-explicit-any": {
"count": 6
@@ -1970,6 +2134,11 @@
"count": 1
}
},
+ "app/components/explore/try-app/tab.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/goto-anything/actions/commands/command-bus.ts": {
"ts/no-explicit-any": {
"count": 2
@@ -1981,6 +2150,9 @@
}
},
"app/components/goto-anything/actions/commands/slash.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 3
+ },
"ts/no-explicit-any": {
"count": 1
}
@@ -1998,6 +2170,9 @@
"app/components/goto-anything/context.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
+ },
+ "react-refresh/only-export-components": {
+ "count": 1
}
},
"app/components/goto-anything/index.spec.tsx": {
@@ -2194,6 +2369,11 @@
"count": 4
}
},
+ "app/components/plugins/install-plugin/install-bundle/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/plugins/install-plugin/install-bundle/item/github-item.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -2270,6 +2450,11 @@
"count": 2
}
},
+ "app/components/plugins/plugin-auth/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 3
+ }
+ },
"app/components/plugins/plugin-auth/plugin-auth-in-agent.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -2354,6 +2539,9 @@
}
},
"app/components/plugins/plugin-detail-panel/subscription-list/create/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ },
"ts/no-explicit-any": {
"count": 1
}
@@ -2394,6 +2582,9 @@
}
},
"app/components/plugins/plugin-page/context.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 2
+ },
"ts/no-explicit-any": {
"count": 1
}
@@ -2758,6 +2949,11 @@
"count": 1
}
},
+ "app/components/workflow/block-selector/constants.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/workflow/block-selector/featured-tools.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 2
@@ -2779,6 +2975,11 @@
"count": 1
}
},
+ "app/components/workflow/block-selector/index-bar.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/workflow/block-selector/market-place-plugin/action.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -2817,11 +3018,26 @@
"count": 1
}
},
+ "app/components/workflow/block-selector/view-type-select.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/workflow/candidate-node-main.tsx": {
"ts/no-explicit-any": {
"count": 2
}
},
+ "app/components/workflow/context.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
+ "app/components/workflow/datasets-detail-store/provider.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/workflow/header/run-mode.tsx": {
"no-console": {
"count": 1
@@ -2830,11 +3046,21 @@
"count": 1
}
},
+ "app/components/workflow/header/test-run-menu.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/workflow/header/view-workflow-history.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
+ "app/components/workflow/hooks-store/provider.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/workflow/hooks-store/store.ts": {
"ts/no-explicit-any": {
"count": 6
@@ -2955,10 +3181,18 @@
}
},
"app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ },
"ts/no-explicit-any": {
"count": 6
}
},
+ "app/components/workflow/nodes/_base/components/entry-node-container.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/workflow/nodes/_base/components/error-handle/default-value.tsx": {
"ts/no-explicit-any": {
"count": 1
@@ -2984,6 +3218,16 @@
"count": 1
}
},
+ "app/components/workflow/nodes/_base/components/layout/index.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 7
+ }
+ },
+ "app/components/workflow/nodes/_base/components/mcp-tool-availability.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/workflow/nodes/_base/components/memory-config.tsx": {
"unicorn/prefer-number-properties": {
"count": 1
@@ -3067,6 +3311,9 @@
}
},
"app/components/workflow/nodes/_base/components/workflow-panel/tab.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ },
"ts/no-explicit-any": {
"count": 1
}
@@ -3120,6 +3367,9 @@
}
},
"app/components/workflow/nodes/agent/panel.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ },
"ts/no-explicit-any": {
"count": 1
}
@@ -3295,6 +3545,9 @@
}
},
"app/components/workflow/nodes/human-input/components/variable-in-markdown.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 2
+ },
"ts/no-explicit-any": {
"count": 8
}
@@ -3424,6 +3677,11 @@
"count": 2
}
},
+ "app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/context.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 3
+ }
+ },
"app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/auto-width-input.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@@ -3721,6 +3979,11 @@
"count": 1
}
},
+ "app/components/workflow/note-node/note-editor/toolbar/color-picker.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"app/components/workflow/note-node/note-editor/utils.ts": {
"regexp/no-useless-quantifier": {
"count": 1
@@ -3757,6 +4020,9 @@
}
},
"app/components/workflow/panel/chat-variable-panel/components/object-value-item.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ },
"ts/no-explicit-any": {
"count": 5
},
@@ -4043,6 +4309,11 @@
"count": 8
}
},
+ "app/components/workflow/workflow-history-store.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 2
+ }
+ },
"app/components/workflow/workflow-preview/components/nodes/constants.ts": {
"ts/no-explicit-any": {
"count": 1
@@ -4104,30 +4375,79 @@
}
},
"context/app-context.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 2
+ },
"ts/no-explicit-any": {
"count": 1
}
},
+ "context/datasets-context.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
+ "context/event-emitter.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
+ "context/external-api-panel-context.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
+ "context/external-knowledge-api-context.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
+ "context/global-public-context.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 4
+ }
+ },
"context/hooks/use-trigger-events-limit-modal.ts": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
}
},
+ "context/mitt-context.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 3
+ }
+ },
"context/modal-context.test.tsx": {
"ts/no-explicit-any": {
"count": 3
}
},
"context/modal-context.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 2
+ },
"ts/no-explicit-any": {
"count": 5
}
},
"context/provider-context.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 3
+ },
"ts/no-explicit-any": {
"count": 1
}
},
+ "context/web-app-context.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
+ "context/workspace-context.tsx": {
+ "react-refresh/only-export-components": {
+ "count": 1
+ }
+ },
"hooks/use-async-window-open.spec.ts": {
"ts/no-explicit-any": {
"count": 6
@@ -4164,6 +4484,9 @@
"hooks/use-pay.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 4
+ },
+ "react-refresh/only-export-components": {
+ "count": 3
}
},
"i18n-config/README.md": {
diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs
index 9ef3f8d04f..3f3bef8c03 100644
--- a/web/eslint.config.mjs
+++ b/web/eslint.config.mjs
@@ -4,7 +4,7 @@ import pluginQuery from '@tanstack/eslint-plugin-query'
import sonar from 'eslint-plugin-sonarjs'
import storybook from 'eslint-plugin-storybook'
import tailwind from 'eslint-plugin-tailwindcss'
-import difyI18n from './eslint-rules/index.js'
+import dify from './eslint-rules/index.js'
export default antfu(
{
@@ -104,44 +104,25 @@ export default antfu(
'tailwindcss/migration-from-tailwind-2': 'warn',
},
},
- // dify i18n namespace migration
- // {
- // files: ['**/*.ts', '**/*.tsx'],
- // ignores: ['eslint-rules/**', 'i18n/**', 'i18n-config/**'],
- // plugins: {
- // 'dify-i18n': difyI18n,
- // },
- // rules: {
- // // 'dify-i18n/no-as-any-in-t': ['error', { mode: 'all' }],
- // 'dify-i18n/no-as-any-in-t': 'error',
- // // 'dify-i18n/no-legacy-namespace-prefix': 'error',
- // // 'dify-i18n/require-ns-option': 'error',
- // },
- // },
- // i18n JSON validation rules
+ {
+ plugins: { dify },
+ },
{
files: ['i18n/**/*.json'],
- plugins: {
- 'dify-i18n': difyI18n,
- },
rules: {
'sonarjs/max-lines': 'off',
'max-lines': 'off',
'jsonc/sort-keys': 'error',
- 'dify-i18n/valid-i18n-keys': 'error',
- 'dify-i18n/no-extra-keys': 'error',
- 'dify-i18n/consistent-placeholders': 'error',
+ 'dify/valid-i18n-keys': 'error',
+ 'dify/no-extra-keys': 'error',
+ 'dify/consistent-placeholders': 'error',
},
},
- // package.json version prefix validation
{
files: ['**/package.json'],
- plugins: {
- 'dify-i18n': difyI18n,
- },
rules: {
- 'dify-i18n/no-version-prefix': 'error',
+ 'dify/no-version-prefix': 'error',
},
},
)
diff --git a/web/package.json b/web/package.json
index 0096c6b58a..954366fc89 100644
--- a/web/package.json
+++ b/web/package.json
@@ -22,6 +22,9 @@
"and_uc >= 15.5",
"and_qq >= 14.9"
],
+ "engines": {
+ "node": ">=24"
+ },
"scripts": {
"dev": "next dev",
"dev:inspect": "next dev --inspect",
@@ -160,13 +163,13 @@
"zustand": "5.0.9"
},
"devDependencies": {
- "@antfu/eslint-config": "7.0.1",
+ "@antfu/eslint-config": "7.2.0",
"@chromatic-com/storybook": "5.0.0",
- "@eslint-react/eslint-plugin": "2.7.0",
+ "@eslint-react/eslint-plugin": "2.8.1",
"@mdx-js/loader": "3.1.1",
"@mdx-js/react": "3.1.1",
"@next/bundle-analyzer": "16.1.5",
- "@next/eslint-plugin-next": "16.1.5",
+ "@next/eslint-plugin-next": "16.1.6",
"@next/mdx": "16.1.5",
"@rgrove/parse-xml": "4.2.0",
"@serwist/turbopack": "9.5.0",
@@ -176,7 +179,7 @@
"@storybook/addon-themes": "10.2.0",
"@storybook/nextjs-vite": "10.2.0",
"@storybook/react": "10.2.0",
- "@tanstack/eslint-plugin-query": "5.91.2",
+ "@tanstack/eslint-plugin-query": "5.91.3",
"@tanstack/react-devtools": "0.9.2",
"@tanstack/react-form-devtools": "0.2.12",
"@tanstack/react-query-devtools": "5.90.2",
@@ -184,9 +187,9 @@
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
- "@tsslint/cli": "3.0.1",
- "@tsslint/compat-eslint": "3.0.1",
- "@tsslint/config": "3.0.1",
+ "@tsslint/cli": "3.0.2",
+ "@tsslint/compat-eslint": "3.0.2",
+ "@tsslint/config": "3.0.2",
"@types/js-cookie": "3.0.6",
"@types/js-yaml": "4.0.9",
"@types/negotiator": "0.6.4",
@@ -200,7 +203,7 @@
"@types/semver": "7.7.1",
"@types/sortablejs": "1.15.8",
"@types/uuid": "10.0.0",
- "@typescript-eslint/parser": "8.53.0",
+ "@typescript-eslint/parser": "8.54.0",
"@typescript/native-preview": "7.0.0-dev.20251209.1",
"@vitejs/plugin-react": "5.1.2",
"@vitest/coverage-v8": "4.0.17",
@@ -211,8 +214,8 @@
"eslint": "9.39.2",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-react-refresh": "0.4.26",
- "eslint-plugin-sonarjs": "3.0.5",
- "eslint-plugin-storybook": "10.2.0",
+ "eslint-plugin-sonarjs": "3.0.6",
+ "eslint-plugin-storybook": "10.2.1",
"eslint-plugin-tailwindcss": "3.18.2",
"husky": "9.1.7",
"jsdom": "27.3.0",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index e79dee6936..e018c0268b 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -367,14 +367,14 @@ importers:
version: 5.0.9(@types/react@19.2.9)(immer@11.1.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
devDependencies:
'@antfu/eslint-config':
- specifier: 7.0.1
- version: 7.0.1(@eslint-react/eslint-plugin@2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@next/eslint-plugin-next@16.1.5)(@vue/compiler-sfc@3.5.27)(eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@1.21.7)))(eslint-plugin-react-refresh@0.4.26(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)(vitest@4.0.17)
+ specifier: 7.2.0
+ version: 7.2.0(@eslint-react/eslint-plugin@2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@next/eslint-plugin-next@16.1.6)(@vue/compiler-sfc@3.5.27)(eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@1.21.7)))(eslint-plugin-react-refresh@0.4.26(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)(vitest@4.0.17)
'@chromatic-com/storybook':
specifier: 5.0.0
version: 5.0.0(storybook@10.2.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
'@eslint-react/eslint-plugin':
- specifier: 2.7.0
- version: 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ specifier: 2.8.1
+ version: 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@mdx-js/loader':
specifier: 3.1.1
version: 3.1.1(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3))
@@ -385,8 +385,8 @@ importers:
specifier: 16.1.5
version: 16.1.5
'@next/eslint-plugin-next':
- specifier: 16.1.5
- version: 16.1.5
+ specifier: 16.1.6
+ version: 16.1.6
'@next/mdx':
specifier: 16.1.5
version: 16.1.5(@mdx-js/loader@3.1.1(webpack@5.104.1(esbuild@0.27.2)(uglify-js@3.19.3)))(@mdx-js/react@3.1.1(@types/react@19.2.9)(react@19.2.4))
@@ -415,8 +415,8 @@ importers:
specifier: 10.2.0
version: 10.2.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
'@tanstack/eslint-plugin-query':
- specifier: 5.91.2
- version: 5.91.2(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ specifier: 5.91.3
+ version: 5.91.3(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@tanstack/react-devtools':
specifier: 0.9.2
version: 0.9.2(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)
@@ -439,14 +439,14 @@ importers:
specifier: 14.6.1
version: 14.6.1(@testing-library/dom@10.4.1)
'@tsslint/cli':
- specifier: 3.0.1
- version: 3.0.1(@tsslint/compat-eslint@3.0.1(jiti@1.21.7)(typescript@5.9.3))(typescript@5.9.3)
+ specifier: 3.0.2
+ version: 3.0.2(@tsslint/compat-eslint@3.0.2(jiti@1.21.7)(typescript@5.9.3))(typescript@5.9.3)
'@tsslint/compat-eslint':
- specifier: 3.0.1
- version: 3.0.1(jiti@1.21.7)(typescript@5.9.3)
+ specifier: 3.0.2
+ version: 3.0.2(jiti@1.21.7)(typescript@5.9.3)
'@tsslint/config':
- specifier: 3.0.1
- version: 3.0.1(@tsslint/compat-eslint@3.0.1(jiti@1.21.7)(typescript@5.9.3))(typescript@5.9.3)
+ specifier: 3.0.2
+ version: 3.0.2(@tsslint/compat-eslint@3.0.2(jiti@1.21.7)(typescript@5.9.3))(typescript@5.9.3)
'@types/js-cookie':
specifier: 3.0.6
version: 3.0.6
@@ -487,8 +487,8 @@ importers:
specifier: 10.0.0
version: 10.0.0
'@typescript-eslint/parser':
- specifier: 8.53.0
- version: 8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ specifier: 8.54.0
+ version: 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript/native-preview':
specifier: 7.0.0-dev.20251209.1
version: 7.0.0-dev.20251209.1
@@ -520,11 +520,11 @@ importers:
specifier: 0.4.26
version: 0.4.26(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-sonarjs:
- specifier: 3.0.5
- version: 3.0.5(eslint@9.39.2(jiti@1.21.7))
+ specifier: 3.0.6
+ version: 3.0.6(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-storybook:
- specifier: 10.2.0
- version: 10.2.0(eslint@9.39.2(jiti@1.21.7))(storybook@10.2.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
+ specifier: 10.2.1
+ version: 10.2.1(eslint@9.39.2(jiti@1.21.7))(storybook@10.2.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
eslint-plugin-tailwindcss:
specifier: 3.18.2
version: 3.18.2(tailwindcss@3.4.18(tsx@4.21.0)(yaml@2.8.2))
@@ -672,8 +672,8 @@ packages:
'@amplitude/targeting@0.2.0':
resolution: {integrity: sha512-/50ywTrC4hfcfJVBbh5DFbqMPPfaIOivZeb5Gb+OGM03QrA+lsUqdvtnKLNuWtceD4H6QQ2KFzPJ5aAJLyzVDA==}
- '@antfu/eslint-config@7.0.1':
- resolution: {integrity: sha512-QbCDrLPo2Bpn9/W5PnpGvUuD/EIKhiCmLBuIj9ylxeMvl47XSkXy3MZyinqUVsBJzk196B7BcJQByDZRr5TbZQ==}
+ '@antfu/eslint-config@7.2.0':
+ resolution: {integrity: sha512-I/GWDvkvUfp45VolhrMpOdkfBC69f6lstJi0BCSooylQZwH4OTJPkbXCkp4lKh9V4BeMrcO3G5iC+YIfY28/aA==}
hasBin: true
peerDependencies:
'@eslint-react/eslint-plugin': ^2.0.1
@@ -1114,48 +1114,44 @@ packages:
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
- '@eslint-community/regexpp@4.12.1':
- resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==}
- engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
-
'@eslint-community/regexpp@4.12.2':
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
- '@eslint-react/ast@2.7.0':
- resolution: {integrity: sha512-GGrvel9+kR++wK7orcS2kS1xtHpY0o0rh6hbHbiGVWsSiZmg0X8jZfK1nSf8a3FLJR2WLtQlUsrrtJ4hObaqeQ==}
+ '@eslint-react/ast@2.8.1':
+ resolution: {integrity: sha512-4D442lxeFvvd9PMvBbA621rfz/Ne8Kod8RW0/FLKO0vx+IOxm74pP6be1uU56rqL9TvoIHxjclBjfgXplEF+Yw==}
engines: {node: '>=20.19.0'}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
- '@eslint-react/core@2.7.0':
- resolution: {integrity: sha512-xeRSnzLI35Msr2lnGjH4vxgOwohODy2FaXRmXUS1IpmMRDp1Ct+7I3SDknfeW/YExjGZXvpxR0uD2P9dSjU6NA==}
+ '@eslint-react/core@2.8.1':
+ resolution: {integrity: sha512-zF73p8blyuX+zrfgyTtpKesichYzK+G54TEjFWtzagWIbnqQjtVscebL/eGep72oWzAOd5B04ACBvJ2hW4fp5g==}
engines: {node: '>=20.19.0'}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
- '@eslint-react/eff@2.7.0':
- resolution: {integrity: sha512-+uUI53LkS6EDU0ysVUeM2SdyZQwt/xEfh4OSJ0JMLT8fJbseZY8c0hyev7X5arifcLs0PVPHwUP1IPcNhSLOFw==}
+ '@eslint-react/eff@2.8.1':
+ resolution: {integrity: sha512-ZASOs8oTZJSiu1giue7V87GEKQvlKLfGfLppal6Rl+aKnfIEz+vartmjpH12pkFQZ9ESRyHzYbU533S6pEDoNg==}
engines: {node: '>=20.19.0'}
- '@eslint-react/eslint-plugin@2.7.0':
- resolution: {integrity: sha512-Bog14dOrsG/jBA9B8URZPJMI6dZuEwqHdkPcTuIkJe92EjFj8NwyziNGFXKY3j7o9AU9ILCBbjfC4JFq56lwjQ==}
+ '@eslint-react/eslint-plugin@2.8.1':
+ resolution: {integrity: sha512-ob+SSDnTPnA5dhiWWJLfyHRLEzWnjilCsohgo5s9PPKF5b5bjxG+c/rwqhQwT3M9Ey83mGNdkrLzt00SOfr4pw==}
engines: {node: '>=20.19.0'}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
- '@eslint-react/shared@2.7.0':
- resolution: {integrity: sha512-/lF5uiGYd+XIfO5t2YMC5RdbQ9lxLkxfL4icZgrbiJIPndirAKjFNl1cdXd+C/qqRCYDACrTPqI8HEL1T4N1Iw==}
+ '@eslint-react/shared@2.8.1':
+ resolution: {integrity: sha512-NDmJBiMiPDXR6qeZzYOtiILHxWjYwBHxquQ/bMQkWcWK+1qF5LeD8UTRcWtBpZoMPi3sNBWwR3k2Sc5HWZpJ7g==}
engines: {node: '>=20.19.0'}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
- '@eslint-react/var@2.7.0':
- resolution: {integrity: sha512-EFztHstOAYYCrFFNUOPZ7+J3o/X/zawqPKgLL7b5/271rhL6/DMxUmTcKtJIHO7hCdFPMcGT+vPxe+omq62Ukg==}
+ '@eslint-react/var@2.8.1':
+ resolution: {integrity: sha512-iHIdEBz6kgW4dEFdhEjpy9SEQ6+d4RYg+WBzHg5J5ktT2xSQFi77Dq6Wtemik6QvvAPnYLRseQxgW+m+1rQlfA==}
engines: {node: '>=20.19.0'}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
@@ -1186,6 +1182,10 @@ packages:
resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/config-helpers@0.5.1':
+ resolution: {integrity: sha512-QN8067dXsXAl9HIvqws7STEviheRFojX3zek5OpC84oBxDGqizW9731ByF/ASxqQihbWrVDdZXS+Ihnsckm9dg==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24}
+
'@eslint/core@0.14.0':
resolution: {integrity: sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1724,8 +1724,8 @@ packages:
'@next/env@16.1.5':
resolution: {integrity: sha512-CRSCPJiSZoi4Pn69RYBDI9R7YK2g59vLexPQFXY0eyw+ILevIenCywzg+DqmlBik9zszEnw2HLFOUlLAcJbL7g==}
- '@next/eslint-plugin-next@16.1.5':
- resolution: {integrity: sha512-gUWcEsOl+1W7XakmouClcJ0TNFCkblvDUho31wulbDY9na0C6mGtBTSXGRU5GXJY65GjGj0zNaCD/GaBp888Mg==}
+ '@next/eslint-plugin-next@16.1.6':
+ resolution: {integrity: sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==}
'@next/mdx@16.1.5':
resolution: {integrity: sha512-TYzfGfZiXtf6HXZpqJoKq+2DRB1FjY9BR1HWhfl7WoSW/BAEr6X+WmdrdrCtqNpkY8VSoWHVWP0KNbyTqY7ZTA==}
@@ -2851,8 +2851,8 @@ packages:
peerDependencies:
solid-js: 1.9.11
- '@tanstack/eslint-plugin-query@5.91.2':
- resolution: {integrity: sha512-UPeWKl/Acu1IuuHJlsN+eITUHqAaa9/04geHHPedY8siVarSaWprY0SVMKrkpKfk5ehRT7+/MZ5QwWuEtkWrFw==}
+ '@tanstack/eslint-plugin-query@5.91.3':
+ resolution: {integrity: sha512-5GMGZMYFK9dOvjpdedjJs4hU40EdPuO2AjzObQzP7eOSsikunCfrXaU3oNGXSsvoU9ve1Z1xQZZuDyPi0C1M7Q==}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
@@ -2958,18 +2958,18 @@ packages:
peerDependencies:
'@testing-library/dom': '>=7.21.4'
- '@tsslint/cli@3.0.1':
- resolution: {integrity: sha512-y5yzMFl6sKQNsomuGInmFzMiKW37xxDcJauHnPqYoCWL8LldNLnaUOBqx0illfNZ0FDAiSuV/oshC/NG8/F2Tw==}
+ '@tsslint/cli@3.0.2':
+ resolution: {integrity: sha512-8lyZcDEs86zitz0wZ5QRdswY6xGz8j+WL11baN4rlpwahtPgYatujpYV5gpoKeyMAyerlNTdQh6u2LUJLoLNyQ==}
engines: {node: '>=22.6.0'}
hasBin: true
peerDependencies:
typescript: '*'
- '@tsslint/compat-eslint@3.0.1':
- resolution: {integrity: sha512-cojBaB1C9RxWjDfCvLBhbffshyizb+Cf1Os9NXHuzyQOPvU1IwYPW5Sxo1RU19pCOE9/TvQcuxgnGfwbkk/Dig==}
+ '@tsslint/compat-eslint@3.0.2':
+ resolution: {integrity: sha512-2TzSJPybCEfU/kHNi9UybwI//A7Fe14CwqmNuJ4fR4WYGpfIclXqfDJwsn5U1NzrWbHjWzRSntJITQPNw1SCNA==}
- '@tsslint/config@3.0.1':
- resolution: {integrity: sha512-1S8YYLrZE22xfH3GtDXRO7YzkeQj9+FjoxaWhYQsjWDU82HHeSRWq5d2UzPSN/ac6WFmFq8yApXIGylfvrG6MA==}
+ '@tsslint/config@3.0.2':
+ resolution: {integrity: sha512-oHzteAwL6NHVrLzJnrpqMwewEFOydhDH228weO4wkHW8SwvE4oVV5qrKmjwL69ClYt5Le3y2aGDzGou+GuTbKg==}
engines: {node: '>=22.6.0'}
hasBin: true
peerDependencies:
@@ -2981,12 +2981,12 @@ packages:
tsl:
optional: true
- '@tsslint/core@3.0.1':
- resolution: {integrity: sha512-8FEczJ20hdpmEH5vm272hS3QAycsk5574yZT6VMS8TUK8kNY4qoRKY/gdOY0nYNYWZrRPs+6dr1TmEVPBZjlvw==}
+ '@tsslint/core@3.0.2':
+ resolution: {integrity: sha512-Cu50e9vBojEMQjbqMoshkgLSoBj1BKbbmhSvzgbo07TiQ1wrOblZjvhU8ygB1fAIIHgU4laExX3pLU5OOeeR9g==}
engines: {node: '>=22.6.0'}
- '@tsslint/types@3.0.1':
- resolution: {integrity: sha512-JPK/+tSJ2hPTwgN173fkenPEnAI2CD0r0FDJ23PfftTc0NM449ZiAFHvs1KuPUOjAvBFIo5BsLr7Kxc1Ekdgtw==}
+ '@tsslint/types@3.0.2':
+ resolution: {integrity: sha512-RbF3TIxu/YQwRpYrH5j2EL3ff4+Lr2SSmwCJmPJfi832F0hpgJj6xB9xKEorrUj0ZaTHE1QOr5SOMe5B6Qv+2Q==}
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -3225,41 +3225,41 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/parser@8.53.0':
- resolution: {integrity: sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==}
+ '@typescript-eslint/parser@8.54.0':
+ resolution: {integrity: sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/project-service@8.53.0':
- resolution: {integrity: sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==}
- engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- peerDependencies:
- typescript: '>=4.8.4 <6.0.0'
-
'@typescript-eslint/project-service@8.53.1':
resolution: {integrity: sha512-WYC4FB5Ra0xidsmlPb+1SsnaSKPmS3gsjIARwbEkHkoWloQmuzcfypljaJcR78uyLA1h8sHdWWPHSLDI+MtNog==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/scope-manager@8.53.0':
- resolution: {integrity: sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==}
+ '@typescript-eslint/project-service@8.54.0':
+ resolution: {integrity: sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/scope-manager@8.53.1':
resolution: {integrity: sha512-Lu23yw1uJMFY8cUeq7JlrizAgeQvWugNQzJp8C3x8Eo5Jw5Q2ykMdiiTB9vBVOOUBysMzmRRmUfwFrZuI2C4SQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/tsconfig-utils@8.53.0':
- resolution: {integrity: sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==}
+ '@typescript-eslint/scope-manager@8.54.0':
+ resolution: {integrity: sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/tsconfig-utils@8.53.1':
+ resolution: {integrity: sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/tsconfig-utils@8.53.1':
- resolution: {integrity: sha512-qfvLXS6F6b1y43pnf0pPbXJ+YoXIC7HKg0UGZ27uMIemKMKA6XH2DTxsEDdpdN29D+vHV07x/pnlPNVLhdhWiA==}
+ '@typescript-eslint/tsconfig-utils@8.54.0':
+ resolution: {integrity: sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
@@ -3271,22 +3271,29 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/types@8.53.0':
- resolution: {integrity: sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==}
+ '@typescript-eslint/type-utils@8.54.0':
+ resolution: {integrity: sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/types@8.53.1':
resolution: {integrity: sha512-jr/swrr2aRmUAUjW5/zQHbMaui//vQlsZcJKijZf3M26bnmLj8LyZUpj8/Rd6uzaek06OWsqdofN/Thenm5O8A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/typescript-estree@8.53.0':
- resolution: {integrity: sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==}
+ '@typescript-eslint/types@8.54.0':
+ resolution: {integrity: sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
+ '@typescript-eslint/typescript-estree@8.53.1':
+ resolution: {integrity: sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/typescript-estree@8.53.1':
- resolution: {integrity: sha512-RGlVipGhQAG4GxV1s34O91cxQ/vWiHJTDHbXRr0li2q/BGg3RR/7NM8QDWgkEgrwQYCvmJV9ichIwyoKCQ+DTg==}
+ '@typescript-eslint/typescript-estree@8.54.0':
+ resolution: {integrity: sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
@@ -3298,14 +3305,21 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
- '@typescript-eslint/visitor-keys@8.53.0':
- resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==}
+ '@typescript-eslint/utils@8.54.0':
+ resolution: {integrity: sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ peerDependencies:
+ eslint: ^8.57.0 || ^9.0.0
+ typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/visitor-keys@8.53.1':
resolution: {integrity: sha512-oy+wV7xDKFPRyNggmXuZQSBzvoLnpmJs+GhzRhPjrxl2b/jIlyjVokzm47CZCDUdXKr2zd7ZLodPfOBpOPyPlg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@typescript-eslint/visitor-keys@8.54.0':
+ resolution: {integrity: sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20251209.1':
resolution: {integrity: sha512-F1cnYi+ZeinYQnaTQKKIsbuoq8vip5iepBkSZXlB8PjbG62LW1edUdktd/nVEc+Q+SEysSQ3jRdk9eU766s5iw==}
cpu: [arm64]
@@ -4236,6 +4250,10 @@ packages:
resolution: {integrity: sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+ diff-sequences@29.6.3:
+ resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
+ engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
@@ -4382,8 +4400,8 @@ packages:
peerDependencies:
eslint: ^9.5.0
- eslint-flat-config-utils@2.1.4:
- resolution: {integrity: sha512-bEnmU5gqzS+4O+id9vrbP43vByjF+8KOs+QuuV4OlqAuXmnRW2zfI/Rza1fQvdihQ5h4DUo0NqFAiViD4mSrzQ==}
+ eslint-flat-config-utils@3.0.0:
+ resolution: {integrity: sha512-bzTam/pSnPANR0GUz4g7lo4fyzlQZwuz/h8ytsSS4w59N/JlXH/l7jmyNVBLxPz3B9/9ntz5ZLevGpazyDXJQQ==}
eslint-json-compat-utils@0.2.1:
resolution: {integrity: sha512-YzEodbDyW8DX8bImKhAcCeu/L31Dd/70Bidx2Qex9OFUtgzXLqtfWL4Hr5fM/aCCB8QUZLuJur0S9k6UfgFkfg==}
@@ -4456,15 +4474,15 @@ packages:
peerDependencies:
eslint: ^9.0.0
- eslint-plugin-react-dom@2.7.0:
- resolution: {integrity: sha512-9dvpfaAG3dC14jkDx5c9yXK9mQkYvxAUphQYfzorCntumQi5iOPsWNhITO+M1P+uIEpoc4HwuWkX42E/395AGQ==}
+ eslint-plugin-react-dom@2.8.1:
+ resolution: {integrity: sha512-VAVs3cp/0XTxdjTeLePtZVadj+om+N1VNVy7hyzSPACfh5ncAicC0zOIc5MB15KUWCj8PoG/ZnVny0YqeubgRg==}
engines: {node: '>=20.19.0'}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
- eslint-plugin-react-hooks-extra@2.7.0:
- resolution: {integrity: sha512-pvjuFvUJkmmHLRjWgJcuRKI+UUq8DddyVU5PrMJY2G3LTYewr4kMHRGaFQ6qg+mbVZWovfxy+VjZjJ8PTfJTDg==}
+ eslint-plugin-react-hooks-extra@2.8.1:
+ resolution: {integrity: sha512-YeZLGzcib6UxlY7Gf+3zz8Mfl7u+OoVj3MukGaTuU6zkm1XQMI8/k4o16bKHuWtUauhn7Udl1bLAWfLgQM5UFw==}
engines: {node: '>=20.19.0'}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
@@ -4476,8 +4494,8 @@ packages:
peerDependencies:
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
- eslint-plugin-react-naming-convention@2.7.0:
- resolution: {integrity: sha512-BENL2tUVW/PSpFjLyfS0WloG5Buh76rvBM1hG/dCEyWDpHA6s4oJpF2Th9J92eKfim48/uprIPkKCB520Ev2nQ==}
+ eslint-plugin-react-naming-convention@2.8.1:
+ resolution: {integrity: sha512-fVj+hSzIe2I6HyPTf1nccMBXq72c4jbM3gk0T+szo/wewEF8/LgenjfquJoxHPpheb1fujFgdlo5HBhsilAX7Q==}
engines: {node: '>=20.19.0'}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
@@ -4488,15 +4506,15 @@ packages:
peerDependencies:
eslint: '>=8.40'
- eslint-plugin-react-web-api@2.7.0:
- resolution: {integrity: sha512-vIuYyHbn2H337YZR8tKqUbzSNAiH6+9jk3atQBEgISJT0NTuwd80nhEPm3oPHfbgB3Sc4+rEhchVTnG+4BsFfg==}
+ eslint-plugin-react-web-api@2.8.1:
+ resolution: {integrity: sha512-NYsZKW1aJZ2XZuYTPzbwYLShvGcuXKRV/5TW61VO56gik/btil4Snt5UtyxshHbvT/zXx/Z+QsHul51/XM4/Qw==}
engines: {node: '>=20.19.0'}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
- eslint-plugin-react-x@2.7.0:
- resolution: {integrity: sha512-/za228LsbKt1OlZ2XxP3R4xouG0rXeeuLyEnpHfKsAcY0mKPklempmQ5s0E9+SqcpQ/Jd+O4Jg9/30RU+vCqfw==}
+ eslint-plugin-react-x@2.8.1:
+ resolution: {integrity: sha512-4IpCMrsb63AVEa9diOApIm+T3wUGIzK+EB5vyYocO31YYPJ16+R7Fh4lV3S3fOuX1+aQ+Ad4SE0cYuZ2pF2Tlg==}
engines: {node: '>=20.19.0'}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
@@ -4508,16 +4526,16 @@ packages:
peerDependencies:
eslint: '>=8.44.0'
- eslint-plugin-sonarjs@3.0.5:
- resolution: {integrity: sha512-dI62Ff3zMezUToi161hs2i1HX1ie8Ia2hO0jtNBfdgRBicAG4ydy2WPt0rMTrAe3ZrlqhpAO3w1jcQEdneYoFA==}
+ eslint-plugin-sonarjs@3.0.6:
+ resolution: {integrity: sha512-3mVUqsAUSylGfkJMj2v0aC2Cu/eUunDLm+XMjLf0uLjAZao205NWF3g6EXxcCAFO+rCZiQ6Or1WQkUcU9/sKFQ==}
peerDependencies:
eslint: ^8.0.0 || ^9.0.0
- eslint-plugin-storybook@10.2.0:
- resolution: {integrity: sha512-OtQJ153FOusr8bIMzccjkfMFJEex/3NFx0iXZ+UaeQ0WXearQ+37EGgBay3onkFElyu8AySggq/fdTknPAEvPA==}
+ eslint-plugin-storybook@10.2.1:
+ resolution: {integrity: sha512-5+V+dlzTuZfNKUD8hPbLvCVtggcWfI2lDGTpiq0AENrHeAgcztj17wwDva96lbg/sAG20uX71l8HQo3s/GmpHw==}
peerDependencies:
eslint: '>=8'
- storybook: ^10.2.0
+ storybook: ^10.2.1
eslint-plugin-tailwindcss@3.18.2:
resolution: {integrity: sha512-QbkMLDC/OkkjFQ1iz/5jkMdHfiMu/uwujUHLAJK5iwNHD8RTxVTlsUezE0toTZ6VhybNBsk+gYGPDq2agfeRNA==}
@@ -4560,11 +4578,11 @@ packages:
'@typescript-eslint/parser':
optional: true
- eslint-plugin-yml@1.19.1:
- resolution: {integrity: sha512-bYkOxyEiXh9WxUhVYPELdSHxGG5pOjCSeJOVkfdIyj6tuiHDxrES2WAW1dBxn3iaZQey57XflwLtCYRcNPOiOg==}
- engines: {node: ^14.17.0 || >=16.0.0}
+ eslint-plugin-yml@3.0.0:
+ resolution: {integrity: sha512-kuAW6o3hlFHyF5p7TLon+AtvNWnsvRrb88pqywGMSCEqAP5d1gOMvNGgWLVlKHqmx5RbFhQLcxFDGmS4IU9DwA==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
peerDependencies:
- eslint: '>=6.0.0'
+ eslint: '>=9.38.0'
eslint-processor-vue-blocks@2.0.0:
resolution: {integrity: sha512-u4W0CJwGoWY3bjXAuFpc/b6eK3NQEI8MoeW7ritKj3G3z/WtHrKjkqf+wk8mPEy5rlMGS+k6AZYOw2XBoN/02Q==}
@@ -6556,11 +6574,6 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
- semver@7.7.2:
- resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
- engines: {node: '>=10'}
- hasBin: true
-
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
@@ -7418,10 +7431,6 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
- yaml-eslint-parser@1.3.2:
- resolution: {integrity: sha512-odxVsHAkZYYglR30aPYRY4nUGJnoJ2y1ww2HDvZALo0BDETv9kWbi16J52eHs+PWRNmF4ub6nZqfVOeesOvntg==}
- engines: {node: ^14.17.0 || >=16.0.0}
-
yaml-eslint-parser@2.0.0:
resolution: {integrity: sha512-h0uDm97wvT2bokfwwTmY6kJ1hp6YDFL0nRHwNKz8s/VD1FH/vvZjAKoMUE+un0eaYBSG7/c6h+lJTP+31tjgTw==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
@@ -7656,21 +7665,21 @@ snapshots:
idb: 8.0.3
tslib: 2.8.1
- '@antfu/eslint-config@7.0.1(@eslint-react/eslint-plugin@2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@next/eslint-plugin-next@16.1.5)(@vue/compiler-sfc@3.5.27)(eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@1.21.7)))(eslint-plugin-react-refresh@0.4.26(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)(vitest@4.0.17)':
+ '@antfu/eslint-config@7.2.0(@eslint-react/eslint-plugin@2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(@next/eslint-plugin-next@16.1.6)(@vue/compiler-sfc@3.5.27)(eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@1.21.7)))(eslint-plugin-react-refresh@0.4.26(eslint@9.39.2(jiti@1.21.7)))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)(vitest@4.0.17)':
dependencies:
'@antfu/install-pkg': 1.1.0
'@clack/prompts': 0.11.0
'@eslint-community/eslint-plugin-eslint-comments': 4.6.0(eslint@9.39.2(jiti@1.21.7))
'@eslint/markdown': 7.5.1
'@stylistic/eslint-plugin': 5.7.1(eslint@9.39.2(jiti@1.21.7))
- '@typescript-eslint/eslint-plugin': 8.53.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/eslint-plugin': 8.53.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@vitest/eslint-plugin': 1.6.6(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)(vitest@4.0.17)
ansis: 4.2.0
cac: 6.7.14
eslint: 9.39.2(jiti@1.21.7)
eslint-config-flat-gitignore: 2.1.0(eslint@9.39.2(jiti@1.21.7))
- eslint-flat-config-utils: 2.1.4
+ eslint-flat-config-utils: 3.0.0
eslint-merge-processors: 2.0.0(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-antfu: 3.1.3(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-command: 3.4.0(eslint@9.39.2(jiti@1.21.7))
@@ -7684,9 +7693,9 @@ snapshots:
eslint-plugin-regexp: 2.10.0(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-toml: 1.0.3(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-unicorn: 62.0.0(eslint@9.39.2(jiti@1.21.7))
- eslint-plugin-unused-imports: 4.3.0(@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))
- eslint-plugin-vue: 10.7.0(@stylistic/eslint-plugin@5.7.1(eslint@9.39.2(jiti@1.21.7)))(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@1.21.7)))
- eslint-plugin-yml: 1.19.1(eslint@9.39.2(jiti@1.21.7))
+ eslint-plugin-unused-imports: 4.3.0(@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))
+ eslint-plugin-vue: 10.7.0(@stylistic/eslint-plugin@5.7.1(eslint@9.39.2(jiti@1.21.7)))(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@1.21.7)))
+ eslint-plugin-yml: 3.0.0(eslint@9.39.2(jiti@1.21.7))
eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.27)(eslint@9.39.2(jiti@1.21.7))
globals: 17.1.0
jsonc-eslint-parser: 2.4.2
@@ -7694,10 +7703,10 @@ snapshots:
parse-gitignore: 2.0.0
toml-eslint-parser: 1.0.3
vue-eslint-parser: 10.2.0(eslint@9.39.2(jiti@1.21.7))
- yaml-eslint-parser: 1.3.2
+ yaml-eslint-parser: 2.0.0
optionalDependencies:
- '@eslint-react/eslint-plugin': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@next/eslint-plugin-next': 16.1.5
+ '@eslint-react/eslint-plugin': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@next/eslint-plugin-next': 16.1.6
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@1.21.7))
eslint-plugin-react-refresh: 0.4.26(eslint@9.39.2(jiti@1.21.7))
transitivePeerDependencies:
@@ -8097,63 +8106,60 @@ snapshots:
eslint: 9.39.2(jiti@1.21.7)
eslint-visitor-keys: 3.4.3
- '@eslint-community/regexpp@4.12.1': {}
-
'@eslint-community/regexpp@4.12.2': {}
- '@eslint-react/ast@2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
+ '@eslint-react/ast@2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
- '@eslint-react/eff': 2.7.0
- '@typescript-eslint/types': 8.53.1
- '@typescript-eslint/typescript-estree': 8.53.1(typescript@5.9.3)
- '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/eff': 2.8.1
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
string-ts: 2.3.1
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@eslint-react/core@2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
+ '@eslint-react/core@2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
- '@eslint-react/ast': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@eslint-react/eff': 2.7.0
- '@eslint-react/shared': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@eslint-react/var': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@typescript-eslint/scope-manager': 8.53.1
- '@typescript-eslint/types': 8.53.1
- '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- birecord: 0.1.1
+ '@eslint-react/ast': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/eff': 2.8.1
+ '@eslint-react/shared': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/var': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.54.0
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
ts-pattern: 5.9.0
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@eslint-react/eff@2.7.0': {}
+ '@eslint-react/eff@2.8.1': {}
- '@eslint-react/eslint-plugin@2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
+ '@eslint-react/eslint-plugin@2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
- '@eslint-react/eff': 2.7.0
- '@eslint-react/shared': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@typescript-eslint/scope-manager': 8.53.1
- '@typescript-eslint/type-utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@typescript-eslint/types': 8.53.1
- '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/eff': 2.8.1
+ '@eslint-react/shared': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.54.0
+ '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
- eslint-plugin-react-dom: 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- eslint-plugin-react-hooks-extra: 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- eslint-plugin-react-naming-convention: 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- eslint-plugin-react-web-api: 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- eslint-plugin-react-x: 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ eslint-plugin-react-dom: 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ eslint-plugin-react-hooks-extra: 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ eslint-plugin-react-naming-convention: 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ eslint-plugin-react-web-api: 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ eslint-plugin-react-x: 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
ts-api-utils: 2.4.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@eslint-react/shared@2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
+ '@eslint-react/shared@2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
- '@eslint-react/eff': 2.7.0
- '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/eff': 2.8.1
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
ts-pattern: 5.9.0
typescript: 5.9.3
@@ -8161,13 +8167,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@eslint-react/var@2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
+ '@eslint-react/var@2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
- '@eslint-react/ast': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@eslint-react/eff': 2.7.0
- '@typescript-eslint/scope-manager': 8.53.1
- '@typescript-eslint/types': 8.53.1
- '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/ast': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/eff': 2.8.1
+ '@eslint-react/shared': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.54.0
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
ts-pattern: 5.9.0
typescript: 5.9.3
@@ -8202,6 +8209,10 @@ snapshots:
dependencies:
'@eslint/core': 0.17.0
+ '@eslint/config-helpers@0.5.1':
+ dependencies:
+ '@eslint/core': 1.0.1
+
'@eslint/core@0.14.0':
dependencies:
'@types/json-schema': 7.0.15
@@ -8851,7 +8862,7 @@ snapshots:
'@next/env@16.1.5': {}
- '@next/eslint-plugin-next@16.1.5':
+ '@next/eslint-plugin-next@16.1.6':
dependencies:
fast-glob: 3.3.1
@@ -9909,7 +9920,7 @@ snapshots:
- csstype
- utf-8-validate
- '@tanstack/eslint-plugin-query@5.91.2(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
+ '@tanstack/eslint-plugin-query@5.91.3(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
@@ -10047,11 +10058,11 @@ snapshots:
dependencies:
'@testing-library/dom': 10.4.1
- '@tsslint/cli@3.0.1(@tsslint/compat-eslint@3.0.1(jiti@1.21.7)(typescript@5.9.3))(typescript@5.9.3)':
+ '@tsslint/cli@3.0.2(@tsslint/compat-eslint@3.0.2(jiti@1.21.7)(typescript@5.9.3))(typescript@5.9.3)':
dependencies:
'@clack/prompts': 0.8.2
- '@tsslint/config': 3.0.1(@tsslint/compat-eslint@3.0.1(jiti@1.21.7)(typescript@5.9.3))(typescript@5.9.3)
- '@tsslint/core': 3.0.1
+ '@tsslint/config': 3.0.2(@tsslint/compat-eslint@3.0.2(jiti@1.21.7)(typescript@5.9.3))(typescript@5.9.3)
+ '@tsslint/core': 3.0.2
'@volar/language-core': 2.4.27
'@volar/language-hub': 0.0.1
'@volar/typescript': 2.4.27
@@ -10061,32 +10072,32 @@ snapshots:
- '@tsslint/compat-eslint'
- tsl
- '@tsslint/compat-eslint@3.0.1(jiti@1.21.7)(typescript@5.9.3)':
+ '@tsslint/compat-eslint@3.0.2(jiti@1.21.7)(typescript@5.9.3)':
dependencies:
- '@tsslint/types': 3.0.1
- '@typescript-eslint/parser': 8.53.0(eslint@9.27.0(jiti@1.21.7))(typescript@5.9.3)
+ '@tsslint/types': 3.0.2
+ '@typescript-eslint/parser': 8.54.0(eslint@9.27.0(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.27.0(jiti@1.21.7)
transitivePeerDependencies:
- jiti
- supports-color
- typescript
- '@tsslint/config@3.0.1(@tsslint/compat-eslint@3.0.1(jiti@1.21.7)(typescript@5.9.3))(typescript@5.9.3)':
+ '@tsslint/config@3.0.2(@tsslint/compat-eslint@3.0.2(jiti@1.21.7)(typescript@5.9.3))(typescript@5.9.3)':
dependencies:
- '@tsslint/types': 3.0.1
+ '@tsslint/types': 3.0.2
minimatch: 10.1.1
ts-api-utils: 2.4.0(typescript@5.9.3)
optionalDependencies:
- '@tsslint/compat-eslint': 3.0.1(jiti@1.21.7)(typescript@5.9.3)
+ '@tsslint/compat-eslint': 3.0.2(jiti@1.21.7)(typescript@5.9.3)
transitivePeerDependencies:
- typescript
- '@tsslint/core@3.0.1':
+ '@tsslint/core@3.0.2':
dependencies:
- '@tsslint/types': 3.0.1
+ '@tsslint/types': 3.0.2
minimatch: 10.1.1
- '@tsslint/types@3.0.1': {}
+ '@tsslint/types@3.0.2': {}
'@tybys/wasm-util@0.10.1':
dependencies:
@@ -10346,10 +10357,10 @@ snapshots:
'@types/zen-observable@0.8.3': {}
- '@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
+ '@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
- '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.53.1
'@typescript-eslint/type-utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
'@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
@@ -10362,39 +10373,30 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.53.0(eslint@9.27.0(jiti@1.21.7))(typescript@5.9.3)':
+ '@typescript-eslint/parser@8.54.0(eslint@9.27.0(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/scope-manager': 8.53.0
- '@typescript-eslint/types': 8.53.0
- '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3)
- '@typescript-eslint/visitor-keys': 8.53.0
+ '@typescript-eslint/scope-manager': 8.54.0
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.54.0
debug: 4.4.3
eslint: 9.27.0(jiti@1.21.7)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
+ '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/scope-manager': 8.53.0
- '@typescript-eslint/types': 8.53.0
- '@typescript-eslint/typescript-estree': 8.53.0(typescript@5.9.3)
- '@typescript-eslint/visitor-keys': 8.53.0
+ '@typescript-eslint/scope-manager': 8.54.0
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
+ '@typescript-eslint/visitor-keys': 8.54.0
debug: 4.4.3
eslint: 9.39.2(jiti@1.21.7)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/project-service@8.53.0(typescript@5.9.3)':
- dependencies:
- '@typescript-eslint/tsconfig-utils': 8.53.1(typescript@5.9.3)
- '@typescript-eslint/types': 8.53.1
- debug: 4.4.3
- typescript: 5.9.3
- transitivePeerDependencies:
- - supports-color
-
'@typescript-eslint/project-service@8.53.1(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.53.1(typescript@5.9.3)
@@ -10404,21 +10406,30 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/scope-manager@8.53.0':
+ '@typescript-eslint/project-service@8.54.0(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/types': 8.53.0
- '@typescript-eslint/visitor-keys': 8.53.0
+ '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.54.0
+ debug: 4.4.3
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
'@typescript-eslint/scope-manager@8.53.1':
dependencies:
'@typescript-eslint/types': 8.53.1
'@typescript-eslint/visitor-keys': 8.53.1
- '@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.9.3)':
+ '@typescript-eslint/scope-manager@8.54.0':
+ dependencies:
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/visitor-keys': 8.54.0
+
+ '@typescript-eslint/tsconfig-utils@8.53.1(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
- '@typescript-eslint/tsconfig-utils@8.53.1(typescript@5.9.3)':
+ '@typescript-eslint/tsconfig-utils@8.54.0(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
@@ -10434,16 +10445,28 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/types@8.53.0': {}
+ '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
+ dependencies:
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ debug: 4.4.3
+ eslint: 9.39.2(jiti@1.21.7)
+ ts-api-utils: 2.4.0(typescript@5.9.3)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
'@typescript-eslint/types@8.53.1': {}
- '@typescript-eslint/typescript-estree@8.53.0(typescript@5.9.3)':
+ '@typescript-eslint/types@8.54.0': {}
+
+ '@typescript-eslint/typescript-estree@8.53.1(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/project-service': 8.53.0(typescript@5.9.3)
- '@typescript-eslint/tsconfig-utils': 8.53.0(typescript@5.9.3)
- '@typescript-eslint/types': 8.53.0
- '@typescript-eslint/visitor-keys': 8.53.0
+ '@typescript-eslint/project-service': 8.53.1(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.53.1(typescript@5.9.3)
+ '@typescript-eslint/types': 8.53.1
+ '@typescript-eslint/visitor-keys': 8.53.1
debug: 4.4.3
minimatch: 9.0.5
semver: 7.7.3
@@ -10453,12 +10476,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/typescript-estree@8.53.1(typescript@5.9.3)':
+ '@typescript-eslint/typescript-estree@8.54.0(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/project-service': 8.53.1(typescript@5.9.3)
- '@typescript-eslint/tsconfig-utils': 8.53.1(typescript@5.9.3)
- '@typescript-eslint/types': 8.53.1
- '@typescript-eslint/visitor-keys': 8.53.1
+ '@typescript-eslint/project-service': 8.54.0(typescript@5.9.3)
+ '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3)
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/visitor-keys': 8.54.0
debug: 4.4.3
minimatch: 9.0.5
semver: 7.7.3
@@ -10479,16 +10502,27 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/visitor-keys@8.53.0':
+ '@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
- '@typescript-eslint/types': 8.53.0
- eslint-visitor-keys: 4.2.1
+ '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7))
+ '@typescript-eslint/scope-manager': 8.54.0
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3)
+ eslint: 9.39.2(jiti@1.21.7)
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
'@typescript-eslint/visitor-keys@8.53.1':
dependencies:
'@typescript-eslint/types': 8.53.1
eslint-visitor-keys: 4.2.1
+ '@typescript-eslint/visitor-keys@8.54.0':
+ dependencies:
+ '@typescript-eslint/types': 8.54.0
+ eslint-visitor-keys: 4.2.1
+
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20251209.1':
optional: true
@@ -11501,6 +11535,8 @@ snapshots:
diff-sequences@27.5.1: {}
+ diff-sequences@29.6.3: {}
+
dlv@1.1.3: {}
doctrine@3.0.0:
@@ -11652,8 +11688,9 @@ snapshots:
'@eslint/compat': 1.4.1(eslint@9.39.2(jiti@1.21.7))
eslint: 9.39.2(jiti@1.21.7)
- eslint-flat-config-utils@2.1.4:
+ eslint-flat-config-utils@3.0.0:
dependencies:
+ '@eslint/config-helpers': 0.5.1
pathe: 2.0.3
eslint-json-compat-utils@0.2.1(eslint@9.39.2(jiti@1.21.7))(jsonc-eslint-parser@2.4.2):
@@ -11758,16 +11795,16 @@ snapshots:
yaml: 2.8.2
yaml-eslint-parser: 2.0.0
- eslint-plugin-react-dom@2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
+ eslint-plugin-react-dom@2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
dependencies:
- '@eslint-react/ast': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@eslint-react/core': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@eslint-react/eff': 2.7.0
- '@eslint-react/shared': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@eslint-react/var': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@typescript-eslint/scope-manager': 8.53.1
- '@typescript-eslint/types': 8.53.1
- '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/ast': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/core': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/eff': 2.8.1
+ '@eslint-react/shared': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/var': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.54.0
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
compare-versions: 6.1.1
eslint: 9.39.2(jiti@1.21.7)
string-ts: 2.3.1
@@ -11776,17 +11813,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-plugin-react-hooks-extra@2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
+ eslint-plugin-react-hooks-extra@2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
dependencies:
- '@eslint-react/ast': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@eslint-react/core': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@eslint-react/eff': 2.7.0
- '@eslint-react/shared': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@eslint-react/var': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@typescript-eslint/scope-manager': 8.53.1
- '@typescript-eslint/type-utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@typescript-eslint/types': 8.53.1
- '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/ast': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/core': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/eff': 2.8.1
+ '@eslint-react/shared': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/var': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.54.0
+ '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
string-ts: 2.3.1
ts-pattern: 5.9.0
@@ -11805,17 +11842,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-plugin-react-naming-convention@2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
+ eslint-plugin-react-naming-convention@2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
dependencies:
- '@eslint-react/ast': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@eslint-react/core': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@eslint-react/eff': 2.7.0
- '@eslint-react/shared': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@eslint-react/var': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@typescript-eslint/scope-manager': 8.53.1
- '@typescript-eslint/type-utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@typescript-eslint/types': 8.53.1
- '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/ast': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/core': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/eff': 2.8.1
+ '@eslint-react/shared': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/var': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.54.0
+ '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
compare-versions: 6.1.1
eslint: 9.39.2(jiti@1.21.7)
string-ts: 2.3.1
@@ -11828,16 +11865,17 @@ snapshots:
dependencies:
eslint: 9.39.2(jiti@1.21.7)
- eslint-plugin-react-web-api@2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
+ eslint-plugin-react-web-api@2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
dependencies:
- '@eslint-react/ast': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@eslint-react/core': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@eslint-react/eff': 2.7.0
- '@eslint-react/shared': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@eslint-react/var': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@typescript-eslint/scope-manager': 8.53.1
- '@typescript-eslint/types': 8.53.1
- '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/ast': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/core': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/eff': 2.8.1
+ '@eslint-react/shared': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/var': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.54.0
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ birecord: 0.1.1
eslint: 9.39.2(jiti@1.21.7)
string-ts: 2.3.1
ts-pattern: 5.9.0
@@ -11845,17 +11883,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-plugin-react-x@2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
+ eslint-plugin-react-x@2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
dependencies:
- '@eslint-react/ast': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@eslint-react/core': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@eslint-react/eff': 2.7.0
- '@eslint-react/shared': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@eslint-react/var': 2.7.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@typescript-eslint/scope-manager': 8.53.1
- '@typescript-eslint/type-utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- '@typescript-eslint/types': 8.53.1
- '@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/ast': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/core': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/eff': 2.8.1
+ '@eslint-react/shared': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@eslint-react/var': 2.8.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/scope-manager': 8.54.0
+ '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/types': 8.54.0
+ '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
compare-versions: 6.1.1
eslint: 9.39.2(jiti@1.21.7)
is-immutable-type: 5.0.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
@@ -11877,21 +11915,21 @@ snapshots:
regexp-ast-analysis: 0.7.1
scslre: 0.3.0
- eslint-plugin-sonarjs@3.0.5(eslint@9.39.2(jiti@1.21.7)):
+ eslint-plugin-sonarjs@3.0.6(eslint@9.39.2(jiti@1.21.7)):
dependencies:
- '@eslint-community/regexpp': 4.12.1
+ '@eslint-community/regexpp': 4.12.2
builtin-modules: 3.3.0
bytes: 3.1.2
eslint: 9.39.2(jiti@1.21.7)
functional-red-black-tree: 1.0.1
jsx-ast-utils-x: 0.1.0
lodash.merge: 4.6.2
- minimatch: 9.0.5
+ minimatch: 10.1.1
scslre: 0.3.0
- semver: 7.7.2
+ semver: 7.7.3
typescript: 5.9.3
- eslint-plugin-storybook@10.2.0(eslint@9.39.2(jiti@1.21.7))(storybook@10.2.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3):
+ eslint-plugin-storybook@10.2.1(eslint@9.39.2(jiti@1.21.7))(storybook@10.2.0(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3):
dependencies:
'@typescript-eslint/utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
@@ -11938,13 +11976,13 @@ snapshots:
semver: 7.7.3
strip-indent: 4.1.1
- eslint-plugin-unused-imports@4.3.0(@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)):
+ eslint-plugin-unused-imports@4.3.0(@typescript-eslint/eslint-plugin@8.53.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)):
dependencies:
eslint: 9.39.2(jiti@1.21.7)
optionalDependencies:
- '@typescript-eslint/eslint-plugin': 8.53.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/eslint-plugin': 8.53.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- eslint-plugin-vue@10.7.0(@stylistic/eslint-plugin@5.7.1(eslint@9.39.2(jiti@1.21.7)))(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@1.21.7))):
+ eslint-plugin-vue@10.7.0(@stylistic/eslint-plugin@5.7.1(eslint@9.39.2(jiti@1.21.7)))(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@1.21.7))):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7))
eslint: 9.39.2(jiti@1.21.7)
@@ -11956,17 +11994,18 @@ snapshots:
xml-name-validator: 4.0.0
optionalDependencies:
'@stylistic/eslint-plugin': 5.7.1(eslint@9.39.2(jiti@1.21.7))
- '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
- eslint-plugin-yml@1.19.1(eslint@9.39.2(jiti@1.21.7)):
+ eslint-plugin-yml@3.0.0(eslint@9.39.2(jiti@1.21.7)):
dependencies:
+ '@eslint/core': 1.0.1
+ '@eslint/plugin-kit': 0.5.1
debug: 4.4.3
- diff-sequences: 27.5.1
- escape-string-regexp: 4.0.0
+ diff-sequences: 29.6.3
+ escape-string-regexp: 5.0.0
eslint: 9.39.2(jiti@1.21.7)
- eslint-compat-utils: 0.6.5(eslint@9.39.2(jiti@1.21.7))
natural-compare: 1.4.0
- yaml-eslint-parser: 1.3.2
+ yaml-eslint-parser: 2.0.0
transitivePeerDependencies:
- supports-color
@@ -12643,7 +12682,7 @@ snapshots:
is-immutable-type@5.0.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3):
dependencies:
- '@typescript-eslint/type-utils': 8.53.1(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
+ '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)
eslint: 9.39.2(jiti@1.21.7)
ts-api-utils: 2.4.0(typescript@5.9.3)
ts-declaration-location: 1.0.7(typescript@5.9.3)
@@ -14488,8 +14527,6 @@ snapshots:
semver@6.3.1: {}
- semver@7.7.2: {}
-
semver@7.7.3: {}
serialize-javascript@6.0.2:
@@ -15391,11 +15428,6 @@ snapshots:
yallist@3.1.1: {}
- yaml-eslint-parser@1.3.2:
- dependencies:
- eslint-visitor-keys: 3.4.3
- yaml: 2.8.2
-
yaml-eslint-parser@2.0.0:
dependencies:
eslint-visitor-keys: 5.0.0
From d6a787497fb433783f125a3c6984a40d24452ad2 Mon Sep 17 00:00:00 2001
From: QuantumGhost
Date: Fri, 30 Jan 2026 14:22:32 +0800
Subject: [PATCH 14/14] chore(docker): update plugin daemon version to
0.5.3-local in docker-compose (#31739)
---
docker/docker-compose-template.yaml | 2 +-
docker/docker-compose.middleware.yaml | 2 +-
docker/docker-compose.yaml | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml
index 860e728023..e27b51bcc0 100644
--- a/docker/docker-compose-template.yaml
+++ b/docker/docker-compose-template.yaml
@@ -270,7 +270,7 @@ services:
# plugin daemon
plugin_daemon:
- image: langgenius/dify-plugin-daemon:0.5.2-local
+ image: langgenius/dify-plugin-daemon:0.5.3-local
restart: always
environment:
# Use the shared environment variables.
diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml
index 81c34fc6a2..4a739bbbe0 100644
--- a/docker/docker-compose.middleware.yaml
+++ b/docker/docker-compose.middleware.yaml
@@ -123,7 +123,7 @@ services:
# plugin daemon
plugin_daemon:
- image: langgenius/dify-plugin-daemon:0.5.2-local
+ image: langgenius/dify-plugin-daemon:0.5.3-local
restart: always
env_file:
- ./middleware.env
diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml
index 023fdf4a9d..a5518ceee9 100644
--- a/docker/docker-compose.yaml
+++ b/docker/docker-compose.yaml
@@ -961,7 +961,7 @@ services:
# plugin daemon
plugin_daemon:
- image: langgenius/dify-plugin-daemon:0.5.2-local
+ image: langgenius/dify-plugin-daemon:0.5.3-local
restart: always
environment:
# Use the shared environment variables.