mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-02-11 21:33:23 +08:00
* fix: citation loading Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> * fix: remove unused import & cleanup some code --------- Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
620 lines
19 KiB
TypeScript
620 lines
19 KiB
TypeScript
import '@xyflow/react/dist/style.css'
|
||
|
||
import { RobotOutlined, UserOutlined } from '@ant-design/icons'
|
||
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
|
||
import { getModelLogo } from '@renderer/config/models'
|
||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||
import { useSettings } from '@renderer/hooks/useSettings'
|
||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||
import { RootState } from '@renderer/store'
|
||
import { selectMessagesForTopic } from '@renderer/store/newMessage'
|
||
import { Model } from '@renderer/types'
|
||
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||
import { Controls, Handle, MiniMap, ReactFlow, ReactFlowProvider } from '@xyflow/react'
|
||
import { Edge, Node, NodeTypes, Position, useEdgesState, useNodesState } from '@xyflow/react'
|
||
import { Avatar, Spin, Tooltip } from 'antd'
|
||
import { isEqual } from 'lodash'
|
||
import { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { useSelector } from 'react-redux'
|
||
import styled from 'styled-components'
|
||
|
||
// 定义Tooltip相关样式组件
|
||
const TooltipContent = styled.div`
|
||
max-width: 300px;
|
||
`
|
||
|
||
const TooltipTitle = styled.div`
|
||
font-weight: bold;
|
||
margin-bottom: 8px;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||
padding-bottom: 4px;
|
||
`
|
||
|
||
const TooltipBody = styled.div`
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
margin-bottom: 8px;
|
||
white-space: pre-wrap;
|
||
`
|
||
|
||
const TooltipFooter = styled.div`
|
||
font-size: 12px;
|
||
color: rgba(255, 255, 255, 0.7);
|
||
font-style: italic;
|
||
`
|
||
|
||
// 自定义节点组件
|
||
const CustomNode: FC<{ data: any }> = ({ data }) => {
|
||
const { t } = useTranslation()
|
||
const nodeType = data.type
|
||
let borderColor = 'var(--color-border)'
|
||
let title = ''
|
||
let backgroundColor = 'var(--bg-color)'
|
||
let gradientColor = 'rgba(0, 0, 0, 0.03)'
|
||
let avatar: React.ReactNode | null = null
|
||
|
||
// 根据消息类型设置不同的样式和图标
|
||
if (nodeType === 'user') {
|
||
borderColor = 'var(--color-icon)'
|
||
backgroundColor = 'rgba(var(--color-info-rgb), 0.03)'
|
||
gradientColor = 'rgba(var(--color-info-rgb), 0.08)'
|
||
title = data.userName || t('chat.history.user_node')
|
||
|
||
// 用户头像
|
||
if (data.userAvatar) {
|
||
avatar = <Avatar src={data.userAvatar} alt={title} />
|
||
} else {
|
||
avatar = <Avatar icon={<UserOutlined />} style={{ backgroundColor: 'var(--color-info)' }} />
|
||
}
|
||
} else if (nodeType === 'assistant') {
|
||
borderColor = 'var(--color-primary)'
|
||
backgroundColor = 'rgba(var(--color-primary-rgb), 0.03)'
|
||
gradientColor = 'rgba(var(--color-primary-rgb), 0.08)'
|
||
title = `${data.model || t('chat.history.assistant_node')}`
|
||
|
||
// 模型头像
|
||
if (data.modelInfo) {
|
||
avatar = <ModelAvatar model={data.modelInfo} size={32} />
|
||
} else if (data.modelId) {
|
||
const modelLogo = getModelLogo(data.modelId)
|
||
avatar = (
|
||
<Avatar
|
||
src={modelLogo}
|
||
icon={!modelLogo ? <RobotOutlined /> : undefined}
|
||
style={{ backgroundColor: 'var(--color-primary)' }}
|
||
/>
|
||
)
|
||
} else {
|
||
avatar = <Avatar icon={<RobotOutlined />} style={{ backgroundColor: 'var(--color-primary)' }} />
|
||
}
|
||
}
|
||
|
||
// 处理节点点击事件,滚动到对应消息
|
||
const handleNodeClick = () => {
|
||
if (data.messageId) {
|
||
// 创建一个自定义事件来定位消息并切换标签
|
||
const customEvent = new CustomEvent('flow-navigate-to-message', {
|
||
detail: {
|
||
messageId: data.messageId,
|
||
modelId: data.modelId,
|
||
modelName: data.model,
|
||
nodeType: nodeType
|
||
},
|
||
bubbles: true
|
||
})
|
||
|
||
// 让监听器处理标签切换
|
||
document.dispatchEvent(customEvent)
|
||
|
||
setTimeout(() => {
|
||
EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + data.messageId)
|
||
}, 250)
|
||
}
|
||
}
|
||
|
||
// 隐藏连接点的通用样式
|
||
const handleStyle = {
|
||
opacity: 0,
|
||
width: '12px',
|
||
height: '12px',
|
||
background: 'transparent',
|
||
border: 'none'
|
||
}
|
||
|
||
return (
|
||
<Tooltip
|
||
title={
|
||
<TooltipContent>
|
||
<TooltipTitle>{title}</TooltipTitle>
|
||
<TooltipBody>{data.content}</TooltipBody>
|
||
<TooltipFooter>{t('chat.history.click_to_navigate')}</TooltipFooter>
|
||
</TooltipContent>
|
||
}
|
||
placement="top"
|
||
color="rgba(0, 0, 0, 0.85)"
|
||
mouseEnterDelay={0.3}
|
||
mouseLeaveDelay={0.1}
|
||
destroyTooltipOnHide>
|
||
<CustomNodeContainer
|
||
style={{
|
||
borderColor,
|
||
background: `linear-gradient(135deg, ${backgroundColor} 0%, ${gradientColor} 100%)`,
|
||
boxShadow: `0 4px 10px rgba(0, 0, 0, 0.1), 0 0 0 2px ${borderColor}40`
|
||
}}
|
||
onClick={handleNodeClick}>
|
||
<Handle type="target" position={Position.Top} style={handleStyle} isConnectable={false} />
|
||
<Handle type="target" position={Position.Left} style={handleStyle} isConnectable={false} />
|
||
|
||
<NodeHeader>
|
||
<NodeAvatar>{avatar}</NodeAvatar>
|
||
<NodeTitle>{title}</NodeTitle>
|
||
</NodeHeader>
|
||
<NodeContent title={data.content}>{data.content}</NodeContent>
|
||
|
||
<Handle type="source" position={Position.Bottom} style={handleStyle} isConnectable={false} />
|
||
<Handle type="source" position={Position.Right} style={handleStyle} isConnectable={false} />
|
||
</CustomNodeContainer>
|
||
</Tooltip>
|
||
)
|
||
}
|
||
|
||
// 创建自定义节点类型
|
||
const nodeTypes: NodeTypes = { custom: CustomNode }
|
||
|
||
interface ChatFlowHistoryProps {
|
||
conversationId?: string
|
||
}
|
||
|
||
// 定义节点和边的类型
|
||
type FlowNode = Node<any>
|
||
type FlowEdge = Edge<any>
|
||
|
||
// 统一的边样式
|
||
const commonEdgeStyle = {
|
||
stroke: 'var(--color-border)',
|
||
strokeDasharray: '4,4',
|
||
strokeWidth: 2
|
||
}
|
||
|
||
// 统一的边配置
|
||
const defaultEdgeOptions = {
|
||
animated: true,
|
||
style: commonEdgeStyle,
|
||
type: 'step',
|
||
markerEnd: undefined,
|
||
zIndex: 5
|
||
}
|
||
|
||
const ChatFlowHistory: FC<ChatFlowHistoryProps> = ({ conversationId }) => {
|
||
const { t } = useTranslation()
|
||
const [nodes, setNodes, onNodesChange] = useNodesState<any>([])
|
||
const [edges, setEdges, onEdgesChange] = useEdgesState<any>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const { userName } = useSettings()
|
||
const { theme } = useTheme()
|
||
|
||
const topicId = conversationId
|
||
|
||
// 只在消息实际内容变化时更新,而不是属性变化(如foldSelected)
|
||
const messages = useSelector(
|
||
(state: RootState) => selectMessagesForTopic(state, topicId || ''),
|
||
(prev, next) => {
|
||
// 只比较消息的关键属性,忽略展示相关的属性(如foldSelected)
|
||
if (prev.length !== next.length) return false
|
||
|
||
// 比较每条消息的内容和关键属性,忽略UI状态相关属性
|
||
return prev.every((prevMsg, index) => {
|
||
const nextMsg = next[index]
|
||
const prevMsgContent = getMainTextContent(prevMsg)
|
||
const nextMsgContent = getMainTextContent(nextMsg)
|
||
return (
|
||
prevMsg.id === nextMsg.id &&
|
||
prevMsgContent === nextMsgContent &&
|
||
prevMsg.role === nextMsg.role &&
|
||
prevMsg.createdAt === nextMsg.createdAt &&
|
||
prevMsg.askId === nextMsg.askId &&
|
||
isEqual(prevMsg.model, nextMsg.model)
|
||
)
|
||
})
|
||
}
|
||
)
|
||
|
||
// 获取用户头像
|
||
const userAvatar = useSelector((state: RootState) => state.runtime.avatar)
|
||
|
||
// 消息过滤
|
||
const { userMessages, assistantMessages } = useMemo(() => {
|
||
const userMsgs = messages.filter((msg) => msg.role === 'user')
|
||
const assistantMsgs = messages.filter((msg) => msg.role === 'assistant')
|
||
return { userMessages: userMsgs, assistantMessages: assistantMsgs }
|
||
}, [messages])
|
||
|
||
const buildConversationFlowData = useCallback(() => {
|
||
if (!topicId || !messages.length) return { nodes: [], edges: [] }
|
||
|
||
// 创建节点和边
|
||
const flowNodes: FlowNode[] = []
|
||
const flowEdges: FlowEdge[] = []
|
||
|
||
// 布局参数
|
||
const verticalGap = 200
|
||
const horizontalGap = 350
|
||
const baseX = 150
|
||
|
||
// 如果没有任何消息可以显示,返回空结果
|
||
if (userMessages.length === 0 && assistantMessages.length === 0) {
|
||
return { nodes: [], edges: [] }
|
||
}
|
||
|
||
// 为所有用户消息创建节点
|
||
userMessages.forEach((message, index) => {
|
||
const nodeId = `user-${message.id}`
|
||
const yPosition = index * verticalGap * 2
|
||
|
||
// 获取用户名
|
||
const userNameValue = userName || t('chat.history.user_node')
|
||
|
||
// 获取用户头像
|
||
const msgUserAvatar = userAvatar || null
|
||
|
||
flowNodes.push({
|
||
id: nodeId,
|
||
type: 'custom',
|
||
data: {
|
||
userName: userNameValue,
|
||
content: getMainTextContent(message),
|
||
type: 'user',
|
||
messageId: message.id,
|
||
userAvatar: msgUserAvatar
|
||
},
|
||
position: { x: baseX, y: yPosition },
|
||
sourcePosition: Position.Bottom,
|
||
targetPosition: Position.Top
|
||
})
|
||
|
||
// 找到用户消息之后的助手回复
|
||
const userMsgTime = new Date(message.createdAt).getTime()
|
||
const relatedAssistantMsgs = assistantMessages.filter((aMsg) => {
|
||
const aMsgTime = new Date(aMsg.createdAt).getTime()
|
||
return (
|
||
aMsgTime > userMsgTime &&
|
||
(index === userMessages.length - 1 || aMsgTime < new Date(userMessages[index + 1].createdAt).getTime())
|
||
)
|
||
})
|
||
|
||
// 为相关的助手消息创建节点
|
||
relatedAssistantMsgs.forEach((aMsg, aIndex) => {
|
||
const assistantNodeId = `assistant-${aMsg.id}`
|
||
const isMultipleResponses = relatedAssistantMsgs.length > 1
|
||
const assistantX = baseX + (isMultipleResponses ? horizontalGap * aIndex : 0)
|
||
const assistantY = yPosition + verticalGap
|
||
|
||
// 根据位置确定连接点位置
|
||
let sourcePos = Position.Bottom // 默认向下输出
|
||
let targetPos = Position.Top // 默认从上方输入
|
||
|
||
// 横向排列多个助手消息时调整连接点
|
||
// 注意:现在所有助手节点都直接连接到用户节点,而不是相互连接
|
||
if (isMultipleResponses) {
|
||
// 所有助手节点都使用顶部作为输入点(从用户节点)
|
||
targetPos = Position.Top
|
||
|
||
// 所有助手节点都使用底部作为输出点(到下一个用户节点)
|
||
sourcePos = Position.Bottom
|
||
}
|
||
|
||
const aMsgAny = aMsg as any
|
||
|
||
// 获取模型名称
|
||
const modelName = (aMsgAny.model && aMsgAny.model.name) || t('chat.history.assistant_node')
|
||
|
||
// 获取模型ID
|
||
const modelId = (aMsgAny.model && aMsgAny.model.id) || ''
|
||
|
||
// 完整的模型信息
|
||
const modelInfo = aMsgAny.model as Model | undefined
|
||
|
||
flowNodes.push({
|
||
id: assistantNodeId,
|
||
type: 'custom',
|
||
data: {
|
||
model: modelName,
|
||
content: getMainTextContent(aMsg),
|
||
type: 'assistant',
|
||
messageId: aMsg.id,
|
||
modelId: modelId,
|
||
modelInfo
|
||
},
|
||
position: { x: assistantX, y: assistantY },
|
||
sourcePosition: sourcePos,
|
||
targetPosition: targetPos
|
||
})
|
||
|
||
// 连接消息 - 将每个助手节点直接连接到用户节点
|
||
if (aIndex === 0) {
|
||
// 连接用户消息到第一个助手回复
|
||
flowEdges.push({
|
||
id: `edge-${nodeId}-to-${assistantNodeId}`,
|
||
source: nodeId,
|
||
target: assistantNodeId
|
||
})
|
||
} else {
|
||
// 直接连接用户消息到所有其他助手回复
|
||
flowEdges.push({
|
||
id: `edge-${nodeId}-to-${assistantNodeId}`,
|
||
source: nodeId,
|
||
target: assistantNodeId
|
||
})
|
||
}
|
||
})
|
||
|
||
// 连接相邻的用户消息
|
||
if (index > 0) {
|
||
const prevUserNodeId = `user-${userMessages[index - 1].id}`
|
||
const prevUserTime = new Date(userMessages[index - 1].createdAt).getTime()
|
||
|
||
// 查找前一个用户消息的所有助手回复
|
||
const prevAssistantMsgs = assistantMessages.filter((aMsg) => {
|
||
const aMsgTime = new Date(aMsg.createdAt).getTime()
|
||
return aMsgTime > prevUserTime && aMsgTime < userMsgTime
|
||
})
|
||
|
||
if (prevAssistantMsgs.length > 0) {
|
||
// 所有前一个用户的助手消息都连接到当前用户消息
|
||
prevAssistantMsgs.forEach((aMsg) => {
|
||
const assistantId = `assistant-${aMsg.id}`
|
||
flowEdges.push({
|
||
id: `edge-${assistantId}-to-${nodeId}`,
|
||
source: assistantId,
|
||
target: nodeId
|
||
})
|
||
})
|
||
} else {
|
||
// 如果没有助手消息,直接连接两个用户消息
|
||
flowEdges.push({
|
||
id: `edge-${prevUserNodeId}-to-${nodeId}`,
|
||
source: prevUserNodeId,
|
||
target: nodeId
|
||
})
|
||
}
|
||
}
|
||
})
|
||
|
||
// 处理孤立的助手消息(没有对应的用户消息)
|
||
const orphanAssistantMsgs = assistantMessages.filter(
|
||
(aMsg) => !flowNodes.some((node) => node.id === `assistant-${aMsg.id}`)
|
||
)
|
||
|
||
if (orphanAssistantMsgs.length > 0) {
|
||
// 在图表顶部添加这些孤立消息
|
||
const startY = flowNodes.length > 0 ? Math.min(...flowNodes.map((node) => node.position.y)) - verticalGap * 2 : 0
|
||
|
||
orphanAssistantMsgs.forEach((aMsg, index) => {
|
||
const assistantNodeId = `orphan-assistant-${aMsg.id}`
|
||
|
||
// 获取模型数据
|
||
const aMsgAny = aMsg as any
|
||
|
||
// 获取模型名称
|
||
const modelName = (aMsgAny.model && aMsgAny.model.name) || t('chat.history.assistant_node')
|
||
|
||
// 获取模型ID
|
||
const modelId = (aMsgAny.model && aMsgAny.model.id) || ''
|
||
|
||
// 完整的模型信息
|
||
const modelInfo = aMsgAny.model as Model | undefined
|
||
|
||
flowNodes.push({
|
||
id: assistantNodeId,
|
||
type: 'custom',
|
||
data: {
|
||
model: modelName,
|
||
content: getMainTextContent(aMsg),
|
||
type: 'assistant',
|
||
messageId: aMsg.id,
|
||
modelId: modelId,
|
||
modelInfo
|
||
},
|
||
position: { x: baseX, y: startY - index * verticalGap },
|
||
sourcePosition: Position.Bottom,
|
||
targetPosition: Position.Top
|
||
})
|
||
|
||
// 连接相邻的孤立消息
|
||
if (index > 0) {
|
||
const prevNodeId = `orphan-assistant-${orphanAssistantMsgs[index - 1].id}`
|
||
flowEdges.push({
|
||
id: `edge-${prevNodeId}-to-${assistantNodeId}`,
|
||
source: prevNodeId,
|
||
target: assistantNodeId
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
return { nodes: flowNodes, edges: flowEdges }
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [topicId, messages, userMessages, assistantMessages, t])
|
||
|
||
useEffect(() => {
|
||
setLoading(true)
|
||
setTimeout(() => {
|
||
const { nodes: flowNodes, edges: flowEdges } = buildConversationFlowData()
|
||
setNodes([...flowNodes])
|
||
setEdges([...flowEdges])
|
||
setLoading(false)
|
||
}, 500)
|
||
}, [buildConversationFlowData, setNodes, setEdges])
|
||
|
||
return (
|
||
<FlowContainer>
|
||
{loading ? (
|
||
<LoadingContainer>
|
||
<Spin size="large" />
|
||
</LoadingContainer>
|
||
) : nodes.length > 0 ? (
|
||
<ReactFlowProvider>
|
||
<div style={{ width: '100%', height: '100%' }}>
|
||
<ReactFlow
|
||
nodes={nodes}
|
||
edges={edges}
|
||
onNodesChange={onNodesChange}
|
||
onEdgesChange={onEdgesChange}
|
||
nodeTypes={nodeTypes}
|
||
nodesDraggable={false}
|
||
nodesConnectable={false}
|
||
edgesFocusable={true}
|
||
zoomOnDoubleClick={true}
|
||
preventScrolling={true}
|
||
elementsSelectable={true}
|
||
selectNodesOnDrag={false}
|
||
nodesFocusable={true}
|
||
zoomOnScroll={true}
|
||
panOnScroll={false}
|
||
minZoom={0.4}
|
||
maxZoom={1}
|
||
defaultEdgeOptions={defaultEdgeOptions}
|
||
fitView={true}
|
||
fitViewOptions={{
|
||
padding: 0.3,
|
||
includeHiddenNodes: false,
|
||
minZoom: 0.4,
|
||
maxZoom: 1
|
||
}}
|
||
proOptions={{ hideAttribution: true }}
|
||
className="react-flow-container"
|
||
colorMode={theme === 'auto' ? 'system' : theme}>
|
||
<Controls showInteractive={false} />
|
||
<MiniMap
|
||
nodeStrokeWidth={3}
|
||
zoomable
|
||
pannable
|
||
nodeColor={(node) => (node.data.type === 'user' ? 'var(--color-info)' : 'var(--color-primary)')}
|
||
/>
|
||
</ReactFlow>
|
||
</div>
|
||
</ReactFlowProvider>
|
||
) : (
|
||
<EmptyContainer>
|
||
<EmptyText>{t('chat.history.no_messages')}</EmptyText>
|
||
</EmptyContainer>
|
||
)}
|
||
</FlowContainer>
|
||
)
|
||
}
|
||
|
||
// 样式组件定义
|
||
const FlowContainer = styled.div`
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 500px;
|
||
`
|
||
|
||
const LoadingContainer = styled.div`
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 500px;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
`
|
||
|
||
const EmptyContainer = styled.div`
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 500px;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
color: var(--color-text-secondary);
|
||
`
|
||
|
||
const EmptyText = styled.div`
|
||
font-size: 16px;
|
||
margin-bottom: 8px;
|
||
font-weight: bold;
|
||
`
|
||
|
||
const CustomNodeContainer = styled.div`
|
||
padding: 12px;
|
||
border-radius: 10px;
|
||
border: 2px solid;
|
||
width: 280px;
|
||
height: 120px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease-in-out;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||
|
||
&:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow:
|
||
0 6px 10px rgba(0, 0, 0, 0.1),
|
||
0 0 0 2px ${(props) => props.style?.borderColor || 'var(--color-border)'}80 !important;
|
||
filter: brightness(1.02);
|
||
}
|
||
|
||
/* 添加点击动画效果 */
|
||
&:active {
|
||
transform: scale(0.98);
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||
transition: all 0.1s ease;
|
||
}
|
||
`
|
||
|
||
const NodeHeader = styled.div`
|
||
font-weight: bold;
|
||
margin-bottom: 8px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid rgba(0, 0, 0, 0.2);
|
||
color: var(--color-text);
|
||
display: flex;
|
||
align-items: center;
|
||
min-height: 32px;
|
||
`
|
||
|
||
const NodeAvatar = styled.span`
|
||
margin-right: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
|
||
.ant-avatar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||
}
|
||
`
|
||
|
||
const NodeTitle = styled.span`
|
||
flex: 1;
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
`
|
||
|
||
const NodeContent = styled.div`
|
||
margin: 2px 0;
|
||
color: var(--color-text);
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 3;
|
||
-webkit-box-orient: vertical;
|
||
line-height: 1.5;
|
||
word-break: break-word;
|
||
font-size: 14px;
|
||
padding: 3px;
|
||
`
|
||
|
||
// 确保组件使用React.memo包装以减少不必要的重渲染
|
||
export default memo(ChatFlowHistory)
|