/* * @Author: ZhaoYing * @Date: 2026-02-03 16:58:03 * @Last Modified by: ZhaoYing * @Last Modified time: 2026-03-03 13:46:22 */ /** * 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, Form, Dropdown, type MenuProps, App, Divider } from 'antd' import { SettingOutlined } from '@ant-design/icons' import clsx from 'clsx' import dayjs from 'dayjs' import { getConversationHistory, sendConversation, getConversationDetail, getShareToken, getExperienceConfig } from '@/api/application' import type { HistoryItem, QueryParams, UploadFileListModalRef } from './types' import Empty from '@/components/Empty' import { formatDateTime } from '@/utils/format'; import { randomString } from '@/utils/common' import BgImg from '@/assets/images/conversation/bg.png' import ChatEmpty from '@/assets/images/empty/chatEmpty.png' import Chat from '@/components/Chat' import type { ChatItem } from '@/components/Chat/types' import ButtonCheckbox from '@/components/ButtonCheckbox' import MemoryFunctionIcon from '@/assets/images/conversation/memoryFunction.svg' import OnlineIcon from '@/assets/images/conversation/online.svg' import OnlineCheckedIcon from '@/assets/images/conversation/onlineChecked.svg' import MemoryFunctionCheckedIcon from '@/assets/images/conversation/memoryFunctionChecked.svg' import { type SSEMessage } from '@/utils/stream' import UploadFiles from './components/FileUpload' import AudioRecorder from '@/components/AudioRecorder' import { shareFileUploadUrlWithoutApiPrefix } from '@/api/fileStorage' import UploadFileListModal from './components/UploadFileListModal' import type { VariableConfigModalRef } from '@/views/Workflow/types' import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types' import VariableConfigModal from '@/views/Workflow/components/Chat/VariableConfigModal'; /** * Conversation component for shared applications */ const Conversation: FC = () => { const { t } = useTranslation() const { message: messageApi } = 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 [streamLoading, setStreamLoading] = 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 [shareToken, setShareToken] = useState(localStorage.getItem(`shareToken_${token}`)) const [form] = Form.useForm() const queryValues = Form.useWatch([], form) const uploadFileListModalRef = useRef(null) const variableConfigModalRef = useRef(null) const [variables, setVariables] = useState([]) // Workflow input variables /** * Opens the variable configuration modal */ const handleEditVariables = () => { variableConfigModalRef.current?.handleOpen(variables) } /** * Saves updated variable values from the modal */ const handleSave = (values: Variable[]) => { setVariables([...values]) } 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[] } setVariables(response.variables || []) }) } 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[] } setChatList(response?.messages || []) }) } else { setChatList([]) } }, [conversation_id]) /** Add user message to chat */ const addUserMessage = (message: string = '', files?: any[]) => { const newUserMessage: ChatItem = { conversation_id, role: 'user', content: message, created_at: Date.now(), files }; setChatList(prev => [...prev, newUserMessage]) } /** Add empty assistant message placeholder */ const addAssistantMessage = () => { const newAssistantMessage: ChatItem = { created_at: Date.now(), role: 'assistant', content: '', } setChatList(prev => [...prev, newAssistantMessage]) } /** Update assistant message with streaming content */ const updateAssistantMessage = (content: string = '') => { if (!content) return if (streamLoading) { setStreamLoading(false) } setChatList(prev => { const lastList = [...prev] const lastIndex = lastList.length - 1 const lastMsg = lastList[lastIndex] if (lastMsg?.role === 'assistant') { return [ ...lastList.slice(0, lastList.length - 1), { ...lastMsg, content: lastMsg.content + content } ] } return prev }) } const isNeedVariableConfig = variables.some(vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === '')) /** Send message and handle streaming response */ const handleSend = () => { if (!token || !shareToken) { return } const { files = [], ...rest } = queryValues || {} // Validate required variables before sending 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) setStreamLoading(true) addUserMessage(message, files) addAssistantMessage() let currentConversationId: string | null = null const handleStreamMessage = (data: SSEMessage[]) => { data.forEach((item) => { switch(item.event) { case 'start': case 'node_start': const { conversation_id: newId } = item.data as { conversation_id: string } currentConversationId = newId break case 'message': const { content, chunk, conversation_id: curId } = item.data as { content: string; chunk: string; conversation_id: string; } updateAssistantMessage(content ?? chunk) if (curId) { currentConversationId = curId; } break case 'end': case 'workflow_end': setLoading(false) if (currentConversationId && currentConversationId !== conversation_id) { setConversationId(currentConversationId) } getHistory(true) break } }) }; form.setFieldValue('files', []) sendConversation({ ...rest, message: 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 }, handleStreamMessage, shareToken) .catch(() => { setLoading(false) setStreamLoading(false) }) .finally(() => { setLoading(false) setStreamLoading(false) }) } const fileChange = (file?: any) => { form.setFieldValue('files', [...(queryValues.files || []), file]) } const handleRecordingComplete = async (file: any) => { form.setFieldValue('files', [...(queryValues.files || []), { uid: file.file_id, response: { data: file }, thumbUrl: file.url, type: file.type }]) } const handleShowUpload: MenuProps['onClick'] = ({ key }) => { switch(key) { case 'define': uploadFileListModalRef.current?.handleOpen() break } } const addFileList = (fileList?: any[]) => { if (!fileList || fileList.length <= 0) return form.setFieldValue('files', [...(queryValues.files || []), ...fileList]) } const updateFileList = (fileList?: any[]) => { console.log('fileList', fileList) form.setFieldValue('files', [...(fileList || [])]) } return (
handleChangeHistory(null)} >
{t('memoryConversation.startANewConversation')}
{historyList.length > 0 &&
} // endMessage={It is all, nothing more 🤐} scrollableTarget="scrollableDiv" > {Object.entries(groupHistoryList).map(([date, items]) => (
{date.replace(/\u200e|\u200f/g, '')}
{items.map(item => (
handleChangeHistory(item.id)} > {item.title}
))}
))}
}
} contentClassName={!queryValues?.files?.length ? "rb:h-[calc(100%-144px)]" : "rb:h-[calc(100%-208px)]"} data={chatList} streamLoading={streamLoading} loading={loading} onChange={setMessage} onSend={handleSend} labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')} fileList={queryValues?.files || []} fileChange={updateFileList} >
) }, ], onClick: handleShowUpload }} >
{t(`memoryConversation.web_search`)} {t(`memoryConversation.memory`)} {variables.length > 0 && (
{t(`memoryConversation.variableConfig`)}
)}
) } export default Conversation