Spaces:
Sleeping
Sleeping
import styles from './index.module.less'; | |
import { useEffect, useState, useRef, Children } from 'react'; | |
import MindMapItem from './mindMapItem'; | |
import PackIcon from '@/assets/pack-up.svg'; | |
import SendIcon from '@/assets/sendIcon.svg'; | |
import { Tooltip, Input, message } from 'antd'; | |
import IconFont from '@/components/iconfont'; | |
import ReactMarkdown from "react-markdown"; | |
import ShowRightIcon from "@/assets/show-right-icon.png"; | |
import rehypeRaw from 'rehype-raw'; | |
import classNames from 'classnames'; | |
import { fetchEventSource } from '@microsoft/fetch-event-source'; | |
import { GET_SSE_DATA } from '@/config/cgi'; | |
import { replaceStr } from '@/utils/tools'; | |
const RenderTest = () => { | |
let eventSource: any = null; | |
let sseTimer: any = useRef(null); | |
const [isWaiting, setIsWaiting] = useState(false); | |
const [question, setQuestion] = useState(""); | |
const [stashQuestion, setStashQuestion] = useState(""); | |
const [isEnd, setIsEnd] = useState(false); | |
const [showEndNode, setShowEndNode] = useState(false); | |
// 一组节点的渲染草稿 | |
const [draft, setDraft] = useState(''); | |
// 一轮完整对话结束 | |
const [chatIsOver, setChatIsOver] = useState(true); | |
// 一组节点的思考草稿是不是打印结束 | |
const [draftEnd, setDraftEnd] = useState(false); | |
const [progress1, setProgress1] = useState(''); | |
const [progress2, setProgress2] = useState(''); | |
const [progressEnd, setProgressEnd] = useState(false); | |
const [conclusion, setConclusion] = useState(''); | |
const [stashConclusion, setstashConclusion] = useState(''); | |
const [query, setQuery] = useState([]); | |
const [searchList, setSearchList] = useState([]); | |
// 整体的渲染树 | |
const [renderData, setRenderData] = useState<any[]>([]); | |
const [currentNode, setCurrentNode] = useState<any>(null); | |
// 渲染minddata里的第几个item | |
const [renderIndex, setRenderIndex] = useState<number>(0); | |
const [response, setResponse] = useState(""); | |
const [currentStep, setCurrentStep] = useState(0); | |
// steps展开收起的信息 | |
const [collapseInfo, setCollapseInfo] = useState([true, true]); | |
const [mapWidth, setMapWidth] = useState(0); | |
// 是否展示右侧内容 | |
const [showRight, setShowRight] = useState(true); | |
const [currentNodeRendering, setCurrentNodeRendering] = useState(false); | |
const [selectedIds, setSelectedIds] = useState([]); | |
const [nodeName, setNodeName] = useState(''); | |
const hasHighlight = useRef(false); | |
const conclusionRender = useRef(false); | |
const nodeDraftRender = useRef(false); | |
const [obj, setObj] = useState<any>(null); | |
const [nodeOutputEnd, setNodeEnd] = useState(false); | |
const [adjList, setAdjList] = useState([]); | |
const TEXT_INTERVAL = 20; | |
const SEARCHLIST_INTERVAL = 80; | |
const toggleRight = () => { | |
setShowRight(!showRight); | |
}; | |
const findAndUpdateStatus = (nodes: any[], targetNode: any) => { | |
return nodes.map((node) => { | |
if (node.state === 1 && node.id !== 0) { | |
return { ...node, state: 3 }; | |
} | |
if (node.name === targetNode) { | |
return { ...node, state: 1 }; | |
} | |
if (node.children) { | |
// 递归地在子节点中查找 | |
node.children = findAndUpdateStatus(node.children, targetNode); | |
} | |
return node; | |
}); | |
} | |
const generateEndStyle = () => { | |
// 获取所有class为endline的div元素 | |
const endlineDivs = document.getElementsByClassName('endline'); | |
const mindMap = document.getElementById("mindMap"); | |
// 确保至少有两个元素 | |
if (endlineDivs.length >= 2 && mindMap) { | |
// 获取第一个和最后一个元素的边界框(bounding rectangle) | |
const firstRect = endlineDivs[0].getBoundingClientRect(); | |
const lastRect = endlineDivs[endlineDivs.length - 1].getBoundingClientRect(); | |
const mindMapRect = mindMap?.getBoundingClientRect(); | |
// 计算y值的差值 | |
const yDiff = lastRect.top - firstRect.top; | |
// const top = firstRect.top - mindMapRect.top; | |
// 如果需要包含元素的完整高度(不仅仅是顶部位置),可以加上元素的高度 | |
// const yDiffWithHeight = yDiff + (lastRect.height - firstRect.height); | |
return { | |
top: firstRect.top - mindMapRect.top, | |
height: yDiff + 1 | |
}; | |
} else { | |
return { | |
top: '50%', | |
height: 0 | |
}; | |
} | |
}; | |
const generateWidth = () => { | |
const articles = document.querySelectorAll('article'); | |
// 确保至少有两个元素 | |
if (articles?.length) { | |
let maxRight = 0; | |
articles.forEach((item, index) => { | |
if (item.getBoundingClientRect().right > maxRight) { | |
maxRight = item.getBoundingClientRect().right; | |
} | |
}) | |
const firstArticle = articles[0].getBoundingClientRect(); | |
if (maxRight - firstArticle.left + 200 > mapWidth) { | |
return maxRight - firstArticle.left + 200 | |
} else { | |
return mapWidth; | |
} | |
} else { | |
return 100; | |
} | |
}; | |
// 逐字渲染 | |
const renderDraft = (str: string, type: string, endCallback: () => void) => { | |
// 已经输出的字符数量 | |
let outputIndex = 0; | |
// 输出字符的函数 | |
const outputText = () => { | |
// 给出高亮后draft输出的结束标志 | |
if (type === 'stepDraft-1' && outputIndex + 3 > str?.length) { | |
nodeDraftRender.current = true; | |
} | |
// 如果还有字符未输出 | |
if (outputIndex < str?.length) { | |
// 获取接下来要输出的1个字符(或剩余字符,如果不足3个) | |
let chunk = str.slice(outputIndex, Math.min(outputIndex + 10, str.length)); | |
// 更新已输出字符的索引 | |
outputIndex += chunk.length; | |
if (type === 'thought') { | |
setDraft(str.slice(0, outputIndex)); | |
} else if (type === "stepDraft-0") { | |
setProgress1(str.slice(0, outputIndex)); | |
} else if (type === "stepDraft-1") { | |
setProgress2(str.slice(0, outputIndex)); | |
} else if (type === "conclusion") { | |
setConclusion(str.slice(0, outputIndex)); | |
} else if (type === "response") { | |
setResponse(str.slice(0, outputIndex)); | |
} | |
} else { | |
// 如果没有更多字符需要输出,则清除定时器 | |
clearInterval(intervalId); | |
endCallback && endCallback() | |
} | |
} | |
// 设定定时器ID | |
let intervalId = setInterval(outputText, TEXT_INTERVAL); | |
} | |
// 渲染搜索结果renderSearchList | |
const renderSearchList = () => { | |
let outputIndex = 0; | |
const content = JSON.parse(currentNode.actions[currentStep].result[0].content); | |
const arr: any = Object.keys(content).map(item => { | |
return { id: item, ...content[item] }; | |
}); | |
const len = Object.keys(content).length; | |
const outputText = () => { | |
outputIndex++; | |
if (outputIndex < len + 1) { | |
setSearchList(arr.slice(0, outputIndex)); | |
} else { | |
clearInterval(intervalId); | |
} | |
}; | |
// 设定定时器ID | |
let intervalId = setInterval(outputText, SEARCHLIST_INTERVAL); | |
}; | |
// 高亮searchList | |
const highLightSearchList = (ids: any) => { | |
setSelectedIds([]); | |
const newStep = currentStep + 1; | |
setCurrentStep(newStep); | |
const highlightArr: any = [...searchList]; | |
highlightArr.forEach((item: any) => { | |
if (ids.includes(Number(item.id))) { | |
item.highLight = true; | |
} | |
}) | |
highlightArr.sort((item1: any, item2: any) => { | |
if (item1.highLight === item2.highLight) { | |
return 0; | |
} | |
// 如果item1是highlight,放在前面 | |
if (item1.highLight) { | |
return -1; | |
} | |
// 如果item2是highlight,放在后面 | |
return 1; | |
}) | |
setSearchList(highlightArr); | |
renderDraft(currentNode.actions[1].thought, `stepDraft-1`, () => { }); | |
hasHighlight.current = true; // 标记为高亮已执行 | |
}; | |
// 渲染结论 | |
const renderConclusion = () => { | |
const res = window.localStorage.getItem('nodeRes') || ''; | |
const replaced = replaceStr(res); | |
// setTimeout(() => { setCollapseInfo([false, false]); }, 2000); | |
setCollapseInfo([false, false]); | |
setConclusion(replaced); | |
setstashConclusion(res); | |
// 给出conclusion结束的条件 | |
if (stashConclusion.length + 5 > res.length) { | |
conclusionRender.current = true; | |
setProgressEnd(true); | |
} | |
}; | |
// 渲染query | |
const renderQuery = (endCallback: () => void) => { | |
const queries = currentNode.actions[currentStep]?.args?.query; | |
setQuery(queries); | |
endCallback && endCallback(); | |
}; | |
const renderSteps = () => { | |
setCurrentNodeRendering(true); | |
const queryEndCallback = () => { | |
if (currentNode.actions[currentStep].result[0].content) { | |
if (currentNode.actions[currentStep].type === "BingBrowser.search" || currentNode.actions[currentStep].type === "BingBrowser") { | |
renderSearchList(); | |
} | |
} | |
}; | |
const thoughtEndCallback = () => { | |
if (currentNode.actions[currentStep]?.args?.query?.length) { | |
renderQuery(queryEndCallback); | |
} else { | |
queryEndCallback(); | |
} | |
}; | |
if (currentNode.actions[currentStep].thought) { | |
renderDraft(currentNode.actions[currentStep].thought, `stepDraft-${currentStep}`, thoughtEndCallback); | |
} | |
} | |
// 展开收起 | |
const toggleCard = (index: number) => { | |
const arr = [...collapseInfo]; | |
arr[index] = !arr[index]; | |
setCollapseInfo(arr); | |
}; | |
// 渲染过程中保持渲染文字可见 | |
const keepScrollTop = (divA: any, divB: any) => { | |
// 获取 divB 的当前高度 | |
const bHeight = divB.offsetHeight; | |
// 检查 divA 是否需要滚动(即 divB 的高度是否大于 divA 的可视高度) | |
if (bHeight > divA.offsetHeight) { | |
// 滚动到 divB 的底部在 divA 的可视区域内 | |
divA.scrollTop = bHeight - divA.offsetHeight; | |
} | |
}; | |
useEffect(() => { | |
setRenderData([ | |
{ | |
id: 0, | |
state: 3, | |
name: '原始问题', | |
children: adjList | |
} | |
]) | |
}, [JSON.stringify(adjList)]); | |
useEffect(() => { | |
console.log('render data changed-----', renderData); | |
}, [renderData]); | |
useEffect(() => { | |
if (currentStep === 1) { | |
setCollapseInfo([false, true]); | |
} | |
}, [currentStep]); | |
useEffect(() => { | |
if (nodeOutputEnd && !localStorage.getItem('nodeRes')) { | |
// 如果节点输出结束了,但是response还没有结束,认为节点渲染已结束 | |
conclusionRender.current = true; | |
setProgressEnd(true); | |
return; | |
} | |
if (nodeDraftRender.current && localStorage.getItem('nodeRes')) { | |
renderConclusion(); | |
} | |
}, [localStorage.getItem('nodeRes'), nodeDraftRender.current, nodeOutputEnd]); | |
useEffect(() => { | |
if (obj?.response?.nodes[obj.current_node]?.detail?.state !== 1) { | |
setIsWaiting(true); | |
} | |
if (obj?.response?.nodes?.[obj.current_node].detail?.state === 0 && currentNode?.current_node === obj.current_node) { | |
console.log('node render end-----', obj); | |
setNodeEnd(true); | |
} | |
if (obj?.current_node && obj?.response?.state === 3) { | |
// 当node节点的数据可以开始渲染时,给currentnode赋值 | |
// update conclusion | |
if (obj.response.nodes[obj.current_node]?.detail?.actions?.length === 2 && | |
obj.response.nodes[obj.current_node]?.detail?.state === 1 && | |
obj.response.nodes[obj.current_node]?.detail.response) { | |
window.localStorage.setItem('nodeRes', obj.response.nodes[obj.current_node]?.detail.response); | |
} | |
if (obj.current_node && | |
(obj.response.nodes[obj.current_node]?.detail?.state === 1) && | |
obj.response.nodes[obj.current_node]?.detail?.actions?.length && | |
currentStep === 0 && | |
currentNode?.current_node !== obj?.current_node | |
) { | |
// 更新当前渲染节点 | |
console.log('update current node----'); | |
setIsWaiting(false); | |
setCurrentNode({ ...obj.response.nodes[obj.current_node]?.detail, current_node: obj.current_node }); | |
} | |
// 设置highlight | |
if (!selectedIds.length && | |
obj.response.nodes[obj.current_node]?.detail?.actions?.[1]?.type === 'BingBrowser.select' && | |
(obj.response.nodes[obj.current_node]?.detail?.state === 1)) { | |
setSelectedIds(obj.response.nodes[obj.current_node]?.detail?.actions?.[1]?.args?.select_ids || []); | |
setCurrentNode({ ...obj.response.nodes[obj.current_node]?.detail, current_node: obj.current_node }); | |
} | |
} | |
}, [obj]); | |
useEffect(() => { | |
// 输出思考过程 | |
if (!currentNode || currentNodeRendering) { return; } | |
renderSteps(); | |
}, [currentNode, currentNodeRendering, selectedIds]); | |
useEffect(() => { | |
if (!hasHighlight.current && selectedIds.length && currentNode?.actions.length === 2) { | |
// 渲染高亮的search信息 | |
highLightSearchList(selectedIds); | |
} | |
}, [selectedIds, currentNode]); | |
useEffect(() => { | |
// 当前节点渲染结束 | |
if (nodeName && nodeName !== currentNode?.current_node && progressEnd && !isEnd) { | |
resetNode(nodeName); | |
setMapWidth(generateWidth()); | |
} | |
}, [nodeName, currentNode, progressEnd, isEnd]); | |
let responseTimer: any = useRef(null); | |
useEffect(() => { | |
if (isEnd) { | |
responseTimer.current = setInterval(() => { | |
const divA = document.getElementById('chatArea') as HTMLDivElement; | |
const divB = document.getElementById('messageWindowId') as HTMLDivElement; | |
keepScrollTop(divA, divB); | |
if (chatIsOver) { | |
clearInterval(responseTimer.current); | |
} | |
}, 500); | |
setTimeout(() => { | |
setShowEndNode(true); | |
}, 300); | |
} else if (responseTimer.current) { | |
// 如果 isEnd 变为 false,清除定时器 | |
clearInterval(responseTimer.current); | |
responseTimer.current = null; | |
} | |
// 返回清理函数,确保组件卸载时清除定时器 | |
return () => { | |
if (responseTimer.current) { | |
clearInterval(responseTimer.current); | |
responseTimer.current = null; | |
} | |
}; | |
}, [isEnd, chatIsOver]); | |
useEffect(() => { | |
setRenderData([]); | |
setResponse(''); | |
setDraft(''); | |
setIsEnd(false); | |
setShowRight(true); | |
window.localStorage.setItem('nodeRes', ''); | |
window.localStorage.setItem('finishedNodes', ''); | |
}, [question]); | |
const resetNode = (targetNode: string) => { | |
if (targetNode === 'response') return; // 如果开始response了,所有节点都渲染完了,不需要reset | |
// 渲染下一个节点前,初始化状态 | |
const newData = findAndUpdateStatus(renderData, targetNode); | |
console.log('reset node------', targetNode, renderData); | |
setCurrentStep(0); | |
setQuery([]); | |
setSearchList([]); | |
setConclusion(''); | |
setCollapseInfo([true, true]); | |
setProgress1(''); | |
setProgress2(''); | |
setProgressEnd(false); | |
setCurrentNode(null); | |
setCurrentNodeRendering(false); | |
setSelectedIds([]); | |
setNodeEnd(false); | |
hasHighlight.current = false; | |
nodeDraftRender.current = false; | |
conclusionRender.current = false; | |
window.localStorage.setItem('nodeRes', ''); | |
}; | |
const formatData = (data: any) => { | |
try { | |
setIsWaiting(false); | |
const obj = JSON.parse(data); | |
if (!obj.current_node && obj.response.state === 0) { | |
console.log('chat is over end-------'); | |
setChatIsOver(true); | |
return; | |
} | |
if (!obj.current_node && obj.response.state === 9) { | |
setShowRight(false); | |
setIsEnd(true); | |
const replaced = replaceStr(obj.response.response); | |
setResponse(replaced); | |
return; | |
} | |
if (!obj.current_node && obj.response.state === 1 && !currentNode) { | |
// 有thought,没有node | |
setDraftEnd(false); | |
setDraft(obj.response.response); | |
} | |
if (!obj.current_node && (obj.response.state !== 1 || obj.response.state !== 0 || obj.response.state !== 9)) { | |
// 有thought,没有node, 不用处理渲染 | |
//console.log('loading-------------', obj); | |
setDraftEnd(true); | |
setIsWaiting(true); | |
} | |
if (obj.current_node && obj.response.state === 3) { | |
setNodeName(obj.current_node); | |
// 有node | |
setObj(obj); | |
const newAdjList = obj.response?.adjacency_list; | |
if (newAdjList?.length > 0) { | |
setAdjList(newAdjList); | |
} | |
} | |
} catch (err) { | |
console.log('format error-----', err); | |
} | |
}; | |
const startEventSource = () => { | |
if (!chatIsOver) { | |
message.warning('有对话进行中!'); | |
return; | |
} | |
setQuestion(stashQuestion); | |
setChatIsOver(false); | |
const postData = { | |
inputs: [ | |
{ | |
role: 'user', | |
content: stashQuestion | |
} | |
] | |
} | |
const ctrl = new AbortController(); | |
eventSource = fetchEventSource(GET_SSE_DATA, { | |
method: 'POST', | |
headers: { | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify(postData), | |
onmessage(ev) { | |
formatData(ev.data); | |
}, | |
onerror(err) { | |
console.log('sse error------', err); | |
}, | |
// signal: ctrl.signal, | |
}); | |
}; | |
const abortEventSource = () => { | |
if (eventSource) { | |
eventSource.close(); // 或使用其他方法关闭连接,具体取决于库的API | |
eventSource = null; | |
console.log('EventSource connection aborted due to timeout.'); | |
message.error('连接中断,2s后即将刷新页面---'); | |
setTimeout(() => { | |
location.reload(); | |
}, 2000); | |
} | |
}; | |
return <div className={styles.mainPage} style={!showRight ? { maxWidth: '1000px' } : {}}> | |
<div className={styles.chatContent}> | |
<div className={styles.top} id="chatArea"> | |
<div id="messageWindowId"> | |
{ | |
question && <div className={styles.question}> | |
<span>{question}</span> | |
</div> | |
} | |
{ | |
(draft || response || renderData?.length > 0) && | |
<div className={styles.answer}> | |
{ | |
renderData?.length > 0 ? <div className={styles.inner}> | |
<div className={styles.mapArea}> | |
<ul className={styles.mindmap} id="mindMap" style={isEnd ? { width: mapWidth, overflow: "hidden" } : {}}> | |
{renderData.map((item: any) => ( | |
<MindMapItem key={item.name} item={item} isEnd={isEnd} /> | |
))} | |
{showEndNode && | |
<div className={styles.end} style={generateEndStyle()}> | |
<div className={styles.node}> | |
<article>最终回复</article> | |
</div> | |
</div> | |
} | |
</ul> | |
</div> | |
</div> : <></> | |
} | |
{ | |
!response && <div className={styles.draft}> | |
{/* {!draftEnd && draft && <div className={styles.loading}> | |
<div></div> | |
<div></div> | |
<div></div> | |
</div>} */} | |
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{replaceStr(draft)}</ReactMarkdown> | |
</div> | |
} | |
{response && <div className={styles.response}> | |
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{response}</ReactMarkdown> | |
</div>} | |
</div> | |
} | |
</div> | |
</div> | |
<div className={styles.sendArea}> | |
<Input type="text" placeholder='说点什么吧~ Shift+Enter 换行 ; Enter 发送' onChange={(e) => { setStashQuestion(e.target.value) }} | |
onPressEnter={startEventSource} /> | |
<button onClick={startEventSource}> | |
<img src={SendIcon} /> | |
发送 | |
</button> | |
</div> | |
<div className={styles.notice}>如果想要更丝滑的体验,请在本地搭建-<a href='https://github.com/InternLM/MindSearch' target='_blank'>MindSearch <IconFont type='icon-GithubFilled' /></a></div> | |
</div> | |
{showRight && <div className={styles.progressContent}> | |
{ | |
currentNode && <> | |
<div className={styles.toggleIcon} onClick={toggleRight}> | |
<Tooltip placement="top" title="收起"> | |
<img src={PackIcon} /> | |
</Tooltip></div> | |
<div className={styles.titleNode}>{currentNode?.content || currentNode?.node}</div> | |
{ | |
currentNode?.actions?.length ? <> | |
{ | |
currentNode.actions.map((item: any, idx: number) => ( | |
currentStep >= idx && <div className={classNames( | |
styles.steps, | |
item.type === "BingBrowser.search" ? styles.thinking : styles.select | |
)} key={`step-${idx}`}> | |
<div className={styles.title}> | |
<i></i>{item.type === "BingBrowser.search" ? "思考" : item.type === "BingBrowser.select" ? "信息来源" : "信息整合"} | |
<div className={styles.open} onClick={() => { toggleCard(idx) }}> | |
<IconFont type={collapseInfo[idx] ? "icon-shouqi" : "icon-xiangxiazhankai"} /> | |
</div> | |
</div> | |
<div className={classNames( | |
styles.con, | |
!collapseInfo[idx] ? styles.collapsed : "" | |
)}> | |
{ | |
item.type === "BingBrowser.search" && <div className={styles.thought}> | |
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{progress1}</ReactMarkdown> | |
</div> | |
} | |
{ | |
item.type === "BingBrowser.search" && query.length > 0 && <div className={styles.query}> | |
<div className={styles.subTitle}><IconFont type="icon-SearchOutlined" />搜索关键词</div> | |
{ | |
query.map((item, index) => (<div key={`query-item-${item}`} className={classNames(styles.queryItem, styles.fadeIn)}> | |
{item} | |
</div>)) | |
} | |
</div> | |
} | |
{ | |
currentStep === idx && searchList.length > 0 && <div className={styles.searchList}> | |
{item.type === "BingBrowser.search" && <div className={styles.subTitle}><IconFont type="icon-DocOutlined" />信息来源</div>} | |
{ | |
item.type === "BingBrowser.select" && <div className={styles.thought}> | |
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{progress2}</ReactMarkdown> | |
</div> | |
} | |
<div className={styles.scrollCon} style={(searchList.length > 5 && currentStep === 0) ? { height: '300px' } : {}}> | |
<div className={styles.inner} style={(searchList.length > 5 && currentStep === 0) ? { position: 'absolute', bottom: 0, left: 0 } : {}}> | |
{ | |
searchList.map((item: any, num: number) => ( | |
<div className={classNames( | |
styles.searchItem, | |
item.highLight ? styles.highLight : "" | |
)} key={`search-item-${item.url}-${idx}`}> | |
<p className={styles.summ}>{item.id}. {item?.title}</p> | |
<p className={styles.url}>{item?.url}</p> | |
</div> | |
)) | |
} | |
</div> | |
</div> | |
</div> | |
} | |
</div> | |
</div> | |
)) | |
} | |
</> : <></> | |
} | |
</> | |
} | |
{ | |
conclusion && <div className={styles.steps}> | |
<div className={styles.title}> | |
<i></i>信息整合 | |
</div> | |
<div className={styles.conclusion}> | |
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{conclusion}</ReactMarkdown> | |
</div> | |
</div> | |
} | |
{isWaiting && question && <div className={styles.loading99}></div>} | |
</div>} | |
{ | |
!showRight && <div className={styles.showRight} onClick={toggleRight}> | |
<img src={ShowRightIcon} /> | |
</div> | |
} | |
</div> | |
}; | |
export default RenderTest; | |