mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-26 03:31:24 +08:00
1322
This commit is contained in:
parent
53643e81f0
commit
607cded6c9
@ -127,6 +127,7 @@
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^5.1.91",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"tar": "^7.4.3",
|
||||
"turndown": "^7.2.0",
|
||||
@ -170,6 +171,7 @@
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/react-infinite-scroll-component": "^5.0.0",
|
||||
"@types/react-syntax-highlighter": "^15",
|
||||
"@types/tinycolor2": "^1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"analytics": "^0.8.16",
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
@use './animation.scss';
|
||||
@import '../fonts/icon-fonts/iconfont.css';
|
||||
@import '../fonts/ubuntu/ubuntu.css';
|
||||
@import './inline-tool-block.css';
|
||||
|
||||
:root {
|
||||
--color-white: #ffffff;
|
||||
@ -29,6 +30,11 @@
|
||||
--color-background-opacity: rgba(34, 34, 34, 0.7);
|
||||
--inner-glow-opacity: 0.3; // For the glassmorphism effect in the dropdown menu
|
||||
|
||||
/* 添加工具块背景色变量 */
|
||||
--color-bg-1: var(--color-black-soft);
|
||||
--color-bg-2: var(--color-black-mute);
|
||||
--color-bg-3: var(--color-gray-3);
|
||||
|
||||
--color-primary: #00b96b;
|
||||
--color-primary-soft: #00b96b99;
|
||||
--color-primary-mute: #00b96b33;
|
||||
@ -101,6 +107,11 @@ body[theme-mode='light'] {
|
||||
--color-background-opacity: rgba(235, 235, 235, 0.7);
|
||||
--inner-glow-opacity: 0.1;
|
||||
|
||||
/* 添加工具块背景色变量 - 亮色主题 */
|
||||
--color-bg-1: var(--color-white);
|
||||
--color-bg-2: var(--color-white-soft);
|
||||
--color-bg-3: var(--color-white-mute);
|
||||
|
||||
--color-primary: #00b96b;
|
||||
--color-primary-soft: #00b96b99;
|
||||
--color-primary-mute: #00b96b33;
|
||||
|
||||
78
src/renderer/src/assets/styles/inline-tool-block.css
Normal file
78
src/renderer/src/assets/styles/inline-tool-block.css
Normal file
@ -0,0 +1,78 @@
|
||||
/* 内联工具调用块样式 */
|
||||
.inline-tool-block {
|
||||
margin: 12px 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-bg-1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.inline-tool-block:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 工具调用和工具结果之间的分隔 */
|
||||
.message-content-container .inline-tool-block + .inline-tool-block {
|
||||
margin-top: -5px;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
/* 工具调用后面紧跟的工具结果 */
|
||||
.message-content-container .tool-use-marker + .tool-result-marker {
|
||||
margin-top: -5px;
|
||||
}
|
||||
|
||||
/* 工具调用标题栏 */
|
||||
.inline-tool-block .tool-header {
|
||||
background-color: var(--color-bg-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 工具名称 */
|
||||
.inline-tool-block .tool-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* 工具状态指示器 */
|
||||
.inline-tool-block .status-indicator {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* 工具内容区域 */
|
||||
.inline-tool-block .tool-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* 代码块 */
|
||||
.inline-tool-block pre {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background-color: var(--color-bg-2);
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
color: var(--color-text);
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
/* 加载中状态 */
|
||||
.inline-tool-block .loading-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { StyleProvider } from '@ant-design/cssinjs'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import React, { memo, useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { StyleSheetManager } from 'styled-components'
|
||||
|
||||
@ -60,4 +60,5 @@ const ShadowDOMRenderer: React.FC<Props> = ({ children }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default ShadowDOMRenderer
|
||||
// 使用 memo 包装组件,避免不必要的重渲染
|
||||
export default memo(ShadowDOMRenderer)
|
||||
|
||||
@ -2,7 +2,7 @@ import { SoundOutlined } from '@ant-design/icons'
|
||||
import TTSService from '@renderer/services/TTSService'
|
||||
import { Message } from '@renderer/types'
|
||||
import { Tooltip } from 'antd'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -11,69 +11,39 @@ interface TTSButtonProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface SegmentedPlaybackState {
|
||||
isSegmentedPlayback: boolean
|
||||
segments: {
|
||||
text: string
|
||||
isLoaded: boolean
|
||||
isLoading: boolean
|
||||
}[]
|
||||
currentSegmentIndex: number
|
||||
isPlaying: boolean
|
||||
}
|
||||
// 移除未使用的接口
|
||||
|
||||
const TTSButton: React.FC<TTSButtonProps> = ({ message, className }) => {
|
||||
const { t } = useTranslation()
|
||||
// 只保留必要的状态
|
||||
const [isSpeaking, setIsSpeaking] = useState(false)
|
||||
// 分段播放状态
|
||||
const [, setSegmentedPlaybackState] = useState<SegmentedPlaybackState>({
|
||||
isSegmentedPlayback: false,
|
||||
segments: [],
|
||||
currentSegmentIndex: 0,
|
||||
isPlaying: false
|
||||
})
|
||||
|
||||
// 添加TTS状态变化事件监听器
|
||||
useEffect(() => {
|
||||
const handleTTSStateChange = (event: CustomEvent) => {
|
||||
// 使用 useCallback 记忆化事件处理函数,避免不必要的重新创建
|
||||
const handleTTSStateChange = useCallback(
|
||||
(event: CustomEvent) => {
|
||||
const { isPlaying } = event.detail
|
||||
console.log('TTS按钮检测到TTS状态变化:', isPlaying)
|
||||
setIsSpeaking(isPlaying)
|
||||
}
|
||||
},
|
||||
[setIsSpeaking]
|
||||
)
|
||||
|
||||
// 添加TTS状态变化事件监听器
|
||||
useEffect(() => {
|
||||
// 添加事件监听器
|
||||
window.addEventListener('tts-state-change', handleTTSStateChange as EventListener)
|
||||
|
||||
// 初始化时检查TTS状态
|
||||
const isCurrentlyPlaying = TTSService.isCurrentlyPlaying()
|
||||
setIsSpeaking(isCurrentlyPlaying)
|
||||
|
||||
// 组件卸载时移除事件监听器
|
||||
return () => {
|
||||
window.removeEventListener('tts-state-change', handleTTSStateChange as EventListener)
|
||||
}
|
||||
}, [])
|
||||
}, [handleTTSStateChange])
|
||||
|
||||
// 监听分段播放状态变化
|
||||
useEffect(() => {
|
||||
const handleSegmentedPlaybackUpdate = (event: CustomEvent) => {
|
||||
console.log('检测到分段播放状态更新:', event.detail)
|
||||
setSegmentedPlaybackState(event.detail)
|
||||
}
|
||||
|
||||
// 添加事件监听器
|
||||
window.addEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
|
||||
|
||||
// 组件卸载时移除事件监听器
|
||||
return () => {
|
||||
window.removeEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 初始化时检查TTS状态
|
||||
useEffect(() => {
|
||||
// 检查当前是否正在播放
|
||||
const isCurrentlyPlaying = TTSService.isCurrentlyPlaying()
|
||||
if (isCurrentlyPlaying !== isSpeaking) {
|
||||
setIsSpeaking(isCurrentlyPlaying)
|
||||
}
|
||||
}, [isSpeaking])
|
||||
// 移除未使用的分段播放状态事件监听器和冗余的初始化检查
|
||||
|
||||
const handleTTS = useCallback(async () => {
|
||||
if (isSpeaking) {
|
||||
@ -139,4 +109,5 @@ const TTSActionButton = styled.div`
|
||||
}
|
||||
`
|
||||
|
||||
export default TTSButton
|
||||
// 使用 memo 包装组件,避免不必要的重渲染
|
||||
export default memo(TTSButton)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { TextSegmenter } from '@renderer/services/tts/TextSegmenter'
|
||||
import TTSService from '@renderer/services/TTSService'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface TTSHighlightedTextProps {
|
||||
@ -53,26 +53,28 @@ const TTSHighlightedText: React.FC<TTSHighlightedTextProps> = ({ text }) => {
|
||||
}, [])
|
||||
|
||||
// 处理段落点击
|
||||
const handleSegmentClick = (index: number) => {
|
||||
// 使用 useCallback 记忆化函数,避免不必要的重新创建
|
||||
const handleSegmentClick = useCallback((index: number) => {
|
||||
TTSService.playFromSegment(index)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (segments.length === 0) {
|
||||
return <div>{text}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<TextContainer>
|
||||
{segments.map((segment, index) => (
|
||||
<TextSegment
|
||||
key={index}
|
||||
className={index === currentSegmentIndex ? 'active' : ''}
|
||||
onClick={() => handleSegmentClick(index)}>
|
||||
{segment}
|
||||
</TextSegment>
|
||||
))}
|
||||
</TextContainer>
|
||||
)
|
||||
// 使用 useMemo 记忆化列表渲染结果,避免不必要的重新计算
|
||||
const renderedSegments = useMemo(() => {
|
||||
return segments.map((segment, index) => (
|
||||
<TextSegment
|
||||
key={index}
|
||||
className={index === currentSegmentIndex ? 'active' : ''}
|
||||
onClick={() => handleSegmentClick(index)}>
|
||||
{segment}
|
||||
</TextSegment>
|
||||
))
|
||||
}, [segments, currentSegmentIndex, handleSegmentClick])
|
||||
|
||||
return <TextContainer>{renderedSegments}</TextContainer>
|
||||
}
|
||||
|
||||
const TextContainer = styled.div`
|
||||
@ -93,4 +95,5 @@ const TextSegment = styled.span`
|
||||
}
|
||||
`
|
||||
|
||||
export default TTSHighlightedText
|
||||
// 使用 memo 包装组件,避免不必要的重渲染
|
||||
export default memo(TTSHighlightedText)
|
||||
|
||||
@ -4,7 +4,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShowTopics } from '@renderer/hooks/useStore'
|
||||
import { Assistant, Topic } from '@renderer/types'
|
||||
import { Flex } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { FC, memo, useMemo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Inputbar from './Inputbar/Inputbar'
|
||||
@ -19,32 +19,55 @@ interface Props {
|
||||
}
|
||||
|
||||
const Chat: FC<Props> = (props) => {
|
||||
// 使用传入的 assistant 对象,避免重复获取
|
||||
// 如果 useAssistant 提供了额外的功能或状态更新,则保留此调用
|
||||
const { assistant } = useAssistant(props.assistant.id)
|
||||
const { topicPosition, messageStyle } = useSettings()
|
||||
const { showTopics } = useShowTopics()
|
||||
|
||||
// 使用 useMemo 优化渲染,只有当相关依赖变化时才重新创建元素
|
||||
const messagesComponent = useMemo(
|
||||
() => (
|
||||
<Messages
|
||||
key={props.activeTopic.id}
|
||||
assistant={assistant}
|
||||
topic={props.activeTopic}
|
||||
setActiveTopic={props.setActiveTopic}
|
||||
/>
|
||||
),
|
||||
[props.activeTopic.id, assistant, props.setActiveTopic]
|
||||
)
|
||||
|
||||
const inputbarComponent = useMemo(
|
||||
() => (
|
||||
<QuickPanelProvider>
|
||||
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
|
||||
</QuickPanelProvider>
|
||||
),
|
||||
[assistant, props.setActiveTopic, props.activeTopic]
|
||||
)
|
||||
|
||||
const tabsComponent = useMemo(() => {
|
||||
if (topicPosition !== 'right' || !showTopics) return null
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
activeAssistant={assistant}
|
||||
activeTopic={props.activeTopic}
|
||||
setActiveAssistant={props.setActiveAssistant}
|
||||
setActiveTopic={props.setActiveTopic}
|
||||
position="right"
|
||||
/>
|
||||
)
|
||||
}, [topicPosition, showTopics, assistant, props.activeTopic, props.setActiveAssistant, props.setActiveTopic])
|
||||
|
||||
return (
|
||||
<Container id="chat" className={messageStyle}>
|
||||
<Main id="chat-main" vertical flex={1} justify="space-between">
|
||||
<Messages
|
||||
key={props.activeTopic.id}
|
||||
assistant={assistant}
|
||||
topic={props.activeTopic}
|
||||
setActiveTopic={props.setActiveTopic}
|
||||
/>
|
||||
<QuickPanelProvider>
|
||||
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
|
||||
</QuickPanelProvider>
|
||||
{messagesComponent}
|
||||
{inputbarComponent}
|
||||
</Main>
|
||||
{topicPosition === 'right' && showTopics && (
|
||||
<Tabs
|
||||
activeAssistant={assistant}
|
||||
activeTopic={props.activeTopic}
|
||||
setActiveAssistant={props.setActiveAssistant}
|
||||
setActiveTopic={props.setActiveTopic}
|
||||
position="right"
|
||||
/>
|
||||
)}
|
||||
{tabsComponent}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@ -63,4 +86,4 @@ const Main = styled(Flex)`
|
||||
transform: translateZ(0);
|
||||
`
|
||||
|
||||
export default Chat
|
||||
export default memo(Chat)
|
||||
|
||||
@ -9,7 +9,7 @@ import { parseJSON } from '@renderer/utils'
|
||||
import { escapeBrackets, removeSvgEmptyLines, withGeminiGrounding } from '@renderer/utils/formats'
|
||||
import { findCitationInChildren, sanitizeSchema } from '@renderer/utils/markdown'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { type FC, useMemo } from 'react'
|
||||
import { type FC, memo, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
@ -22,6 +22,7 @@ import remarkCjkFriendly from 'remark-cjk-friendly'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
|
||||
import InlineToolBlock from '../Messages/InlineToolBlock'
|
||||
import EditableCodeBlock from './EditableCodeBlock'
|
||||
import ImagePreview from './ImagePreview'
|
||||
import Link from './Link'
|
||||
@ -47,6 +48,87 @@ const Markdown: FC<Props> = ({ message }) => {
|
||||
return [rehypeRaw, [rehypeSanitize, sanitizeSchema], mathEngine === 'KaTeX' ? rehypeKatex : rehypeMathjax]
|
||||
}, [mathEngine])
|
||||
|
||||
// 处理工具调用 - 采用通用方法
|
||||
const processToolUse = (content: string) => {
|
||||
// 使用正则表达式匹配所有工具调用标签
|
||||
const toolUseRegex = /<tool_use>([\s\S]*?)<\/tool_use>|<tool_use>([\s\S]*?)(?:<\/tool|$)/g
|
||||
|
||||
// 工具结果正则表达式
|
||||
const toolResultRegex =
|
||||
/<tool_use_result>[\s\S]*?<n>([\s\S]*?)<\/n>[\s\S]*?<r>([\s\S]*?)<\/r>[\s\S]*?<\/tool_use_result>/g
|
||||
|
||||
// 替换所有工具调用标签为自定义标记
|
||||
let processedContent = content.replace(toolUseRegex, (_, content1, content2) => {
|
||||
// 工具调用内容可能在content1或content2中
|
||||
const toolContent = content1 || content2 || ''
|
||||
|
||||
// 尝试提取工具ID和参数
|
||||
const lines = toolContent.trim().split('\n')
|
||||
|
||||
// 如果至少有两行,则第一行可能是工具ID
|
||||
if (lines.length >= 2) {
|
||||
const toolId = lines[0].trim()
|
||||
// 将剩余行作为参数
|
||||
const argsText = lines.slice(1).join('\n').trim()
|
||||
|
||||
// 尝试解析参数为JSON
|
||||
try {
|
||||
// 尝试处理常见的JSON格式问题
|
||||
let fixedArgsText = argsText
|
||||
|
||||
// 如果是非标准JSON格式,尝试修复
|
||||
if (fixedArgsText.startsWith('[') || fixedArgsText.startsWith('{')) {
|
||||
// 将单引号替换为双引号
|
||||
fixedArgsText = fixedArgsText.replace(/(['"])([^'"]*)\1/g, '"$2"')
|
||||
// 将单引号键值对替换为双引号键值对
|
||||
fixedArgsText = fixedArgsText.replace(/([\w]+):/g, '"$1":')
|
||||
}
|
||||
|
||||
// 尝试解析JSON
|
||||
let parsedArgs
|
||||
try {
|
||||
parsedArgs = JSON.parse(fixedArgsText)
|
||||
} catch (e) {
|
||||
// 如果解析失败,尝试添加缺失的右大括号
|
||||
if (fixedArgsText.includes('{') && !fixedArgsText.endsWith('}')) {
|
||||
fixedArgsText = fixedArgsText + '}'
|
||||
try {
|
||||
parsedArgs = JSON.parse(fixedArgsText)
|
||||
} catch (e2) {
|
||||
// 如果仍然失败,使用原始文本
|
||||
parsedArgs = argsText
|
||||
}
|
||||
} else {
|
||||
parsedArgs = argsText
|
||||
}
|
||||
}
|
||||
|
||||
// 返回工具调用标记
|
||||
return `<div class="tool-use-marker" data-tool-name="${toolId}" data-tool-args='${typeof parsedArgs === 'object' ? JSON.stringify(parsedArgs) : parsedArgs}'></div>`
|
||||
} catch (e) {
|
||||
// 如果解析失败,使用原始文本
|
||||
console.error('Failed to parse tool args:', e)
|
||||
return `<div class="tool-use-marker" data-tool-name="${toolId}" data-tool-args='${argsText}'></div>`
|
||||
}
|
||||
} else {
|
||||
// 如果只有一行,则将整个内容作为工具调用
|
||||
return `<div class="tool-use-marker" data-tool-name="unknown" data-tool-args='${toolContent}'></div>`
|
||||
}
|
||||
})
|
||||
|
||||
// 替换工具结果标签为自定义标记
|
||||
processedContent = processedContent.replace(toolResultRegex, (_, toolName, result) => {
|
||||
return `<div class="tool-result-marker" data-tool-name="${toolName.trim()}" data-result='${result.trim()}'></div>`
|
||||
})
|
||||
|
||||
return processedContent
|
||||
}
|
||||
|
||||
// 处理后的消息内容
|
||||
const processedMessageContent = useMemo(() => {
|
||||
return processToolUse(messageContent)
|
||||
}, [messageContent])
|
||||
|
||||
const components = useMemo(() => {
|
||||
const baseComponents = {
|
||||
a: (props: any) => <Link {...props} citationData={parseJSON(findCitationInChildren(props.children))} />,
|
||||
@ -55,11 +137,12 @@ const Markdown: FC<Props> = ({ message }) => {
|
||||
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />,
|
||||
// 自定义处理think标签
|
||||
think: (props: any) => {
|
||||
// 将think标签内容渲染为带样式的div
|
||||
// 将think标签内容渲染为带样式的span,避免在p标签内使用div导致的hydration错误
|
||||
return (
|
||||
<div
|
||||
<span
|
||||
className="thinking-content"
|
||||
style={{
|
||||
display: 'block',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
padding: '10px 15px',
|
||||
borderRadius: '8px',
|
||||
@ -68,20 +151,101 @@ const Markdown: FC<Props> = ({ message }) => {
|
||||
fontStyle: 'italic',
|
||||
color: 'var(--color-text-2)'
|
||||
}}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: '5px' }}>思考过程:</div>
|
||||
<span style={{ display: 'block', fontWeight: 'bold', marginBottom: '5px' }}>思考过程:</span>
|
||||
{props.children}
|
||||
</div>
|
||||
</span>
|
||||
)
|
||||
},
|
||||
// 处理工具调用标记
|
||||
div: (props: any) => {
|
||||
if (props.className === 'tool-use-marker') {
|
||||
const toolName = props['data-tool-name']
|
||||
let toolArgs
|
||||
try {
|
||||
toolArgs = JSON.parse(props['data-tool-args'])
|
||||
} catch (e) {
|
||||
toolArgs = props['data-tool-args']
|
||||
}
|
||||
|
||||
// 如果消息中包含工具调用结果,则显示实际结果
|
||||
const mcpTools = message?.metadata?.mcpTools || []
|
||||
|
||||
// 调试信息
|
||||
console.log('Tool name:', toolName)
|
||||
console.log('Message metadata:', message?.metadata)
|
||||
console.log('MCP Tools:', mcpTools)
|
||||
|
||||
// 尝试多种方式匹配工具
|
||||
let toolResponse = mcpTools.find((tool) => tool.id === toolName)
|
||||
|
||||
// 如果没有找到,尝试使用工具名称匹配
|
||||
if (!toolResponse && mcpTools.length > 0) {
|
||||
toolResponse = mcpTools[mcpTools.length - 1] // 使用最后一个工具调用
|
||||
}
|
||||
|
||||
// 创建默认响应
|
||||
const defaultResponse = {
|
||||
isError: false,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '工具调用已完成'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if (toolResponse) {
|
||||
console.log('Found tool response:', toolResponse)
|
||||
return (
|
||||
<InlineToolBlock
|
||||
toolName={toolName}
|
||||
toolArgs={toolArgs}
|
||||
status="done"
|
||||
response={toolResponse.response || defaultResponse}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
// 如果没有找到工具调用结果,则显示完成状态和默认响应
|
||||
return (
|
||||
<InlineToolBlock
|
||||
toolName={toolName}
|
||||
toolArgs={toolArgs}
|
||||
status="done"
|
||||
response={{ isError: false, content: [{ type: 'text' as const, text: '工具调用已完成' }] }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
} else if (props.className === 'tool-result-marker') {
|
||||
const toolName = props['data-tool-name']
|
||||
const result = props['data-result']
|
||||
return (
|
||||
<InlineToolBlock
|
||||
toolName={toolName}
|
||||
toolArgs={null}
|
||||
status="done"
|
||||
response={{
|
||||
isError: false,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: result
|
||||
}
|
||||
]
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <div {...props} />
|
||||
}
|
||||
} as Partial<Components>
|
||||
return baseComponents
|
||||
}, [])
|
||||
}, [message?.metadata])
|
||||
|
||||
if (message.role === 'user' && !renderInputMessageAsMarkdown) {
|
||||
return <p style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>{messageContent}</p>
|
||||
}
|
||||
|
||||
if (messageContent.includes('<style>')) {
|
||||
if (processedMessageContent.includes('<style>')) {
|
||||
components.style = MarkdownShadowDOMRenderer as any
|
||||
}
|
||||
|
||||
@ -96,9 +260,10 @@ const Markdown: FC<Props> = ({ message }) => {
|
||||
footnoteLabelTagName: 'h4',
|
||||
footnoteBackContent: ' '
|
||||
}}>
|
||||
{messageContent}
|
||||
{processedMessageContent}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default Markdown
|
||||
// 使用 memo 包装组件,避免不必要的重渲染
|
||||
export default memo(Markdown)
|
||||
|
||||
@ -10,7 +10,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
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 { FC, memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from 'react-redux'
|
||||
import styled from 'styled-components'
|
||||
@ -77,107 +77,121 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
setHideTimer(timer)
|
||||
}, [])
|
||||
|
||||
const handleChatHistoryClick = () => {
|
||||
// 使用 useCallback 记忆化 handleChatHistoryClick 函数,避免不必要的重新创建
|
||||
const handleChatHistoryClick = useCallback(() => {
|
||||
setShowChatHistory(true)
|
||||
resetHideTimer()
|
||||
}
|
||||
}, [setShowChatHistory, resetHideTimer])
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
// 使用 useCallback 记忆化 handleDrawerClose 函数,避免不必要的重新创建
|
||||
const handleDrawerClose = useCallback(() => {
|
||||
setShowChatHistory(false)
|
||||
}
|
||||
}, [setShowChatHistory])
|
||||
|
||||
const findUserMessages = () => {
|
||||
// 使用 useCallback 记忆化 findUserMessages 函数,避免不必要的重新创建
|
||||
const findUserMessages = useCallback(() => {
|
||||
const container = document.getElementById(containerId)
|
||||
if (!container) return []
|
||||
|
||||
const userMessages = Array.from(container.getElementsByClassName('message-user'))
|
||||
return userMessages as HTMLElement[]
|
||||
}
|
||||
}, [containerId])
|
||||
|
||||
const findAssistantMessages = () => {
|
||||
// 使用 useCallback 记忆化 findAssistantMessages 函数,避免不必要的重新创建
|
||||
const findAssistantMessages = useCallback(() => {
|
||||
const container = document.getElementById(containerId)
|
||||
|
||||
if (!container) return []
|
||||
|
||||
const assistantMessages = Array.from(container.getElementsByClassName('message-assistant'))
|
||||
return assistantMessages as HTMLElement[]
|
||||
}
|
||||
}, [containerId])
|
||||
|
||||
const scrollToMessage = (element: HTMLElement) => {
|
||||
// 使用 useCallback 记忆化 scrollToMessage 函数,避免不必要的重新创建
|
||||
const scrollToMessage = useCallback((element: HTMLElement) => {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const scrollToTop = () => {
|
||||
// 使用 useCallback 记忆化 scrollToTop 函数,避免不必要的重新创建
|
||||
const scrollToTop = useCallback(() => {
|
||||
const container = document.getElementById(containerId)
|
||||
container && container.scrollTo({ top: -container.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
}, [containerId])
|
||||
|
||||
const scrollToBottom = () => {
|
||||
// 使用 useCallback 记忆化 scrollToBottom 函数,避免不必要的重新创建
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const container = document.getElementById(containerId)
|
||||
container && container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
}, [containerId])
|
||||
|
||||
const getCurrentVisibleIndex = (direction: 'up' | 'down') => {
|
||||
const userMessages = findUserMessages()
|
||||
const assistantMessages = findAssistantMessages()
|
||||
const container = document.getElementById(containerId)
|
||||
// 使用 useCallback 记忆化 getCurrentVisibleIndex 函数,避免不必要的重新创建
|
||||
const getCurrentVisibleIndex = useCallback(
|
||||
(direction: 'up' | 'down') => {
|
||||
const userMessages = findUserMessages()
|
||||
const assistantMessages = findAssistantMessages()
|
||||
const container = document.getElementById(containerId)
|
||||
|
||||
if (!container) return -1
|
||||
if (!container) return -1
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const visibleThreshold = containerRect.height * 0.1
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const visibleThreshold = containerRect.height * 0.1
|
||||
|
||||
let visibleIndices: number[] = []
|
||||
let visibleIndices: number[] = []
|
||||
|
||||
for (let i = 0; i < userMessages.length; i++) {
|
||||
const messageRect = userMessages[i].getBoundingClientRect()
|
||||
const visibleHeight =
|
||||
Math.min(messageRect.bottom, containerRect.bottom) - Math.max(messageRect.top, containerRect.top)
|
||||
if (visibleHeight > 0 && visibleHeight >= Math.min(messageRect.height, visibleThreshold)) {
|
||||
visibleIndices.push(i)
|
||||
for (let i = 0; i < userMessages.length; i++) {
|
||||
const messageRect = userMessages[i].getBoundingClientRect()
|
||||
const visibleHeight =
|
||||
Math.min(messageRect.bottom, containerRect.bottom) - Math.max(messageRect.top, containerRect.top)
|
||||
if (visibleHeight > 0 && visibleHeight >= Math.min(messageRect.height, visibleThreshold)) {
|
||||
visibleIndices.push(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleIndices.length > 0) {
|
||||
return direction === 'up' ? Math.max(...visibleIndices) : Math.min(...visibleIndices)
|
||||
}
|
||||
|
||||
visibleIndices = []
|
||||
for (let i = 0; i < assistantMessages.length; i++) {
|
||||
const messageRect = assistantMessages[i].getBoundingClientRect()
|
||||
const visibleHeight =
|
||||
Math.min(messageRect.bottom, containerRect.bottom) - Math.max(messageRect.top, containerRect.top)
|
||||
if (visibleHeight > 0 && visibleHeight >= Math.min(messageRect.height, visibleThreshold)) {
|
||||
visibleIndices.push(i)
|
||||
if (visibleIndices.length > 0) {
|
||||
return direction === 'up' ? Math.max(...visibleIndices) : Math.min(...visibleIndices)
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleIndices.length > 0) {
|
||||
const assistantIndex = direction === 'up' ? Math.max(...visibleIndices) : Math.min(...visibleIndices)
|
||||
return assistantIndex < userMessages.length ? assistantIndex : userMessages.length - 1
|
||||
}
|
||||
visibleIndices = []
|
||||
for (let i = 0; i < assistantMessages.length; i++) {
|
||||
const messageRect = assistantMessages[i].getBoundingClientRect()
|
||||
const visibleHeight =
|
||||
Math.min(messageRect.bottom, containerRect.bottom) - Math.max(messageRect.top, containerRect.top)
|
||||
if (visibleHeight > 0 && visibleHeight >= Math.min(messageRect.height, visibleThreshold)) {
|
||||
visibleIndices.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
if (visibleIndices.length > 0) {
|
||||
const assistantIndex = direction === 'up' ? Math.max(...visibleIndices) : Math.min(...visibleIndices)
|
||||
return assistantIndex < userMessages.length ? assistantIndex : userMessages.length - 1
|
||||
}
|
||||
|
||||
// 修改 handleCloseChatNavigation 函数
|
||||
const handleCloseChatNavigation = () => {
|
||||
return -1
|
||||
},
|
||||
[containerId, findUserMessages, findAssistantMessages]
|
||||
)
|
||||
|
||||
// 使用 useCallback 记忆化 handleCloseChatNavigation 函数,避免不必要的重新创建
|
||||
const handleCloseChatNavigation = useCallback(() => {
|
||||
setIsVisible(false)
|
||||
// 设置手动关闭状态,1分钟内不响应鼠标靠近事件
|
||||
setManuallyClosedUntil(Date.now() + 60000) // 60000毫秒 = 1分钟
|
||||
}
|
||||
}, [setIsVisible, setManuallyClosedUntil])
|
||||
|
||||
const handleScrollToTop = () => {
|
||||
// 使用 useCallback 记忆化 handleScrollToTop 函数,避免不必要的重新创建
|
||||
const handleScrollToTop = useCallback(() => {
|
||||
resetHideTimer()
|
||||
scrollToTop()
|
||||
}
|
||||
}, [resetHideTimer, scrollToTop])
|
||||
|
||||
const handleScrollToBottom = () => {
|
||||
// 使用 useCallback 记忆化 handleScrollToBottom 函数,避免不必要的重新创建
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
resetHideTimer()
|
||||
scrollToBottom()
|
||||
}
|
||||
}, [resetHideTimer, scrollToBottom])
|
||||
|
||||
const handleNextMessage = () => {
|
||||
// 使用 useCallback 记忆化 handleNextMessage 函数,避免不必要的重新创建
|
||||
const handleNextMessage = useCallback(() => {
|
||||
resetHideTimer()
|
||||
const userMessages = findUserMessages()
|
||||
const assistantMessages = findAssistantMessages()
|
||||
@ -202,9 +216,10 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
}
|
||||
|
||||
scrollToMessage(userMessages[targetIndex])
|
||||
}
|
||||
}, [resetHideTimer, findUserMessages, findAssistantMessages, scrollToBottom, getCurrentVisibleIndex, scrollToMessage])
|
||||
|
||||
const handlePrevMessage = () => {
|
||||
// 使用 useCallback 记忆化 handlePrevMessage 函数,避免不必要的重新创建
|
||||
const handlePrevMessage = useCallback(() => {
|
||||
resetHideTimer()
|
||||
const userMessages = findUserMessages()
|
||||
const assistantMessages = findAssistantMessages()
|
||||
@ -228,7 +243,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
}
|
||||
|
||||
scrollToMessage(userMessages[targetIndex])
|
||||
}
|
||||
}, [resetHideTimer, findUserMessages, findAssistantMessages, scrollToTop, getCurrentVisibleIndex, scrollToMessage])
|
||||
|
||||
// Set up scroll event listener and mouse position tracking
|
||||
useEffect(() => {
|
||||
@ -441,4 +456,5 @@ const Divider = styled.div`
|
||||
margin: 0;
|
||||
`
|
||||
|
||||
export default ChatNavigation
|
||||
// 使用 memo 包装组件,避免不必要的重渲染
|
||||
export default memo(ChatNavigation)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||
import Favicon from '@renderer/components/Icons/FallbackFavicon'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import React from 'react'
|
||||
import React, { memo, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -22,23 +22,28 @@ const CitationsList: React.FC<CitationsListProps> = ({ citations }) => {
|
||||
|
||||
if (!citations || citations.length === 0) return null
|
||||
|
||||
// 使用 useMemo 记忆化列表渲染结果,避免不必要的重新计算
|
||||
const renderedCitations = useMemo(() => {
|
||||
return citations.map((citation) => (
|
||||
<HStack key={citation.url || citation.number} style={{ alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-2)' }}>{citation.number}.</span>
|
||||
{citation.showFavicon && citation.url && (
|
||||
<Favicon hostname={new URL(citation.url).hostname} alt={citation.title || citation.hostname || ''} />
|
||||
)}
|
||||
<CitationLink href={citation.url} className="text-nowrap" target="_blank" rel="noopener noreferrer">
|
||||
{citation.title ? citation.title : <span className="hostname">{citation.hostname}</span>}
|
||||
</CitationLink>
|
||||
</HStack>
|
||||
))
|
||||
}, [citations, t])
|
||||
|
||||
return (
|
||||
<CitationsContainer className="footnotes">
|
||||
<CitationsTitle>
|
||||
{t('message.citations')}
|
||||
<InfoCircleOutlined style={{ fontSize: '14px', marginLeft: '4px', opacity: 0.6 }} />
|
||||
</CitationsTitle>
|
||||
{citations.map((citation) => (
|
||||
<HStack key={citation.url || citation.number} style={{ alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-2)' }}>{citation.number}.</span>
|
||||
{citation.showFavicon && citation.url && (
|
||||
<Favicon hostname={new URL(citation.url).hostname} alt={citation.title || citation.hostname || ''} />
|
||||
)}
|
||||
<CitationLink href={citation.url} className="text-nowrap" target="_blank" rel="noopener noreferrer">
|
||||
{citation.title ? citation.title : <span className="hostname">{citation.hostname}</span>}
|
||||
</CitationLink>
|
||||
</HStack>
|
||||
))}
|
||||
{renderedCitations}
|
||||
</CitationsContainer>
|
||||
)
|
||||
}
|
||||
@ -78,4 +83,5 @@ const CitationLink = styled.a`
|
||||
}
|
||||
`
|
||||
|
||||
export default CitationsList
|
||||
// 使用 memo 包装组件,避免不必要的重渲染
|
||||
export default memo(CitationsList)
|
||||
|
||||
138
src/renderer/src/pages/home/Messages/CustomCollapse.tsx
Normal file
138
src/renderer/src/pages/home/Messages/CustomCollapse.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import React, { FC, useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface CustomCollapseProps {
|
||||
title: React.ReactNode
|
||||
children: React.ReactNode
|
||||
isActive: boolean
|
||||
onToggle: () => void
|
||||
id: string
|
||||
}
|
||||
|
||||
const CustomCollapse: FC<CustomCollapseProps> = ({ title, children, isActive, onToggle }) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const [height, setHeight] = useState<number | 'auto'>(0)
|
||||
const [isContentVisible, setIsContentVisible] = useState(false)
|
||||
const [isAnimating, setIsAnimating] = useState(false)
|
||||
const prevActiveRef = useRef(isActive)
|
||||
|
||||
// 使用 requestAnimationFrame 来优化动画性能
|
||||
const animateHeight = useCallback((from: number, to: number, duration: number = 250) => {
|
||||
setIsAnimating(true)
|
||||
const startTime = performance.now()
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
const elapsedTime = currentTime - startTime
|
||||
const progress = Math.min(elapsedTime / duration, 1)
|
||||
// 使用缓动函数使动画更平滑
|
||||
const easeProgress = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress)
|
||||
const currentHeight = from + (to - from) * easeProgress
|
||||
|
||||
setHeight(currentHeight)
|
||||
|
||||
if (progress < 1) {
|
||||
window.requestAnimationFrame(animate)
|
||||
} else {
|
||||
setHeight(to === 0 ? 0 : 'auto')
|
||||
setIsAnimating(false)
|
||||
setIsContentVisible(to !== 0)
|
||||
}
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(animate)
|
||||
}, [])
|
||||
|
||||
// 使用 useLayoutEffect 来测量高度,避免闪烁
|
||||
useLayoutEffect(() => {
|
||||
// 如果状态没有变化,不做任何处理
|
||||
if (prevActiveRef.current === isActive) return
|
||||
|
||||
// 如果正在动画中,不做处理
|
||||
if (isAnimating) return
|
||||
|
||||
prevActiveRef.current = isActive
|
||||
|
||||
if (isActive) {
|
||||
// 展开
|
||||
if (contentRef.current) {
|
||||
const contentHeight = contentRef.current.scrollHeight
|
||||
animateHeight(0, contentHeight)
|
||||
}
|
||||
} else {
|
||||
// 折叠
|
||||
if (contentRef.current) {
|
||||
const currentHeight = contentRef.current.scrollHeight
|
||||
animateHeight(currentHeight, 0)
|
||||
}
|
||||
}
|
||||
}, [isActive, animateHeight, isAnimating])
|
||||
|
||||
return (
|
||||
<CollapseWrapper>
|
||||
<CollapseHeader onClick={onToggle}>{title}</CollapseHeader>
|
||||
<CollapseContent
|
||||
ref={contentRef}
|
||||
style={{
|
||||
height: height === 'auto' ? 'auto' : `${height}px`,
|
||||
// 使用 transform 而不是 height 来触发硬件加速
|
||||
transform: `translateZ(0)`,
|
||||
// 使用 will-change 提前告知浏览器将要发生变化
|
||||
willChange: isAnimating ? 'height' : 'auto',
|
||||
// 使用 contain 限制重绘范围
|
||||
contain: 'content',
|
||||
// 使用 GPU 加速
|
||||
backfaceVisibility: 'hidden'
|
||||
}}
|
||||
$isActive={isActive}>
|
||||
<div
|
||||
style={{
|
||||
display: isContentVisible || isActive ? 'block' : 'none',
|
||||
// 使用 transform 触发硬件加速
|
||||
transform: 'translateZ(0)',
|
||||
// 使用 contain 限制重绘范围
|
||||
contain: 'content'
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
</CollapseContent>
|
||||
</CollapseWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const CollapseWrapper = styled.div`
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-1);
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
`
|
||||
|
||||
const CollapseHeader = styled.div`
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
background-color: var(--color-bg-2);
|
||||
transition: background-color 0.2s;
|
||||
will-change: transform, background-color;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-bg-3);
|
||||
}
|
||||
`
|
||||
|
||||
const CollapseContent = styled.div<{ $isActive: boolean }>`
|
||||
overflow: hidden;
|
||||
/* 移除过渡效果,改用 requestAnimationFrame 手动控制动画 */
|
||||
/* 使用 GPU 加速 */
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
perspective: 1000;
|
||||
-webkit-perspective: 1000;
|
||||
background-color: var(--color-bg-1); /* 添加背景色 */
|
||||
`
|
||||
|
||||
export default CustomCollapse
|
||||
158
src/renderer/src/pages/home/Messages/ExpandedResponseContent.tsx
Normal file
158
src/renderer/src/pages/home/Messages/ExpandedResponseContent.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import { FC, memo, useLayoutEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ExpandedResponseContentProps {
|
||||
content: string
|
||||
fontFamily: string
|
||||
fontSize: string | number
|
||||
onCopy: () => void
|
||||
}
|
||||
|
||||
const ExpandedResponseContent: FC<ExpandedResponseContentProps> = ({ content, fontFamily, fontSize, onCopy }) => {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [visibleContent, setVisibleContent] = useState('')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const contentRef = useRef<string>('')
|
||||
const animationFrameRef = useRef<number | null>(null)
|
||||
|
||||
// 使用 requestAnimationFrame 分批渲染内容
|
||||
useLayoutEffect(() => {
|
||||
if (!content) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
contentRef.current = content
|
||||
|
||||
// 如果内容很短,直接渲染
|
||||
if (content.length < 5000) {
|
||||
setVisibleContent(content)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// 分批渲染大型内容
|
||||
let currentPosition = 0
|
||||
const chunkSize = 5000 // 每次渲染 5000 个字符
|
||||
|
||||
const renderNextChunk = () => {
|
||||
const nextChunk = contentRef.current.slice(0, currentPosition + chunkSize)
|
||||
currentPosition += chunkSize
|
||||
|
||||
setVisibleContent(nextChunk)
|
||||
|
||||
if (currentPosition < contentRef.current.length) {
|
||||
// 还有更多内容要渲染,使用 requestAnimationFrame 而不是 setTimeout
|
||||
animationFrameRef.current = requestAnimationFrame(renderNextChunk)
|
||||
} else {
|
||||
// 所有内容已渲染完成
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 开始渲染第一批,使用 requestAnimationFrame 而不是 setTimeout
|
||||
animationFrameRef.current = requestAnimationFrame(renderNextChunk)
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
}
|
||||
}, [content])
|
||||
|
||||
return (
|
||||
<ExpandedResponseContainer ref={containerRef} style={{ fontFamily, fontSize }}>
|
||||
<ActionButton className="copy-expanded-button" onClick={onCopy} aria-label="复制">
|
||||
<i className="iconfont icon-copy"></i>
|
||||
</ActionButton>
|
||||
|
||||
{isLoading && <LoadingIndicator>正在加载内容...</LoadingIndicator>}
|
||||
|
||||
<CodeBlock dangerouslySetInnerHTML={{ __html: visibleContent }} />
|
||||
</ExpandedResponseContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ExpandedResponseContainer = styled.div`
|
||||
background: var(--color-bg-1);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
will-change: transform; /* 优化渲染性能 */
|
||||
transform: translateZ(0); /* 启用硬件加速 */
|
||||
backface-visibility: hidden; /* 使用 GPU 加速 */
|
||||
-webkit-backface-visibility: hidden;
|
||||
perspective: 1000;
|
||||
-webkit-perspective: 1000;
|
||||
contain: content; /* 限制重绘范围 */
|
||||
|
||||
.copy-expanded-button {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: var(--color-bg-2);
|
||||
border-radius: 4px;
|
||||
z-index: 1;
|
||||
}
|
||||
`
|
||||
|
||||
const LoadingIndicator = styled.div`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: var(--color-bg-2);
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
color: var(--color-text-2);
|
||||
font-size: 14px;
|
||||
z-index: 2;
|
||||
`
|
||||
|
||||
const ActionButton = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-2);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.7;
|
||||
transition: all 0.2s;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-bg-1);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
font-size: 14px;
|
||||
}
|
||||
`
|
||||
|
||||
const CodeBlock = styled.pre`
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--color-text);
|
||||
font-family: ubuntu;
|
||||
contain: content; /* 优化渲染性能 */
|
||||
min-height: 100px;
|
||||
transform: translateZ(0); /* 启用硬件加速 */
|
||||
backface-visibility: hidden; /* 使用 GPU 加速 */
|
||||
-webkit-backface-visibility: hidden;
|
||||
will-change: transform; /* 告知浏览器将要发生变化 */
|
||||
`
|
||||
|
||||
export default memo(ExpandedResponseContent)
|
||||
173
src/renderer/src/pages/home/Messages/InlineToolBlock.tsx
Normal file
173
src/renderer/src/pages/home/Messages/InlineToolBlock.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
import { CheckOutlined, LoadingOutlined, WarningOutlined } from '@ant-design/icons'
|
||||
import { MCPCallToolResponse } from '@renderer/types'
|
||||
import { FC, memo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface InlineToolBlockProps {
|
||||
toolName: string
|
||||
toolArgs: any
|
||||
status: 'pending' | 'invoking' | 'done'
|
||||
response?: MCPCallToolResponse
|
||||
fontFamily?: string
|
||||
}
|
||||
|
||||
const InlineToolBlock: FC<InlineToolBlockProps> = ({ toolName, toolArgs, status, response, fontFamily }) => {
|
||||
const { t } = useTranslation()
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const isInvoking = status === 'invoking'
|
||||
const isDone = status === 'done'
|
||||
const hasError = isDone && response?.isError === true
|
||||
|
||||
// 格式化参数显示
|
||||
const formattedArgs = typeof toolArgs === 'object' ? JSON.stringify(toolArgs, null, 2) : String(toolArgs)
|
||||
|
||||
// 处理响应内容
|
||||
const getResponseContent = () => {
|
||||
if (!response) return ''
|
||||
|
||||
// 如果有content属性,则尝试提取文本
|
||||
if (response.content && Array.isArray(response.content)) {
|
||||
const textContent = response.content
|
||||
.filter((item) => item.type === 'text')
|
||||
.map((item) => item.text)
|
||||
.join('\n')
|
||||
|
||||
if (textContent) return textContent
|
||||
}
|
||||
|
||||
// 如果没有文本内容,则返回完整响应
|
||||
return JSON.stringify(response, null, 2)
|
||||
}
|
||||
|
||||
// 获取响应内容
|
||||
const responseContent = getResponseContent()
|
||||
|
||||
return (
|
||||
<ToolBlockContainer className="inline-tool-block">
|
||||
<ToolHeader onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<ToolInfo>
|
||||
<ToolName>{toolName}</ToolName>
|
||||
<StatusIndicator $isInvoking={isInvoking} $hasError={hasError}>
|
||||
{isInvoking
|
||||
? t('message.tools.invoking')
|
||||
: hasError
|
||||
? t('message.tools.error')
|
||||
: t('message.tools.completed')}
|
||||
{isInvoking && <LoadingOutlined spin style={{ marginLeft: 6 }} />}
|
||||
{isDone && !hasError && <CheckOutlined style={{ marginLeft: 6 }} />}
|
||||
{hasError && <WarningOutlined style={{ marginLeft: 6 }} />}
|
||||
</StatusIndicator>
|
||||
</ToolInfo>
|
||||
</ToolHeader>
|
||||
|
||||
{isExpanded && (
|
||||
<ToolContent>
|
||||
{toolArgs && (
|
||||
<ToolSection>
|
||||
<SectionTitle>参数:</SectionTitle>
|
||||
<CodeBlock style={{ fontFamily }}>{formattedArgs}</CodeBlock>
|
||||
</ToolSection>
|
||||
)}
|
||||
|
||||
{isDone && response && (
|
||||
<ToolSection>
|
||||
<SectionTitle>结果:</SectionTitle>
|
||||
<CodeBlock style={{ fontFamily }}>{responseContent}</CodeBlock>
|
||||
</ToolSection>
|
||||
)}
|
||||
|
||||
{isInvoking && (
|
||||
<LoadingContainer>
|
||||
<LoadingOutlined spin />
|
||||
<span style={{ marginLeft: 8 }}>正在调用工具...</span>
|
||||
</LoadingContainer>
|
||||
)}
|
||||
</ToolContent>
|
||||
)}
|
||||
</ToolBlockContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ToolBlockContainer = styled.div`
|
||||
margin: 10px 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
background-color: var(--color-bg-1);
|
||||
`
|
||||
|
||||
const ToolHeader = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background-color: var(--color-bg-2);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
`
|
||||
|
||||
const ToolInfo = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const ToolName = styled.span`
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
`
|
||||
|
||||
const StatusIndicator = styled.span<{ $isInvoking: boolean; $hasError?: boolean }>`
|
||||
color: ${(props) => {
|
||||
if (props.$hasError) return 'var(--color-error, #ff4d4f)'
|
||||
if (props.$isInvoking) return 'var(--color-primary)'
|
||||
return 'var(--color-success, #52c41a)'
|
||||
}};
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.85;
|
||||
border-left: 1px solid var(--color-border);
|
||||
padding-left: 8px;
|
||||
`
|
||||
|
||||
const ToolContent = styled.div`
|
||||
padding: 12px;
|
||||
`
|
||||
|
||||
const ToolSection = styled.div`
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
`
|
||||
|
||||
const SectionTitle = styled.div`
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
color: var(--color-text-2);
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
const CodeBlock = styled.pre`
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background-color: var(--color-bg-2);
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
color: var(--color-text);
|
||||
max-height: 200px;
|
||||
`
|
||||
|
||||
const LoadingContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
color: var(--color-text-2);
|
||||
`
|
||||
|
||||
export default memo(InlineToolBlock)
|
||||
@ -16,6 +16,7 @@ import { Divider, Dropdown } from 'antd'
|
||||
import { ItemType } from 'antd/es/menu/interface'
|
||||
import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { shallowEqual } from 'react-redux'
|
||||
// import { useSelector } from 'react-redux'; // Removed unused import
|
||||
import styled from 'styled-components' // Ensure styled-components is imported
|
||||
|
||||
@ -81,16 +82,23 @@ const MessageItem: FC<Props> = ({
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
|
||||
const [selectedQuoteText, setSelectedQuoteText] = useState<string>('')
|
||||
const [selectedText, setSelectedText] = useState<string>('')
|
||||
|
||||
// 使用记忆化的上下文菜单项生成函数
|
||||
const getContextMenuItems = useContextMenuItems(t, message)
|
||||
const dispatch = useAppDispatch()
|
||||
const playTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// --- Consolidated State Selection ---
|
||||
const ttsEnabled = useAppSelector((state) => state.settings.ttsEnabled)
|
||||
const voiceCallEnabled = useAppSelector((state) => state.settings.voiceCallEnabled)
|
||||
const autoPlayTTSOutsideVoiceCall = useAppSelector((state) => state.settings.autoPlayTTSOutsideVoiceCall)
|
||||
const isVoiceCallActive = useAppSelector((state) => state.settings.isVoiceCallActive)
|
||||
const lastPlayedMessageId = useAppSelector((state) => state.settings.lastPlayedMessageId)
|
||||
const skipNextAutoTTS = useAppSelector((state) => state.settings.skipNextAutoTTS)
|
||||
// --- Consolidated State Selection with shallowEqual for performance ---
|
||||
const { ttsEnabled, voiceCallEnabled, isVoiceCallActive, lastPlayedMessageId, skipNextAutoTTS } = useAppSelector(
|
||||
(state) => ({
|
||||
ttsEnabled: state.settings.ttsEnabled,
|
||||
voiceCallEnabled: state.settings.voiceCallEnabled,
|
||||
isVoiceCallActive: state.settings.isVoiceCallActive,
|
||||
lastPlayedMessageId: state.settings.lastPlayedMessageId,
|
||||
skipNextAutoTTS: state.settings.skipNextAutoTTS
|
||||
}),
|
||||
shallowEqual
|
||||
) // 使用 shallowEqual 比较函数避免不必要的重渲染
|
||||
// ---------------------------------
|
||||
|
||||
const isLastMessage = index === 0
|
||||
@ -98,6 +106,7 @@ const MessageItem: FC<Props> = ({
|
||||
const showMenubar = !isStreaming && !message.status.includes('ing')
|
||||
|
||||
const fontFamily = useMemo(() => {
|
||||
// 优化:简化字符串操作,减少每次渲染时的计算开销
|
||||
return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY
|
||||
}, [messageFont])
|
||||
|
||||
@ -154,94 +163,100 @@ const MessageItem: FC<Props> = ({
|
||||
}
|
||||
}, [generating, isLastMessage, isAssistantMessage, message.status, message.id, dispatch])
|
||||
|
||||
// --- Auto-play TTS Logic ---
|
||||
useEffect(() => {
|
||||
// --- 使用 useMemo 计算是否应该自动播放 TTS ---
|
||||
const shouldAutoPlayTTS = useMemo(() => {
|
||||
// 基本条件检查
|
||||
if (!isLastMessage || !isAssistantMessage || message.status !== 'success' || generating) {
|
||||
return
|
||||
}
|
||||
if (!ttsEnabled) {
|
||||
return
|
||||
}
|
||||
if (!isLastMessage) return false // 必须是最后一条消息
|
||||
if (!isAssistantMessage) return false // 必须是助手消息
|
||||
if (message.status !== 'success') return false // 消息状态必须是成功
|
||||
if (generating) return false // 正在生成中时不播放
|
||||
if (!ttsEnabled) return false // TTS功能必须启用
|
||||
|
||||
// 语音通话相关条件检查
|
||||
if (voiceCallEnabled === false && autoPlayTTSOutsideVoiceCall === false) {
|
||||
// 简化日志输出
|
||||
console.log('不自动播放TTS: 语音通话功能未启用 + 不允许在语音通话模式外自动播放')
|
||||
return
|
||||
}
|
||||
if (voiceCallEnabled === true && isVoiceCallActive === false && autoPlayTTSOutsideVoiceCall === false) {
|
||||
// 简化日志输出
|
||||
console.log('不自动播放TTS: 语音通话窗口未打开 + 不允许在语音通话模式外自动播放')
|
||||
return
|
||||
if (!voiceCallEnabled || !isVoiceCallActive) {
|
||||
console.log('不自动播放TTS: 语音通话未开启或窗口未激活, ID:', message.id)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否需要跳过自动TTS
|
||||
if (skipNextAutoTTS === true) {
|
||||
console.log('跳过自动TTS: skipNextAutoTTS = true, 消息ID:', message.id)
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查消息是否有内容,且消息是新的(不是上次播放过的消息)
|
||||
if (message.content && message.content.trim() && message.id !== lastPlayedMessageId) {
|
||||
// 简化日志输出
|
||||
console.log('准备自动播放TTS, 消息ID:', message.id)
|
||||
|
||||
// 先设置状态,防止重复播放
|
||||
const currentMessageId = message.id
|
||||
dispatch(setLastPlayedMessageId(currentMessageId))
|
||||
|
||||
// 只有当没有设置过定时器时才设置
|
||||
if (!playTimeoutRef.current) {
|
||||
playTimeoutRef.current = setTimeout(() => {
|
||||
console.log('自动播放TTS: 消息ID:', currentMessageId)
|
||||
TTSService.speakFromMessage(message)
|
||||
// 清除定时器引用
|
||||
playTimeoutRef.current = null
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (playTimeoutRef.current) {
|
||||
clearTimeout(playTimeoutRef.current)
|
||||
playTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
} else if (message.id === lastPlayedMessageId) {
|
||||
// 简化日志输出
|
||||
console.log('不自动播放TTS: 消息已播放过 (lastPlayedMessageId), ID:', message.id)
|
||||
return // 添加返回语句,解决TypeScript错误
|
||||
if (!message.content || !message.content.trim() || message.id === lastPlayedMessageId) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 添加默认返回值,确保所有代码路径都有返回值
|
||||
return
|
||||
// 所有条件都满足,应该自动播放
|
||||
return true
|
||||
}, [
|
||||
isLastMessage,
|
||||
isAssistantMessage,
|
||||
message,
|
||||
message.status,
|
||||
message.content,
|
||||
message.id,
|
||||
generating,
|
||||
ttsEnabled,
|
||||
voiceCallEnabled,
|
||||
autoPlayTTSOutsideVoiceCall,
|
||||
isVoiceCallActive,
|
||||
skipNextAutoTTS,
|
||||
lastPlayedMessageId,
|
||||
dispatch
|
||||
lastPlayedMessageId
|
||||
])
|
||||
|
||||
// --- Highlight message on event ---
|
||||
const messageHighlightHandler = useCallback((highlight: boolean = true) => {
|
||||
if (messageContainerRef.current) {
|
||||
messageContainerRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
if (highlight) {
|
||||
const element = messageContainerRef.current
|
||||
element.classList.add('message-highlight')
|
||||
setTimeout(() => {
|
||||
element?.classList.remove('message-highlight')
|
||||
}, 2500)
|
||||
// --- 简化后的 TTS 自动播放逻辑 ---
|
||||
useEffect(() => {
|
||||
// 如果不应该自动播放,直接返回
|
||||
if (!shouldAutoPlayTTS) return
|
||||
|
||||
console.log('准备自动播放TTS, 消息ID:', message.id)
|
||||
|
||||
// 只有当没有设置过定时器时才设置
|
||||
if (!playTimeoutRef.current) {
|
||||
const currentMessageId = message.id // 捕获当前消息ID
|
||||
|
||||
playTimeoutRef.current = setTimeout(() => {
|
||||
console.log('自动播放TTS: 消息ID:', currentMessageId)
|
||||
// 在播放前再次检查是否还是当前消息
|
||||
if (currentMessageId === message.id) {
|
||||
TTSService.speakFromMessage(message)
|
||||
dispatch(setLastPlayedMessageId(currentMessageId))
|
||||
} else {
|
||||
console.log('跳过播放TTS: 消息ID不匹配, 计划播放:', currentMessageId, '当前消息:', message.id)
|
||||
}
|
||||
|
||||
// 播放完成后清除定时器引用
|
||||
playTimeoutRef.current = null
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (playTimeoutRef.current) {
|
||||
console.log('清理TTS自动播放定时器, 消息ID:', message.id)
|
||||
clearTimeout(playTimeoutRef.current)
|
||||
playTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [shouldAutoPlayTTS, message, dispatch])
|
||||
|
||||
// --- Highlight message on event ---
|
||||
// 使用 useMemo 记忆化消息高亮处理函数,减少重新创建
|
||||
const messageHighlightHandler = useMemo(() => {
|
||||
return (highlight: boolean = true) => {
|
||||
if (messageContainerRef.current) {
|
||||
messageContainerRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
if (highlight) {
|
||||
const element = messageContainerRef.current
|
||||
element.classList.add('message-highlight')
|
||||
setTimeout(() => {
|
||||
element?.classList.remove('message-highlight')
|
||||
}, 2500)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []) // 空依赖数组,因为函数内部使用的是 ref 的 .current 属性
|
||||
|
||||
useEffect(() => {
|
||||
const eventName = `${EVENT_NAMES.LOCATE_MESSAGE}:${message.id}`
|
||||
@ -279,7 +294,7 @@ const MessageItem: FC<Props> = ({
|
||||
{contextMenuPosition && (
|
||||
<Dropdown
|
||||
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }}
|
||||
menu={{ items: getContextMenuItems(t, selectedQuoteText, selectedText, message) }}
|
||||
menu={{ items: getContextMenuItems(selectedQuoteText, selectedText) }}
|
||||
open={true}
|
||||
trigger={['contextMenu']}>
|
||||
{/* FIX 2: Use the styled component instead of inline style */}
|
||||
@ -324,75 +339,74 @@ const MessageItem: FC<Props> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// Updated context menu items function
|
||||
const getContextMenuItems = (
|
||||
t: (key: string) => string,
|
||||
selectedQuoteText: string,
|
||||
selectedText: string,
|
||||
message: Message
|
||||
): ItemType[] => {
|
||||
const items: ItemType[] = []
|
||||
// 使用 hook 封装上下文菜单项生成逻辑,便于在组件内使用
|
||||
const useContextMenuItems = (t: (key: string) => string, message: Message) => {
|
||||
return useMemo(() => {
|
||||
return (selectedQuoteText: string, selectedText: string): ItemType[] => {
|
||||
const items: ItemType[] = []
|
||||
|
||||
if (selectedText) {
|
||||
items.push({
|
||||
key: 'copy',
|
||||
label: t('common.copy'),
|
||||
onClick: () => {
|
||||
navigator.clipboard
|
||||
.writeText(selectedText)
|
||||
.then(() => window.message.success({ content: t('message.copied'), key: 'copy-message' }))
|
||||
.catch((err) => console.error('Failed to copy text: ', err))
|
||||
}
|
||||
})
|
||||
items.push({
|
||||
key: 'quote',
|
||||
label: t('chat.message.quote'),
|
||||
onClick: () => {
|
||||
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
|
||||
}
|
||||
})
|
||||
items.push({
|
||||
key: 'speak_selected',
|
||||
label: t('chat.message.speak_selection') || '朗读选中部分',
|
||||
onClick: () => {
|
||||
// 首先手动关闭菜单
|
||||
document.dispatchEvent(new MouseEvent('click'))
|
||||
if (selectedText) {
|
||||
items.push({
|
||||
key: 'copy',
|
||||
label: t('common.copy'),
|
||||
onClick: () => {
|
||||
navigator.clipboard
|
||||
.writeText(selectedText)
|
||||
.then(() => window.message.success({ content: t('message.copied'), key: 'copy-message' }))
|
||||
.catch((err) => console.error('Failed to copy text: ', err))
|
||||
}
|
||||
})
|
||||
items.push({
|
||||
key: 'quote',
|
||||
label: t('chat.message.quote'),
|
||||
onClick: () => {
|
||||
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
|
||||
}
|
||||
})
|
||||
items.push({
|
||||
key: 'speak_selected',
|
||||
label: t('chat.message.speak_selection') || '朗读选中部分',
|
||||
onClick: () => {
|
||||
// 首先手动关闭菜单
|
||||
document.dispatchEvent(new MouseEvent('click'))
|
||||
|
||||
// 使用setTimeout确保菜单关闭后再执行TTS功能
|
||||
setTimeout(() => {
|
||||
import('@renderer/services/TTSService')
|
||||
.then(({ default: TTSServiceInstance }) => {
|
||||
let textToSpeak = selectedText
|
||||
if (message.content) {
|
||||
const startIndex = message.content.indexOf(selectedText)
|
||||
if (startIndex !== -1) {
|
||||
textToSpeak = selectedText // Just speak selection
|
||||
}
|
||||
}
|
||||
// 传递消息ID,确保进度条和停止按钮正常工作
|
||||
TTSServiceInstance.speak(textToSpeak, false, message.id) // 使用普通播放模式而非分段播放
|
||||
})
|
||||
.catch((err) => console.error('Failed to load or use TTSService:', err))
|
||||
}, 100)
|
||||
// 使用setTimeout确保菜单关闭后再执行TTS功能
|
||||
setTimeout(() => {
|
||||
import('@renderer/services/TTSService')
|
||||
.then(({ default: TTSServiceInstance }) => {
|
||||
let textToSpeak = selectedText
|
||||
if (message.content) {
|
||||
const startIndex = message.content.indexOf(selectedText)
|
||||
if (startIndex !== -1) {
|
||||
textToSpeak = selectedText // Just speak selection
|
||||
}
|
||||
}
|
||||
// 传递消息ID,确保进度条和停止按钮正常工作
|
||||
TTSServiceInstance.speak(textToSpeak, false, message.id) // 使用普通播放模式而非分段播放
|
||||
})
|
||||
.catch((err) => console.error('Failed to load or use TTSService:', err))
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
items.push({ type: 'divider' })
|
||||
}
|
||||
})
|
||||
items.push({ type: 'divider' })
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: 'copy_id',
|
||||
label: t('message.copy_id') || '复制消息ID',
|
||||
onClick: () => {
|
||||
navigator.clipboard
|
||||
.writeText(message.id)
|
||||
.then(() =>
|
||||
window.message.success({ content: t('message.id_copied') || '消息ID已复制', key: 'copy-message-id' })
|
||||
)
|
||||
.catch((err) => console.error('Failed to copy message ID: ', err))
|
||||
items.push({
|
||||
key: 'copy_id',
|
||||
label: t('message.copy_id') || '复制消息ID',
|
||||
onClick: () => {
|
||||
navigator.clipboard
|
||||
.writeText(message.id)
|
||||
.then(() =>
|
||||
window.message.success({ content: t('message.id_copied') || '消息ID已复制', key: 'copy-message-id' })
|
||||
)
|
||||
.catch((err) => console.error('Failed to copy message ID: ', err))
|
||||
}
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
})
|
||||
|
||||
return items
|
||||
}, [t, message.id, message.content]) // 只依赖 t 和 message 的关键属性
|
||||
}
|
||||
|
||||
// Styled components definitions
|
||||
|
||||
@ -11,7 +11,7 @@ import { updateMessageThunk } from '@renderer/store/messages'
|
||||
import type { Message } from '@renderer/types'
|
||||
import { isEmoji, removeLeadingEmoji } from '@renderer/utils'
|
||||
import { Avatar } from 'antd'
|
||||
import { type FC, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { type FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
interface MessageLineProps {
|
||||
@ -54,7 +54,7 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateHeight)
|
||||
}
|
||||
}, [messages])
|
||||
}, [])
|
||||
|
||||
// 函数用于计算根据距离的变化值
|
||||
const calculateValueByDistance = useCallback(
|
||||
@ -130,6 +130,32 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
|
||||
[setSelectedMessage]
|
||||
)
|
||||
|
||||
// 使用 useCallback 记忆化 handleMouseMove 函数,避免不必要的重新创建
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (messagesListRef.current) {
|
||||
const containerRect = e.currentTarget.getBoundingClientRect()
|
||||
const listRect = messagesListRef.current.getBoundingClientRect()
|
||||
setMouseY(e.clientY - listRect.top)
|
||||
|
||||
if (listRect.height > containerRect.height) {
|
||||
const mousePositionRatio = (e.clientY - containerRect.top) / containerRect.height
|
||||
const maxOffset = (containerRect.height - listRect.height) / 2 - 20
|
||||
setListOffsetY(-maxOffset + mousePositionRatio * (maxOffset * 2))
|
||||
} else {
|
||||
setListOffsetY(0)
|
||||
}
|
||||
}
|
||||
},
|
||||
[messagesListRef, setMouseY, setListOffsetY]
|
||||
)
|
||||
|
||||
// 使用 useCallback 记忆化 handleMouseLeave 函数,避免不必要的重新创建
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setMouseY(null)
|
||||
setListOffsetY(0)
|
||||
}, [setMouseY, setListOffsetY])
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const messagesContainer = document.getElementById('messages')
|
||||
if (messagesContainer) {
|
||||
@ -137,29 +163,94 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 使用 useMemo 记忆化列表渲染结果,避免不必要的重新计算
|
||||
const renderedItems = useMemo(() => {
|
||||
if (messages.length === 0) return null
|
||||
|
||||
// 底部锚点
|
||||
const bottomAnchor = (
|
||||
<MessageItem
|
||||
key="bottom-anchor"
|
||||
ref={(el) => {
|
||||
if (el) messageItemsRef.current.set('bottom-anchor', el)
|
||||
else messageItemsRef.current.delete('bottom-anchor')
|
||||
}}
|
||||
style={{
|
||||
opacity: mouseY ? 0.5 + calculateValueByDistance('bottom-anchor', 1) : 0.6
|
||||
}}
|
||||
onClick={scrollToBottom}>
|
||||
<MessageItemContainer
|
||||
style={{ transform: `scale(${1 + calculateValueByDistance('bottom-anchor', 1)})` }}></MessageItemContainer>
|
||||
<Avatar
|
||||
icon={<DownOutlined style={{ color: theme === 'dark' ? 'var(--color-text)' : 'var(--color-primary)' }} />}
|
||||
size={10 + calculateValueByDistance('bottom-anchor', 20)}
|
||||
style={{
|
||||
backgroundColor: theme === 'dark' ? 'var(--color-background-soft)' : 'var(--color-primary-light)',
|
||||
border: `1px solid ${theme === 'dark' ? 'var(--color-border-soft)' : 'var(--color-primary-soft)'}`,
|
||||
opacity: 0.9
|
||||
}}
|
||||
/>
|
||||
</MessageItem>
|
||||
)
|
||||
|
||||
// 消息项列表
|
||||
const messageItems = messages.map((message, index) => {
|
||||
const opacity = 0.5 + calculateValueByDistance(message.id, 1)
|
||||
const scale = 1 + calculateValueByDistance(message.id, 1)
|
||||
const size = 10 + calculateValueByDistance(message.id, 20)
|
||||
const avatarSource = getAvatarSource(isLocalAi, getMessageModelId(message))
|
||||
const username = removeLeadingEmoji(getUserName(message))
|
||||
|
||||
return (
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
ref={(el) => {
|
||||
if (el) messageItemsRef.current.set(message.id, el)
|
||||
else messageItemsRef.current.delete(message.id)
|
||||
}}
|
||||
style={{
|
||||
opacity: mouseY ? opacity : Math.max(0, 0.6 - (0.3 * Math.abs(index - messages.length / 2)) / 5)
|
||||
}}
|
||||
onClick={() => scrollToMessage(message)}>
|
||||
<MessageItemContainer style={{ transform: ` scale(${scale})` }}>
|
||||
<MessageItemTitle>{username}</MessageItemTitle>
|
||||
<MessageItemContent>{message.content.substring(0, 50)}</MessageItemContent>
|
||||
</MessageItemContainer>
|
||||
|
||||
{message.role === 'assistant' ? (
|
||||
<Avatar
|
||||
src={avatarSource}
|
||||
size={size}
|
||||
style={{
|
||||
border: isLocalAi ? '1px solid var(--color-border-soft)' : 'none',
|
||||
filter: theme === 'dark' ? 'invert(0.05)' : undefined
|
||||
}}>
|
||||
A
|
||||
</Avatar>
|
||||
) : (
|
||||
<>
|
||||
{isEmoji(avatar) ? <EmojiAvatar size={size}>{avatar}</EmojiAvatar> : <Avatar src={avatar} size={size} />}
|
||||
</>
|
||||
)}
|
||||
</MessageItem>
|
||||
)
|
||||
})
|
||||
|
||||
return [bottomAnchor, ...messageItems]
|
||||
}, [
|
||||
messages,
|
||||
mouseY,
|
||||
theme,
|
||||
avatar,
|
||||
isLocalAi,
|
||||
calculateValueByDistance,
|
||||
getUserName,
|
||||
scrollToMessage,
|
||||
scrollToBottom
|
||||
])
|
||||
|
||||
if (messages.length === 0) return null
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (messagesListRef.current) {
|
||||
const containerRect = e.currentTarget.getBoundingClientRect()
|
||||
const listRect = messagesListRef.current.getBoundingClientRect()
|
||||
setMouseY(e.clientY - listRect.top)
|
||||
|
||||
if (listRect.height > containerRect.height) {
|
||||
const mousePositionRatio = (e.clientY - containerRect.top) / containerRect.height
|
||||
const maxOffset = (containerRect.height - listRect.height) / 2 - 20
|
||||
setListOffsetY(-maxOffset + mousePositionRatio * (maxOffset * 2))
|
||||
} else {
|
||||
setListOffsetY(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setMouseY(null)
|
||||
setListOffsetY(0)
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageLineContainer
|
||||
ref={containerRef}
|
||||
@ -167,73 +258,7 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
|
||||
onMouseLeave={handleMouseLeave}
|
||||
$height={containerHeight}>
|
||||
<MessagesList ref={messagesListRef} style={{ transform: `translateY(${listOffsetY}px)` }}>
|
||||
<MessageItem
|
||||
key="bottom-anchor"
|
||||
ref={(el) => {
|
||||
if (el) messageItemsRef.current.set('bottom-anchor', el)
|
||||
else messageItemsRef.current.delete('bottom-anchor')
|
||||
}}
|
||||
style={{
|
||||
opacity: mouseY ? 0.5 + calculateValueByDistance('bottom-anchor', 1) : 0.6
|
||||
}}
|
||||
onClick={scrollToBottom}>
|
||||
<MessageItemContainer
|
||||
style={{ transform: `scale(${1 + calculateValueByDistance('bottom-anchor', 1)})` }}></MessageItemContainer>
|
||||
<Avatar
|
||||
icon={<DownOutlined style={{ color: theme === 'dark' ? 'var(--color-text)' : 'var(--color-primary)' }} />}
|
||||
size={10 + calculateValueByDistance('bottom-anchor', 20)}
|
||||
style={{
|
||||
backgroundColor: theme === 'dark' ? 'var(--color-background-soft)' : 'var(--color-primary-light)',
|
||||
border: `1px solid ${theme === 'dark' ? 'var(--color-border-soft)' : 'var(--color-primary-soft)'}`,
|
||||
opacity: 0.9
|
||||
}}
|
||||
/>
|
||||
</MessageItem>
|
||||
{messages.map((message, index) => {
|
||||
const opacity = 0.5 + calculateValueByDistance(message.id, 1)
|
||||
const scale = 1 + calculateValueByDistance(message.id, 1)
|
||||
const size = 10 + calculateValueByDistance(message.id, 20)
|
||||
const avatarSource = getAvatarSource(isLocalAi, getMessageModelId(message))
|
||||
const username = removeLeadingEmoji(getUserName(message))
|
||||
|
||||
return (
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
ref={(el) => {
|
||||
if (el) messageItemsRef.current.set(message.id, el)
|
||||
else messageItemsRef.current.delete(message.id)
|
||||
}}
|
||||
style={{
|
||||
opacity: mouseY ? opacity : Math.max(0, 0.6 - (0.3 * Math.abs(index - messages.length / 2)) / 5)
|
||||
}}
|
||||
onClick={() => scrollToMessage(message)}>
|
||||
<MessageItemContainer style={{ transform: ` scale(${scale})` }}>
|
||||
<MessageItemTitle>{username}</MessageItemTitle>
|
||||
<MessageItemContent>{message.content.substring(0, 50)}</MessageItemContent>
|
||||
</MessageItemContainer>
|
||||
|
||||
{message.role === 'assistant' ? (
|
||||
<Avatar
|
||||
src={avatarSource}
|
||||
size={size}
|
||||
style={{
|
||||
border: isLocalAi ? '1px solid var(--color-border-soft)' : 'none',
|
||||
filter: theme === 'dark' ? 'invert(0.05)' : undefined
|
||||
}}>
|
||||
A
|
||||
</Avatar>
|
||||
) : (
|
||||
<>
|
||||
{isEmoji(avatar) ? (
|
||||
<EmojiAvatar size={size}>{avatar}</EmojiAvatar>
|
||||
) : (
|
||||
<Avatar src={avatar} size={size} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MessageItem>
|
||||
)
|
||||
})}
|
||||
{renderedItems}
|
||||
</MessagesList>
|
||||
</MessageLineContainer>
|
||||
)
|
||||
@ -321,4 +346,5 @@ const EmojiAvatar = styled.div<{ size: number }>`
|
||||
border: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
export default MessageAnchorLine
|
||||
// 使用 memo 包装组件,避免不必要的重渲染
|
||||
export default memo(MessageAnchorLine)
|
||||
|
||||
@ -12,7 +12,7 @@ import FileManager from '@renderer/services/FileManager'
|
||||
import { FileType, FileTypes, Message } from '@renderer/types'
|
||||
import { download } from '@renderer/utils/download'
|
||||
import { Image as AntdImage, Space, Upload } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { FC, memo, useCallback, useMemo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface Props {
|
||||
@ -20,12 +20,13 @@ interface Props {
|
||||
}
|
||||
|
||||
const MessageAttachments: FC<Props> = ({ message }) => {
|
||||
const handleCopyImage = async (image: FileType) => {
|
||||
// 使用 useCallback 记忆化复制图片函数,避免不必要的重新创建
|
||||
const handleCopyImage = useCallback(async (image: FileType) => {
|
||||
const data = await FileManager.readFile(image)
|
||||
const blob = new Blob([data], { type: 'image/png' })
|
||||
const item = new ClipboardItem({ [blob.type]: blob })
|
||||
await navigator.clipboard.write([item])
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!message.files) {
|
||||
return null
|
||||
@ -34,50 +35,65 @@ const MessageAttachments: FC<Props> = ({ message }) => {
|
||||
if (message?.files && message.files[0]?.type === FileTypes.IMAGE) {
|
||||
return (
|
||||
<Container style={{ marginBottom: 8 }}>
|
||||
{message.files?.map((image) => (
|
||||
<Image
|
||||
src={FileManager.getFileUrl(image)}
|
||||
key={image.id}
|
||||
width="33%"
|
||||
preview={{
|
||||
toolbarRender: (
|
||||
_,
|
||||
{
|
||||
transform: { scale },
|
||||
actions: { onFlipY, onFlipX, onRotateLeft, onRotateRight, onZoomOut, onZoomIn, onReset }
|
||||
}
|
||||
) => (
|
||||
<ToobarWrapper size={12} className="toolbar-wrapper">
|
||||
<SwapOutlined rotate={90} onClick={onFlipY} />
|
||||
<SwapOutlined onClick={onFlipX} />
|
||||
<RotateLeftOutlined onClick={onRotateLeft} />
|
||||
<RotateRightOutlined onClick={onRotateRight} />
|
||||
<ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} />
|
||||
<ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} />
|
||||
<UndoOutlined onClick={onReset} />
|
||||
<CopyOutlined onClick={() => handleCopyImage(image)} />
|
||||
<DownloadOutlined onClick={() => download(FileManager.getFileUrl(image))} />
|
||||
</ToobarWrapper>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{message.files?.map((image) => {
|
||||
// 使用 useCallback 记忆化工具栏渲染函数,避免不必要的重新创建
|
||||
const memoizedToolbarRender = useCallback(
|
||||
(
|
||||
_,
|
||||
{
|
||||
transform: { scale },
|
||||
actions: { onFlipY, onFlipX, onRotateLeft, onRotateRight, onZoomOut, onZoomIn, onReset }
|
||||
}
|
||||
) => (
|
||||
<ToobarWrapper size={12} className="toolbar-wrapper">
|
||||
<SwapOutlined rotate={90} onClick={onFlipY} />
|
||||
<SwapOutlined onClick={onFlipX} />
|
||||
<RotateLeftOutlined onClick={onRotateLeft} />
|
||||
<RotateRightOutlined onClick={onRotateRight} />
|
||||
<ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} />
|
||||
<ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} />
|
||||
<UndoOutlined onClick={onReset} />
|
||||
<CopyOutlined onClick={() => handleCopyImage(image)} />
|
||||
<DownloadOutlined onClick={() => download(FileManager.getFileUrl(image))} />
|
||||
</ToobarWrapper>
|
||||
),
|
||||
[image, handleCopyImage] // 依赖于当前循环的 image 对象和 handleCopyImage 函数
|
||||
)
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={FileManager.getFileUrl(image)}
|
||||
key={image.id}
|
||||
width="33%"
|
||||
preview={{
|
||||
toolbarRender: memoizedToolbarRender // 使用记忆化的函数
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
// 使用 useMemo 记忆化文件列表,避免不必要的重新计算
|
||||
const memoizedFileList = useMemo(() => {
|
||||
return message.files?.map((file) => {
|
||||
// 使用 FileManager.getFileUrl 来获取文件URL,它会处理路径问题
|
||||
const fileUrl = FileManager.getFileUrl(file)
|
||||
console.log('消息附件URL:', fileUrl)
|
||||
|
||||
return {
|
||||
uid: file.id,
|
||||
url: fileUrl,
|
||||
status: 'done' as const, // 使用 as const 来指定类型
|
||||
name: FileManager.formatFileName(file)
|
||||
}
|
||||
})
|
||||
}, [message.files])
|
||||
|
||||
return (
|
||||
<Container style={{ marginTop: 2, marginBottom: 8 }} className="message-attachments">
|
||||
<Upload
|
||||
listType="text"
|
||||
disabled
|
||||
fileList={message.files?.map((file) => ({
|
||||
uid: file.id,
|
||||
url: 'file://' + FileManager.getSafePath(file),
|
||||
status: 'done',
|
||||
name: FileManager.formatFileName(file)
|
||||
}))}
|
||||
/>
|
||||
<Upload listType="text" disabled fileList={memoizedFileList} />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@ -108,4 +124,5 @@ const ToobarWrapper = styled(Space)`
|
||||
}
|
||||
`
|
||||
|
||||
export default MessageAttachments
|
||||
// 使用 memo 包装组件,避免不必要的重渲染
|
||||
export default memo(MessageAttachments)
|
||||
|
||||
@ -20,7 +20,6 @@ import MessageAttachments from './MessageAttachments'
|
||||
import MessageError from './MessageError'
|
||||
import MessageImage from './MessageImage'
|
||||
import MessageThought from './MessageThought'
|
||||
import MessageTools from './MessageTools'
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
@ -235,7 +234,7 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Flex gap="8px" wrap style={{ marginBottom: 10 }}>
|
||||
<Flex gap="4px" wrap style={{ marginBottom: '2px' }}>
|
||||
{message.mentions?.map((model) => <MentionTag key={getModelUniqId(model)}>{'@' + model.name}</MentionTag>)}
|
||||
</Flex>
|
||||
{message.referencedMessages && message.referencedMessages.length > 0 && (
|
||||
@ -329,25 +328,24 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
|
||||
/>
|
||||
)}
|
||||
<div className="message-content-tools">
|
||||
{/* These components display tool/thought info separately at the top */}
|
||||
{/* Only display thought info at the top */}
|
||||
<MessageThought message={message} />
|
||||
<MessageTools message={message} />
|
||||
</div>
|
||||
{isSegmentedPlayback ? (
|
||||
// Apply regex replacement here for TTS
|
||||
<TTSHighlightedText text={processedContent.replace(tagsToRemoveRegex, '')} />
|
||||
) : (
|
||||
// Apply regex replacement here for Markdown display
|
||||
<Markdown message={{ ...message, content: processedContent.replace(tagsToRemoveRegex, '') }} />
|
||||
// Don't remove XML tags, let Markdown component handle them
|
||||
<Markdown message={{ ...message, content: processedContent }} />
|
||||
)}
|
||||
{message.metadata?.generateImage && <MessageImage message={message} />}
|
||||
{message.translatedContent && (
|
||||
<Fragment>
|
||||
<Divider style={{ margin: 0, marginBottom: 10 }}>
|
||||
<Divider style={{ margin: 0, marginBottom: 5 }}>
|
||||
<TranslationOutlined />
|
||||
</Divider>
|
||||
{message.translatedContent === t('translate.processing') ? (
|
||||
<BeatLoader color="var(--color-text-2)" size="10" style={{ marginBottom: 15 }} />
|
||||
<BeatLoader color="var(--color-text-2)" size="10" style={{ marginBottom: 5 }} />
|
||||
) : (
|
||||
// Render translated content (assuming it doesn't need tag removal, adjust if needed)
|
||||
<Markdown message={{ ...message, content: message.translatedContent }} />
|
||||
@ -428,10 +426,10 @@ const SearchingContainer = styled.div`
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
background-color: var(--color-background-mute);
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 10px;
|
||||
gap: 10px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 5px;
|
||||
gap: 8px;
|
||||
`
|
||||
|
||||
const MentionTag = styled.span`
|
||||
@ -446,15 +444,15 @@ const SearchingText = styled.div`
|
||||
`
|
||||
|
||||
const SearchEntryPoint = styled.div`
|
||||
margin: 10px 2px;
|
||||
margin: 5px 2px;
|
||||
`
|
||||
|
||||
// 引用消息样式 - 使用全局样式
|
||||
const referenceStyles = `
|
||||
.reference-collapse {
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 5px;
|
||||
border: 1px solid var(--color-border) !important;
|
||||
border-radius: 8px !important;
|
||||
border-radius: 6px !important;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-1) !important;
|
||||
|
||||
@ -536,8 +534,8 @@ const referenceStyles = `
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 12px !important;
|
||||
padding-top: 8px !important;
|
||||
padding: 8px !important;
|
||||
padding-top: 5px !important;
|
||||
padding-bottom: 2px !important;
|
||||
}
|
||||
|
||||
@ -554,7 +552,7 @@ const referenceStyles = `
|
||||
}
|
||||
|
||||
.reference-bottom-spacing {
|
||||
height: 10px;
|
||||
height: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -572,8 +570,8 @@ try {
|
||||
referenceStyles +
|
||||
`
|
||||
.message-content-tools {
|
||||
margin-top: 20px; /* Adjust as needed */
|
||||
margin-bottom: 10px; /* Add space before main content */
|
||||
margin-top: 5px; /* 进一步减少顶部间距 */
|
||||
margin-bottom: 2px; /* 进一步减少底部间距 */
|
||||
}
|
||||
`
|
||||
document.head.appendChild(styleElement)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Message } from '@renderer/types'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
import { Alert as AntdAlert } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { FC, memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -48,10 +48,12 @@ const MessageError: FC<{ message: Message }> = ({ message }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const MessageErrorInfo: FC<{ message: Message }> = ({ message }) => {
|
||||
const { t } = useTranslation()
|
||||
// 将常量提取到组件外部,避免在每次渲染时重新创建
|
||||
const HTTP_ERROR_CODES = [400, 401, 403, 404, 429, 500, 502, 503, 504]
|
||||
|
||||
const HTTP_ERROR_CODES = [400, 401, 403, 404, 429, 500, 502, 503, 504]
|
||||
// 使用 memo 包装 MessageErrorInfo 组件
|
||||
const MessageErrorInfo: FC<{ message: Message }> = memo(({ message }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Add more robust checks: ensure error is an object and status is a number before accessing/including
|
||||
if (
|
||||
@ -64,7 +66,7 @@ const MessageErrorInfo: FC<{ message: Message }> = ({ message }) => {
|
||||
}
|
||||
|
||||
return <Alert description={t('error.chat.response')} type="error" />
|
||||
}
|
||||
})
|
||||
|
||||
const Alert = styled(AntdAlert)`
|
||||
margin: 15px 0 8px;
|
||||
@ -72,4 +74,5 @@ const Alert = styled(AntdAlert)`
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
export default MessageError
|
||||
// 使用 memo 包装组件,避免不必要的重渲染
|
||||
export default memo(MessageError)
|
||||
|
||||
@ -38,12 +38,27 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
||||
return messages[0]?.id
|
||||
}, [messages])
|
||||
|
||||
// 记录当前选中的消息 ID
|
||||
const selectedMessageIdRef = useRef<string | null>(null)
|
||||
|
||||
const setSelectedMessage = useCallback(
|
||||
(message: Message) => {
|
||||
messages.forEach(async (m) => {
|
||||
await editMessage(m.id, { foldSelected: m.id === message.id })
|
||||
})
|
||||
// 优化:只更新当前选中的消息和之前选中的消息,而不是所有消息
|
||||
const previousSelectedId = selectedMessageIdRef.current
|
||||
const newSelectedId = message.id
|
||||
|
||||
// 更新引用以跟踪当前选中的消息 ID
|
||||
selectedMessageIdRef.current = newSelectedId
|
||||
|
||||
// 如果有之前选中的消息,将其设置为非选中状态
|
||||
if (previousSelectedId && previousSelectedId !== newSelectedId) {
|
||||
editMessage(previousSelectedId, { foldSelected: false })
|
||||
}
|
||||
|
||||
// 将新选中的消息设置为选中状态
|
||||
editMessage(newSelectedId, { foldSelected: true })
|
||||
|
||||
// 滚动到选中的消息
|
||||
setTimeout(() => {
|
||||
const messageElement = document.getElementById(`message-${message.id}`)
|
||||
if (messageElement) {
|
||||
@ -51,7 +66,7 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
||||
}
|
||||
}, 200)
|
||||
},
|
||||
[editMessage, messages]
|
||||
[editMessage]
|
||||
)
|
||||
|
||||
const isGrouped = messageLength > 1 && messages.every((m) => m.role === 'assistant')
|
||||
@ -76,12 +91,9 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messageLength])
|
||||
|
||||
// 添加对流程图节点点击事件的监听
|
||||
useEffect(() => {
|
||||
// 只在组件挂载和消息数组变化时添加监听器
|
||||
if (!isGrouped || messageLength <= 1) return
|
||||
|
||||
const handleFlowNavigate = (event: CustomEvent) => {
|
||||
// 使用 useMemo 记忆化流程图导航处理函数,减少重新创建
|
||||
const handleFlowNavigate = useMemo(() => {
|
||||
return (event: CustomEvent) => {
|
||||
const { messageId } = event.detail
|
||||
|
||||
// 查找对应的消息在当前消息组中的索引
|
||||
@ -98,6 +110,12 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [messages, selectedIndex, setSelectedIndex, setSelectedMessage])
|
||||
|
||||
// 添加对流程图节点点击事件的监听
|
||||
useEffect(() => {
|
||||
// 只在组件挂载和消息数组变化时添加监听器
|
||||
if (!isGrouped || messageLength <= 1) return
|
||||
|
||||
// 添加事件监听器
|
||||
document.addEventListener('flow-navigate-to-message', handleFlowNavigate as EventListener)
|
||||
@ -106,17 +124,14 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
||||
return () => {
|
||||
document.removeEventListener('flow-navigate-to-message', handleFlowNavigate as EventListener)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messages, selectedIndex, isGrouped, messageLength])
|
||||
}, [isGrouped, messageLength, handleFlowNavigate])
|
||||
|
||||
// 添加对LOCATE_MESSAGE事件的监听
|
||||
useEffect(() => {
|
||||
// 为每个消息注册一个定位事件监听器
|
||||
const eventHandlers: { [key: string]: () => void } = {}
|
||||
// 使用 useMemo 创建消息定位处理函数映射,减少重新创建
|
||||
const messageLocateHandlers = useMemo(() => {
|
||||
const handlers: { [key: string]: () => void } = {}
|
||||
|
||||
messages.forEach((message) => {
|
||||
const eventName = EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id
|
||||
const handler = () => {
|
||||
handlers[message.id] = () => {
|
||||
// 检查消息是否处于可见状态
|
||||
const element = document.getElementById(`message-${message.id}`)
|
||||
if (element) {
|
||||
@ -131,7 +146,19 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return handlers
|
||||
}, [messages, setSelectedMessage])
|
||||
|
||||
// 添加对LOCATE_MESSAGE事件的监听
|
||||
useEffect(() => {
|
||||
// 为每个消息注册一个定位事件监听器
|
||||
const eventHandlers: { [key: string]: () => void } = {}
|
||||
|
||||
// 注册所有消息的事件监听器
|
||||
Object.entries(messageLocateHandlers).forEach(([messageId, handler]) => {
|
||||
const eventName = `${EVENT_NAMES.LOCATE_MESSAGE}:${messageId}`
|
||||
eventHandlers[eventName] = handler
|
||||
EventEmitter.on(eventName, handler)
|
||||
})
|
||||
@ -143,7 +170,7 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
||||
EventEmitter.off(eventName, handler)
|
||||
})
|
||||
}
|
||||
}, [messages, setSelectedMessage])
|
||||
}, [messageLocateHandlers])
|
||||
|
||||
// 使用useMemo缓存消息渲染结果,减少重复计算
|
||||
const renderedMessages = useMemo(() => {
|
||||
@ -229,10 +256,16 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
|
||||
<MessageGroupMenuBar
|
||||
multiModelMessageStyle={multiModelMessageStyle}
|
||||
setMultiModelMessageStyle={(style) => {
|
||||
// 优化:使用批量更新消息样式,避免多次调用 editMessage
|
||||
setMultiModelMessageStyle(style)
|
||||
messages.forEach((message) => {
|
||||
editMessage(message.id, { multiModelMessageStyle: style })
|
||||
})
|
||||
|
||||
// 如果有批量更新消息的API,可以使用批量 API
|
||||
// 如: editMessagesBatch(messages.map(m => m.id), { multiModelMessageStyle: style })
|
||||
|
||||
// 如果没有批量 API,使用 Promise.all 并行处理所有更新
|
||||
Promise.all(messages.map((message) => editMessage(message.id, { multiModelMessageStyle: style }))).catch(
|
||||
(err) => console.error('Failed to update message styles:', err)
|
||||
)
|
||||
}}
|
||||
messages={messages}
|
||||
selectMessageId={getSelectedMessageId()}
|
||||
|
||||
@ -6,7 +6,7 @@ import { useAppDispatch } from '@renderer/store'
|
||||
import { setFoldDisplayMode } from '@renderer/store/settings'
|
||||
import { Message, Model } from '@renderer/types'
|
||||
import { Avatar, Segmented as AntdSegmented, Tooltip } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { FC, memo, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -24,11 +24,59 @@ const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, selec
|
||||
const { foldDisplayMode } = useSettings()
|
||||
const isCompact = foldDisplayMode === 'compact'
|
||||
|
||||
// 使用 useCallback 记忆化显示模式切换函数,避免不必要的重新创建
|
||||
const handleDisplayModeToggle = useCallback(() => {
|
||||
dispatch(setFoldDisplayMode(isCompact ? 'expanded' : 'compact'))
|
||||
}, [dispatch, isCompact])
|
||||
|
||||
// 使用 useCallback 记忆化选择消息函数,避免不必要的重新创建
|
||||
const handleSegmentedChange = useCallback(
|
||||
(value: unknown) => {
|
||||
const messageId = value as string
|
||||
const message = messages.find((message) => message.id === messageId) as Message
|
||||
setSelectedMessage(message)
|
||||
},
|
||||
[messages, setSelectedMessage]
|
||||
)
|
||||
|
||||
// 使用 useCallback 记忆化头像点击函数,避免不必要的重新创建
|
||||
const handleAvatarClick = useCallback(
|
||||
(message: Message) => {
|
||||
setSelectedMessage(message)
|
||||
},
|
||||
[setSelectedMessage]
|
||||
)
|
||||
|
||||
// 使用 useMemo 记忆化紧凑模式的头像列表,避免不必要的重新计算
|
||||
const compactModeAvatars = useMemo(() => {
|
||||
return messages.map((message, index) => (
|
||||
<Tooltip key={index} title={message.model?.name} placement="top" mouseEnterDelay={0.2}>
|
||||
<AvatarWrapper
|
||||
className="avatar-wrapper"
|
||||
isSelected={message.id === selectMessageId}
|
||||
onClick={() => handleAvatarClick(message)}>
|
||||
<ModelAvatar model={message.model as Model} size={28} />
|
||||
</AvatarWrapper>
|
||||
</Tooltip>
|
||||
))
|
||||
}, [messages, selectMessageId, handleAvatarClick])
|
||||
|
||||
// 使用 useMemo 记忆化展开模式的选项数组,避免不必要的重新计算
|
||||
const expandedModeOptions = useMemo(() => {
|
||||
return messages.map((message) => ({
|
||||
label: (
|
||||
<SegmentedLabel>
|
||||
<ModelAvatar model={message.model as Model} size={20} />
|
||||
<ModelName>{message.model?.name}</ModelName>
|
||||
</SegmentedLabel>
|
||||
),
|
||||
value: message.id
|
||||
}))
|
||||
}, [messages])
|
||||
|
||||
return (
|
||||
<ModelsWrapper>
|
||||
<DisplayModeToggle
|
||||
displayMode={foldDisplayMode}
|
||||
onClick={() => dispatch(setFoldDisplayMode(isCompact ? 'expanded' : 'compact'))}>
|
||||
<DisplayModeToggle displayMode={foldDisplayMode} onClick={handleDisplayModeToggle}>
|
||||
<Tooltip
|
||||
title={
|
||||
foldDisplayMode === 'compact'
|
||||
@ -43,37 +91,13 @@ const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, selec
|
||||
<ModelsContainer $displayMode={foldDisplayMode}>
|
||||
{foldDisplayMode === 'compact' ? (
|
||||
/* Compact style display */
|
||||
<Avatar.Group className="avatar-group">
|
||||
{messages.map((message, index) => (
|
||||
<Tooltip key={index} title={message.model?.name} placement="top" mouseEnterDelay={0.2}>
|
||||
<AvatarWrapper
|
||||
className="avatar-wrapper"
|
||||
isSelected={message.id === selectMessageId}
|
||||
onClick={() => {
|
||||
setSelectedMessage(message)
|
||||
}}>
|
||||
<ModelAvatar model={message.model as Model} size={28} />
|
||||
</AvatarWrapper>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Avatar.Group>
|
||||
<Avatar.Group className="avatar-group">{compactModeAvatars}</Avatar.Group>
|
||||
) : (
|
||||
/* Expanded style display */
|
||||
<Segmented
|
||||
value={selectMessageId}
|
||||
onChange={(value) => {
|
||||
const message = messages.find((message) => message.id === value) as Message
|
||||
setSelectedMessage(message)
|
||||
}}
|
||||
options={messages.map((message) => ({
|
||||
label: (
|
||||
<SegmentedLabel>
|
||||
<ModelAvatar model={message.model as Model} size={20} />
|
||||
<ModelName>{message.model?.name}</ModelName>
|
||||
</SegmentedLabel>
|
||||
),
|
||||
value: message.id
|
||||
}))}
|
||||
onChange={handleSegmentedChange}
|
||||
options={expandedModeOptions}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
@ -253,4 +277,5 @@ const ModelName = styled.span`
|
||||
font-size: 12px;
|
||||
`
|
||||
|
||||
export default MessageGroupModelList
|
||||
// 使用 memo 包装组件,避免不必要的重渲染
|
||||
export default memo(MessageGroupModelList)
|
||||
|
||||
@ -6,7 +6,7 @@ import { useAppDispatch } from '@renderer/store'
|
||||
import { setGridColumns, setGridPopoverTrigger } from '@renderer/store/settings'
|
||||
import { Col, Row, Select, Slider } from 'antd'
|
||||
import { Popover } from 'antd'
|
||||
import { FC, useState } from 'react'
|
||||
import { FC, memo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const MessageGroupSettings: FC = () => {
|
||||
@ -56,4 +56,5 @@ const MessageGroupSettings: FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageGroupSettings
|
||||
// 使用 memo 包装组件,避免不必要的重渲染
|
||||
export default memo(MessageGroupSettings)
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import { Message } from '@renderer/types'
|
||||
import { Image as AntdImage, Space } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { FC, memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
@ -21,103 +21,118 @@ interface Props {
|
||||
const MessageImage: FC<Props> = ({ message }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onDownload = (imageBase64: string, index: number) => {
|
||||
try {
|
||||
const link = document.createElement('a')
|
||||
link.href = imageBase64
|
||||
link.download = `image-${Date.now()}-${index}.png`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.message.success(t('message.download.success'))
|
||||
} catch (error) {
|
||||
console.error('下载图片失败:', error)
|
||||
window.message.error(t('message.download.failed'))
|
||||
}
|
||||
}
|
||||
// 使用 useCallback 记忆化下载函数,避免不必要的重新创建
|
||||
const onDownload = useCallback(
|
||||
(imageBase64: string, index: number) => {
|
||||
try {
|
||||
const link = document.createElement('a')
|
||||
link.href = imageBase64
|
||||
link.download = `image-${Date.now()}-${index}.png`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.message.success(t('message.download.success'))
|
||||
} catch (error) {
|
||||
console.error('下载图片失败:', error)
|
||||
window.message.error(t('message.download.failed'))
|
||||
}
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
// 复制图片到剪贴板
|
||||
const onCopy = async (type: string, image: string) => {
|
||||
try {
|
||||
switch (type) {
|
||||
case 'base64': {
|
||||
// 处理 base64 格式的图片
|
||||
const parts = image.split(';base64,')
|
||||
if (parts.length === 2) {
|
||||
const mimeType = parts[0].replace('data:', '')
|
||||
const base64Data = parts[1]
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteArrays: Uint8Array[] = []
|
||||
const onCopy = useCallback(
|
||||
async (type: string, image: string) => {
|
||||
try {
|
||||
switch (type) {
|
||||
case 'base64': {
|
||||
// 处理 base64 格式的图片
|
||||
const parts = image.split(';base64,')
|
||||
if (parts.length === 2) {
|
||||
const mimeType = parts[0].replace('data:', '')
|
||||
const base64Data = parts[1]
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteArrays: Uint8Array[] = []
|
||||
|
||||
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
|
||||
const slice = byteCharacters.slice(offset, offset + 512)
|
||||
const byteNumbers = new Array(slice.length)
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
byteNumbers[i] = slice.charCodeAt(i)
|
||||
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
|
||||
const slice = byteCharacters.slice(offset, offset + 512)
|
||||
const byteNumbers = new Array(slice.length)
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
byteNumbers[i] = slice.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
byteArrays.push(byteArray)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
byteArrays.push(byteArray)
|
||||
|
||||
const blob = new Blob(byteArrays, { type: mimeType })
|
||||
await navigator.clipboard.write([new ClipboardItem({ [mimeType]: blob })])
|
||||
} else {
|
||||
throw new Error('无效的 base64 图片格式')
|
||||
}
|
||||
|
||||
const blob = new Blob(byteArrays, { type: mimeType })
|
||||
await navigator.clipboard.write([new ClipboardItem({ [mimeType]: blob })])
|
||||
} else {
|
||||
throw new Error('无效的 base64 图片格式')
|
||||
break
|
||||
}
|
||||
break
|
||||
case 'url':
|
||||
{
|
||||
// 处理 URL 格式的图片
|
||||
const response = await fetch(image)
|
||||
const blob = await response.blob()
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob
|
||||
})
|
||||
])
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'url':
|
||||
{
|
||||
// 处理 URL 格式的图片
|
||||
const response = await fetch(image)
|
||||
const blob = await response.blob()
|
||||
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob
|
||||
})
|
||||
])
|
||||
}
|
||||
break
|
||||
window.message.success(t('message.copy.success'))
|
||||
} catch (error) {
|
||||
console.error('复制图片失败:', error)
|
||||
window.message.error(t('message.copy.failed'))
|
||||
}
|
||||
|
||||
window.message.success(t('message.copy.success'))
|
||||
} catch (error) {
|
||||
console.error('复制图片失败:', error)
|
||||
window.message.error(t('message.copy.failed'))
|
||||
}
|
||||
}
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
return (
|
||||
<Container style={{ marginBottom: 8 }}>
|
||||
{message.metadata?.generateImage!.images.map((image, index) => (
|
||||
<Image
|
||||
src={image}
|
||||
key={`image-${index}`}
|
||||
width="33%"
|
||||
preview={{
|
||||
toolbarRender: (
|
||||
_,
|
||||
{
|
||||
transform: { scale },
|
||||
actions: { onFlipY, onFlipX, onRotateLeft, onRotateRight, onZoomOut, onZoomIn, onReset }
|
||||
}
|
||||
) => (
|
||||
<ToobarWrapper size={12} className="toolbar-wrapper">
|
||||
<SwapOutlined rotate={90} onClick={onFlipY} />
|
||||
<SwapOutlined onClick={onFlipX} />
|
||||
<RotateLeftOutlined onClick={onRotateLeft} />
|
||||
<RotateRightOutlined onClick={onRotateRight} />
|
||||
<ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} />
|
||||
<ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} />
|
||||
<UndoOutlined onClick={onReset} />
|
||||
<CopyOutlined onClick={() => onCopy(message.metadata?.generateImage?.type!, image)} />
|
||||
<DownloadOutlined onClick={() => onDownload(image, index)} />
|
||||
</ToobarWrapper>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{message.metadata?.generateImage!.images.map((image, index) => {
|
||||
// 使用 useCallback 记忆化工具栏渲染函数,避免不必要的重新创建
|
||||
const memoizedToolbarRender = useCallback(
|
||||
(
|
||||
_: any,
|
||||
{
|
||||
transform: { scale },
|
||||
actions: { onFlipY, onFlipX, onRotateLeft, onRotateRight, onZoomOut, onZoomIn, onReset }
|
||||
}: any
|
||||
) => (
|
||||
<ToobarWrapper size={12} className="toolbar-wrapper">
|
||||
<SwapOutlined rotate={90} onClick={onFlipY} />
|
||||
<SwapOutlined onClick={onFlipX} />
|
||||
<RotateLeftOutlined onClick={onRotateLeft} />
|
||||
<RotateRightOutlined onClick={onRotateRight} />
|
||||
<ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} />
|
||||
<ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} />
|
||||
<UndoOutlined onClick={onReset} />
|
||||
<CopyOutlined onClick={() => onCopy(message.metadata?.generateImage?.type!, image)} />
|
||||
<DownloadOutlined onClick={() => onDownload(image, index)} />
|
||||
</ToobarWrapper>
|
||||
),
|
||||
[image, index, onCopy, onDownload, message.metadata?.generateImage?.type]
|
||||
)
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={image}
|
||||
key={`image-${index}`}
|
||||
width="33%"
|
||||
preview={{
|
||||
toolbarRender: memoizedToolbarRender
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@ -145,4 +160,5 @@ const ToobarWrapper = styled(Space)`
|
||||
}
|
||||
`
|
||||
|
||||
export default MessageImage
|
||||
// 使用 memo 包装组件,避免不必要的重渲染
|
||||
export default memo(MessageImage)
|
||||
|
||||
@ -339,25 +339,42 @@ const MessageMenubar: FC<Props> = (props) => {
|
||||
].filter(Boolean)
|
||||
}
|
||||
],
|
||||
[message, messageContainerRef, onEdit, onNewBranch, t, topic.name, exportMenuOptions]
|
||||
// 优化依赖项,只包含必要的属性
|
||||
[
|
||||
message.id,
|
||||
message.content,
|
||||
message.createdAt,
|
||||
messageContainerRef,
|
||||
onEdit,
|
||||
onNewBranch,
|
||||
t,
|
||||
topic.name,
|
||||
exportMenuOptions
|
||||
]
|
||||
)
|
||||
|
||||
const onRegenerate = async (e: React.MouseEvent | undefined) => {
|
||||
e?.stopPropagation?.()
|
||||
if (loading) return
|
||||
const selectedModel = isGrouped ? model : assistantModel
|
||||
const _message = resetAssistantMessage(message, selectedModel)
|
||||
editMessage(message.id, { ..._message })
|
||||
resendMessage(_message, assistant)
|
||||
}
|
||||
const onRegenerate = useCallback(
|
||||
async (e: React.MouseEvent | undefined) => {
|
||||
e?.stopPropagation?.()
|
||||
if (loading) return
|
||||
const selectedModel = isGrouped ? model : assistantModel
|
||||
const _message = resetAssistantMessage(message, selectedModel)
|
||||
editMessage(message.id, { ..._message })
|
||||
resendMessage(_message, assistant)
|
||||
},
|
||||
[loading, isGrouped, model, assistantModel, message, editMessage, resendMessage, assistant]
|
||||
)
|
||||
|
||||
const onMentionModel = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (loading) return
|
||||
const selectedModel = await SelectModelPopup.show({ model })
|
||||
if (!selectedModel) return
|
||||
resendMessage(message, { ...assistant, model: selectedModel }, true)
|
||||
}
|
||||
const onMentionModel = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (loading) return
|
||||
const selectedModel = await SelectModelPopup.show({ model })
|
||||
if (!selectedModel) return
|
||||
resendMessage(message, { ...assistant, model: selectedModel }, true)
|
||||
},
|
||||
[loading, model, message, assistant, resendMessage]
|
||||
)
|
||||
|
||||
const onUseful = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { selectStreamMessage } from '@renderer/store/messages'
|
||||
import { selectRegularMessage, selectStreamMessage } from '@renderer/store/messages'
|
||||
import { Assistant, Message, Topic } from '@renderer/types'
|
||||
import { memo, useMemo } from 'react'
|
||||
import styled from 'styled-components'
|
||||
@ -34,19 +34,8 @@ const MessageStream: React.FC<MessageStreamProps> = ({
|
||||
// 获取流式消息,使用选择器减少不必要的重新渲染
|
||||
const streamMessage = useAppSelector((state) => selectStreamMessage(state, _message.topicId, _message.id))
|
||||
|
||||
// 获取常规消息,使用选择器减少不必要的重新渲染
|
||||
const regularMessage = useAppSelector((state) => {
|
||||
// 如果是用户消息,直接使用传入的_message
|
||||
if (_message.role === 'user') {
|
||||
return _message
|
||||
}
|
||||
|
||||
// 对于助手消息,从store中查找最新状态
|
||||
const topicMessages = state.messages?.messagesByTopic?.[_message.topicId]
|
||||
if (!topicMessages) return _message
|
||||
|
||||
return topicMessages.find((m) => m.id === _message.id) || _message
|
||||
})
|
||||
// 获取常规消息,使用记忆化选择器减少不必要的重新渲染
|
||||
const regularMessage = useAppSelector((state) => selectRegularMessage(state, _message.topicId, _message.id, _message))
|
||||
|
||||
// 使用useMemo缓存计算结果
|
||||
const { isStreaming, message } = useMemo(() => {
|
||||
@ -70,13 +59,7 @@ const MessageStream: React.FC<MessageStreamProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// 使用自定义比较函数的memo包装组件,只在关键属性变化时重新渲染
|
||||
export default memo(MessageStream, (prevProps, nextProps) => {
|
||||
// 只在关键属性变化时重新渲染
|
||||
return (
|
||||
prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.content === nextProps.message.content &&
|
||||
prevProps.message.status === nextProps.message.status &&
|
||||
prevProps.topic.id === nextProps.topic.id
|
||||
)
|
||||
})
|
||||
// 使用 React.memo 包装组件,使用默认的浅层比较
|
||||
// 这样可以确保所有属性变化都能触发重新渲染
|
||||
// 对于这种组件,默认的浅层比较通常更安全和简单
|
||||
export default memo(MessageStream)
|
||||
|
||||
@ -2,7 +2,7 @@ import { CheckOutlined } from '@ant-design/icons'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { Message } from '@renderer/types'
|
||||
import { Collapse, message as antdMessage, Tooltip } from 'antd'
|
||||
import { FC, useEffect, useMemo, useState } from 'react'
|
||||
import { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BarLoader from 'react-spinners/BarLoader'
|
||||
import styled from 'styled-components'
|
||||
@ -33,57 +33,64 @@ const MessageThought: FC<Props> = ({ message }) => {
|
||||
return null
|
||||
}
|
||||
|
||||
const copyThought = () => {
|
||||
// 使用 useCallback 记忆化 copyThought 函数,避免不必要的重新创建
|
||||
const copyThought = useCallback(() => {
|
||||
if (message.reasoning_content) {
|
||||
navigator.clipboard.writeText(message.reasoning_content)
|
||||
antdMessage.success({ content: t('message.copied'), key: 'copy-message' })
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
}, [message.reasoning_content, t])
|
||||
|
||||
const thinkingTime = message.metrics?.time_thinking_millsec || 0
|
||||
const thinkingTimeSeconds = (thinkingTime / 1000).toFixed(1)
|
||||
const isPaused = message.status === 'paused'
|
||||
|
||||
// 使用 useMemo 记忆化 Collapse 的 items 数组,避免不必要的重新创建
|
||||
const collapseItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'thought',
|
||||
label: (
|
||||
<MessageTitleLabel>
|
||||
<TinkingText>
|
||||
{isThinking ? t('chat.thinking') : t('chat.deeply_thought', { secounds: thinkingTimeSeconds })}
|
||||
</TinkingText>
|
||||
{isThinking && !isPaused && <BarLoader color="#9254de" />}
|
||||
{(!isThinking || isPaused) && (
|
||||
<Tooltip title={t('common.copy')} mouseEnterDelay={0.8}>
|
||||
<ActionButton
|
||||
className="message-action-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
copyThought()
|
||||
}}
|
||||
aria-label={t('common.copy')}>
|
||||
{!copied && <i className="iconfont icon-copy"></i>}
|
||||
{copied && <CheckOutlined style={{ color: 'var(--color-primary)' }} />}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</MessageTitleLabel>
|
||||
),
|
||||
children: (
|
||||
<div style={{ fontFamily, fontSize }}>
|
||||
<Markdown message={{ ...message, content: message.reasoning_content || '' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
[isThinking, isPaused, t, thinkingTimeSeconds, copied, copyThought, fontFamily, fontSize, message.reasoning_content]
|
||||
)
|
||||
|
||||
return (
|
||||
<CollapseContainer
|
||||
activeKey={activeKey}
|
||||
size="small"
|
||||
onChange={() => setActiveKey((key) => (key ? '' : 'thought'))}
|
||||
className="message-thought-container"
|
||||
items={[
|
||||
{
|
||||
key: 'thought',
|
||||
label: (
|
||||
<MessageTitleLabel>
|
||||
<TinkingText>
|
||||
{isThinking ? t('chat.thinking') : t('chat.deeply_thought', { secounds: thinkingTimeSeconds })}
|
||||
</TinkingText>
|
||||
{isThinking && !isPaused && <BarLoader color="#9254de" />}
|
||||
{(!isThinking || isPaused) && (
|
||||
<Tooltip title={t('common.copy')} mouseEnterDelay={0.8}>
|
||||
<ActionButton
|
||||
className="message-action-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
copyThought()
|
||||
}}
|
||||
aria-label={t('common.copy')}>
|
||||
{!copied && <i className="iconfont icon-copy"></i>}
|
||||
{copied && <CheckOutlined style={{ color: 'var(--color-primary)' }} />}
|
||||
</ActionButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</MessageTitleLabel>
|
||||
),
|
||||
children: (
|
||||
<div style={{ fontFamily, fontSize }}>
|
||||
<Markdown message={{ ...message, content: message.reasoning_content }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]}
|
||||
items={collapseItems}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -132,4 +139,5 @@ const ActionButton = styled.button`
|
||||
}
|
||||
`
|
||||
|
||||
export default MessageThought
|
||||
// 使用 memo 包装组件,避免不必要的重渲染
|
||||
export default memo(MessageThought)
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import { CheckOutlined, ExpandOutlined, LoadingOutlined, WarningOutlined } from '@ant-design/icons'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { Message } from '@renderer/types'
|
||||
import { Collapse, message as antdMessage, Modal, Tooltip } from 'antd'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { FC, useMemo, useState } from 'react'
|
||||
import { message as antdMessage, Modal, Tooltip } from 'antd'
|
||||
import { FC, memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import CustomCollapse from './CustomCollapse'
|
||||
import ExpandedResponseContent from './ExpandedResponseContent'
|
||||
import ToolResponseContent from './ToolResponseContent'
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
}
|
||||
@ -23,25 +26,55 @@ const MessageTools: FC<Props> = ({ message }) => {
|
||||
: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans","Helvetica Neue", sans-serif'
|
||||
}, [messageFont])
|
||||
|
||||
// 使用 useCallback 记忆化 copyContent 函数,避免不必要的重新创建
|
||||
const copyContent = useCallback(
|
||||
(content: string, toolId: string) => {
|
||||
navigator.clipboard.writeText(content)
|
||||
antdMessage.success({ content: t('message.copied'), key: 'copy-message' })
|
||||
setCopiedMap((prev) => ({ ...prev, [toolId]: true }))
|
||||
setTimeout(() => setCopiedMap((prev) => ({ ...prev, [toolId]: false })), 2000)
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
// 使用 activeKeys 状态管理折叠面板的展开/折叠状态
|
||||
|
||||
const toolResponses = message.metadata?.mcpTools || []
|
||||
|
||||
if (isEmpty(toolResponses)) {
|
||||
return null
|
||||
}
|
||||
// 预处理响应数据,避免在展开时计算
|
||||
const responseStringsRef = useRef<Record<string, string>>({})
|
||||
|
||||
const copyContent = (content: string, toolId: string) => {
|
||||
navigator.clipboard.writeText(content)
|
||||
antdMessage.success({ content: t('message.copied'), key: 'copy-message' })
|
||||
setCopiedMap((prev) => ({ ...prev, [toolId]: true }))
|
||||
setTimeout(() => setCopiedMap((prev) => ({ ...prev, [toolId]: false })), 2000)
|
||||
}
|
||||
// 使用 useLayoutEffect 在渲染前预处理数据
|
||||
useLayoutEffect(() => {
|
||||
const strings: Record<string, string> = {}
|
||||
let hasChanges = false
|
||||
|
||||
const handleCollapseChange = (keys: string | string[]) => {
|
||||
setActiveKeys(Array.isArray(keys) ? keys : [keys])
|
||||
}
|
||||
for (const toolResponse of toolResponses) {
|
||||
if (toolResponse.status === 'done' && toolResponse.response) {
|
||||
// 如果该响应已经处理过,则跳过
|
||||
if (responseStringsRef.current[toolResponse.id]) {
|
||||
strings[toolResponse.id] = responseStringsRef.current[toolResponse.id]
|
||||
continue
|
||||
}
|
||||
|
||||
// Format tool responses for collapse items
|
||||
const getCollapseItems = () => {
|
||||
try {
|
||||
strings[toolResponse.id] = JSON.stringify(toolResponse.response, null, 2)
|
||||
hasChanges = true
|
||||
} catch (error) {
|
||||
console.error('Error stringifying response:', error)
|
||||
strings[toolResponse.id] = String(toolResponse.response)
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
responseStringsRef.current = { ...responseStringsRef.current, ...strings }
|
||||
}
|
||||
}, [toolResponses])
|
||||
|
||||
// 使用 useMemo 记忆化 getCollapseItems 函数返回的 items 数组,避免不必要的重新计算
|
||||
const collapseItems = useMemo(() => {
|
||||
const items: { key: string; label: React.ReactNode; children: React.ReactNode }[] = []
|
||||
// Add tool responses
|
||||
for (const toolResponse of toolResponses) {
|
||||
@ -79,8 +112,9 @@ const MessageTools: FC<Props> = ({ message }) => {
|
||||
className="message-action-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// 使用预处理的响应数据
|
||||
setExpandedResponse({
|
||||
content: JSON.stringify(response, null, 2),
|
||||
content: responseStringsRef.current[id] || '',
|
||||
title: tool.name
|
||||
})
|
||||
}}
|
||||
@ -93,7 +127,9 @@ const MessageTools: FC<Props> = ({ message }) => {
|
||||
className="message-action-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
copyContent(JSON.stringify(result, null, 2), id)
|
||||
// 使用预处理的响应数据
|
||||
const resultString = JSON.stringify(result, null, 2)
|
||||
copyContent(resultString, id)
|
||||
}}
|
||||
aria-label={t('common.copy')}>
|
||||
{!copiedMap[id] && <i className="iconfont icon-copy"></i>}
|
||||
@ -105,29 +141,38 @@ const MessageTools: FC<Props> = ({ message }) => {
|
||||
</ActionButtonsContainer>
|
||||
</MessageTitleLabel>
|
||||
),
|
||||
children: isDone && result && (
|
||||
<ToolResponseContainer style={{ fontFamily, fontSize: '12px' }}>
|
||||
<CodeBlock>{JSON.stringify(result, null, 2)}</CodeBlock>
|
||||
</ToolResponseContainer>
|
||||
)
|
||||
children: isDone && result && <ToolResponseContent result={result} fontFamily={fontFamily} fontSize="12px" />
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}, [toolResponses, t, copiedMap, copyContent])
|
||||
|
||||
// 如果没有工具响应,则不渲染组件
|
||||
if (toolResponses.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CollapseContainer
|
||||
activeKey={activeKeys}
|
||||
size="small"
|
||||
onChange={handleCollapseChange}
|
||||
className="message-tools-container"
|
||||
items={getCollapseItems()}
|
||||
expandIcon={({ isActive }) => (
|
||||
<CollapsibleIcon className={`iconfont ${isActive ? 'icon-chevron-down' : 'icon-chevron-right'}`} />
|
||||
)}
|
||||
/>
|
||||
<ToolsContainer className="message-tools-container">
|
||||
{collapseItems.map((item) => (
|
||||
<CustomCollapse
|
||||
key={item.key}
|
||||
id={item.key as string}
|
||||
title={item.label}
|
||||
isActive={activeKeys.includes(item.key as string)}
|
||||
onToggle={() => {
|
||||
if (activeKeys.includes(item.key as string)) {
|
||||
setActiveKeys(activeKeys.filter((k) => k !== item.key))
|
||||
} else {
|
||||
setActiveKeys([...activeKeys, item.key as string])
|
||||
}
|
||||
}}>
|
||||
{item.children}
|
||||
</CustomCollapse>
|
||||
))}
|
||||
</ToolsContainer>
|
||||
|
||||
<Modal
|
||||
title={expandedResponse?.title}
|
||||
@ -136,47 +181,36 @@ const MessageTools: FC<Props> = ({ message }) => {
|
||||
footer={null}
|
||||
width="80%"
|
||||
centered
|
||||
styles={{ body: { maxHeight: '80vh', overflow: 'auto' } }}>
|
||||
destroyOnClose={true} // 关闭时销毁内容,减少内存占用
|
||||
maskClosable={true} // 点击遮罩关闭
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
padding: '0', // 减少内边距
|
||||
contain: 'content' // 优化渲染
|
||||
},
|
||||
mask: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.45)', // 调整遮罩透明度
|
||||
backdropFilter: 'blur(2px)' // 模糊效果,提升视觉体验
|
||||
}
|
||||
}}>
|
||||
{expandedResponse && (
|
||||
<ExpandedResponseContainer style={{ fontFamily, fontSize }}>
|
||||
<ActionButton
|
||||
className="copy-expanded-button"
|
||||
onClick={() => {
|
||||
if (expandedResponse) {
|
||||
navigator.clipboard.writeText(expandedResponse.content)
|
||||
antdMessage.success({ content: t('message.copied'), key: 'copy-expanded' })
|
||||
}
|
||||
}}
|
||||
aria-label={t('common.copy')}>
|
||||
<i className="iconfont icon-copy"></i>
|
||||
</ActionButton>
|
||||
<CodeBlock>{expandedResponse.content}</CodeBlock>
|
||||
</ExpandedResponseContainer>
|
||||
<ExpandedResponseContent
|
||||
content={expandedResponse.content}
|
||||
fontFamily={fontFamily}
|
||||
fontSize={fontSize}
|
||||
onCopy={() => {
|
||||
navigator.clipboard.writeText(expandedResponse.content)
|
||||
antdMessage.success({ content: t('message.copied'), key: 'copy-expanded' })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const CollapseContainer = styled(Collapse)`
|
||||
margin-bottom: 15px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
.ant-collapse-header {
|
||||
background-color: var(--color-bg-2);
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-bg-3);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 0 !important;
|
||||
}
|
||||
`
|
||||
|
||||
const MessageTitleLabel = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@ -221,6 +255,14 @@ const ActionButtonsContainer = styled.div`
|
||||
margin-left: auto;
|
||||
`
|
||||
|
||||
const ToolsContainer = styled.div`
|
||||
margin-bottom: 15px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-1);
|
||||
border: 1px solid var(--color-border);
|
||||
`
|
||||
|
||||
const ActionButton = styled.button`
|
||||
background: none;
|
||||
border: none;
|
||||
@ -251,51 +293,5 @@ const ActionButton = styled.button`
|
||||
}
|
||||
`
|
||||
|
||||
const CollapsibleIcon = styled.i`
|
||||
color: var(--color-text-2);
|
||||
font-size: 12px;
|
||||
transition: transform 0.2s;
|
||||
`
|
||||
|
||||
const ToolResponseContainer = styled.div`
|
||||
background: var(--color-bg-1);
|
||||
border-radius: 0 0 4px 4px;
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
max-height: 300px;
|
||||
border-top: none;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const CodeBlock = styled.pre`
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--color-text);
|
||||
font-family: ubuntu;
|
||||
`
|
||||
|
||||
const ExpandedResponseContainer = styled.div`
|
||||
background: var(--color-bg-1);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
|
||||
.copy-expanded-button {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: var(--color-bg-2);
|
||||
border-radius: 4px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--color-text);
|
||||
}
|
||||
`
|
||||
|
||||
export default MessageTools
|
||||
// 使用 memo 包装组件,避免不必要的重渲染
|
||||
export default memo(MessageTools)
|
||||
|
||||
@ -57,15 +57,24 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
|
||||
}, [messages])
|
||||
|
||||
useEffect(() => {
|
||||
const newDisplayMessages = computeDisplayMessages(messages, 0, displayCount)
|
||||
setDisplayMessages(newDisplayMessages)
|
||||
setHasMore(messages.length > displayCount)
|
||||
// 优化:使用 requestAnimationFrame 来延迟计算,避免阻塞主线程
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
const newDisplayMessages = computeDisplayMessages(messages, 0, displayCount)
|
||||
setDisplayMessages(newDisplayMessages)
|
||||
setHasMore(messages.length > displayCount)
|
||||
})
|
||||
|
||||
// 清理函数,取消未执行的 requestAnimationFrame
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [messages, displayCount])
|
||||
|
||||
const maxWidth = useMemo(() => {
|
||||
// 优化:缓存计算结果,减少字符串拼接
|
||||
const showRightTopics = showTopics && topicPosition === 'right'
|
||||
const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : ''
|
||||
const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : ''
|
||||
|
||||
// 使用模板字符串代替多次字符串拼接,提高可读性
|
||||
return `calc(100vw - var(--sidebar-width) ${minusAssistantsWidth} ${minusRightTopicsWidth} - 5px)`
|
||||
}, [showAssistants, showTopics, topicPosition])
|
||||
|
||||
@ -201,15 +210,67 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
|
||||
// 使用requestAnimationFrame代替setTimeout,更好地与浏览器渲染周期同步
|
||||
requestAnimationFrame(() => {
|
||||
const currentLength = displayMessages.length
|
||||
const newMessages = computeDisplayMessages(messages, currentLength, LOAD_MORE_COUNT)
|
||||
|
||||
// 优化:只计算新的消息批次,而不是重新计算整个列表
|
||||
// 获取已经反转的消息数组
|
||||
const reversedMessages = [...messages].reverse()
|
||||
|
||||
// 从当前显示的消息数量开始,获取下一批消息
|
||||
const nextBatchMessages = reversedMessages.slice(currentLength, currentLength + LOAD_MORE_COUNT)
|
||||
|
||||
// 对这批新消息应用相同的处理逻辑,确保一致性
|
||||
const processedBatch = processMessageBatch(nextBatchMessages)
|
||||
|
||||
// 批量更新状态,减少渲染次数
|
||||
setDisplayMessages((prev) => [...prev, ...newMessages])
|
||||
setDisplayMessages((prev) => [...prev, ...processedBatch])
|
||||
setHasMore(currentLength + LOAD_MORE_COUNT < messages.length)
|
||||
setIsLoadingMore(false)
|
||||
})
|
||||
}, [displayMessages.length, hasMore, isLoadingMore, messages])
|
||||
|
||||
// 辅助函数:处理一批消息,应用与computeDisplayMessages相同的逻辑
|
||||
// 但只处理传入的批次,不处理整个消息列表
|
||||
const processMessageBatch = (messageBatch: Message[]) => {
|
||||
const userIdSet = new Set() // 用户消息 id 集合
|
||||
const assistantIdSet = new Set() // 助手消息 askId 集合
|
||||
const processedIds = new Set<string>() // 用于跟踪已处理的消息ID
|
||||
const batchDisplayMessages: Message[] = []
|
||||
const messageIdMap = new Map<string, boolean>() // 用于快速查找消息ID是否存在
|
||||
|
||||
// 处理单条消息的函数
|
||||
const processMessage = (message: Message) => {
|
||||
if (!message) return
|
||||
|
||||
// 跳过已处理的消息ID
|
||||
if (processedIds.has(message.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
processedIds.add(message.id) // 标记此消息ID为已处理
|
||||
|
||||
const idSet = message.role === 'user' ? userIdSet : assistantIdSet
|
||||
const messageId = message.role === 'user' ? message.id : message.askId
|
||||
|
||||
if (!idSet.has(messageId)) {
|
||||
idSet.add(messageId)
|
||||
batchDisplayMessages.push(message)
|
||||
messageIdMap.set(message.id, true)
|
||||
return
|
||||
}
|
||||
|
||||
// 使用Map进行O(1)复杂度的查找,替代O(n)复杂度的数组some方法
|
||||
if (message.role === 'assistant' && !messageIdMap.has(message.id)) {
|
||||
batchDisplayMessages.push(message)
|
||||
messageIdMap.set(message.id, true)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理批次中的每条消息
|
||||
messageBatch.forEach(processMessage)
|
||||
|
||||
return batchDisplayMessages
|
||||
}
|
||||
|
||||
useShortcut('copy_last_message', () => {
|
||||
const lastMessage = last(messages)
|
||||
if (lastMessage) {
|
||||
@ -258,8 +319,15 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
|
||||
</InfiniteScroll>
|
||||
<Prompt assistant={assistant} key={assistant.prompt} topic={topic} />
|
||||
</NarrowLayout>
|
||||
{messageNavigation === 'anchor' && <MessageAnchorLine messages={displayMessages} />}
|
||||
{messageNavigation === 'buttons' && <ChatNavigation containerId="messages" />}
|
||||
{useMemo(() => {
|
||||
if (messageNavigation === 'anchor') {
|
||||
return <MessageAnchorLine messages={displayMessages} />
|
||||
}
|
||||
if (messageNavigation === 'buttons') {
|
||||
return <ChatNavigation containerId="messages" />
|
||||
}
|
||||
return null
|
||||
}, [messageNavigation, displayMessages])}
|
||||
<TTSStopButton />
|
||||
</Container>
|
||||
)
|
||||
|
||||
105
src/renderer/src/pages/home/Messages/ToolResponseContent.tsx
Normal file
105
src/renderer/src/pages/home/Messages/ToolResponseContent.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { FC, memo, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ToolResponseContentProps {
|
||||
result: any
|
||||
fontFamily: string
|
||||
fontSize: string | number
|
||||
}
|
||||
|
||||
const ToolResponseContent: FC<ToolResponseContentProps> = ({ result, fontFamily, fontSize }) => {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isContentReady, setIsContentReady] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const contentRef = useRef<string>('')
|
||||
|
||||
// 预处理 JSON 数据,使用 useLayoutEffect 在渲染前完成
|
||||
useLayoutEffect(() => {
|
||||
// 使用 setTimeout 将处理移到下一个微任务,避免阻塞主线程
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
contentRef.current = JSON.stringify(result, null, 2)
|
||||
} catch (error) {
|
||||
console.error('Error stringifying result:', error)
|
||||
contentRef.current = String(result)
|
||||
}
|
||||
setIsContentReady(true)
|
||||
}, 0)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [result])
|
||||
|
||||
// 使用 IntersectionObserver 检测组件是否可见
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
setIsVisible(true)
|
||||
observer.disconnect() // 一旦可见,就不再需要观察
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: '100px' } // 10% 可见时触发,增加 rootMargin 提前加载
|
||||
)
|
||||
|
||||
observer.observe(containerRef.current)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ToolResponseContainer ref={containerRef} style={{ fontFamily, fontSize }}>
|
||||
{isVisible && isContentReady ? (
|
||||
<CodeBlock>{contentRef.current}</CodeBlock>
|
||||
) : (
|
||||
<LoadingPlaceholder>加载中...</LoadingPlaceholder>
|
||||
)}
|
||||
</ToolResponseContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const ToolResponseContainer = styled.div`
|
||||
background: var(--color-bg-1);
|
||||
border-radius: 0 0 4px 4px;
|
||||
padding: 12px 16px;
|
||||
overflow: auto;
|
||||
max-height: 300px;
|
||||
border-top: none;
|
||||
position: relative;
|
||||
will-change: transform; /* 优化渲染性能 */
|
||||
transform: translateZ(0); /* 启用硬件加速 */
|
||||
backface-visibility: hidden; /* 使用 GPU 加速 */
|
||||
-webkit-backface-visibility: hidden;
|
||||
perspective: 1000;
|
||||
-webkit-perspective: 1000;
|
||||
contain: content; /* 限制重绘范围 */
|
||||
background-color: var(--color-bg-1); /* 确保背景色 */
|
||||
`
|
||||
|
||||
const CodeBlock = styled.pre`
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--color-text);
|
||||
font-family: ubuntu;
|
||||
contain: content; /* 优化渲染性能 */
|
||||
transform: translateZ(0); /* 启用硬件加速 */
|
||||
backface-visibility: hidden; /* 使用 GPU 加速 */
|
||||
-webkit-backface-visibility: hidden;
|
||||
`
|
||||
|
||||
const LoadingPlaceholder = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 50px;
|
||||
color: var(--color-text-2);
|
||||
font-size: 14px;
|
||||
transform: translateZ(0); /* 启用硬件加速 */
|
||||
backface-visibility: hidden; /* 使用 GPU 加速 */
|
||||
-webkit-backface-visibility: hidden;
|
||||
`
|
||||
|
||||
// 使用 memo 包装组件,避免不必要的重渲染
|
||||
export default memo(ToolResponseContent)
|
||||
@ -32,7 +32,13 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
|
||||
const formattedKey = newKey.trim()
|
||||
const keys = [...currentKeys, formattedKey]
|
||||
const uniqueKeys = [...new Set(keys)]
|
||||
onApiKeyChange(uniqueKeys.join(','))
|
||||
const newApiKey = uniqueKeys.join(',')
|
||||
|
||||
// Only update if the value has actually changed
|
||||
if (newApiKey !== currentApiKey) {
|
||||
onApiKeyChange(newApiKey)
|
||||
}
|
||||
|
||||
setNewKey('')
|
||||
setIsAddKeyModalVisible(false)
|
||||
}
|
||||
@ -48,7 +54,13 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
|
||||
|
||||
const allKeys = [...currentKeys, ...importedKeys]
|
||||
const uniqueKeys = [...new Set(allKeys)]
|
||||
onApiKeyChange(uniqueKeys.join(','))
|
||||
const newApiKey = uniqueKeys.join(',')
|
||||
|
||||
// 只有当值确实发生变化时才更新
|
||||
if (newApiKey !== currentApiKey) {
|
||||
onApiKeyChange(newApiKey)
|
||||
}
|
||||
|
||||
setImportText('')
|
||||
setIsImportModalVisible(false)
|
||||
}
|
||||
@ -102,11 +114,7 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
|
||||
{currentKeys.map((key, index) => (
|
||||
<KeyItem key={index}>
|
||||
<Text>{maskApiKey(key)}</Text>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => copyKey(key)}
|
||||
/>
|
||||
<Button type="text" icon={<CopyOutlined />} onClick={() => copyKey(key)} />
|
||||
</KeyItem>
|
||||
))}
|
||||
</KeysListContainer>
|
||||
@ -121,7 +129,13 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
|
||||
okButtonProps={{ disabled: !newKey.trim() }}>
|
||||
<Input.Password
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(formatApiKeys(e.target.value))}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
const formattedValue = formatApiKeys(e.target.value)
|
||||
if (formattedValue !== newKey) {
|
||||
setNewKey(formattedValue)
|
||||
}
|
||||
}}
|
||||
placeholder={t('settings.provider.gemini.enter_key')}
|
||||
autoFocus
|
||||
/>
|
||||
@ -153,6 +167,18 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
|
||||
<Input.TextArea
|
||||
value={importText}
|
||||
onChange={(e) => setImportText(e.target.value)}
|
||||
onBlur={(e) => {
|
||||
// 处理多行文本格式化
|
||||
const lines = e.target.value.split('\n')
|
||||
const formattedLines = lines.map((line) => {
|
||||
return formatApiKeys(line)
|
||||
})
|
||||
const formattedText = formattedLines.join('\n')
|
||||
|
||||
if (formattedText !== importText) {
|
||||
setImportText(formattedText)
|
||||
}
|
||||
}}
|
||||
placeholder={t('settings.provider.gemini.enter_keys')}
|
||||
rows={8}
|
||||
/>
|
||||
|
||||
@ -345,30 +345,34 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
|
||||
</SettingHelpTextRow>
|
||||
)}
|
||||
{/* 显示API密钥列表 */}
|
||||
{provider.id !== 'gemini' && provider.id !== 'ollama' && provider.id !== 'lmstudio' && provider.id !== 'copilot' && apiKey.includes(',') && (
|
||||
<KeysListContainer>
|
||||
{apiKey
|
||||
.split(',')
|
||||
.map((key) => key.trim())
|
||||
.filter((key) => key !== '')
|
||||
.map((key, index) => (
|
||||
<KeyItem key={index}>
|
||||
<Typography.Text>{maskApiKey(key)}</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(key)
|
||||
window.message.success({
|
||||
content: t('common.copied'),
|
||||
duration: 2
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</KeyItem>
|
||||
))}
|
||||
</KeysListContainer>
|
||||
)}
|
||||
{provider.id !== 'gemini' &&
|
||||
provider.id !== 'ollama' &&
|
||||
provider.id !== 'lmstudio' &&
|
||||
provider.id !== 'copilot' &&
|
||||
apiKey.includes(',') && (
|
||||
<KeysListContainer>
|
||||
{apiKey
|
||||
.split(',')
|
||||
.map((key) => key.trim())
|
||||
.filter((key) => key !== '')
|
||||
.map((key, index) => (
|
||||
<KeyItem key={index}>
|
||||
<Typography.Text>{maskApiKey(key)}</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(key)
|
||||
window.message.success({
|
||||
content: t('common.copied'),
|
||||
duration: 2
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</KeyItem>
|
||||
))}
|
||||
</KeysListContainer>
|
||||
)}
|
||||
<SettingSubtitle>{t('settings.provider.api_host')}</SettingSubtitle>
|
||||
<Space.Compact style={{ width: '100%', marginTop: 5 }}>
|
||||
<Input
|
||||
|
||||
@ -105,8 +105,31 @@ class FileManager {
|
||||
}
|
||||
|
||||
static getFileUrl(file: FileType) {
|
||||
const filesPath = store.getState().runtime.filesPath
|
||||
return 'file://' + filesPath + '/' + file.name
|
||||
try {
|
||||
const filesPath = store.getState().runtime.filesPath
|
||||
|
||||
// 如果文件有完整路径,优先使用
|
||||
if (file.path) {
|
||||
// 使用完整路径生成URL
|
||||
return 'file://' + file.path.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
// 确保文件名是正确的
|
||||
const fileName = file.name || file.id + file.ext
|
||||
|
||||
// 确保路径格式正确
|
||||
const normalizedPath = filesPath.replace(/\\/g, '/')
|
||||
|
||||
// 构建完整URL
|
||||
const fileUrl = 'file://' + normalizedPath + '/' + fileName
|
||||
|
||||
console.log('生成图片URL:', fileUrl)
|
||||
return fileUrl
|
||||
} catch (error) {
|
||||
console.error('生成文件URL时出错:', error, file)
|
||||
// 返回一个备用URL或空字符串
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
static async updateFile(file: FileType) {
|
||||
|
||||
@ -109,8 +109,7 @@ export class TTSService {
|
||||
}
|
||||
|
||||
// 更新最后播放的消息ID
|
||||
const dispatch = store.dispatch
|
||||
dispatch(setLastPlayedMessageId(message.id))
|
||||
store.dispatch(setLastPlayedMessageId(message.id))
|
||||
console.log('更新最后播放的消息ID:', message.id)
|
||||
|
||||
// 记录当前正在播放的消息ID
|
||||
@ -121,7 +120,7 @@ export class TTSService {
|
||||
console.log('TTS过滤前文本长度:', message.content.length, '过滤后:', filteredText.length)
|
||||
|
||||
// 播放过滤后的文本
|
||||
return this.speak(filteredText, segmented)
|
||||
return this.speak(filteredText, segmented, message.id)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -188,7 +187,6 @@ export class TTSService {
|
||||
}
|
||||
|
||||
// 获取最新的设置
|
||||
// 强制刷新状态对象,确保获取最新的设置
|
||||
const latestSettings = store.getState().settings
|
||||
const serviceType = latestSettings.ttsServiceType || 'openai'
|
||||
console.log('使用的TTS服务类型:', serviceType)
|
||||
@ -202,14 +200,13 @@ export class TTSService {
|
||||
if (messageId) {
|
||||
this.playingMessageId = messageId
|
||||
// 更新最后播放的消息ID
|
||||
const dispatch = store.dispatch
|
||||
dispatch(setLastPlayedMessageId(messageId))
|
||||
store.dispatch(setLastPlayedMessageId(messageId))
|
||||
console.log('更新最后播放的消息ID:', messageId)
|
||||
}
|
||||
|
||||
if (segmented) {
|
||||
// 分段播放模式
|
||||
return await this.speakSegmented(text, serviceType, latestSettings)
|
||||
return await this.speakSegmented(text, serviceType, latestSettings, messageId)
|
||||
}
|
||||
|
||||
console.log('当前TTS设置详情:', {
|
||||
@ -398,10 +395,16 @@ export class TTSService {
|
||||
* 分段播放模式
|
||||
* @param text 要播放的文本
|
||||
* @param serviceType TTS服务类型
|
||||
* @param settings 设置
|
||||
* @param settings 应用设置对象
|
||||
* @param messageId 可选的消息ID,用于关联进度条和停止按钮
|
||||
* @returns 是否成功播放
|
||||
*/
|
||||
private async speakSegmented(text: string, serviceType: string, settings: any): Promise<boolean> {
|
||||
private async speakSegmented(
|
||||
text: string,
|
||||
serviceType: string,
|
||||
settings: ReturnType<typeof store.getState>['settings'],
|
||||
messageId?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
console.log('开始分段播放模式')
|
||||
|
||||
@ -427,6 +430,11 @@ export class TTSService {
|
||||
// 重置当前段落索引
|
||||
this.currentSegmentIndex = 0
|
||||
|
||||
// 如果提供了messageId,则设置playingMessageId
|
||||
if (messageId) {
|
||||
this.playingMessageId = messageId
|
||||
}
|
||||
|
||||
// 触发分段播放事件
|
||||
this.emitSegmentedPlaybackEvent()
|
||||
|
||||
@ -455,9 +463,13 @@ export class TTSService {
|
||||
* 加载段落音频
|
||||
* @param index 段落索引
|
||||
* @param serviceType TTS服务类型
|
||||
* @param settings 设置
|
||||
* @param settings 应用设置对象
|
||||
*/
|
||||
private async loadSegmentAudio(index: number, serviceType: string, settings: any): Promise<void> {
|
||||
private async loadSegmentAudio(
|
||||
index: number,
|
||||
serviceType: string,
|
||||
settings: ReturnType<typeof store.getState>['settings']
|
||||
): Promise<void> {
|
||||
if (index < 0 || index >= this.audioSegments.length) {
|
||||
return
|
||||
}
|
||||
@ -625,11 +637,6 @@ export class TTSService {
|
||||
* @param duration 总时长(秒)
|
||||
* @param progress 进度百分比(0-100)
|
||||
*/
|
||||
// 记录上次输出日志的进度百分比 - 已禁用日志输出
|
||||
// private lastLoggedProgress: number = -1;
|
||||
// 记录上次日志输出时间,用于节流 - 已禁用日志输出
|
||||
// private lastLogTime: number = 0;
|
||||
|
||||
private emitProgressUpdateEvent(currentTime: number, duration: number, progress: number): void {
|
||||
// 创建事件数据
|
||||
const eventData = {
|
||||
@ -640,22 +647,6 @@ export class TTSService {
|
||||
progress
|
||||
}
|
||||
|
||||
// 完全关闭进度更新日志输出
|
||||
// const now = Date.now();
|
||||
// const currentProgressTens = Math.floor(progress / 10);
|
||||
// if ((now - this.lastLogTime >= 500) && // 时间节流
|
||||
// (currentProgressTens !== Math.floor(this.lastLoggedProgress / 10) ||
|
||||
// progress === 0 || progress >= 100)) {
|
||||
// console.log('发送TTS进度更新事件:', {
|
||||
// messageId: this.playingMessageId ? this.playingMessageId.substring(0, 8) : null,
|
||||
// progress: Math.round(progress),
|
||||
// currentTime: Math.round(currentTime),
|
||||
// duration: Math.round(duration)
|
||||
// });
|
||||
// this.lastLoggedProgress = progress;
|
||||
// this.lastLogTime = now;
|
||||
// }
|
||||
|
||||
// 触发事件
|
||||
window.dispatchEvent(new CustomEvent('tts-progress-update', { detail: eventData }))
|
||||
}
|
||||
|
||||
@ -663,10 +663,38 @@ export const selectError = (state: RootState): string | null => {
|
||||
return messagesState?.error || null
|
||||
}
|
||||
|
||||
export const selectStreamMessage = (state: RootState, topicId: string, messageId: string): Message | null => {
|
||||
const messagesState = state.messages as MessagesState
|
||||
return messagesState.streamMessagesByTopic[topicId]?.[messageId] || null
|
||||
}
|
||||
// 使用 createSelector 记忆化流式消息选择器
|
||||
// 这样可以避免在 Redux store 状态变化时不必要的重新计算
|
||||
export const selectStreamMessage = createSelector(
|
||||
[
|
||||
(state: RootState) => state.messages.streamMessagesByTopic,
|
||||
(_, topicId: string) => topicId,
|
||||
(_, __, messageId: string) => messageId
|
||||
],
|
||||
(streamMessagesByTopic, topicId, messageId) => streamMessagesByTopic[topicId]?.[messageId] || null
|
||||
)
|
||||
|
||||
// 使用 createSelector 记忆化常规消息选择器
|
||||
export const selectRegularMessage = createSelector(
|
||||
[
|
||||
(state: RootState) => state.messages.messagesByTopic,
|
||||
(_, topicId: string) => topicId,
|
||||
(_, __, messageId: string) => messageId,
|
||||
(_, __, ___, originalMessage: Message) => originalMessage
|
||||
],
|
||||
(messagesByTopic, topicId, messageId, originalMessage) => {
|
||||
// 如果是用户消息,直接使用原始消息
|
||||
if (originalMessage.role === 'user') {
|
||||
return originalMessage
|
||||
}
|
||||
|
||||
// 对于助手消息,从 store 中查找最新状态
|
||||
const topicMessages = messagesByTopic[topicId]
|
||||
if (!topicMessages) return originalMessage
|
||||
|
||||
return topicMessages.find((m) => m.id === messageId) || originalMessage
|
||||
}
|
||||
)
|
||||
|
||||
export const {
|
||||
setTopicLoading,
|
||||
|
||||
@ -367,45 +367,117 @@ export function parseToolUse(content: string, mcpTools: MCPTool[]): MCPToolRespo
|
||||
if (!content || !mcpTools || mcpTools.length === 0) {
|
||||
return []
|
||||
}
|
||||
const toolUsePattern =
|
||||
|
||||
// 支持三种格式的工具调用
|
||||
// 1. 标准格式: <tool_use><name>工具名</name><arguments>参数</arguments></tool_use>
|
||||
const standardToolUsePattern =
|
||||
/<tool_use>([\s\S]*?)<name>([\s\S]*?)<\/name>([\s\S]*?)<arguments>([\s\S]*?)<\/arguments>([\s\S]*?)<\/tool_use>/g
|
||||
|
||||
// 2. Roo Code格式: <工具名><参数名>参数值</参数名></工具名>
|
||||
const rooCodeToolUsePattern = new RegExp(`<(${mcpTools.map((tool) => tool.id).join('|')})>([\s\S]*?)<\/\\1>`, 'g')
|
||||
|
||||
// 3. 简化格式: <tool_use>工具ID参数JSON</tool_use>
|
||||
const simplifiedToolUsePattern = /<tool_use>\s*([\w\d]+)\s*([\s\S]*?)\s*<\/tool_use>/g
|
||||
|
||||
const tools: MCPToolResponse[] = []
|
||||
let match
|
||||
let idx = 0
|
||||
// Find all tool use blocks
|
||||
while ((match = toolUsePattern.exec(content)) !== null) {
|
||||
// const fullMatch = match[0]
|
||||
|
||||
// 处理标准格式
|
||||
let match
|
||||
while ((match = standardToolUsePattern.exec(content)) !== null) {
|
||||
const toolName = match[2].trim()
|
||||
const toolArgs = match[4].trim()
|
||||
|
||||
// Try to parse the arguments as JSON
|
||||
// 尝试解析参数为JSON
|
||||
let parsedArgs
|
||||
try {
|
||||
parsedArgs = JSON.parse(toolArgs)
|
||||
} catch (error) {
|
||||
// If parsing fails, use the string as is
|
||||
// 如果解析失败,使用字符串原样
|
||||
parsedArgs = toolArgs
|
||||
}
|
||||
// console.log(`Parsed arguments for tool "${toolName}":`, parsedArgs)
|
||||
|
||||
const mcpTool = mcpTools.find((tool) => tool.id === toolName)
|
||||
if (!mcpTool) {
|
||||
console.error(`Tool "${toolName}" not found in MCP tools`)
|
||||
continue
|
||||
}
|
||||
|
||||
// Add to tools array
|
||||
// 添加到工具数组
|
||||
tools.push({
|
||||
id: `${toolName}-${idx++}`, // Unique ID for each tool use
|
||||
id: `${toolName}-${idx++}`, // 每个工具调用的唯一ID
|
||||
tool: {
|
||||
...mcpTool,
|
||||
inputSchema: parsedArgs
|
||||
},
|
||||
status: 'pending'
|
||||
})
|
||||
|
||||
// Remove the tool use block from the content
|
||||
// content = content.replace(fullMatch, '')
|
||||
}
|
||||
|
||||
// 处理Roo Code格式
|
||||
while ((match = rooCodeToolUsePattern.exec(content)) !== null) {
|
||||
const toolName = match[1].trim()
|
||||
const toolContent = match[2].trim()
|
||||
|
||||
// 解析参数
|
||||
const params: Record<string, any> = {}
|
||||
const paramPattern = /<([\w\d_]+)>([\s\S]*?)<\/\1>/g
|
||||
let paramMatch
|
||||
|
||||
while ((paramMatch = paramPattern.exec(toolContent)) !== null) {
|
||||
const paramName = paramMatch[1].trim()
|
||||
const paramValue = paramMatch[2].trim()
|
||||
params[paramName] = paramValue
|
||||
}
|
||||
|
||||
const mcpTool = mcpTools.find((tool) => tool.id === toolName)
|
||||
if (!mcpTool) {
|
||||
console.error(`Tool "${toolName}" not found in MCP tools`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 添加到工具数组
|
||||
tools.push({
|
||||
id: `${toolName}-${idx++}`,
|
||||
tool: {
|
||||
...mcpTool,
|
||||
inputSchema: { type: 'object', title: 'Input', properties: params }
|
||||
},
|
||||
status: 'pending'
|
||||
})
|
||||
}
|
||||
|
||||
// 处理简化格式
|
||||
while ((match = simplifiedToolUsePattern.exec(content)) !== null) {
|
||||
const toolName = match[1].trim()
|
||||
const toolArgs = match[2].trim()
|
||||
|
||||
// 尝试解析参数为JSON
|
||||
let parsedArgs
|
||||
try {
|
||||
parsedArgs = JSON.parse(toolArgs)
|
||||
} catch (error) {
|
||||
// 如果解析失败,使用字符串原样
|
||||
parsedArgs = toolArgs
|
||||
}
|
||||
|
||||
const mcpTool = mcpTools.find((tool) => tool.id === toolName)
|
||||
if (!mcpTool) {
|
||||
console.error(`Tool "${toolName}" not found in MCP tools`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 添加到工具数组
|
||||
tools.push({
|
||||
id: `${toolName}-${idx++}`,
|
||||
tool: {
|
||||
...mcpTool,
|
||||
inputSchema: parsedArgs
|
||||
},
|
||||
status: 'pending'
|
||||
})
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
|
||||
146
yarn.lock
146
yarn.lock
@ -479,7 +479,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.7":
|
||||
"@babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.7":
|
||||
version: 7.27.0
|
||||
resolution: "@babel/runtime@npm:7.27.0"
|
||||
dependencies:
|
||||
@ -4418,6 +4418,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/hast@npm:^2.0.0":
|
||||
version: 2.3.10
|
||||
resolution: "@types/hast@npm:2.3.10"
|
||||
dependencies:
|
||||
"@types/unist": "npm:^2"
|
||||
checksum: 10c0/16daac35d032e656defe1f103f9c09c341a6dc553c7ec17b388274076fa26e904a71ea5ea41fd368a6d5f1e9e53be275c80af7942b9c466d8511d261c9529c7e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/hast@npm:^3.0.0, @types/hast@npm:^3.0.4":
|
||||
version: 3.0.4
|
||||
resolution: "@types/hast@npm:3.0.4"
|
||||
@ -4625,6 +4634,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-syntax-highlighter@npm:^15":
|
||||
version: 15.5.13
|
||||
resolution: "@types/react-syntax-highlighter@npm:15.5.13"
|
||||
dependencies:
|
||||
"@types/react": "npm:*"
|
||||
checksum: 10c0/e3bca325b27519fb063d3370de20d311c188ec16ffc01e5bc77bdf2d7320756725ee3d0246922cd5d38b75c5065a1bc43d0194e92ecf6556818714b4ffb0967a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/react-transition-group@npm:^4.4.12":
|
||||
version: 4.4.12
|
||||
resolution: "@types/react-transition-group@npm:4.4.12"
|
||||
@ -4985,6 +5003,7 @@ __metadata:
|
||||
"@types/react": "npm:^19.0.12"
|
||||
"@types/react-dom": "npm:^19.0.4"
|
||||
"@types/react-infinite-scroll-component": "npm:^5.0.0"
|
||||
"@types/react-syntax-highlighter": "npm:^15"
|
||||
"@types/react-transition-group": "npm:^4.4.12"
|
||||
"@types/tinycolor2": "npm:^1"
|
||||
"@vitejs/plugin-react": "npm:^4.3.4"
|
||||
@ -5059,6 +5078,7 @@ __metadata:
|
||||
react-router: "npm:6"
|
||||
react-router-dom: "npm:6"
|
||||
react-spinners: "npm:^0.14.1"
|
||||
react-syntax-highlighter: "npm:^15.6.1"
|
||||
react-transition-group: "npm:^4.4.5"
|
||||
redux: "npm:^5.0.1"
|
||||
redux-persist: "npm:^6.0.0"
|
||||
@ -6583,6 +6603,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"comma-separated-tokens@npm:^1.0.0":
|
||||
version: 1.0.8
|
||||
resolution: "comma-separated-tokens@npm:1.0.8"
|
||||
checksum: 10c0/c3bcfeaa6d50313528a006a40bcc0f9576086665c9b48d4b3a76ddd63e7d6174734386c98be1881cbf6ecfc25e1db61cd775a7b896d2ea7a65de28f83a0f9b17
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"comma-separated-tokens@npm:^2.0.0":
|
||||
version: 2.0.3
|
||||
resolution: "comma-separated-tokens@npm:2.0.3"
|
||||
@ -9139,6 +9166,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fault@npm:^1.0.0":
|
||||
version: 1.0.4
|
||||
resolution: "fault@npm:1.0.4"
|
||||
dependencies:
|
||||
format: "npm:^0.2.0"
|
||||
checksum: 10c0/c86c11500c1b676787296f31ade8473adcc6784f118f07c1a9429730b6288d0412f96e069ce010aa57e4f65a9cccb5abee8868bbe3c5f10de63b20482c9baebd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fd-slicer@npm:~1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "fd-slicer@npm:1.1.0"
|
||||
@ -9410,6 +9446,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"format@npm:^0.2.0":
|
||||
version: 0.2.2
|
||||
resolution: "format@npm:0.2.2"
|
||||
checksum: 10c0/6032ba747541a43abf3e37b402b2f72ee08ebcb58bf84d816443dd228959837f1cddf1e8775b29fa27ff133f4bd146d041bfca5f9cf27f048edf3d493cf8fee6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"formdata-node@npm:^4.3.2":
|
||||
version: 4.4.1
|
||||
resolution: "formdata-node@npm:4.4.1"
|
||||
@ -10195,6 +10238,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hast-util-parse-selector@npm:^2.0.0":
|
||||
version: 2.2.5
|
||||
resolution: "hast-util-parse-selector@npm:2.2.5"
|
||||
checksum: 10c0/29b7ee77960ded6a99d30c287d922243071cc07b39f2006f203bd08ee54eb8f66bdaa86ef6527477c766e2382d520b60ee4e4087f189888c35d8bcc020173648
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hast-util-parse-selector@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "hast-util-parse-selector@npm:4.0.0"
|
||||
@ -10314,6 +10364,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hastscript@npm:^6.0.0":
|
||||
version: 6.0.0
|
||||
resolution: "hastscript@npm:6.0.0"
|
||||
dependencies:
|
||||
"@types/hast": "npm:^2.0.0"
|
||||
comma-separated-tokens: "npm:^1.0.0"
|
||||
hast-util-parse-selector: "npm:^2.0.0"
|
||||
property-information: "npm:^5.0.0"
|
||||
space-separated-tokens: "npm:^1.0.0"
|
||||
checksum: 10c0/f76d9cf373cb075c8523c8ad52709f09f7e02b7c9d3152b8d35c65c265b9f1878bed6023f215a7d16523921036d40a7da292cb6f4399af9b5eccac2a5a5eb330
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hastscript@npm:^9.0.0":
|
||||
version: 9.0.1
|
||||
resolution: "hastscript@npm:9.0.1"
|
||||
@ -10339,6 +10402,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"highlight.js@npm:^10.4.1, highlight.js@npm:~10.7.0":
|
||||
version: 10.7.3
|
||||
resolution: "highlight.js@npm:10.7.3"
|
||||
checksum: 10c0/073837eaf816922427a9005c56c42ad8786473dc042332dfe7901aa065e92bc3d94ebf704975257526482066abb2c8677cc0326559bb8621e046c21c5991c434
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"highlightjs-vue@npm:^1.0.0":
|
||||
version: 1.0.0
|
||||
resolution: "highlightjs-vue@npm:1.0.0"
|
||||
checksum: 10c0/9be378c70b864ca5eee87b07859222e31c946a8ad176227e54f7006a498223974ebe19fcce6e38ad5eb3c1ed0e16a580c4edefdf2cb882b6dfab1c3866cc047a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"hmac-drbg@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "hmac-drbg@npm:1.0.1"
|
||||
@ -11908,6 +11985,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lowlight@npm:^1.17.0":
|
||||
version: 1.20.0
|
||||
resolution: "lowlight@npm:1.20.0"
|
||||
dependencies:
|
||||
fault: "npm:^1.0.0"
|
||||
highlight.js: "npm:~10.7.0"
|
||||
checksum: 10c0/728bce6f6fe8b157f48d3324e597f452ce0eed2ccff1c0f41a9047380f944e971eb45bceb31f08fbb64d8f338dabb166f10049b35b92c7ec5cf0241d6adb3dea
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lru-cache@npm:^10.2.0, lru-cache@npm:^10.4.3":
|
||||
version: 10.4.3
|
||||
resolution: "lru-cache@npm:10.4.3"
|
||||
@ -14782,6 +14869,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prismjs@npm:^1.27.0":
|
||||
version: 1.30.0
|
||||
resolution: "prismjs@npm:1.30.0"
|
||||
checksum: 10c0/f56205bfd58ef71ccfcbcb691fd0eb84adc96c6ff21b0b69fc6fdcf02be42d6ef972ba4aed60466310de3d67733f6a746f89f2fb79c00bf217406d465b3e8f23
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prismjs@npm:~1.27.0":
|
||||
version: 1.27.0
|
||||
resolution: "prismjs@npm:1.27.0"
|
||||
checksum: 10c0/841cbf53e837a42df9155c5ce1be52c4a0a8967ac916b52a27d066181a3578186c634e52d06d0547fb62b65c486b99b95f826dd54966619f9721b884f486b498
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"proc-log@npm:^2.0.1":
|
||||
version: 2.0.1
|
||||
resolution: "proc-log@npm:2.0.1"
|
||||
@ -14852,6 +14953,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"property-information@npm:^5.0.0":
|
||||
version: 5.6.0
|
||||
resolution: "property-information@npm:5.6.0"
|
||||
dependencies:
|
||||
xtend: "npm:^4.0.0"
|
||||
checksum: 10c0/d54b77c31dc13bb6819559080b2c67d37d94be7dc271f404f139a16a57aa96fcc0b3ad806d4a5baef9e031744853e4afe3df2e37275aacb1f78079bbb652c5af
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"property-information@npm:^6.0.0":
|
||||
version: 6.5.0
|
||||
resolution: "property-information@npm:6.5.0"
|
||||
@ -15770,6 +15880,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-syntax-highlighter@npm:^15.6.1":
|
||||
version: 15.6.1
|
||||
resolution: "react-syntax-highlighter@npm:15.6.1"
|
||||
dependencies:
|
||||
"@babel/runtime": "npm:^7.3.1"
|
||||
highlight.js: "npm:^10.4.1"
|
||||
highlightjs-vue: "npm:^1.0.0"
|
||||
lowlight: "npm:^1.17.0"
|
||||
prismjs: "npm:^1.27.0"
|
||||
refractor: "npm:^3.6.0"
|
||||
peerDependencies:
|
||||
react: ">= 0.14.0"
|
||||
checksum: 10c0/4a4cf4695c45d7a6b25078970fb79ae5a85edeba5be0a2508766ee18e8aee1c0c4cdd97bf54f5055e4af671fe7e5e71348e81cafe09a0eb07a763ae876b7f073
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-transition-group@npm:^4.4.5":
|
||||
version: 4.4.5
|
||||
resolution: "react-transition-group@npm:4.4.5"
|
||||
@ -15913,6 +16039,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"refractor@npm:^3.6.0":
|
||||
version: 3.6.0
|
||||
resolution: "refractor@npm:3.6.0"
|
||||
dependencies:
|
||||
hastscript: "npm:^6.0.0"
|
||||
parse-entities: "npm:^2.0.0"
|
||||
prismjs: "npm:~1.27.0"
|
||||
checksum: 10c0/63ab62393c8c2fd7108c2ea1eff721c0ad2a1a6eee60fdd1b47f4bb25cf298667dc97d041405b3e718b0817da12b37a86ed07ebee5bd2ca6405611f1bae456db
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"regenerator-runtime@npm:^0.13.3":
|
||||
version: 0.13.11
|
||||
resolution: "regenerator-runtime@npm:0.13.11"
|
||||
@ -16966,6 +17103,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"space-separated-tokens@npm:^1.0.0":
|
||||
version: 1.1.5
|
||||
resolution: "space-separated-tokens@npm:1.1.5"
|
||||
checksum: 10c0/3ee0a6905f89e1ffdfe474124b1ade9fe97276a377a0b01350bc079b6ec566eb5b219e26064cc5b7f3899c05bde51ffbc9154290b96eaf82916a1e2c2c13ead9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"space-separated-tokens@npm:^2.0.0":
|
||||
version: 2.0.2
|
||||
resolution: "space-separated-tokens@npm:2.0.2"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user