feat:增加聊天记录流程图,方便查看 (#3772)

* feat: 聊天记录流程图

* fix: 修复偶尔多标签切换不滚动的问题
This commit is contained in:
africa1207 2025-03-23 13:35:16 +08:00 committed by GitHub
parent 640ca19cba
commit eba746a3bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1058 additions and 29 deletions

View File

@ -64,6 +64,10 @@
"@llm-tools/embedjs-loader-xml": "^0.1.28",
"@llm-tools/embedjs-openai": "^0.1.28",
"@modelcontextprotocol/sdk": "patch:@modelcontextprotocol/sdk@npm%3A1.6.1#~/.yarn/patches/@modelcontextprotocol-sdk-npm-1.6.1-b46313efe7.patch",
"@notionhq/client": "^2.2.15",
"@tryfabric/martian": "^1.2.4",
"@types/react-infinite-scroll-component": "^5.0.0",
"@xyflow/react": "^12.4.4",
"adm-zip": "^0.5.16",
"docx": "^9.0.2",
"electron-log": "^5.1.5",

View File

@ -200,6 +200,25 @@
"topics.prompt.tips": "Topic Prompts: Additional supplementary prompts provided for the current topic",
"topics.title": "Topics",
"topics.unpinned": "Unpinned Topics",
"topics.new": "New Topic",
"translate": "Translate",
"navigation": {
"prev": "Previous Message",
"next": "Next Message",
"first": "Already at the first message",
"last": "Already at the last message",
"history": "Chat History"
},
"history": {
"title": "Chat History",
"coming_soon": "Chat workflow diagram coming soon",
"no_messages": "No Messages Found",
"start_conversation": "Start a conversation to see the chat flow diagram",
"user_node": "User",
"assistant_node": "Assistant",
"view_full_content": "View Full Content",
"click_to_navigate": "Click to navigate to the message"
}
"translate": "Translate"
},
"code_block": {

View File

@ -200,6 +200,25 @@
"topics.prompt.tips": "トピック提示語:現在のトピックに対して追加の補足提示語を提供",
"topics.title": "トピック",
"topics.unpinned": "固定解除",
"topics.new": "新しいトピック",
"translate": "翻訳",
"navigation": {
"prev": "前のメッセージ",
"next": "次のメッセージ",
"first": "最初のメッセージです",
"last": "最後のメッセージです",
"history": "チャット履歴"
},
"history": {
"title": "チャット履歴",
"coming_soon": "チャットワークフロー図がすぐに登場します",
"no_messages": "メッセージが見つかりませんでした",
"start_conversation": "チャットを開始してチャットワークフロー図を確認してください",
"user_node": "ユーザー",
"assistant_node": "アシスタント",
"view_full_content": "完全な内容を表示",
"click_to_navigate": "メッセージに移動"
}
"translate": "翻訳"
},
"code_block": {

View File

@ -200,6 +200,25 @@
"topics.prompt.tips": "Тематические подсказки: Дополнительные подсказки, предоставленные для текущей темы",
"topics.title": "Топики",
"topics.unpinned": "Открепленные темы",
"topics.new": "Новый топик",
"translate": "Перевести",
"navigation": {
"prev": "Предыдущее сообщение",
"next": "Следующее сообщение",
"first": "Уже первое сообщение",
"last": "Уже последнее сообщение",
"history": "История чата"
},
"history": {
"title": "История чата",
"coming_soon": "График работы чата скоро появится",
"no_messages": "Сообщения не найдены",
"start_conversation": "Начните диалог, чтобы просмотреть график работы чата",
"user_node": "Пользователь",
"assistant_node": "Ассистент",
"view_full_content": "Показать полное содержимое",
"click_to_navigate": "Перейти к сообщению"
}
"translate": "Перевести"
},
"code_block": {

View File

@ -200,6 +200,25 @@
"topics.prompt.tips": "话题提示词: 针对当前话题提供额外的补充提示词",
"topics.title": "话题",
"topics.unpinned": "取消固定",
"topics.new": "开始新对话",
"translate": "翻译",
"navigation": {
"prev": "上一条消息",
"next": "下一条消息",
"first": "已经是第一条消息",
"last": "已经是最后一条消息",
"history": "聊天历史"
},
"history": {
"title": "聊天历史",
"coming_soon": "聊天工作流图表即将上线",
"no_messages": "没有找到消息",
"start_conversation": "开始对话以查看聊天流程图",
"user_node": "用户",
"assistant_node": "助手",
"view_full_content": "查看完整内容",
"click_to_navigate": "点击跳转到对应消息"
}
"translate": "翻译"
},
"code_block": {

View File

@ -200,6 +200,25 @@
"topics.prompt.tips": "話題提示詞:針對目前話題提供額外的補充提示詞",
"topics.title": "話題",
"topics.unpinned": "取消固定",
"topics.new": "開始新對話",
"translate": "翻譯",
"navigation": {
"prev": "上一條訊息",
"next": "下一條訊息",
"first": "已經是第一條訊息",
"last": "已經是最後一條訊息",
"history": "聊天歷史"
},
"history": {
"title": "聊天歷史",
"coming_soon": "聊天工作流圖表即將上線",
"no_messages": "沒有找到訊息",
"start_conversation": "開始對話以查看聊天流程圖",
"user_node": "用戶",
"assistant_node": "助手",
"view_full_content": "查看完整內容",
"click_to_navigate": "點擊跳轉到對應訊息"
}
"translate": "翻譯"
},
"code_block": {

View File

@ -0,0 +1,614 @@
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 { useSettings } from '@renderer/hooks/useSettings'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { RootState } from '@renderer/store'
import { selectTopicMessages } from '@renderer/store/messages'
import { Model } from '@renderer/types'
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: JSX.Element | 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, string>
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 topicId = conversationId
// 只在消息实际内容变化时更新而不是属性变化如foldSelected
const messages = useSelector(
(state: RootState) => selectTopicMessages(state, topicId || ''),
(prev, next) => {
// 只比较消息的关键属性忽略展示相关的属性如foldSelected
if (prev.length !== next.length) return false
// 比较每条消息的内容和关键属性忽略UI状态相关属性
return prev.every((prevMsg, index) => {
const nextMsg = next[index]
return (
prevMsg.id === nextMsg.id &&
prevMsg.content === nextMsg.content &&
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: message.content,
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: aMsg.content,
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: aMsg.content,
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 }
}, [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">
<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, (prevProps, nextProps) => {
return prevProps.conversationId === nextProps.conversationId
})

View File

@ -1,10 +1,15 @@
import { DownOutlined, UpOutlined } from '@ant-design/icons'
import { DownOutlined, HistoryOutlined, UpOutlined } from '@ant-design/icons'
import { useSettings } from '@renderer/hooks/useSettings'
import { Button, Tooltip } from 'antd'
import { RootState } from '@renderer/store'
import { selectCurrentTopicId } from '@renderer/store/messages'
import { Button, Drawer, Tooltip } from 'antd'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
import ChatFlowHistory from './ChatFlowHistory'
interface ChatNavigationProps {
containerId: string
}
@ -14,6 +19,8 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
const [isVisible, setIsVisible] = useState(false)
const [isNearButtons, setIsNearButtons] = useState(false)
const [hideTimer, setHideTimer] = useState<NodeJS.Timeout | null>(null)
const [showChatHistory, setShowChatHistory] = useState(false)
const currentTopicId = useSelector((state: RootState) => selectCurrentTopicId(state))
const lastMoveTime = useRef(0)
const { topicPosition, showTopics } = useSettings()
const showRightTopics = topicPosition === 'right' && showTopics
@ -59,6 +66,15 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
setHideTimer(timer)
}, [])
const handleChatHistoryClick = () => {
setShowChatHistory(true)
resetHideTimer()
}
const handleDrawerClose = () => {
setShowChatHistory(false)
}
const findUserMessages = () => {
const container = document.getElementById(containerId)
if (!container) return []
@ -259,31 +275,58 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
])
return (
<NavigationContainer
$isVisible={isVisible}
$right={right}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}>
<ButtonGroup>
<Tooltip title={t('chat.navigation.prev')} placement="left">
<NavigationButton
type="text"
icon={<UpOutlined />}
onClick={handlePrevMessage}
aria-label={t('chat.navigation.prev')}
/>
</Tooltip>
<Divider />
<Tooltip title={t('chat.navigation.next')} placement="left">
<NavigationButton
type="text"
icon={<DownOutlined />}
onClick={handleNextMessage}
aria-label={t('chat.navigation.next')}
/>
</Tooltip>
</ButtonGroup>
</NavigationContainer>
<>
<NavigationContainer
$isVisible={isVisible}
$right={right}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}>
<ButtonGroup>
<Tooltip title={t('chat.navigation.prev')} placement="left">
<NavigationButton
type="text"
icon={<UpOutlined />}
onClick={handlePrevMessage}
aria-label={t('chat.navigation.prev')}
/>
</Tooltip>
<Divider />
<Tooltip title={t('chat.navigation.next')} placement="left">
<NavigationButton
type="text"
icon={<DownOutlined />}
onClick={handleNextMessage}
aria-label={t('chat.navigation.next')}
/>
</Tooltip>
<Divider />
<Tooltip title={t('chat.navigation.history')} placement="left">
<NavigationButton
type="text"
icon={<HistoryOutlined />}
onClick={handleChatHistoryClick}
aria-label={t('chat.navigation.history')}
/>
</Tooltip>
</ButtonGroup>
</NavigationContainer>
<Drawer
title={t('chat.history.title')}
placement="right"
onClose={handleDrawerClose}
open={showChatHistory}
width={680}
destroyOnClose
styles={{
body: {
padding: 0,
height: 'calc(100% - 55px)'
}
}}>
<ChatFlowHistory conversationId={currentTopicId || undefined} />
</Drawer>
</>
)
}

