Spaces:
Running
Running
import styles from './index.module.less'; | |
import { useEffect, useState, useRef, useMemo, useContext } from 'react'; | |
import { Input, message, Tooltip } from 'antd'; | |
import ShowRightIcon from './assets/think-progress-icon.svg'; | |
import { MindsearchContext } from './provider/context'; | |
import ChatRight from './components/chat-right'; | |
import { useNavigate, useParams } from 'react-router-dom'; | |
import { fetchEventSource } from '@microsoft/fetch-event-source'; | |
import SessionItem from './components/session-item'; | |
import classNames from 'classnames'; | |
import Notice from './components/notice'; | |
interface INodeInfo { | |
isEnd?: boolean; // 该节点是否结束 | |
current_node?: string; | |
thinkingData?: string; // step1 思考 | |
queries?: []; | |
readingData?: string; // step2 思考 | |
searchList?: []; | |
conclusion?: string; // 节点的结论 | |
selectedIds?: number[]; | |
subQuestion?: string; // 节点的标题 | |
conclusionRef: any[]; | |
outputing?: boolean; | |
}; | |
interface IFormattedData { | |
question?: string; | |
nodes?: any; | |
adjacency_list?: object; | |
response?: string; | |
responseRefList?: any[]; | |
chatIsOver?: boolean; | |
}; | |
interface INodeItem { | |
id: string; | |
name: string; | |
state: number; | |
}; | |
class FatalError extends Error { }; | |
class RetriableError extends Error { }; | |
const MindSearchCon = () => { | |
const navigate = useNavigate(); | |
const params = useParams<{ id: string; robotId: string }>(); | |
const [qaList, setQaList] = useState<IFormattedData[]>([]); | |
const [formatted, setFormatted] = useState<IFormattedData>({}); | |
const [question, setQuestion] = useState(''); | |
const [stashedQuestion, setStashedQuestion] = useState<string>(''); | |
const [newChatTip, setNewChatTip] = useState<Boolean>(false); | |
const [singleObj, setSingleObj] = useState<any>(null); | |
const [isEnd, setIsEnd] = useState(false); | |
const [inputFocused, setFocused] = useState(false); | |
// 一轮完整对话结束 | |
const [chatIsOver, setChatIsOver] = useState(true); | |
const [currentNodeInfo, setCurrentNode] = useState<any>(null); | |
const [currentNodeName, setCurrentNodeName] = useState<string>(''); | |
const [activeNode, setActiveNode] = useState<string>(''); | |
// 是否展示右侧内容 | |
const [showRight, setShowRight] = useState(false); | |
const [adjList, setAdjList] = useState<any>({}); | |
const [historyNode, setHistoryNode] = useState<any>(null); | |
const [hasNewChat, setHasNewChat] = useState(false); | |
// 新开会话 | |
const openNewChat = () => { | |
location.reload(); | |
}; | |
const toggleRight = () => { | |
setShowRight(!showRight); | |
}; | |
// 渲染过程中保持渲染文字可见 | |
const keepScrollTop = () => { | |
const divA = document.getElementById('chatArea') as HTMLDivElement; | |
const divB = document.getElementById('messageWindowId') as HTMLDivElement; | |
// 获取 divB 的当前高度 | |
const bHeight = divB.offsetHeight; | |
// 检查 divA 是否需要滚动(即 divB 的高度是否大于 divA 的可视高度) | |
if (bHeight > divA.offsetHeight) { | |
// 滚动到 divB 的底部在 divA 的可视区域内 | |
divA.scrollTop = bHeight - divA.offsetHeight + 30; | |
} | |
}; | |
const initPageState = () => { | |
setSingleObj(null); | |
setCurrentNodeName(''); | |
setCurrentNode(null); | |
setFormatted({}); | |
setAdjList({}); | |
setShowRight(false); | |
setIsEnd(false); | |
}; | |
const responseTimer: any = useRef(null); | |
useEffect(() => { | |
// console.log('[ms]---', formatted, chatIsOver, responseTimer.current); | |
if (chatIsOver && formatted?.response) { | |
// 一轮对话结束 | |
setQaList((pre) => { | |
return pre.concat(formatted); | |
}); | |
initPageState(); | |
setCurrentNodeName('customer-0'); | |
} | |
if (!chatIsOver && !responseTimer.current) { | |
responseTimer.current = setInterval(() => { | |
keepScrollTop(); | |
}, 50); | |
} | |
if (responseTimer.current && chatIsOver) { | |
// 如果 isEnd 变为 false,清除定时器 | |
clearInterval(responseTimer.current); | |
responseTimer.current = null; | |
} | |
}, [formatted?.response, chatIsOver, responseTimer.current, newChatTip]); | |
useEffect(() => { | |
if (formatted?.question) { | |
setHistoryNode(null); | |
setChatIsOver(false); | |
} | |
}, [formatted?.question]); | |
// 存储节点信息 | |
const stashNodeInfo = (fullInfo: any, nodeName: string) => { | |
// console.log('stash node info------', fullInfo, fullInfo?.response?.stream_state); | |
const content = JSON.parse(fullInfo?.response?.content || '{}') || {}; | |
const searchListStashed: any = Object.keys(content).map((item) => { | |
return { id: item, ...content[item] }; | |
}); | |
const stashedList = JSON.parse(localStorage?.stashedNodes || '{}'); | |
const nodeInfo = stashedList[nodeName] || {}; | |
if (fullInfo?.content) { | |
nodeInfo.subQuestion = fullInfo.content; | |
} | |
if (fullInfo?.response?.formatted?.thought) { | |
// step1 思考 | |
if (!nodeInfo?.readingData && !nodeInfo?.queries?.length) { | |
nodeInfo.thinkingData = fullInfo?.response?.formatted?.thought; | |
} | |
// step2 思考 | |
if (nodeInfo?.thinkingData && nodeInfo?.queries?.length && nodeInfo?.searchList?.length && !nodeInfo?.selectedIds?.length && !nodeInfo?.conclusion) { | |
nodeInfo.readingData = fullInfo?.response?.formatted?.thought; | |
} | |
// conclusion | |
if (nodeInfo?.startConclusion && fullInfo?.response?.stream_state === 1) { | |
nodeInfo.conclusion = fullInfo?.response?.formatted?.thought; | |
} | |
} | |
if (fullInfo?.response?.formatted?.action?.parameters?.query?.length && !nodeInfo.queries?.length) { | |
nodeInfo.queries = fullInfo?.response?.formatted.action.parameters.query; | |
} | |
if (searchListStashed?.length && !nodeInfo.conclusionRef) { | |
nodeInfo.searchList = searchListStashed; | |
nodeInfo.conclusionRef = content; | |
} | |
if (Array.isArray(fullInfo?.response?.formatted?.action?.parameters?.select_ids) && !nodeInfo?.selectedIds?.length) { | |
nodeInfo.selectedIds = fullInfo?.response?.formatted.action.parameters.select_ids; | |
nodeInfo.startConclusion = true; | |
} | |
if (fullInfo?.response?.stream_state) { | |
nodeInfo.outputing = true; | |
} else { | |
nodeInfo.outputing = false; | |
} | |
const nodesList: any = {}; | |
nodesList[nodeName] = { | |
current_node: nodeName, | |
...nodeInfo, | |
}; | |
window.localStorage.stashedNodes = JSON.stringify({ ...stashedList, ...nodesList }); | |
}; | |
const formatData = (obj: any) => { | |
// 嫦娥6号上有哪些国际科学载荷?它们的作用分别是什么? | |
try { | |
// 更新邻接表 | |
if (obj?.response?.formatted?.adjacency_list) { | |
setAdjList(obj.response?.formatted?.adjacency_list); | |
} | |
if (!obj?.current_node && obj?.response?.formatted?.thought && obj?.response?.stream_state === 1) { | |
// 有thought,没有node, planner思考过程 | |
setFormatted((pre: IFormattedData) => { | |
return { | |
...pre, | |
response: obj.response.formatted.thought, | |
}; | |
}); | |
} | |
if (obj?.response?.formatted?.ref2url && !formatted?.responseRefList) { | |
setFormatted((pre: IFormattedData) => { | |
return { | |
...pre, | |
responseRefList: obj?.response?.formatted?.ref2url, | |
}; | |
}); | |
} | |
if (obj?.current_node || obj?.response?.formatted?.node) { | |
// 有node, 临时存储node信息 | |
stashNodeInfo(obj?.response?.formatted?.node?.[obj.current_node], obj.current_node); | |
} | |
} catch (err) { | |
console.log(err); | |
} | |
}; | |
const handleError = (errCode: number, msg: string) => { | |
message.warning(msg || '请求出错了,请稍后再试'); | |
if (errCode === -20032 || errCode === -20033 || errCode === -20039) { | |
// 敏感词校验失败, 新开会话 | |
openNewChat(); | |
return; | |
} | |
console.log('handle error------', msg); | |
setChatIsOver(true); | |
initPageState(); | |
}; | |
const startEventSource = () => { | |
console.log('start event--------'); | |
if (qaList?.length > 4) { | |
setNewChatTip(true); | |
message.warning('对话数已达上限,请在新对话中聊天'); | |
keepScrollTop(); | |
return; | |
} | |
setFormatted({ ...formatted, question }); | |
setQuestion(''); | |
setChatIsOver(false); | |
const ctrl = new AbortController(); | |
const url = '/solve'; | |
// const queryData = { | |
// cancel: true, | |
// prompt: question, | |
// }; | |
const postData = { | |
inputs: question | |
} | |
fetchEventSource(url, { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify(postData), | |
openWhenHidden: true, | |
signal: ctrl.signal, | |
onmessage(ev) { | |
try { | |
const res = (ev?.data && JSON.parse(ev.data)) || null; | |
if (res?.response?.stream_state === 0) { | |
setChatIsOver(true); | |
setFormatted((pre: IFormattedData) => { | |
return { | |
...pre, | |
chatIsOver: true, | |
}; | |
}); | |
} else { | |
formatData(res); | |
setSingleObj(res); | |
} | |
} catch (err) { | |
console.log('error on sse---', err); | |
handleError(0, '请求出错了,请稍后再试!'); | |
} | |
}, | |
onerror(err) { | |
console.log('error on sse---', err); | |
handleError(0, ''); | |
ctrl.abort(); | |
if (err instanceof FatalError) { | |
throw err; | |
} | |
}, | |
onclose() { | |
// params?.id && handleUpdateHistoryItem(params?.id); | |
} | |
}); | |
}; | |
// 点击节点 | |
const handleNodeClick = (node: string, idx: number) => { | |
if (isEnd && !chatIsOver) return; // 当节点输出完成,最终response进行中,不允许点击按钮,点击无效 | |
const isFromHistory = qaList?.[idx]?.nodes?.[node]; | |
setShowRight(true); | |
setActiveNode(node); | |
if (isFromHistory) { | |
const info = qaList?.[idx]?.nodes?.[node]; | |
if (!info) { | |
message.error('没有读取到节点信息'); | |
} | |
setHistoryNode(info); | |
} else { | |
setCurrentNodeName(node); | |
} | |
}; | |
// 解析历史记录或者搜索返回的数据 | |
const formatHistoryNode = (originNodeInfo: any) => { | |
// console.log('format history node--------', originNodeInfo); | |
const searchContent = JSON.parse(originNodeInfo?.memory?.[1]?.content || '{}') || {}; | |
const searchListStashed: any = Object.keys(searchContent).map((item) => { | |
return { id: item, ...searchContent[item] }; | |
}); | |
const nodeInfo: INodeInfo = { | |
current_node: originNodeInfo?.current_node || String(Date.now()), | |
thinkingData: originNodeInfo?.memory?.[0]?.formatted?.thought || '', // step1 思考 | |
queries: originNodeInfo?.memory?.[0]?.formatted?.action?.parameters?.query || [], | |
readingData: originNodeInfo?.memory?.[2]?.formatted?.thought || '', // step2 思考 | |
searchList: searchListStashed, | |
conclusionRef: searchContent, | |
conclusion: originNodeInfo?.memory?.[4]?.formatted?.thought || '', // 节点的结论 | |
selectedIds: originNodeInfo?.memory?.[2]?.formatted?.action?.parameters?.select_ids || [], | |
subQuestion: originNodeInfo?.content, // 节点的标题 | |
isEnd: true, | |
outputing: false | |
}; | |
return nodeInfo; | |
}; | |
const createSseChat = () => { | |
if (submitDisabled) { | |
return; | |
} | |
setQuestion(stashedQuestion); | |
setStashedQuestion(''); | |
setCurrentNodeName('customer-0'); | |
}; | |
const checkNodesOutputFinish = () => { | |
const adjListStr = JSON.stringify(adjList); | |
// 服务端没有能准确描述所有节点输出完成的状态,前端从邻接表信息中寻找response信息,不保证完全准确,因为也可能不返回 | |
if (adjListStr.includes('"name":"response"')) { | |
setIsEnd(true); | |
} | |
}; | |
useEffect(() => { | |
if (!adjList) return; | |
if (isEnd) { | |
// 所有节点输出完成时收起右侧 | |
setShowRight(false); | |
} else { | |
checkNodesOutputFinish(); | |
} | |
setFormatted((pre: IFormattedData) => { | |
return { | |
...pre, | |
adjacency_list: adjList, | |
}; | |
}); | |
}, [adjList, isEnd]); | |
useEffect(() => { | |
const findStashNode = localStorage?.stashedNodes && JSON.parse(localStorage?.stashedNodes || '{}'); | |
if (!findStashNode || !currentNodeName) return; | |
currentNodeName === 'customer-0' ? setCurrentNode(null) : setCurrentNode(findStashNode?.[currentNodeName]); | |
currentNodeName !== 'customer-0' && setShowRight(true); | |
}, [currentNodeName, localStorage?.stashedNodes]); | |
useEffect(() => { | |
if (!singleObj) return; | |
if ((!currentNodeName || currentNodeName === 'customer-0') && singleObj?.current_node) { | |
setCurrentNodeName(singleObj?.current_node); | |
} | |
}, [singleObj, currentNodeName]); | |
useEffect(() => { | |
if (question) { | |
startEventSource(); | |
} | |
}, [question]); | |
useEffect(() => { | |
if (!showRight) { | |
setActiveNode(''); | |
} | |
}, [showRight]); | |
useEffect(() => { | |
localStorage.stashedNodes = ''; | |
localStorage.reformatStashedNodes = ''; | |
return () => { | |
// 返回清理函数,确保组件卸载时清除定时器 | |
if (responseTimer.current) { | |
clearInterval(responseTimer.current); | |
responseTimer.current = null; | |
} | |
}; | |
}, []); | |
const submitDisabled = useMemo(() => { | |
return newChatTip || !stashedQuestion || !chatIsOver; | |
}, [newChatTip, stashedQuestion, chatIsOver]); | |
return ( | |
<MindsearchContext.Provider value={{ | |
isEnd, | |
chatIsOver, | |
activeNode: activeNode | |
}}> | |
<div className={styles.mainPage} style={!showRight ? { maxWidth: '840px' } : {}}> | |
<div className={styles.chatContent}> | |
<div className={classNames( | |
styles.top, | |
(isEnd && !chatIsOver) ? styles.mb12 : '' | |
)} id="chatArea"> | |
<div id="messageWindowId"> | |
{qaList.length > 0 && | |
qaList.map((item: IFormattedData, idx) => { | |
return ( | |
<div key={`qa-item-${idx}`} className={styles.qaItem}> | |
{ | |
item.question && <SessionItem | |
item={item} | |
handleNodeClick={handleNodeClick} | |
idx={idx} | |
key={`session-item-${idx}`} | |
/> | |
} | |
</div> | |
); | |
}) | |
} | |
{ | |
formatted?.question && | |
<SessionItem | |
item={{ ...formatted, chatIsOver, isEnd, adjacency_list: adjList }} | |
handleNodeClick={handleNodeClick} | |
idx={qaList.length} | |
/> | |
} | |
</div> | |
{newChatTip && ( | |
<div className={styles.newChatTip}> | |
<span> | |
对话数已达上限,请在 <a onClick={openNewChat}>新对话</a> 中聊天 | |
</span> | |
</div> | |
)} | |
</div> | |
<div className={classNames( | |
styles.input, | |
inputFocused ? styles.focus : '' | |
)}> | |
<div className={styles.inputMain}> | |
<div className={styles.inputMainBox}> | |
<Input | |
className={styles.textarea} | |
variant="borderless" | |
value={stashedQuestion} | |
placeholder={'开始提问吧'} | |
onChange={(e) => { | |
setStashedQuestion(e.target.value); | |
}} | |
onPressEnter={createSseChat} | |
onFocus={() => { setFocused(true) }} | |
onBlur={() => { setFocused(false) }} | |
/> | |
<div className={classNames(styles.send, submitDisabled && styles.disabled)} onClick={createSseChat}> | |
<i className="iconfont icon-Frame1" /> | |
</div> | |
</div> | |
</div> | |
</div> | |
<Notice /> | |
</div> | |
{showRight ? ( | |
<ChatRight | |
nodeInfo={currentNodeInfo} | |
historyNode={historyNode} | |
toggleRight={toggleRight} | |
key={currentNodeName} | |
chatIsOver={chatIsOver} | |
/> | |
) : ( | |
<div className={styles.showRight}> | |
<div className={classNames( | |
styles.actionIcon, | |
isEnd && !chatIsOver ? styles.forbidden : '' | |
)} onClick={toggleRight}> | |
<Tooltip placement="leftTop" title="思考过程"> | |
<img src={ShowRightIcon} /> | |
</Tooltip> | |
</div> | |
</div> | |
)} | |
</div> | |
</MindsearchContext.Provider> | |
); | |
}; | |
export default MindSearchCon; | |