FEAT: NEW WORKFLOW ENGINE (#3160)

Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: Yeuoly <admin@srmxy.cn>
Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: StyleZhang <jasonapring2015@outlook.com>
Co-authored-by: jyong <jyong@dify.ai>
Co-authored-by: nite-knite <nkCoding@gmail.com>
Co-authored-by: jyong <718720800@qq.com>
This commit is contained in:
takatost
2024-04-08 18:51:46 +08:00
committed by GitHub
parent 2fb9850af5
commit 7753ba2d37
1161 changed files with 103836 additions and 10327 deletions

View File

@@ -0,0 +1,122 @@
import {
forwardRef,
memo,
useCallback,
useImperativeHandle,
useMemo,
} from 'react'
import { useNodes } from 'reactflow'
import { BlockEnum } from '../../types'
import {
useStore,
useWorkflowStore,
} from '../../store'
import type { StartNodeType } from '../../nodes/start/types'
import Empty from './empty'
import UserInput from './user-input'
import { useChat } from './hooks'
import type { ChatWrapperRefType } from './index'
import Chat from '@/app/components/base/chat/chat'
import type { OnSend } from '@/app/components/base/chat/types'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import {
fetchSuggestedQuestions,
stopChatMessageResponding,
} from '@/service/debug'
import { useStore as useAppStore } from '@/app/components/app/store'
const ChatWrapper = forwardRef<ChatWrapperRefType>((_, ref) => {
const nodes = useNodes<StartNodeType>()
const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
const startVariables = startNode?.data.variables
const appDetail = useAppStore(s => s.appDetail)
const workflowStore = useWorkflowStore()
const featuresStore = useFeaturesStore()
const inputs = useStore(s => s.inputs)
const features = featuresStore!.getState().features
const config = useMemo(() => {
return {
opening_statement: features.opening?.opening_statement || '',
suggested_questions: features.opening?.suggested_questions || [],
suggested_questions_after_answer: features.suggested,
text_to_speech: features.text2speech,
speech_to_text: features.speech2text,
retriever_resource: features.citation,
sensitive_word_avoidance: features.moderation,
file_upload: features.file,
}
}, [features])
const {
conversationId,
chatList,
handleStop,
isResponding,
suggestedQuestions,
handleSend,
handleRestart,
} = useChat(
config,
{
inputs,
promptVariables: (startVariables as any) || [],
},
[],
taskId => stopChatMessageResponding(appDetail!.id, taskId),
)
const doSend = useCallback<OnSend>((query, files) => {
handleSend(
{
query,
files,
inputs: workflowStore.getState().inputs,
conversation_id: conversationId,
},
{
onGetSuggestedQuestions: (messageId, getAbortController) => fetchSuggestedQuestions(appDetail!.id, messageId, getAbortController),
},
)
}, [conversationId, handleSend, workflowStore, appDetail])
useImperativeHandle(ref, () => {
return {
handleRestart,
}
}, [handleRestart])
return (
<Chat
config={{
...config,
supportCitationHitInfo: true,
} as any}
chatList={chatList}
isResponding={isResponding}
chatContainerclassName='px-4'
chatContainerInnerClassName='pt-6'
chatFooterClassName='px-4 rounded-bl-2xl'
chatFooterInnerClassName='pb-4'
onSend={doSend}
onStopResponding={handleStop}
chatNode={(
<>
<UserInput />
{
!chatList.length && (
<Empty />
)
}
</>
)}
suggestedQuestions={suggestedQuestions}
showPromptLog
chatAnswerContainerInner='!pr-2'
/>
)
})
ChatWrapper.displayName = 'ChatWrapper'
export default memo(ChatWrapper)

View File

@@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next'
import { ChatBotSlim } from '@/app/components/base/icons/src/vender/line/communication'
const Empty = () => {
const { t } = useTranslation()
return (
<div className='absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2'>
<div className='flex justify-center mb-2'>
<ChatBotSlim className='w-12 h-12 text-gray-300' />
</div>
<div className='w-[256px] text-center text-[13px] text-gray-400'>
{t('workflow.common.previewPlaceholder')}
</div>
</div>
)
}
export default Empty

View File

@@ -0,0 +1,339 @@
import {
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { produce, setAutoFreeze } from 'immer'
import { useWorkflowRun } from '../../hooks'
import { WorkflowRunningStatus } from '../../types'
import type {
ChatItem,
Inputs,
PromptVariable,
} from '@/app/components/base/chat/types'
import { useToastContext } from '@/app/components/base/toast'
import { TransferMethod } from '@/types/app'
import type { VisionFile } from '@/types/app'
import { replaceStringWithValues } from '@/app/components/app/configuration/prompt-value-panel'
type GetAbortController = (abortController: AbortController) => void
type SendCallback = {
onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise<any>
}
export const useChat = (
config: any,
promptVariablesConfig?: {
inputs: Inputs
promptVariables: PromptVariable[]
},
prevChatList?: ChatItem[],
stopChat?: (taskId: string) => void,
) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const { handleRun } = useWorkflowRun()
const hasStopResponded = useRef(false)
const connversationId = useRef('')
const taskIdRef = useRef('')
const [chatList, setChatList] = useState<ChatItem[]>(prevChatList || [])
const chatListRef = useRef<ChatItem[]>(prevChatList || [])
const [isResponding, setIsResponding] = useState(false)
const isRespondingRef = useRef(false)
const [suggestedQuestions, setSuggestQuestions] = useState<string[]>([])
const suggestedQuestionsAbortControllerRef = useRef<AbortController | null>(null)
useEffect(() => {
setAutoFreeze(false)
return () => {
setAutoFreeze(true)
}
}, [])
const handleUpdateChatList = useCallback((newChatList: ChatItem[]) => {
setChatList(newChatList)
chatListRef.current = newChatList
}, [])
const handleResponding = useCallback((isResponding: boolean) => {
setIsResponding(isResponding)
isRespondingRef.current = isResponding
}, [])
const getIntroduction = useCallback((str: string) => {
return replaceStringWithValues(str, promptVariablesConfig?.promptVariables || [], promptVariablesConfig?.inputs || {})
}, [promptVariablesConfig?.inputs, promptVariablesConfig?.promptVariables])
useEffect(() => {
if (config?.opening_statement) {
handleUpdateChatList(produce(chatListRef.current, (draft) => {
const index = draft.findIndex(item => item.isOpeningStatement)
if (index > -1) {
draft[index] = {
...draft[index],
content: getIntroduction(config.opening_statement),
suggestedQuestions: config.suggested_questions,
}
}
else {
draft.unshift({
id: `${Date.now()}`,
content: getIntroduction(config.opening_statement),
isAnswer: true,
isOpeningStatement: true,
suggestedQuestions: config.suggested_questions,
})
}
}))
}
}, [config?.opening_statement, getIntroduction, config?.suggested_questions, handleUpdateChatList])
const handleStop = useCallback(() => {
hasStopResponded.current = true
handleResponding(false)
if (stopChat && taskIdRef.current)
stopChat(taskIdRef.current)
if (suggestedQuestionsAbortControllerRef.current)
suggestedQuestionsAbortControllerRef.current.abort()
}, [handleResponding, stopChat])
const handleRestart = useCallback(() => {
connversationId.current = ''
taskIdRef.current = ''
handleStop()
const newChatList = config?.opening_statement
? [{
id: `${Date.now()}`,
content: config.opening_statement,
isAnswer: true,
isOpeningStatement: true,
suggestedQuestions: config.suggested_questions,
}]
: []
handleUpdateChatList(newChatList)
setSuggestQuestions([])
}, [
config,
handleStop,
handleUpdateChatList,
])
const updateCurrentQA = useCallback(({
responseItem,
questionId,
placeholderAnswerId,
questionItem,
}: {
responseItem: ChatItem
questionId: string
placeholderAnswerId: string
questionItem: ChatItem
}) => {
const newListWithAnswer = produce(
chatListRef.current.filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
(draft) => {
if (!draft.find(item => item.id === questionId))
draft.push({ ...questionItem })
draft.push({ ...responseItem })
})
handleUpdateChatList(newListWithAnswer)
}, [handleUpdateChatList])
const handleSend = useCallback((
params: any,
{
onGetSuggestedQuestions,
}: SendCallback,
) => {
if (isRespondingRef.current) {
notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
return false
}
const questionId = `question-${Date.now()}`
const questionItem = {
id: questionId,
content: params.query,
isAnswer: false,
message_files: params.files,
}
const placeholderAnswerId = `answer-placeholder-${Date.now()}`
const placeholderAnswerItem = {
id: placeholderAnswerId,
content: '',
isAnswer: true,
}
const newList = [...chatListRef.current, questionItem, placeholderAnswerItem]
handleUpdateChatList(newList)
// answer
const responseItem: ChatItem = {
id: `${Date.now()}`,
content: '',
agent_thoughts: [],
message_files: [],
isAnswer: true,
}
handleResponding(true)
const bodyParams = {
conversation_id: connversationId.current,
...params,
}
if (bodyParams?.files?.length) {
bodyParams.files = bodyParams.files.map((item: VisionFile) => {
if (item.transfer_method === TransferMethod.local_file) {
return {
...item,
url: '',
}
}
return item
})
}
let hasSetResponseId = false
handleRun(
params,
{
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
responseItem.content = responseItem.content + message
if (messageId && !hasSetResponseId) {
responseItem.id = messageId
hasSetResponseId = true
}
if (isFirstMessage && newConversationId)
connversationId.current = newConversationId
taskIdRef.current = taskId
if (messageId)
responseItem.id = messageId
updateCurrentQA({
responseItem,
questionId,
placeholderAnswerId,
questionItem,
})
},
async onCompleted(hasError?: boolean, errorMessage?: string) {
handleResponding(false)
if (hasError) {
if (errorMessage) {
responseItem.content = errorMessage
responseItem.isError = true
const newListWithAnswer = produce(
chatListRef.current.filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
(draft) => {
if (!draft.find(item => item.id === questionId))
draft.push({ ...questionItem })
draft.push({ ...responseItem })
})
handleUpdateChatList(newListWithAnswer)
}
return
}
if (config?.suggested_questions_after_answer?.enabled && !hasStopResponded.current && onGetSuggestedQuestions) {
const { data }: any = await onGetSuggestedQuestions(
responseItem.id,
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
)
setSuggestQuestions(data)
}
},
onMessageEnd: (messageEnd) => {
responseItem.citation = messageEnd.metadata?.retriever_resources || []
const newListWithAnswer = produce(
chatListRef.current.filter(item => item.id !== responseItem.id && item.id !== placeholderAnswerId),
(draft) => {
if (!draft.find(item => item.id === questionId))
draft.push({ ...questionItem })
draft.push({ ...responseItem })
})
handleUpdateChatList(newListWithAnswer)
},
onMessageReplace: (messageReplace) => {
responseItem.content = messageReplace.answer
},
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: [],
}
handleUpdateChatList(produce(chatListRef.current, (draft) => {
const currentIndex = draft.findIndex(item => item.id === responseItem.id)
draft[currentIndex] = {
...draft[currentIndex],
...responseItem,
}
}))
},
onWorkflowFinished: ({ data }) => {
responseItem.workflowProcess!.status = data.status as WorkflowRunningStatus
handleUpdateChatList(produce(chatListRef.current, (draft) => {
const currentIndex = draft.findIndex(item => item.id === responseItem.id)
draft[currentIndex] = {
...draft[currentIndex],
...responseItem,
}
}))
},
onNodeStarted: ({ data }) => {
responseItem.workflowProcess!.tracing!.push(data as any)
handleUpdateChatList(produce(chatListRef.current, (draft) => {
const currentIndex = draft.findIndex(item => item.id === responseItem.id)
draft[currentIndex] = {
...draft[currentIndex],
...responseItem,
}
}))
},
onNodeFinished: ({ data }) => {
const currentIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === data.node_id)
responseItem.workflowProcess!.tracing[currentIndex] = {
...(responseItem.workflowProcess!.tracing[currentIndex].extras
? { extras: responseItem.workflowProcess!.tracing[currentIndex].extras }
: {}),
...data,
} as any
handleUpdateChatList(produce(chatListRef.current, (draft) => {
const currentIndex = draft.findIndex(item => item.id === responseItem.id)
draft[currentIndex] = {
...draft[currentIndex],
...responseItem,
}
}))
},
},
)
}, [handleRun, handleResponding, handleUpdateChatList, notify, t, updateCurrentQA, config.suggested_questions_after_answer?.enabled])
return {
conversationId: connversationId.current,
chatList,
handleSend,
handleStop,
handleRestart,
isResponding,
suggestedQuestions,
}
}

