/* * @Author: ZhaoYing * @Date: 2026-02-03 16:58:03 * @Last Modified by: ZhaoYing * @Last Modified time: 2026-04-07 21:21:52 */ /** * Conversation Page * Public conversation interface for shared applications * Supports conversation history, streaming responses, and memory/web search features */ import { type FC, useState, useEffect, useRef } from 'react' import { useParams, useLocation } from 'react-router-dom' import { useTranslation } from 'react-i18next' import InfiniteScroll from 'react-infinite-scroll-component'; import { Flex, Skeleton, App, Tooltip } from 'antd' import clsx from 'clsx' import dayjs from 'dayjs' import { getConversationHistory, sendConversation, getConversationDetail, getShareToken, getExperienceConfig } from '@/api/application' import type { HistoryItem } from './types' import Empty from '@/components/Empty' import { formatDateTime } from '@/utils/format'; import { randomString } from '@/utils/common' import ChatEmpty from '@/assets/images/empty/chatEmpty.png' import Chat from '@/components/Chat' import type { ChatItem } from '@/components/Chat/types' import { type SSEMessage } from '@/utils/stream' import { shareFileUploadUrlWithoutApiPrefix, getFileStatusById } from '@/api/fileStorage' import ChatToolbar, { type ChatToolbarRef } from '@/components/Chat/ChatToolbar' import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types' import type { Variable as AppVariable } from '@/views/ApplicationConfig/components/VariableList/types' import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'; import { replaceVariables } from '@/views/ApplicationConfig/Agent' const Conversation: FC = () => { const { t } = useTranslation() const { message: messageApi, modal } = App.useApp() const { token } = useParams() const location = useLocation() const searchParams = new URLSearchParams(location.search) const userId = searchParams.get('user_id') const [loading, setLoading] = useState(false) const [message, setMessage] = useState('') const [conversation_id, setConversationId] = useState(null) const [historyList, setHistoryList] = useState([]) const [groupHistoryList, setGroupHistoryList] = useState>({}) const [chatList, setChatList] = useState([]) const [pageLoading, setPageLoading] = useState(false); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const scrollRef = useRef(null); const toolbarRef = useRef(null) const audioPollingRef = useRef>>(new Map()) const [shareToken, setShareToken] = useState(localStorage.getItem(`shareToken_${token}`)) const [fileList, setFileList] = useState([]) const [webSearch, setWebSearch] = useState(false) const [isHasMemory, setIsHasMemory] = useState(false) const [memory, setMemory] = useState(true) const [features, setFeatures] = useState({} as FeaturesConfigForm) const [config, setConfig] = useState>({}) const [audioStatusMap, setAudioStatusMap] = useState>({}) const streamLoadingRef = useRef(false) const [isDeepThinking, setIsDeepThinking] = useState>({}) const [thinking, setThinking] = useState(false) useEffect(() => { return () => { audioPollingRef.current.forEach((timer) => clearInterval(timer)) audioPollingRef.current.clear() } }, []) useEffect(() => { const shareToken = localStorage.getItem(`shareToken_${token}`) setShareToken(shareToken) if (shareToken && shareToken !== '') return getShareToken(token as string, userId || randomString(12, false)) .then(res => { const response = res as { access_token: string } || {} localStorage.setItem(`shareToken_${token}`, response.access_token ?? '') setShareToken(response.access_token ?? '') }) }, [token]) useEffect(() => { if (token && page === 1 && hasMore && historyList.length === 0 && shareToken) { getHistory() } }, [token, shareToken, page, hasMore, historyList]) useEffect(() => { if (shareToken && token) { getExperienceConfig(token) .then(res => { const response = res as { variables: Variable[]; features: FeaturesConfigForm; model_parameters?: Record; app_type: string; memory: boolean; } toolbarRef.current?.setVariables(response.variables || []) setConfig(response) setFeatures(response.features) setIsHasMemory((response.app_type === 'workflow' && response.memory) || response.memory) setIsDeepThinking(response.model_parameters?.deep_thinking || false) }) } else { setChatList([]) } }, [shareToken, token]) /** Group conversation history by date */ const groupHistoryByDate = (items: HistoryItem[]): Record => { return items.reduce((groups: Record, item) => { const date = formatDateTime(item.created_at, 'YYYY-MM-DD') if (!groups[date]) { groups[date] = []; } groups[date].push(item); return groups; }, {}); } /** Fetch conversation history with pagination */ const getHistory = (flag: boolean = false) => { if (!token || (pageLoading || !hasMore) && !flag) return setPageLoading(true); getConversationHistory(token, { page: flag ? 1 : page, pagesize: 20 }) .then(res => { const response = res as { items: HistoryItem[], page: { hasnext: boolean; page: number; pagesize: number; total: number } } const results = response?.items || [] let list = [] if (flag) { setHistoryList(results); list = [...results] } else { setHistoryList(historyList.concat(results)); list = [...historyList, ...results] } setHistoryList(list) setGroupHistoryList(groupHistoryByDate(list)) if (page === 1 && !flag) { setConversationId(list[0]?.id || '') } setPage(response.page.page + 1); setHasMore(response.page.hasnext); setLoading(false); }) .finally(() => setPageLoading(false)) } /** Switch to different conversation or start new one */ const handleChangeHistory = (id: string | null) => { if (id !== conversation_id) setConversationId(id) if (!id) setMessage('') } useEffect(() => { if (conversation_id) { getConversationDetail(token as string, conversation_id) .then(res => { const response = res as { messages: ChatItem[] } const messages = response?.messages || [] const historyAudioUrls = new Set(messages.map(m => m.meta_data?.audio_url).filter(Boolean)) audioPollingRef.current.forEach((timer, key) => { if (!historyAudioUrls.has(key)) { clearInterval(timer) audioPollingRef.current.delete(key) } }) messages.forEach(msg => { if (msg.role === 'assistant' && msg.meta_data?.audio_url && msg.meta_data?.audio_status === 'pending') { startAudioPolling(msg.meta_data.audio_url, msg.meta_data.audio_url) } }) setChatList(messages.map(msg => { if (msg.role === 'assistant' && msg.meta_data?.audio_url && audioPollingRef.current.has(msg.meta_data.audio_url)) { return { ...msg, meta_data: { ...msg.meta_data, audio_status: 'pending' } } } return msg })) }) } else { if (features?.opening_statement?.enabled && features?.opening_statement?.statement) { const variables = toolbarRef.current?.getVariables() || [] setChatList([{ role: 'assistant', content: replaceVariables(features?.opening_statement.statement, variables as unknown as AppVariable[]), created_at: Date.now(), meta_data: { suggested_questions: features.opening_statement?.suggested_questions } }]) } else { setChatList([]) } } }, [conversation_id, features?.opening_statement?.statement]) const addUserMessage = (message: string = '', files?: any[]) => { setChatList(prev => [...prev, { conversation_id, role: 'user', content: message, created_at: Date.now(), meta_data: { files }, }]) } const addAssistantMessage = () => { setChatList(prev => [...prev, { created_at: Date.now(), role: 'assistant', content: '' }]) } const updateAssistantMessage = (content: string = '', audio_url?: string, audio_status?: string, citations?: any[]) => { if (!content && !audio_url && (!citations || citations?.length < 1)) return if (streamLoadingRef.current) streamLoadingRef.current = false setChatList(prev => { const lastList = [...prev] const lastIndex = lastList.length - 1 const lastMsg = lastList[lastIndex] if (lastMsg?.role === 'assistant') { return [ ...lastList.slice(0, lastIndex), { ...lastMsg, content: lastMsg.content + content, meta_data: { ...(lastMsg.meta_data || {}), audio_url: audio_url || lastMsg.meta_data?.audio_url, audio_status: audio_status || lastMsg.meta_data?.audio_status, citations: citations || lastMsg.meta_data?.citations } } ] } return prev }) } const updateAssistantReasoningMessage = (content: string = '') => { if (!content) return if (streamLoadingRef.current) streamLoadingRef.current = false setChatList(prev => { const lastList = [...prev] const lastIndex = lastList.length - 1 const lastMsg = lastList[lastIndex] if (lastMsg?.role === 'assistant') { return [ ...lastList.slice(0, lastIndex), { ...lastMsg, meta_data: { ...(lastMsg.meta_data || {}), reasoning_content: (lastMsg.meta_data?.reasoning_content || '') + content } } ] } return prev }) } useEffect(() => { if (!Object.keys(audioStatusMap).length) return setChatList(prev => prev.map(msg => { if (msg.role === 'assistant' && msg.meta_data?.audio_url && audioStatusMap[msg.meta_data.audio_url]) { return { ...msg, meta_data: { ...msg.meta_data, audio_status: audioStatusMap[msg.meta_data.audio_url] } } } return msg })) }, [audioStatusMap, chatList.length]) const startAudioPolling = (audioUrl: string, idToPoll: string) => { if (audioPollingRef.current.has(idToPoll)) return const fileId = audioUrl.split('/').pop() if (!fileId) return const timer = setInterval(() => { getFileStatusById(fileId) .then(res => { const { status } = res as { status: string } if (status && status !== 'pending') { setAudioStatusMap(prev => ({ ...prev, [idToPoll]: status })) clearInterval(audioPollingRef.current.get(idToPoll)) audioPollingRef.current.delete(idToPoll) } }) .catch(() => { clearInterval(audioPollingRef.current.get(idToPoll)) audioPollingRef.current.delete(idToPoll) }) }, 2000) audioPollingRef.current.set(idToPoll, timer) } /** Send message and handle streaming response */ const handleSend = (msg?: string) => { if (!token || !shareToken) return const files = (toolbarRef.current?.getFiles() || []).filter(item => !['uploading', 'error'].includes(item.status)) const variables = toolbarRef.current?.getVariables() || [] let isCanSend = true const params: Record = {} if (variables.length > 0) { const needRequired: string[] = [] variables.forEach(vo => { params[vo.name] = vo.value ?? vo.defaultValue 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) return setLoading(true) streamLoadingRef.current = true addUserMessage(msg || message, files) addAssistantMessage() toolbarRef.current?.setFiles([]) setFileList([]) let currentConversationId: string | null = null const handleStreamMessage = (data: SSEMessage[]) => { data.forEach((item) => { const { content, conversation_id: curId, audio_url, citations } = item.data as { content: string; conversation_id: string; audio_url?: string; citations?: { document_id: string; file_name: string; knowledge_id: string; score: string; }[] } switch (item.event) { case 'start': case 'node_start': const { conversation_id: newId } = item.data as { conversation_id: string } currentConversationId = newId break case 'reasoning': updateAssistantReasoningMessage(content) if (curId) currentConversationId = curId; break case 'message': updateAssistantMessage(content, audio_url, audio_url ? 'pending' : undefined) if (curId) currentConversationId = curId; break case 'end': case 'workflow_end': if (audio_url) { updateAssistantMessage(content, audio_url, 'pending', citations) const { file_id } = item.data as { file_id?: string } const idToPoll = file_id || audio_url || '' const fileId = audio_url.split('/').pop() if (fileId && idToPoll) { startAudioPolling(audio_url, idToPoll) } } else { getHistory(true) if (currentConversationId && currentConversationId !== conversation_id) { setConversationId(currentConversationId) } } if (citations && citations.length > 0) { updateAssistantMessage(content, audio_url, undefined, citations) } setLoading(false) getHistory(true) if (currentConversationId && currentConversationId !== conversation_id) { setConversationId(currentConversationId) } break } }) }; sendConversation({ web_search: webSearch, memory, message: msg || message || '', stream: true, conversation_id: conversation_id || null, 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 } } }), variables: params, thinking, }, handleStreamMessage, shareToken) .catch(() => { setLoading(false) streamLoadingRef.current = false }) .finally(() => { setLoading(false) streamLoadingRef.current = false }) } const handleChangeMemory = () => { if (config.app_type === 'workflow') return; let value = !memory modal.confirm({ title: value ? t('memoryConversation.memoryTipTitle') : t('memoryConversation.memoryCancelTipTitle'), okText: t('common.confirm'), cancelText: t('common.cancel'), onOk: () => { setMemory(value) }, onCancel: () => { setMemory(!value) } }) } const handleChangeDeepThinking = () => { setThinking(prev => !prev) } const handleChangeVariables = (variables: Variable[]) => { setChatList(prev => { const firstMsg = prev[0] if (firstMsg && firstMsg.role === 'assistant' && firstMsg.content && features?.opening_statement?.enabled && features?.opening_statement.statement && variables.length > 0) { firstMsg.content = replaceVariables(features?.opening_statement.statement, variables as unknown as AppVariable[]) return [firstMsg, ...prev.slice(1)] } return prev }) } console.log('chatList', chatList, streamLoadingRef.current) return (
{t('memoryConversation.chatTitle')}
handleChangeHistory(null)} >
{t('memoryConversation.startANewConversation')}
{historyList.length > 0 &&
} scrollableTarget="scrollableDiv" > {Object.entries(groupHistoryList).map(([date, items]) => (
{date.replace(/\u200e|\u200f/g, '')}
{items.map(item => (
handleChangeHistory(item.id)} > {item.title}
))}
))}
}
} contentClassName={!fileList.length ? "rb:h-[calc(100%-144px)] rb:w-full" : "rb:h-[calc(100%-208px)] rb:w-full"} data={chatList} streamLoading={streamLoadingRef.current} loading={loading} onChange={setMessage} onSend={handleSend} labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')} conversationId={conversation_id} fileList={fileList} fileChange={(list) => { setFileList(list || []) toolbarRef.current?.setFiles(list || []) }} > {isDeepThinking &&
} {features?.web_search?.enabled && setWebSearch(prev => !prev)} >
} {isHasMemory &&
} : undefined } onVariablesChange={handleChangeVariables} />
) } export default Conversation