This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,205 @@
import { memo, useCallback, useEffect, 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 ConversationVariableModal from './conversation-variable-modal'
import { useChat } from './hooks'
import type { ChatWrapperRefType } from './index'
import Chat from '@/app/components/base/chat/chat'
import type { ChatItem, OnSend } from '@/app/components/base/chat/types'
import { useFeatures } from '@/app/components/base/features/hooks'
import {
fetchSuggestedQuestions,
stopChatMessageResponding,
} from '@/service/debug'
import { useStore as useAppStore } from '@/app/components/app/store'
import { getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/chat/utils'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
type ChatWrapperProps = {
showConversationVariableModal: boolean
onConversationModalHide: () => void
showInputsFieldsPanel: boolean
onHide: () => void
}
const ChatWrapper = (
{
ref,
showConversationVariableModal,
onConversationModalHide,
showInputsFieldsPanel,
onHide,
}: ChatWrapperProps & {
ref: React.RefObject<ChatWrapperRefType>;
},
) => {
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 { inputs, setInputs } = useStore(s => ({
inputs: s.inputs,
setInputs: s.setInputs,
}))
const initialInputs = useMemo(() => {
const initInputs: Record<string, any> = {}
if (startVariables) {
startVariables.forEach((variable) => {
if (variable.default)
initInputs[variable.variable] = variable.default
})
}
return initInputs
}, [startVariables])
const features = useFeatures(s => s.features)
const config = useMemo(() => {
return {
opening_statement: features.opening?.enabled ? (features.opening?.opening_statement || '') : '',
suggested_questions: features.opening?.enabled ? (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.opening, features.suggested, features.text2speech, features.speech2text, features.citation, features.moderation, features.file])
const setShowFeaturesPanel = useStore(s => s.setShowFeaturesPanel)
const {
conversationId,
chatList,
handleStop,
isResponding,
suggestedQuestions,
handleSend,
handleRestart,
setTargetMessageId,
} = useChat(
config,
{
inputs,
inputsForm: (startVariables || []) as any,
},
[],
taskId => stopChatMessageResponding(appDetail!.id, taskId),
)
const handleRestartChat = useCallback(() => {
handleRestart()
setInputs(initialInputs)
}, [handleRestart, setInputs, initialInputs])
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
handleSend(
{
query: message,
files,
inputs: workflowStore.getState().inputs,
conversation_id: conversationId,
parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || undefined,
},
{
onGetSuggestedQuestions: (messageId, getAbortController) => fetchSuggestedQuestions(appDetail!.id, messageId, getAbortController),
},
)
}, [handleSend, workflowStore, conversationId, chatList, appDetail])
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
const parentAnswer = chatList.find(item => item.id === question.parentMessageId)
doSend(editedQuestion ? editedQuestion.message : question.content,
editedQuestion ? editedQuestion.files : question.message_files,
true,
isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null,
)
}, [chatList, doSend])
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription((v: any) => {
if (v.type === EVENT_WORKFLOW_STOP)
handleStop()
})
useImperativeHandle(ref, () => {
return {
handleRestart: handleRestartChat,
}
}, [handleRestartChat])
useEffect(() => {
if (Object.keys(initialInputs).length > 0) {
setInputs({
...initialInputs,
...inputs,
})
}
}, [initialInputs])
useEffect(() => {
if (isResponding)
onHide()
}, [isResponding, onHide])
return (
<>
<Chat
config={{
...config,
supportCitationHitInfo: true,
} as any}
chatList={chatList}
isResponding={isResponding}
chatContainerClassName='px-3'
chatContainerInnerClassName='pt-6 w-full max-w-full mx-auto'
chatFooterClassName='px-4 rounded-bl-2xl'
chatFooterInnerClassName='pb-0'
showFileUpload
showFeatureBar
onFeatureBarClick={setShowFeaturesPanel}
onSend={doSend}
inputs={inputs}
inputsForm={(startVariables || []) as any}
onRegenerate={doRegenerate}
onStopResponding={handleStop}
chatNode={(
<>
{showInputsFieldsPanel && <UserInput />}
{
!chatList.length && (
<Empty />
)
}
</>
)}
noSpacing
suggestedQuestions={suggestedQuestions}
showPromptLog
chatAnswerContainerInner='!pr-2'
switchSibling={setTargetMessageId}
/>
{showConversationVariableModal && (
<ConversationVariableModal
conversationID={conversationId}
onHide={onConversationModalHide}
/>
)}
</>
)
}
ChatWrapper.displayName = 'ChatWrapper'
export default memo(ChatWrapper)