View File

@@ -0,0 +1,52 @@
import {
memo,
useRef,
} from 'react'
import { useKeyPress } from 'ahooks'
import { useTranslation } from 'react-i18next'
import ChatWrapper from './chat-wrapper'
import Button from '@/app/components/base/button'
import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows'
export type ChatWrapperRefType = {
handleRestart: () => void
}
const DebugAndPreview = () => {
const { t } = useTranslation()
const chatRef = useRef({ handleRestart: () => {} })
useKeyPress('shift.r', () => {
chatRef.current.handleRestart()
}, {
exactMatch: true,
})
return (
<div
className={`
flex flex-col w-[400px] rounded-l-2xl h-full border border-black/[0.02] shadow-xl
`}
style={{
background: 'linear-gradient(156deg, rgba(242, 244, 247, 0.80) 0%, rgba(242, 244, 247, 0.00) 99.43%), var(--white, #FFF)',
}}
>
<div className='shrink-0 flex items-center justify-between px-4 pt-3 pb-2 font-semibold text-gray-900'>
{t('workflow.common.debugAndPreview').toLocaleUpperCase()}
<Button
className='pl-2.5 pr-[7px] h-8 bg-white border-[0.5px] border-gray-200 shadow-xs rounded-lg text-[13px] text-primary-600 font-semibold'
onClick={() => chatRef.current.handleRestart()}
>
<RefreshCcw01 className='mr-1 w-3.5 h-3.5' />
{t('common.operation.refresh')}
<div className='ml-2 px-1 leading-[18px] rounded-md border border-gray-200 bg-gray-50 text-[11px] text-gray-500 font-medium'>Shift</div>
<div className='ml-0.5 px-1 leading-[18px] rounded-md border border-gray-200 bg-gray-50 text-[11px] text-gray-500 font-medium'>R</div>
</Button>
</div>
<div className='grow rounded-b-2xl overflow-y-auto'>
<ChatWrapper ref={chatRef} />
</div>
</div>
)
}
export default memo(DebugAndPreview)

