/* * @Author: ZhaoYing * @Date: 2026-02-03 16:27:39 * @Last Modified by: ZhaoYing * @Last Modified time: 2026-03-31 15:02:07 */ /** * Chat debugging component for application testing * Supports both single agent and multi-agent cluster modes * Provides real-time streaming responses and conversation history */ import { type FC, useEffect, useState, useRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom' import clsx from 'clsx' import { App, Flex, Tooltip } from 'antd'; import ChatIcon from '@/assets/images/application/chat.png' import DebuggingEmpty from '@/assets/images/application/debuggingEmpty.png' import type { ChatData, Config, FeaturesConfigForm } from '../types' import { runCompare, draftRun } from '@/api/application' import Empty from '@/components/Empty' import ChatContent from '@/components/Chat/ChatContent' import type { ChatItem } from '@/components/Chat/types' import { type SSEMessage } from '@/utils/stream' import ChatInput from '@/components/Chat/ChatInput' import ChatToolbar from '@/components/Chat/ChatToolbar' import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar' import type { Variable } from './VariableList/types' import { getFileStatusById } from '@/api/fileStorage' /** * Component props */ interface ChatProps { /** List of chat configurations for comparison */ chatList: ChatData[]; /** Application configuration data */ data: Config; /** Update chat list state */ updateChatList: React.Dispatch>; /** Save configuration before running */ handleSave: (flag?: boolean) => Promise; /** Source type: multi-agent cluster or single agent */ source?: 'multi_agent' | 'agent'; /** chatVariables prop */ chatVariables?: Variable[]; handleEditVariables?: () => void; } /** * Chat debugging component * Allows testing application with different model configurations side-by-side */ const Chat: FC = ({ chatList, data, updateChatList, handleSave, source = 'agent', chatVariables, handleEditVariables }) => { const { t } = useTranslation(); const { id } = useParams() const { message: messageApi } = App.useApp() const toolbarRef = useRef(null) const audioPollingRef = useRef>>(new Map()) const msgCreatedAtRef = useRef>(new Map()) const [loading, setLoading] = useState(false) const [isCluster, setIsCluster] = useState(source === 'multi_agent') const [conversationId, setConversationId] = useState(null) const [compareLoading, setCompareLoading] = useState(false) const [fileList, setFileList] = useState([]) const [message, setMessage] = useState(undefined) const [features, setFeatures] = useState({} as FeaturesConfigForm) const [audioStatusMap, setAudioStatusMap] = useState>({}) useEffect(() => { setCompareLoading(false) setLoading(false) return () => { audioPollingRef.current.forEach(timer => clearInterval(timer)) audioPollingRef.current.clear() } }, [chatList.map(item => item.label).join(',')]) useEffect(() => { return () => { audioPollingRef.current.forEach(timer => clearInterval(timer)) audioPollingRef.current.clear() } }, []) useEffect(() => { if (data?.features) setFeatures(data.features) }, [data?.features]) useEffect(() => { setIsCluster(source === 'multi_agent') toolbarRef.current?.setFiles([]) setMessage(undefined) }, [source, toolbarRef.current]) /** Add user message to all chat lists */ const addUserMessage = (message: string, files: any[]) => { const newUserMessage: ChatItem = { role: 'user', content: message, created_at: Date.now(), meta_data: { files }, }; updateChatList(prev => prev.map(item => ({ ...item, list: [...(item.list || []), newUserMessage] }))) } /** Add empty assistant message placeholder */ const addAssistantMessage = () => { const assistantMessage: ChatItem = { role: 'assistant', content: '', created_at: Date.now(), }; if (isCluster) { updateChatList(prev => prev.map(item => ({ ...item, list: [...(item.list || []), assistantMessage] }))) } else { const assistantMessages: Record = {} chatList.forEach(item => { assistantMessages[item.model_config_id as string] = assistantMessage msgCreatedAtRef.current.set(item.model_config_id as string, assistantMessage.created_at as number) }) updateChatList(prev => prev.map(item => ({ ...item, list: [...(item.list || []), assistantMessages[item.model_config_id as string]] }))) } } /** Update assistant message with streaming content */ const updateAssistantReasoningMessage = (content?: string, model_config_id?: string, conversation_id?: string) => { if (!content || !model_config_id) return updateChatList(prev => { const targetIndex = prev.findIndex(item => item.model_config_id === model_config_id); if (targetIndex !== -1) { const modelChatList = [...prev] const curModelChat = modelChatList[targetIndex] const curChatMsgList = curModelChat.list || [] const lastMsg = curChatMsgList[curChatMsgList.length - 1] if (lastMsg && lastMsg.role === 'assistant') { modelChatList[targetIndex] = { ...modelChatList[targetIndex], conversation_id, list: [ ...curChatMsgList.slice(0, curChatMsgList.length - 1), { ...lastMsg, meta_data: { reasoning_content: (lastMsg.meta_data?.reasoning_content || '') + (content || ''), } } ] } } return [...modelChatList] } return prev; }) } /** Update assistant message with streaming content */ const updateAssistantMessage = (content?: string, model_config_id?: string, conversation_id?: string, audio_url?: string, citations?: any[]) => { if ((!content && !audio_url && (!citations || citations?.length < 1)) || !model_config_id) return updateChatList(prev => { const targetIndex = prev.findIndex(item => item.model_config_id === model_config_id); if (targetIndex !== -1) { const modelChatList = [...prev] const curModelChat = modelChatList[targetIndex] const curChatMsgList = curModelChat.list || [] const lastMsg = curChatMsgList[curChatMsgList.length - 1] if (lastMsg && lastMsg.role === 'assistant') { modelChatList[targetIndex] = { ...modelChatList[targetIndex], conversation_id, list: [ ...curChatMsgList.slice(0, curChatMsgList.length - 1), { ...lastMsg, content: lastMsg.content + (content || ''), meta_data: { ...(lastMsg.meta_data || {}), ...(audio_url !== undefined ? { audio_url, audio_status: 'pending' } : {}), citations: citations || lastMsg.meta_data?.citations } } ] } } return [...modelChatList] } return prev; }) } /** Update assistant message when error occurs */ const updateErrorAssistantMessage = (message_length: number, model_config_id?: string) => { if (message_length > 0 || !model_config_id) return updateChatList(prev => { const targetIndex = prev.findIndex(item => item.model_config_id === model_config_id); if (targetIndex > -1) { const modelChatList = [...prev] const curModelChat = modelChatList[targetIndex] const curChatMsgList = curModelChat.list || [] const lastMsg = curChatMsgList[curChatMsgList.length - 2] modelChatList[targetIndex] = { ...modelChatList[targetIndex], list: [ ...curChatMsgList.slice(0, curChatMsgList.length - 2), { ...lastMsg, ...(lastMsg.role === 'user' ? { status: 'error' } : { content: null }) } ] } return [...modelChatList] } return prev }) } useEffect(() => { updateChatList(prev => prev.map(item => ({ ...item, list: item.list?.map(msg => { console.log('item', item) const id = `${item.model_config_id}_${msg.meta_data?.audio_url}` if (msg.role === 'assistant' && msg.meta_data?.audio_url && audioStatusMap[id]) { return { ...msg, meta_data: { ...msg.meta_data, audio_status: audioStatusMap[id] } } } return msg }) }))) }, [chatList.length, audioStatusMap]) /** Send message for agent comparison mode */ const handleSend = (msg?: string) => { if (loading || !id) return setLoading(true) setCompareLoading(true) const files = (fileList || []).filter(item => !['uploading', 'error'].includes(item.status)) handleSave(false) .then(() => { const message = msg if (!message?.trim()) return // Validate required variables before sending let isCanSend = true const params: Record = {} if (chatVariables && chatVariables.length > 0) { const needRequired: string[] = [] chatVariables.forEach(vo => { params[vo.name] = vo.value if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) { isCanSend = false needRequired.push(vo.name) } }) if (needRequired.length) { messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`) } } if (!isCanSend) { setLoading(false) setCompareLoading(false) return } addUserMessage(message, files) setMessage(undefined) toolbarRef.current?.setFiles([]) setFileList([]) addAssistantMessage() const handleStreamMessage = (data: SSEMessage[]) => { setCompareLoading(false) data.map(item => { const { model_config_id, conversation_id, content, message_length, audio_url, citations } = item.data as { model_config_id: string; conversation_id: string; content: string; message_length: number; audio_url: string; citations?: { document_id: string; file_name: string; knowledge_id: string; score: string; }[] }; switch (item.event) { case 'model_reasoning': updateAssistantReasoningMessage(content, model_config_id, conversation_id) break; case 'model_message': updateAssistantMessage(content, model_config_id, conversation_id, audio_url) break; case 'model_end': const idToPoll = `${model_config_id}_${audio_url}` if (audio_url && !audioStatusMap[idToPoll]) { setAudioStatusMap(prev => ({ ...prev, [idToPoll]: 'pending' })) } if (audio_url) { updateAssistantMessage(content, model_config_id, conversation_id, audio_url, citations) const fileId = audio_url.split('/').pop() if (fileId && idToPoll && !audioPollingRef.current.has(idToPoll)) { const timer = setInterval(() => { getFileStatusById(fileId) .then(res => { const { status } = res as { status: string } if (status && status !== 'pending') { clearInterval(audioPollingRef.current.get(idToPoll)) audioPollingRef.current.delete(idToPoll) setAudioStatusMap(prev => ({ ...prev, [idToPoll]: status })) } }) .catch((e) => { console.log('[audio poll] error', e) clearInterval(audioPollingRef.current.get(idToPoll)) audioPollingRef.current.delete(idToPoll) }) }, 2000) audioPollingRef.current.set(idToPoll, timer) } } if (citations && citations.length > 0) { updateAssistantMessage(content, model_config_id, conversation_id, audio_url, citations) } updateErrorAssistantMessage(message_length, model_config_id) break; case 'compare_end': setLoading(false); break; } }) }; setTimeout(() => { runCompare(id, { message, files: files.map(file => { if (file.url) { return file } else { return { type: file.type, transfer_method: 'local_file', upload_file_id: file.response.data.file_id } } }), models: chatList.map(item => ({ model_config_id: item.model_config_id, label: item.label, model_parameters: item.model_parameters, conversation_id: item.conversation_id })), variables: params, parallel: true, stream: true, timeout: 60, }, handleStreamMessage) .catch(() => { setLoading(false) setCompareLoading(false) updateClusterErrorAssistantMessage(0) }) .finally(() => { setLoading(false) setCompareLoading(false) }) }, 0) }) .catch(() => { setLoading(false) setCompareLoading(false) }) } /** Add assistant message for cluster mode */ const addClusterAssistantMessage = () => { const assistantMessage: ChatItem = { role: 'assistant', content: '', created_at: Date.now() }; updateChatList(prev => prev.map(item => ({ ...item, list: [...(item.list || []), assistantMessage] }))) } /** Update cluster assistant message with content */ const updateClusterAssistantMessage = (content?: string) => { if (!content) return updateChatList(prev => { const modelChatList = [...prev] const curChatMsgList = modelChatList[0].list || [] const lastMsg = curChatMsgList[curChatMsgList.length - 1] if (lastMsg.role === 'assistant') { modelChatList[0] = { ...modelChatList[0], list: [ ...curChatMsgList.slice(0, curChatMsgList.length - 1), { ...lastMsg, content: lastMsg.content + content } ] } } return [...modelChatList] }) } /** Update cluster message when error occurs */ const updateClusterErrorAssistantMessage = (message_length: number) => { if (message_length > 0) return updateChatList(prev => { const modelChatList = [...prev] const curChatMsgList = modelChatList[0].list || [] const lastMsg = curChatMsgList[curChatMsgList.length - 1] if (lastMsg.role === 'assistant') { modelChatList[0] = { ...modelChatList[0], list: [ ...curChatMsgList.slice(0, curChatMsgList.length - 1), { ...lastMsg, content: null } ] } } return [...modelChatList] }) } /** Send message for cluster mode */ const handleClusterSend = (msg?: string) => { if (loading || !id) return setLoading(true) setCompareLoading(true) const files = (fileList || []).filter(item => !['uploading', 'error'].includes(item.status)) handleSave(false) .then(() => { const message = msg if (!message || message.trim() === '') return addUserMessage(message, files) setMessage(undefined) toolbarRef.current?.setFiles([]) setFileList([]) addClusterAssistantMessage() const handleStreamMessage = (data: SSEMessage[]) => { setCompareLoading(false) data.map(item => { const { conversation_id, content, message_length } = item.data as { conversation_id: string, content: string, message_length: number }; switch (item.event) { case 'start': if (conversation_id && conversationId !== conversation_id) { setConversationId(conversation_id); } break case 'message': updateClusterAssistantMessage(content) if (conversation_id && conversationId !== conversation_id) { setConversationId(conversation_id); } break; case 'model_end': updateClusterErrorAssistantMessage(message_length) break; case 'compare_end': setLoading(false); break; } }) }; setTimeout(() => { draftRun(id, { message, conversation_id: conversationId, stream: true, files: files.map(file => { if (file.url) { return file } else { return { type: file.type, transfer_method: 'local_file', upload_file_id: file.response.data.file_id } } }), }, handleStreamMessage ) .catch(() => { setLoading(false) setCompareLoading(false) updateClusterErrorAssistantMessage(0) }) .finally(() => { setLoading(false) setCompareLoading(false) }) }, 0) }) .catch(() => { setLoading(false) setCompareLoading(false) }) } /** Delete chat configuration from list */ const handleDelete = (index: number) => { updateChatList(chatList.filter((_, voIndex) => voIndex !== index)) } console.log('chatList', chatList) const isHasLabel = useMemo(() => chatList.some(item => item.label), [chatList]) const isNeedVariableConfig = useMemo(() => chatVariables?.some(vo => vo.required && !vo.value), [chatVariables]) return ( {chatList.length === 0 ? : <>
{chatList.map((chat, index) => ( 1, })}> {chat.label &&
{chat.label}
handleDelete(index)} >
} 0, 'rb:h-[calc(100vh-433px)]': !isCluster && fileList?.length > 0, "rb:pr-4": index !== chatList.length - 1 && chatList.length > 1, "rb:pl-4": index !== 0 && chatList.length > 1, }} contentClassNames={{ 'rb:max-w-100!': chatList.length === 1, 'rb:max-w-70!': chatList.length === 2, 'rb:max-w-45!': chatList.length === 3, 'rb:max-w-24!': chatList.length === 4, }} empty={} onSend={isCluster ? handleClusterSend : handleSend} data={chat.list || []} streamLoading={compareLoading} labelPosition="top" labelFormat={(item) => item.role === 'user' ? t('application.you') : chat.label || t(`application.ai`)} errorDesc={t('application.ReplyException')} />
))}
{ setFileList(list || []) toolbarRef.current?.setFiles(list || []) }} fileList={fileList} onSend={isCluster ? handleClusterSend : handleSend} > 0 ?(
): null } />
}
) } export default Chat;