View File

@ -1,6 +1,7 @@
import Scrollbar from '@renderer/components/Scrollbar'
import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
import { useSettings } from '@renderer/hooks/useSettings'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { MultiModelMessageStyle } from '@renderer/store/settings'
import type { Message, Topic } from '@renderer/types'
import { classNames } from '@renderer/utils'
@ -36,6 +37,38 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
setSelectedIndex(messageLength - 1)
}, [messageLength])
// 添加对流程图节点点击事件的监听
useEffect(() => {
// 只在组件挂载和消息数组变化时添加监听器
if (!isGrouped || messageLength <= 1) return
const handleFlowNavigate = (event: CustomEvent) => {
const { messageId } = event.detail
// 查找对应的消息在当前消息组中的索引
const targetIndex = messages.findIndex((msg) => msg.id === messageId)
// 如果找到消息且不是当前选中的索引,则切换标签
if (targetIndex !== -1 && targetIndex !== selectedIndex) {
setSelectedIndex(targetIndex)
// 使用setSelectedMessage函数来切换标签这是处理foldSelected的关键
const targetMessage = messages[targetIndex]
if (targetMessage) {
setSelectedMessage(targetMessage)
}
}
}
// 添加事件监听器
document.addEventListener('flow-navigate-to-message', handleFlowNavigate as EventListener)
// 清理函数
return () => {
document.removeEventListener('flow-navigate-to-message', handleFlowNavigate as EventListener)
}
}, [messages, selectedIndex, isGrouped, messageLength])
const setSelectedMessage = useCallback(
(message: Message) => {
messages.forEach(async (m) => {
@ -47,11 +80,47 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}, 100)
}, 200)
},
[editMessage, messages]
)
// 添加对LOCATE_MESSAGE事件的监听
useEffect(() => {
// 为每个消息注册一个定位事件监听器
const eventHandlers: { [key: string]: () => void } = {}
messages.forEach((message) => {
const eventName = EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id
const handler = () => {
// 检查消息是否处于可见状态
const element = document.getElementById(`message-${message.id}`)
if (element) {
const display = window.getComputedStyle(element).display
if (display === 'none') {
// 如果消息隐藏,先切换标签
setSelectedMessage(message)
} else {
// 直接滚动
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
}
eventHandlers[eventName] = handler
EventEmitter.on(eventName, handler)
})
// 清理函数
return () => {
// 移除所有事件监听器
Object.entries(eventHandlers).forEach(([eventName, handler]) => {
EventEmitter.off(eventName, handler)
})
}
}, [messages, setSelectedMessage])
const renderMessage = useCallback(
(message: Message & { index: number }, index: number) => {
const isGridGroupMessage = isGrid && message.role === 'assistant' && isGrouped

206
yarn.lock
View File

@ -2798,6 +2798,71 @@ __metadata:
languageName: node
linkType: hard
"@types/command-line-args@npm:^5.2.3":
version: 5.2.3
resolution: "@types/command-line-args@npm:5.2.3"
checksum: 10c0/3a9bc58fd26e546391f6369dd28c03d59349dc4ac39eada1a5c39cc3578e02e4aac222615170e0db79b198ffba2af84fdbdda46e08c6edc4da42bc17ea85200f
languageName: node
linkType: hard
"@types/command-line-usage@npm:^5.0.4":
version: 5.0.4
resolution: "@types/command-line-usage@npm:5.0.4"
checksum: 10c0/67840ebf4bcfee200c07d978669ad596fe2adc350fd5c19d44ec2248623575d96ec917f513d1d59453f8f57e879133861a4cc41c20045c07f6c959f1fcaac7ad
languageName: node
linkType: hard
"@types/d3-color@npm:*":
version: 3.1.3
resolution: "@types/d3-color@npm:3.1.3"
checksum: 10c0/65eb0487de606eb5ad81735a9a5b3142d30bc5ea801ed9b14b77cb14c9b909f718c059f13af341264ee189acf171508053342142bdf99338667cea26a2d8d6ae
languageName: node
linkType: hard
"@types/d3-drag@npm:^3.0.7":
version: 3.0.7
resolution: "@types/d3-drag@npm:3.0.7"
dependencies:
"@types/d3-selection": "npm:*"
checksum: 10c0/65e29fa32a87c72d26c44b5e2df3bf15af21cd128386bcc05bcacca255927c0397d0cd7e6062aed5f0abd623490544a9d061c195f5ed9f018fe0b698d99c079d
languageName: node
linkType: hard
"@types/d3-interpolate@npm:*":
version: 3.0.4
resolution: "@types/d3-interpolate@npm:3.0.4"
dependencies:
"@types/d3-color": "npm:*"
checksum: 10c0/066ebb8da570b518dd332df6b12ae3b1eaa0a7f4f0c702e3c57f812cf529cc3500ec2aac8dc094f31897790346c6b1ebd8cd7a077176727f4860c2b181a65ca4
languageName: node
linkType: hard
"@types/d3-selection@npm:*, @types/d3-selection@npm:^3.0.10":
version: 3.0.11
resolution: "@types/d3-selection@npm:3.0.11"
checksum: 10c0/0c512956c7503ff5def4bb32e0c568cc757b9a2cc400a104fc0f4cfe5e56d83ebde2a97821b6f2cb26a7148079d3b86a2f28e11d68324ed311cf35c2ed980d1d
languageName: node
linkType: hard
"@types/d3-transition@npm:^3.0.8":
version: 3.0.9
resolution: "@types/d3-transition@npm:3.0.9"
dependencies:
"@types/d3-selection": "npm:*"
checksum: 10c0/4f68b9df7ac745b3491216c54203cbbfa0f117ae4c60e2609cdef2db963582152035407fdff995b10ee383bae2f05b7743493f48e1b8e46df54faa836a8fb7b5
languageName: node
linkType: hard
"@types/d3-zoom@npm:^3.0.8":
version: 3.0.8
resolution: "@types/d3-zoom@npm:3.0.8"
dependencies:
"@types/d3-interpolate": "npm:*"
"@types/d3-selection": "npm:*"
checksum: 10c0/1dbdbcafddcae12efb5beb6948546963f29599e18bc7f2a91fb69cc617c2299a65354f2d47e282dfb86fec0968406cd4fb7f76ba2d2fb67baa8e8d146eb4a547
languageName: node
linkType: hard
"@types/debug@npm:^4.0.0, @types/debug@npm:^4.1.6":
version: 4.1.12
resolution: "@types/debug@npm:4.1.12"
@ -3306,6 +3371,35 @@ __metadata:
languageName: node
linkType: hard
"@xyflow/react@npm:^12.4.4":
version: 12.4.4
resolution: "@xyflow/react@npm:12.4.4"
dependencies:
"@xyflow/system": "npm:0.0.52"
classcat: "npm:^5.0.3"
zustand: "npm:^4.4.0"
peerDependencies:
react: ">=17"
react-dom: ">=17"
checksum: 10c0/abce710e98a414def5f5cb847831ad49e25e3de5ebeaee4e4f9c8b651c1a507b1d61b8e11b8f1f519e9f7500f37d07218fbbd23d797c137e1e3f5515dc759c98
languageName: node
linkType: hard
"@xyflow/system@npm:0.0.52":
version: 0.0.52
resolution: "@xyflow/system@npm:0.0.52"
dependencies:
"@types/d3-drag": "npm:^3.0.7"
"@types/d3-selection": "npm:^3.0.10"
"@types/d3-transition": "npm:^3.0.8"
"@types/d3-zoom": "npm:^3.0.8"
d3-drag: "npm:^3.0.0"
d3-selection: "npm:^3.0.0"
d3-zoom: "npm:^3.0.0"
checksum: 10c0/34822192065f4d1358c781882b5f7e1eba6d6953aa741e480de704080a7d0609823b124d90fec17a747226150e980c12d9cd145642aa795ff94054c6cc67dbaa
languageName: node
linkType: hard
"CherryStudio@workspace:.":
version: 0.0.0-use.local
resolution: "CherryStudio@workspace:."
@ -3355,6 +3449,7 @@ __metadata:
"@types/react-infinite-scroll-component": "npm:^5.0.0"
"@types/tinycolor2": "npm:^1"
"@vitejs/plugin-react": "npm:^4.2.1"
"@xyflow/react": "npm:^12.4.4"
adm-zip: "npm:^0.5.16"
antd: "npm:^5.22.5"
applescript: "npm:^1.0.0"
@ -4592,6 +4687,13 @@ __metadata:
languageName: node
linkType: hard
"classcat@npm:^5.0.3":
version: 5.0.5
resolution: "classcat@npm:5.0.5"
checksum: 10c0/ff8d273055ef9b518529cfe80fd0486f7057a9917373807ff802d75ceb46e8f8e148f41fa094ee7625c8f34642cfaa98395ff182d9519898da7cbf383d4a210d
languageName: node
linkType: hard
"classnames@npm:2.x, classnames@npm:^2.2.1, classnames@npm:^2.2.3, classnames@npm:^2.2.5, classnames@npm:^2.2.6, classnames@npm:^2.3.1, classnames@npm:^2.3.2, classnames@npm:^2.5.1":
version: 2.5.1
resolution: "classnames@npm:2.5.1"
@ -5010,6 +5112,88 @@ __metadata:
languageName: node
linkType: hard
"d3-color@npm:1 - 3":
version: 3.1.0
resolution: "d3-color@npm:3.1.0"
checksum: 10c0/a4e20e1115fa696fce041fbe13fbc80dc4c19150fa72027a7c128ade980bc0eeeba4bcf28c9e21f0bce0e0dbfe7ca5869ef67746541dcfda053e4802ad19783c
languageName: node
linkType: hard
"d3-dispatch@npm:1 - 3":
version: 3.0.1
resolution: "d3-dispatch@npm:3.0.1"
checksum: 10c0/6eca77008ce2dc33380e45d4410c67d150941df7ab45b91d116dbe6d0a3092c0f6ac184dd4602c796dc9e790222bad3ff7142025f5fd22694efe088d1d941753
languageName: node
linkType: hard
"d3-drag@npm:2 - 3, d3-drag@npm:^3.0.0":
version: 3.0.0
resolution: "d3-drag@npm:3.0.0"
dependencies:
d3-dispatch: "npm:1 - 3"
d3-selection: "npm:3"
checksum: 10c0/d2556e8dc720741a443b595a30af403dd60642dfd938d44d6e9bfc4c71a962142f9a028c56b61f8b4790b65a34acad177d1263d66f103c3c527767b0926ef5aa
languageName: node
linkType: hard
"d3-ease@npm:1 - 3":
version: 3.0.1
resolution: "d3-ease@npm:3.0.1"
checksum: 10c0/fec8ef826c0cc35cda3092c6841e07672868b1839fcaf556e19266a3a37e6bc7977d8298c0fcb9885e7799bfdcef7db1baaba9cd4dcf4bc5e952cf78574a88b0
languageName: node
linkType: hard
"d3-interpolate@npm:1 - 3":
version: 3.0.1
resolution: "d3-interpolate@npm:3.0.1"
dependencies:
d3-color: "npm:1 - 3"
checksum: 10c0/19f4b4daa8d733906671afff7767c19488f51a43d251f8b7f484d5d3cfc36c663f0a66c38fe91eee30f40327443d799be17169f55a293a3ba949e84e57a33e6a
languageName: node
linkType: hard
"d3-selection@npm:2 - 3, d3-selection@npm:3, d3-selection@npm:^3.0.0":
version: 3.0.0
resolution: "d3-selection@npm:3.0.0"
checksum: 10c0/e59096bbe8f0cb0daa1001d9bdd6dbc93a688019abc97d1d8b37f85cd3c286a6875b22adea0931b0c88410d025563e1643019161a883c516acf50c190a11b56b
languageName: node
linkType: hard
"d3-timer@npm:1 - 3":
version: 3.0.1
resolution: "d3-timer@npm:3.0.1"
checksum: 10c0/d4c63cb4bb5461d7038aac561b097cd1c5673969b27cbdd0e87fa48d9300a538b9e6f39b4a7f0e3592ef4f963d858c8a9f0e92754db73116770856f2fc04561a
languageName: node
linkType: hard
"d3-transition@npm:2 - 3":
version: 3.0.1
resolution: "d3-transition@npm:3.0.1"
dependencies:
d3-color: "npm:1 - 3"
d3-dispatch: "npm:1 - 3"
d3-ease: "npm:1 - 3"
d3-interpolate: "npm:1 - 3"
d3-timer: "npm:1 - 3"
peerDependencies:
d3-selection: 2 - 3
checksum: 10c0/4e74535dda7024aa43e141635b7522bb70cf9d3dfefed975eb643b36b864762eca67f88fafc2ca798174f83ca7c8a65e892624f824b3f65b8145c6a1a88dbbad
languageName: node
linkType: hard
"d3-zoom@npm:^3.0.0":
version: 3.0.0
resolution: "d3-zoom@npm:3.0.0"
dependencies:
d3-dispatch: "npm:1 - 3"
d3-drag: "npm:2 - 3"
d3-interpolate: "npm:1 - 3"
d3-selection: "npm:2 - 3"
d3-transition: "npm:2 - 3"
checksum: 10c0/ee2036479049e70d8c783d594c444fe00e398246048e3f11a59755cd0e21de62ece3126181b0d7a31bf37bcf32fd726f83ae7dea4495ff86ec7736ce5ad36fd3
languageName: node
linkType: hard
"dashdash@npm:^1.12.0":
version: 1.14.1
resolution: "dashdash@npm:1.14.1"
@ -15166,7 +15350,7 @@ __metadata:
languageName: node
linkType: hard
"use-sync-external-store@npm:^1.0.0, use-sync-external-store@npm:^1.4.0":
"use-sync-external-store@npm:^1.0.0, use-sync-external-store@npm:^1.2.2, use-sync-external-store@npm:^1.4.0":
version: 1.4.0
resolution: "use-sync-external-store@npm:1.4.0"
peerDependencies:
@ -15861,6 +16045,26 @@ __metadata:
languageName: node
linkType: hard
"zustand@npm:^4.4.0":
version: 4.5.6
resolution: "zustand@npm:4.5.6"
dependencies:
use-sync-external-store: "npm:^1.2.2"
peerDependencies:
"@types/react": ">=16.8"
immer: ">=9.0.6"
react: ">=16.8"
peerDependenciesMeta:
"@types/react":
optional: true
immer:
optional: true
react:
optional: true
checksum: 10c0/5b39aff2ef57e5a8ada647261ec1115697d397be311c51461d9ea81b5b63c6d2c498b960477ad2db72dc21db6aa229a92bdf644f6a8ecf7b1d71df5b4a5e95d3
languageName: node
linkType: hard
"zwitch@npm:^1.0.0":
version: 1.0.5
resolution: "zwitch@npm:1.0.5"