View File

@@ -0,0 +1,80 @@
import {
memo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useNodes } from 'reactflow'
import FormItem from '../../nodes/_base/components/before-run-form/form-item'
import { BlockEnum } from '../../types'
import {
useStore,
useWorkflowStore,
} from '../../store'
import type { StartNodeType } from '../../nodes/start/types'
import { ChevronDown } from '@/app/components/base/icons/src/vender/line/arrows'
const UserInput = () => {
const { t } = useTranslation()
const workflowStore = useWorkflowStore()
const [expanded, setExpanded] = useState(true)
const inputs = useStore(s => s.inputs)
const nodes = useNodes<StartNodeType>()
const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
const variables = startNode?.data.variables || []
const handleValueChange = (variable: string, v: string) => {
workflowStore.getState().setInputs({
...inputs,
[variable]: v,
})
}
if (!variables.length)
return null
return (
<div
className={`
relative rounded-xl border z-[1]
${!expanded ? 'bg-indigo-25 border-indigo-100 shadow-none' : 'bg-white shadow-xs border-transparent'}
`}
>
<div
className={`
flex items-center px-2 pt-4 h-[18px] text-[13px] font-semibold cursor-pointer
${!expanded ? 'text-indigo-800' : 'text-gray-800'}
`}
onClick={() => setExpanded(!expanded)}
>
<ChevronDown
className={`mr-1 w-3 h-3 ${!expanded ? '-rotate-90 text-indigo-600' : 'text-gray-300'}`}
/>
{t('workflow.panel.userInputField').toLocaleUpperCase()}
</div>
<div className='px-2 pt-1 pb-3'>
{
expanded && (
<div className='py-2 text-[13px] text-gray-900'>
{
variables.map(variable => (
<div
key={variable.variable}
className='mb-2 last-of-type:mb-0'
>
<FormItem
payload={variable}
value={inputs[variable.variable]}
onChange={v => handleValueChange(variable.variable, v)}
/>
</div>
))
}
</div>
)
}
</div>
</div>
)
}
export default memo(UserInput)