View File

@@ -0,0 +1,156 @@
'use client'
import React, { useCallback } from 'react'
import { useMount } from 'ahooks'
import { useTranslation } from 'react-i18next'
import { capitalize } from 'lodash-es'
import copy from 'copy-to-clipboard'
import { RiCloseLine } from '@remixicon/react'
import Modal from '@/app/components/base/modal'
import { BubbleX } from '@/app/components/base/icons/src/vender/line/others'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import {
Copy,
CopyCheck,
} from '@/app/components/base/icons/src/vender/line/files'
import { useStore } from '@/app/components/workflow/store'
import type {
ConversationVariable,
} from '@/app/components/workflow/types'
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import useTimestamp from '@/hooks/use-timestamp'
import { fetchCurrentValueOfConversationVariable } from '@/service/workflow'
import cn from '@/utils/classnames'
import { noop } from 'lodash-es'
export type Props = {
conversationID: string
onHide: () => void
}
const ConversationVariableModal = ({
conversationID,
onHide,
}: Props) => {
const { t } = useTranslation()
const { formatTime } = useTimestamp()
const varList = useStore(s => s.conversationVariables) as ConversationVariable[]
const appID = useStore(s => s.appId)
const [currentVar, setCurrentVar] = React.useState<ConversationVariable>(varList[0])
const [latestValueMap, setLatestValueMap] = React.useState<Record<string, string>>({})
const [latestValueTimestampMap, setLatestValueTimestampMap] = React.useState<Record<string, number>>({})
const getChatVarLatestValues = useCallback(async () => {
if (conversationID && varList.length > 0) {
const res = await fetchCurrentValueOfConversationVariable({
url: `/apps/${appID}/conversation-variables`,
params: { conversation_id: conversationID },
})
if (res.data.length > 0) {
const valueMap = res.data.reduce((acc: any, cur) => {
acc[cur.id] = cur.value
return acc
}, {})
setLatestValueMap(valueMap)
const timestampMap = res.data.reduce((acc: any, cur) => {
acc[cur.id] = cur.updated_at
return acc
}, {})
setLatestValueTimestampMap(timestampMap)
}
}
}, [appID, conversationID, varList.length])
const [isCopied, setIsCopied] = React.useState(false)
const handleCopy = useCallback(() => {
copy(currentVar.value)
setIsCopied(true)
setTimeout(() => {
setIsCopied(false)
}, 2000)
}, [currentVar.value])
useMount(() => {
getChatVarLatestValues()
})
return (
<Modal
isShow
onClose={noop}
className={cn('h-[640px] w-[920px] max-w-[920px] p-0')}
>
<div className='absolute right-4 top-4 cursor-pointer p-2' onClick={onHide}>
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
</div>
<div className='flex h-full w-full'>
{/* LEFT */}
<div className='flex h-full w-[224px] shrink-0 flex-col border-r border-divider-burn bg-background-sidenav-bg'>
<div className='system-xl-semibold shrink-0 pb-3 pl-5 pr-4 pt-5 text-text-primary'>{t('workflow.chatVariable.panelTitle')}</div>
<div className='grow overflow-y-auto px-3 py-2'>
{varList.map(chatVar => (
<div key={chatVar.id} className={cn('radius-md group mb-0.5 flex cursor-pointer items-center p-2 hover:bg-state-base-hover', currentVar.id === chatVar.id && 'bg-state-base-hover')} onClick={() => setCurrentVar(chatVar)}>
<BubbleX className={cn('mr-1 h-4 w-4 shrink-0 text-text-tertiary group-hover:text-util-colors-teal-teal-700', currentVar.id === chatVar.id && 'text-util-colors-teal-teal-700')} />
<div title={chatVar.name} className={cn('system-sm-medium truncate text-text-tertiary group-hover:text-util-colors-teal-teal-700', currentVar.id === chatVar.id && 'text-util-colors-teal-teal-700')}>{chatVar.name}</div>
</div>
))}
</div>
</div>
{/* RIGHT */}
<div className='flex h-full w-0 grow flex-col bg-components-panel-bg'>
<div className='shrink-0 p-4 pb-2'>
<div className='flex items-center gap-1 py-1'>
<div className='system-xl-semibold text-text-primary'>{currentVar.name}</div>
<div className='system-xs-medium text-text-tertiary'>{capitalize(currentVar.value_type)}</div>
</div>
</div>
<div className='flex h-0 grow flex-col p-4 pt-2'>
<div className='mb-2 flex shrink-0 items-center gap-2'>
<div className='system-xs-medium-uppercase shrink-0 text-text-tertiary'>{t('workflow.chatVariable.storedContent').toLocaleUpperCase()}</div>
<div className='h-px grow' style={{
background: 'linear-gradient(to right, rgba(16, 24, 40, 0.08) 0%, rgba(255, 255, 255) 100%)',
}}></div>
{latestValueTimestampMap[currentVar.id] && (
<div className='system-xs-regular shrink-0 text-text-tertiary'>{t('workflow.chatVariable.updatedAt')}{formatTime(latestValueTimestampMap[currentVar.id], t('appLog.dateTimeFormat') as string)}</div>
)}
</div>
<div className='grow overflow-y-auto'>
{currentVar.value_type !== ChatVarType.Number && currentVar.value_type !== ChatVarType.String && (
<div className='flex h-full flex-col rounded-lg bg-components-input-bg-normal px-2 pb-2'>
<div className='flex h-7 shrink-0 items-center justify-between pl-3 pr-2 pt-1'>
<div className='system-xs-semibold text-text-secondary'>JSON</div>
<div className='flex items-center p-1'>
{!isCopied
? (
<Copy className='h-4 w-4 cursor-pointer text-text-tertiary' onClick={handleCopy} />
)
: (
<CopyCheck className='h-4 w-4 text-text-tertiary' />
)
}
</div>
</div>
<div className='grow pl-4'>
<CodeEditor
readOnly
noWrapper
isExpand
language={CodeLanguage.json}
value={latestValueMap[currentVar.id] || ''}
isJSONStringifyBeauty
/>
</div>
</div>
)}
{(currentVar.value_type === ChatVarType.Number || currentVar.value_type === ChatVarType.String) && (
<div className='system-md-regular h-full overflow-y-auto overflow-x-hidden rounded-lg bg-components-input-bg-normal px-4 py-3 text-components-input-text-filled'>{latestValueMap[currentVar.id] || ''}</div>
)}
</div>
</div>
</div>
</div>
</Modal>
)
}
export default ConversationVariableModal

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='mb-2 flex justify-center'>
<ChatBotSlim className='h-12 w-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,516 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { produce, setAutoFreeze } from 'immer'
import { uniqBy } from 'lodash-es'
import {
useSetWorkflowVarsWithValue,
useWorkflowRun,
} from '../../hooks'
import { NodeRunningStatus, WorkflowRunningStatus } from '../../types'
import { useWorkflowStore } from '../../store'
import { DEFAULT_ITER_TIMES, DEFAULT_LOOP_TIMES } from '../../constants'
import type {
ChatItem,
ChatItemInTree,
Inputs,
} from '@/app/components/base/chat/types'
import type { InputForm } from '@/app/components/base/chat/chat/type'
import {
getProcessedInputs,
processOpeningStatement,
} from '@/app/components/base/chat/chat/utils'
import { useToastContext } from '@/app/components/base/toast'
import { TransferMethod } from '@/types/app'
import {
getProcessedFiles,
getProcessedFilesFromResponse,
} from '@/app/components/base/file-uploader/utils'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { getThreadMessages } from '@/app/components/base/chat/utils'
import { useInvalidAllLastRun } from '@/service/use-workflow'
import { useHooksStore } from '../../hooks-store'
type GetAbortController = (abortController: AbortController) => void
type SendCallback = {
onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise<any>
}
export const useChat = (
config: any,
formSettings?: {
inputs: Inputs
inputsForm: InputForm[]
},
prevChatTree?: ChatItemInTree[],
stopChat?: (taskId: string) => void,
) => {
const { t } = useTranslation()
const { notify } = useToastContext()
const { handleRun } = useWorkflowRun()
const hasStopResponded = useRef(false)
const workflowStore = useWorkflowStore()
const conversationId = useRef('')
const taskIdRef = useRef('')
const [isResponding, setIsResponding] = useState(false)
const isRespondingRef = useRef(false)
const configsMap = useHooksStore(s => s.configsMap)
const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId)
const { fetchInspectVars } = useSetWorkflowVarsWithValue()
const [suggestedQuestions, setSuggestQuestions] = useState<string[]>([])
const suggestedQuestionsAbortControllerRef = useRef<AbortController | null>(null)
const {
setIterTimes,
setLoopTimes,
} = workflowStore.getState()
const handleResponding = useCallback((isResponding: boolean) => {
setIsResponding(isResponding)
isRespondingRef.current = isResponding
}, [])
const [chatTree, setChatTree] = useState<ChatItemInTree[]>(prevChatTree || [])
const chatTreeRef = useRef<ChatItemInTree[]>(chatTree)
const [targetMessageId, setTargetMessageId] = useState<string>()
const threadMessages = useMemo(() => getThreadMessages(chatTree, targetMessageId), [chatTree, targetMessageId])
const getIntroduction = useCallback((str: string) => {
return processOpeningStatement(str, formSettings?.inputs || {}, formSettings?.inputsForm || [])
}, [formSettings?.inputs, formSettings?.inputsForm])
/** Final chat list that will be rendered */
const chatList = useMemo(() => {
const ret = [...threadMessages]
if (config?.opening_statement) {
const index = threadMessages.findIndex(item => item.isOpeningStatement)
if (index > -1) {
ret[index] = {
...ret[index],
content: getIntroduction(config.opening_statement),
suggestedQuestions: config.suggested_questions?.map((item: string) => getIntroduction(item)),
}
}
else {
ret.unshift({
id: `${Date.now()}`,
content: getIntroduction(config.opening_statement),
isAnswer: true,
isOpeningStatement: true,
suggestedQuestions: config.suggested_questions?.map((item: string) => getIntroduction(item)),
})
}
}
return ret
}, [threadMessages, config?.opening_statement, getIntroduction, config?.suggested_questions])
useEffect(() => {
setAutoFreeze(false)
return () => {
setAutoFreeze(true)
}
}, [])
/** Find the target node by bfs and then operate on it */
const produceChatTreeNode = useCallback((targetId: string, operation: (node: ChatItemInTree) => void) => {
return produce(chatTreeRef.current, (draft) => {
const queue: ChatItemInTree[] = [...draft]
while (queue.length > 0) {
const current = queue.shift()!
if (current.id === targetId) {
operation(current)
break
}
if (current.children)
queue.push(...current.children)
}
})
}, [])
const handleStop = useCallback(() => {
hasStopResponded.current = true
handleResponding(false)
if (stopChat && taskIdRef.current)
stopChat(taskIdRef.current)
setIterTimes(DEFAULT_ITER_TIMES)
setLoopTimes(DEFAULT_LOOP_TIMES)
if (suggestedQuestionsAbortControllerRef.current)
suggestedQuestionsAbortControllerRef.current.abort()
}, [handleResponding, setIterTimes, setLoopTimes, stopChat])
const handleRestart = useCallback(() => {
conversationId.current = ''
taskIdRef.current = ''
handleStop()
setIterTimes(DEFAULT_ITER_TIMES)
setLoopTimes(DEFAULT_LOOP_TIMES)
setChatTree([])
setSuggestQuestions([])
}, [
handleStop,
setIterTimes,
setLoopTimes,
])
const updateCurrentQAOnTree = useCallback(({
parentId,
responseItem,
placeholderQuestionId,
questionItem,
}: {
parentId?: string
responseItem: ChatItem
placeholderQuestionId: string
questionItem: ChatItem
}) => {
let nextState: ChatItemInTree[]
const currentQA = { ...questionItem, children: [{ ...responseItem, children: [] }] }
if (!parentId && !chatTree.some(item => [placeholderQuestionId, questionItem.id].includes(item.id))) {
// QA whose parent is not provided is considered as a first message of the conversation,
// and it should be a root node of the chat tree
nextState = produce(chatTree, (draft) => {
draft.push(currentQA)
})
}
else {
// find the target QA in the tree and update it; if not found, insert it to its parent node
nextState = produceChatTreeNode(parentId!, (parentNode) => {
const questionNodeIndex = parentNode.children!.findIndex(item => [placeholderQuestionId, questionItem.id].includes(item.id))
if (questionNodeIndex === -1)
parentNode.children!.push(currentQA)
else
parentNode.children![questionNodeIndex] = currentQA
})
}
setChatTree(nextState)
chatTreeRef.current = nextState
}, [chatTree, produceChatTreeNode])
const handleSend = useCallback((
params: {
query: string
files?: FileEntity[]
parent_message_id?: string
[key: string]: any
},
{
onGetSuggestedQuestions,
}: SendCallback,
) => {
if (isRespondingRef.current) {
notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
return false
}
const parentMessage = threadMessages.find(item => item.id === params.parent_message_id)
const placeholderQuestionId = `question-${Date.now()}`
const questionItem = {
id: placeholderQuestionId,
content: params.query,
isAnswer: false,
message_files: params.files,
parentMessageId: params.parent_message_id,
}
const placeholderAnswerId = `answer-placeholder-${Date.now()}`
const placeholderAnswerItem = {
id: placeholderAnswerId,
content: '',
isAnswer: true,
parentMessageId: questionItem.id,
siblingIndex: parentMessage?.children?.length ?? chatTree.length,
}
setTargetMessageId(parentMessage?.id)
updateCurrentQAOnTree({
parentId: params.parent_message_id,
responseItem: placeholderAnswerItem,
placeholderQuestionId,
questionItem,
})
// answer
const responseItem: ChatItem = {
id: placeholderAnswerId,
content: '',
agent_thoughts: [],
message_files: [],
isAnswer: true,
parentMessageId: questionItem.id,
siblingIndex: parentMessage?.children?.length ?? chatTree.length,
}
handleResponding(true)
const { files, inputs, ...restParams } = params
const bodyParams = {
files: getProcessedFiles(files || []),
inputs: getProcessedInputs(inputs || {}, formSettings?.inputsForm || []),
...restParams,
}
if (bodyParams?.files?.length) {
bodyParams.files = bodyParams.files.map((item) => {
if (item.transfer_method === TransferMethod.local_file) {
return {
...item,
url: '',
}
}
return item
})
}
let hasSetResponseId = false
handleRun(
bodyParams,
{
onData: (message: string, isFirstMessage: boolean, { conversationId: newConversationId, messageId, taskId }: any) => {
responseItem.content = responseItem.content + message
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: params.parent_message_id,
})
},
async onCompleted(hasError?: boolean, errorMessage?: string) {
handleResponding(false)
fetchInspectVars({})
invalidAllLastRun()
if (hasError) {
if (errorMessage) {
responseItem.content = errorMessage
responseItem.isError = true
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
}
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([])
}
}
},
onMessageEnd: (messageEnd) => {
responseItem.citation = messageEnd.metadata?.retriever_resources || []
const processedFilesFromResponse = getProcessedFilesFromResponse(messageEnd.files || [])
responseItem.allFiles = uniqBy([...(responseItem.allFiles || []), ...(processedFilesFromResponse || [])], 'id')
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
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: [],
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
onWorkflowFinished: ({ data }) => {
responseItem.workflowProcess!.status = data.status as WorkflowRunningStatus
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
onIterationStart: ({ data }) => {
responseItem.workflowProcess!.tracing!.push({
...data,
status: NodeRunningStatus.Running,
})
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
onIterationFinish: ({ data }) => {
const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.id)
if (currentTracingIndex > -1) {
responseItem.workflowProcess!.tracing[currentTracingIndex] = {
...responseItem.workflowProcess!.tracing[currentTracingIndex],
...data,
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
}
},
onLoopStart: ({ data }) => {
responseItem.workflowProcess!.tracing!.push({
...data,
status: NodeRunningStatus.Running,
})
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
onLoopFinish: ({ data }) => {
const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.id)
if (currentTracingIndex > -1) {
responseItem.workflowProcess!.tracing[currentTracingIndex] = {
...responseItem.workflowProcess!.tracing[currentTracingIndex],
...data,
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
}
},
onNodeStarted: ({ data }) => {
responseItem.workflowProcess!.tracing!.push({
...data,
status: NodeRunningStatus.Running,
} as any)
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
onNodeRetry: ({ data }) => {
responseItem.workflowProcess!.tracing!.push(data)
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
},
onNodeFinished: ({ data }) => {
const currentTracingIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.id === data.id)
if (currentTracingIndex > -1) {
responseItem.workflowProcess!.tracing[currentTracingIndex] = {
...responseItem.workflowProcess!.tracing[currentTracingIndex],
...data,
}
updateCurrentQAOnTree({
placeholderQuestionId,
questionItem,
responseItem,
parentId: params.parent_message_id,
})
}
},
onAgentLog: ({ data }) => {
const currentNodeIndex = responseItem.workflowProcess!.tracing!.findIndex(item => item.node_id === data.node_id)
if (currentNodeIndex > -1) {
const current = responseItem.workflowProcess!.tracing![currentNodeIndex]
if (current.execution_metadata) {
if (current.execution_metadata.agent_log) {
const currentLogIndex = current.execution_metadata.agent_log.findIndex(log => log.message_id === data.message_id)
if (currentLogIndex > -1) {
current.execution_metadata.agent_log[currentLogIndex] = {
...current.execution_metadata.agent_log[currentLogIndex],
...data,
}
}
else {
current.execution_metadata.agent_log.push(data)
}
}
else {
current.execution_metadata.agent_log = [data]
}
}
else {
current.execution_metadata = {
agent_log: [data],
} as any
}
responseItem.workflowProcess!.tracing[currentNodeIndex] = {
...current,
}
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])
return {
conversationId: conversationId.current,
chatList,
setTargetMessageId,
handleSend,
handleStop,
handleRestart,
isResponding,
suggestedQuestions,
}
}

View File

@@ -0,0 +1,145 @@
/**
* Debug and Preview Panel Width Persistence Tests
* Tests for GitHub issue #22745: Panel width persistence bug fix
*/
import '@testing-library/jest-dom'
type PanelWidthSource = 'user' | 'system'
// Mock localStorage for testing
const createMockLocalStorage = () => {
const storage: Record<string, string> = {}
return {
getItem: jest.fn((key: string) => storage[key] || null),
setItem: jest.fn((key: string, value: string) => {
storage[key] = value
}),
removeItem: jest.fn((key: string) => {
delete storage[key]
}),
clear: jest.fn(() => {
Object.keys(storage).forEach(key => delete storage[key])
}),
get storage() { return { ...storage } },
}
}
// Preview panel width logic
const createPreviewPanelManager = () => {
const storageKey = 'debug-and-preview-panel-width'
return {
updateWidth: (width: number, source: PanelWidthSource = 'user') => {
const newValue = Math.max(400, Math.min(width, 800))
if (source === 'user')
localStorage.setItem(storageKey, `${newValue}`)
return newValue
},
getStoredWidth: () => {
const stored = localStorage.getItem(storageKey)
return stored ? Number.parseFloat(stored) : 400
},
}
}
describe('Debug and Preview Panel Width Persistence', () => {
let mockLocalStorage: ReturnType<typeof createMockLocalStorage>
beforeEach(() => {
mockLocalStorage = createMockLocalStorage()
Object.defineProperty(globalThis, 'localStorage', {
value: mockLocalStorage,
writable: true,
})
})
afterEach(() => {
jest.clearAllMocks()
})
describe('Preview Panel Width Management', () => {
it('should save user resize to localStorage', () => {
const manager = createPreviewPanelManager()
const result = manager.updateWidth(450, 'user')
expect(result).toBe(450)
expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '450')
})
it('should not save system compression to localStorage', () => {
const manager = createPreviewPanelManager()
const result = manager.updateWidth(300, 'system')
expect(result).toBe(400) // Respects minimum width
expect(localStorage.setItem).not.toHaveBeenCalled()
})
it('should behave identically to Node Panel', () => {
const manager = createPreviewPanelManager()
// Both user and system operations should behave consistently
manager.updateWidth(500, 'user')
expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '500')
manager.updateWidth(200, 'system')
expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('500')
})
})
describe('Dual Panel Scenario', () => {
it('should maintain independence from Node Panel', () => {
localStorage.setItem('workflow-node-panel-width', '600')
localStorage.setItem('debug-and-preview-panel-width', '450')
const manager = createPreviewPanelManager()
// System compresses preview panel
manager.updateWidth(200, 'system')
// Only preview panel storage key should be unaffected
expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('450')
expect(localStorage.getItem('workflow-node-panel-width')).toBe('600')
})
it('should handle F12 scenario consistently', () => {
const manager = createPreviewPanelManager()
// User sets preference
manager.updateWidth(500, 'user')
expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('500')
// F12 opens causing viewport compression
manager.updateWidth(180, 'system')
// User preference preserved
expect(localStorage.getItem('debug-and-preview-panel-width')).toBe('500')
})
})
describe('Consistency with Node Panel', () => {
it('should enforce same minimum width rules', () => {
const manager = createPreviewPanelManager()
// Same 400px minimum as Node Panel
const result = manager.updateWidth(300, 'user')
expect(result).toBe(400)
expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '400')
})
it('should use same source parameter pattern', () => {
const manager = createPreviewPanelManager()
// Default to 'user' when source not specified
manager.updateWidth(500)
expect(localStorage.setItem).toHaveBeenCalledWith('debug-and-preview-panel-width', '500')
// Explicit 'system' source
manager.updateWidth(300, 'system')
expect(localStorage.setItem).toHaveBeenCalledTimes(1) // Only user call
})
})
})

View File

@@ -0,0 +1,142 @@
import {
memo,
useCallback,
useMemo,
useRef,
useState,
} from 'react'
import { RiCloseLine, RiEqualizer2Line } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useNodes } from 'reactflow'
import {
useWorkflowInteractions,
} from '../../hooks'
import { useEdgesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-edges-interactions-without-sync'
import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync'
import { BlockEnum } from '../../types'
import type { StartNodeType } from '../../nodes/start/types'
import { useResizePanel } from '../../nodes/_base/hooks/use-resize-panel'
import ChatWrapper from './chat-wrapper'
import cn from '@/utils/classnames'
import { RefreshCcw01 } from '@/app/components/base/icons/src/vender/line/arrows'
import Tooltip from '@/app/components/base/tooltip'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import { useStore } from '@/app/components/workflow/store'
import { debounce, noop } from 'lodash-es'
export type ChatWrapperRefType = {
handleRestart: () => void
}
const DebugAndPreview = () => {
const { t } = useTranslation()
const chatRef = useRef({ handleRestart: noop })
const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
const { handleNodeCancelRunningStatus } = useNodesInteractionsWithoutSync()
const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync()
const [expanded, setExpanded] = useState(true)
const nodes = useNodes<StartNodeType>()
const selectedNode = nodes.find(node => node.data.selected)
const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
const variables = startNode?.data.variables || []
const visibleVariables = variables
const [showConversationVariableModal, setShowConversationVariableModal] = useState(false)
const handleRestartChat = () => {
handleNodeCancelRunningStatus()
handleEdgeCancelRunningStatus()
chatRef.current.handleRestart()
}
const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth)
const nodePanelWidth = useStore(s => s.nodePanelWidth)
const panelWidth = useStore(s => s.previewPanelWidth)
const setPanelWidth = useStore(s => s.setPreviewPanelWidth)
const handleResize = useCallback((width: number, source: 'user' | 'system' = 'user') => {
if (source === 'user')
localStorage.setItem('debug-and-preview-panel-width', `${width}`)
setPanelWidth(width)
}, [setPanelWidth])
const maxPanelWidth = useMemo(() => {
if (!workflowCanvasWidth)
return 720
if (!selectedNode)
return workflowCanvasWidth - 400
return workflowCanvasWidth - 400 - 400
}, [workflowCanvasWidth, selectedNode, nodePanelWidth])
const {
triggerRef,
containerRef,
} = useResizePanel({
direction: 'horizontal',
triggerDirection: 'left',
minWidth: 400,
maxWidth: maxPanelWidth,
onResize: debounce((width: number) => {
handleResize(width, 'user')
}),
})
return (
<div className='relative h-full'>
<div
ref={triggerRef}
className='absolute -left-1 top-0 flex h-full w-1 cursor-col-resize resize-x items-center justify-center'>
<div className='h-10 w-0.5 rounded-sm bg-state-base-handle hover:h-full hover:bg-state-accent-solid active:h-full active:bg-state-accent-solid'></div>
</div>
<div
ref={containerRef}
className={cn(
'relative flex h-full flex-col rounded-l-2xl border border-r-0 border-components-panel-border bg-chatbot-bg shadow-xl',
)}
style={{ width: `${panelWidth}px` }}
>
<div className='system-xl-semibold flex shrink-0 items-center justify-between px-4 pb-2 pt-3 text-text-primary'>
<div className='h-8'>{t('workflow.common.debugAndPreview').toLocaleUpperCase()}</div>
<div className='flex items-center gap-1'>
<Tooltip
popupContent={t('common.operation.refresh')}
>
<ActionButton onClick={() => handleRestartChat()}>
<RefreshCcw01 className='h-4 w-4' />
</ActionButton>
</Tooltip>
{visibleVariables.length > 0 && (
<div className='relative'>
<Tooltip
popupContent={t('workflow.panel.userInputField')}
>
<ActionButton state={expanded ? ActionButtonState.Active : undefined} onClick={() => setExpanded(!expanded)}>
<RiEqualizer2Line className='h-4 w-4' />
</ActionButton>
</Tooltip>
{expanded && <div className='absolute bottom-[-17px] right-[5px] z-10 h-3 w-3 rotate-45 border-l-[0.5px] border-t-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg' />}
</div>
)}
<div className='mx-3 h-3.5 w-[1px] bg-divider-regular'></div>
<div
className='flex h-6 w-6 cursor-pointer items-center justify-center'
onClick={handleCancelDebugAndPreviewPanel}
>
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
</div>
</div>
</div>
<div className='grow overflow-y-auto rounded-b-2xl'>
<ChatWrapper
ref={chatRef}
showConversationVariableModal={showConversationVariableModal}
onConversationModalHide={() => setShowConversationVariableModal(false)}
showInputsFieldsPanel={expanded}
onHide={() => setExpanded(false)}
/>
</div>
</div>
</div>
)
}
export default memo(DebugAndPreview)

View File

@@ -0,0 +1,58 @@
import {
memo,
} from 'react'
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 cn from '@/utils/classnames'
const UserInput = () => {
const workflowStore = useWorkflowStore()
const inputs = useStore(s => s.inputs)
const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel)
const nodes = useNodes<StartNodeType>()
const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
const variables = startNode?.data.variables || []
const visibleVariables = showDebugAndPreviewPanel ? variables : variables.filter(v => v.hide !== true)
const handleValueChange = (variable: string, v: string) => {
const {
inputs,
setInputs,
} = workflowStore.getState()
setInputs({
...inputs,
[variable]: v,
})
}
if (!visibleVariables.length)
return null
return (
<div className={cn('relative z-[1] rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg shadow-xs')}>
<div className='px-4 pb-4 pt-3'>
{visibleVariables.map((variable, index) => (
<div
key={variable.variable}
className='mb-4 last-of-type:mb-0'
>
<FormItem
autoFocus={index === 0}
payload={variable}
value={inputs[variable.variable]}
onChange={v => handleValueChange(variable.variable, v)}
/>
</div>
))}
</div>
</div>
)
}
export default memo(UserInput)