This commit is contained in:
1600822305 2025-04-20 20:35:03 +08:00
parent 53643e81f0
commit 607cded6c9
35 changed files with 2232 additions and 889 deletions

View File

@ -127,6 +127,7 @@
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.1.91", "pdfjs-dist": "^5.1.91",
"proxy-agent": "^6.5.0", "proxy-agent": "^6.5.0",
"react-syntax-highlighter": "^15.6.1",
"react-transition-group": "^4.4.5", "react-transition-group": "^4.4.5",
"tar": "^7.4.3", "tar": "^7.4.3",
"turndown": "^7.2.0", "turndown": "^7.2.0",
@ -170,6 +171,7 @@
"@types/react": "^19.0.12", "@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@types/react-infinite-scroll-component": "^5.0.0", "@types/react-infinite-scroll-component": "^5.0.0",
"@types/react-syntax-highlighter": "^15",
"@types/tinycolor2": "^1", "@types/tinycolor2": "^1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"analytics": "^0.8.16", "analytics": "^0.8.16",

View File

@ -5,6 +5,7 @@
@use './animation.scss'; @use './animation.scss';
@import '../fonts/icon-fonts/iconfont.css'; @import '../fonts/icon-fonts/iconfont.css';
@import '../fonts/ubuntu/ubuntu.css'; @import '../fonts/ubuntu/ubuntu.css';
@import './inline-tool-block.css';
:root { :root {
--color-white: #ffffff; --color-white: #ffffff;
@ -29,6 +30,11 @@
--color-background-opacity: rgba(34, 34, 34, 0.7); --color-background-opacity: rgba(34, 34, 34, 0.7);
--inner-glow-opacity: 0.3; // For the glassmorphism effect in the dropdown menu --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: #00b96b;
--color-primary-soft: #00b96b99; --color-primary-soft: #00b96b99;
--color-primary-mute: #00b96b33; --color-primary-mute: #00b96b33;
@ -101,6 +107,11 @@ body[theme-mode='light'] {
--color-background-opacity: rgba(235, 235, 235, 0.7); --color-background-opacity: rgba(235, 235, 235, 0.7);
--inner-glow-opacity: 0.1; --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: #00b96b;
--color-primary-soft: #00b96b99; --color-primary-soft: #00b96b99;
--color-primary-mute: #00b96b33; --color-primary-mute: #00b96b33;

View 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);
}

View File

@ -1,5 +1,5 @@
import { StyleProvider } from '@ant-design/cssinjs' 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 { createPortal } from 'react-dom'
import { StyleSheetManager } from 'styled-components' import { StyleSheetManager } from 'styled-components'
@ -60,4 +60,5 @@ const ShadowDOMRenderer: React.FC<Props> = ({ children }) => {
) )
} }
export default ShadowDOMRenderer // 使用 memo 包装组件,避免不必要的重渲染
export default memo(ShadowDOMRenderer)

View File

@ -2,7 +2,7 @@ import { SoundOutlined } from '@ant-design/icons'
import TTSService from '@renderer/services/TTSService' import TTSService from '@renderer/services/TTSService'
import { Message } from '@renderer/types' import { Message } from '@renderer/types'
import { Tooltip } from 'antd' import { Tooltip } from 'antd'
import { useCallback, useEffect, useState } from 'react' import { memo, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -11,69 +11,39 @@ interface TTSButtonProps {
className?: string 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 TTSButton: React.FC<TTSButtonProps> = ({ message, className }) => {
const { t } = useTranslation() const { t } = useTranslation()
// 只保留必要的状态
const [isSpeaking, setIsSpeaking] = useState(false) const [isSpeaking, setIsSpeaking] = useState(false)
// 分段播放状态
const [, setSegmentedPlaybackState] = useState<SegmentedPlaybackState>({
isSegmentedPlayback: false,
segments: [],
currentSegmentIndex: 0,
isPlaying: false
})
// 添加TTS状态变化事件监听器 // 使用 useCallback 记忆化事件处理函数,避免不必要的重新创建
useEffect(() => { const handleTTSStateChange = useCallback(
const handleTTSStateChange = (event: CustomEvent) => { (event: CustomEvent) => {
const { isPlaying } = event.detail const { isPlaying } = event.detail
console.log('TTS按钮检测到TTS状态变化:', isPlaying) console.log('TTS按钮检测到TTS状态变化:', isPlaying)
setIsSpeaking(isPlaying) setIsSpeaking(isPlaying)
} },
[setIsSpeaking]
)
// 添加TTS状态变化事件监听器
useEffect(() => {
// 添加事件监听器 // 添加事件监听器
window.addEventListener('tts-state-change', handleTTSStateChange as EventListener) window.addEventListener('tts-state-change', handleTTSStateChange as EventListener)
// 初始化时检查TTS状态
const isCurrentlyPlaying = TTSService.isCurrentlyPlaying()
setIsSpeaking(isCurrentlyPlaying)
// 组件卸载时移除事件监听器 // 组件卸载时移除事件监听器
return () => { return () => {
window.removeEventListener('tts-state-change', handleTTSStateChange as EventListener) 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 () => { const handleTTS = useCallback(async () => {
if (isSpeaking) { if (isSpeaking) {
@ -139,4 +109,5 @@ const TTSActionButton = styled.div`
} }
` `
export default TTSButton // 使用 memo 包装组件,避免不必要的重渲染
export default memo(TTSButton)

View File

@ -1,6 +1,6 @@
import { TextSegmenter } from '@renderer/services/tts/TextSegmenter' import { TextSegmenter } from '@renderer/services/tts/TextSegmenter'
import TTSService from '@renderer/services/TTSService' 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' import styled from 'styled-components'
interface TTSHighlightedTextProps { 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) TTSService.playFromSegment(index)
} }, [])
if (segments.length === 0) { if (segments.length === 0) {
return <div>{text}</div> return <div>{text}</div>
} }
return ( // 使用 useMemo 记忆化列表渲染结果,避免不必要的重新计算
<TextContainer> const renderedSegments = useMemo(() => {
{segments.map((segment, index) => ( return segments.map((segment, index) => (
<TextSegment <TextSegment
key={index} key={index}
className={index === currentSegmentIndex ? 'active' : ''} className={index === currentSegmentIndex ? 'active' : ''}
onClick={() => handleSegmentClick(index)}> onClick={() => handleSegmentClick(index)}>
{segment} {segment}
</TextSegment> </TextSegment>
))} ))
</TextContainer> }, [segments, currentSegmentIndex, handleSegmentClick])
)
return <TextContainer>{renderedSegments}</TextContainer>
} }
const TextContainer = styled.div` const TextContainer = styled.div`
@ -93,4 +95,5 @@ const TextSegment = styled.span`
} }
` `
export default TTSHighlightedText // 使用 memo 包装组件,避免不必要的重渲染
export default memo(TTSHighlightedText)

View File

@ -4,7 +4,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { useShowTopics } from '@renderer/hooks/useStore' import { useShowTopics } from '@renderer/hooks/useStore'
import { Assistant, Topic } from '@renderer/types' import { Assistant, Topic } from '@renderer/types'
import { Flex } from 'antd' import { Flex } from 'antd'
import { FC } from 'react' import { FC, memo, useMemo } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import Inputbar from './Inputbar/Inputbar' import Inputbar from './Inputbar/Inputbar'
@ -19,32 +19,55 @@ interface Props {
} }
const Chat: FC<Props> = (props) => { const Chat: FC<Props> = (props) => {
// 使用传入的 assistant 对象,避免重复获取
// 如果 useAssistant 提供了额外的功能或状态更新,则保留此调用
const { assistant } = useAssistant(props.assistant.id) const { assistant } = useAssistant(props.assistant.id)
const { topicPosition, messageStyle } = useSettings() const { topicPosition, messageStyle } = useSettings()
const { showTopics } = useShowTopics() 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 ( return (
<Container id="chat" className={messageStyle}> <Container id="chat" className={messageStyle}>
<Main id="chat-main" vertical flex={1} justify="space-between"> <Main id="chat-main" vertical flex={1} justify="space-between">
<Messages {messagesComponent}
key={props.activeTopic.id} {inputbarComponent}
assistant={assistant}
topic={props.activeTopic}
setActiveTopic={props.setActiveTopic}
/>
<QuickPanelProvider>
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
</QuickPanelProvider>
</Main> </Main>
{topicPosition === 'right' && showTopics && ( {tabsComponent}
<Tabs
activeAssistant={assistant}
activeTopic={props.activeTopic}
setActiveAssistant={props.setActiveAssistant}
setActiveTopic={props.setActiveTopic}
position="right"
/>
)}
</Container> </Container>
) )
} }
@ -63,4 +86,4 @@ const Main = styled(Flex)`
transform: translateZ(0); transform: translateZ(0);
` `
export default Chat export default memo(Chat)

View File

@ -9,7 +9,7 @@ import { parseJSON } from '@renderer/utils'
import { escapeBrackets, removeSvgEmptyLines, withGeminiGrounding } from '@renderer/utils/formats' import { escapeBrackets, removeSvgEmptyLines, withGeminiGrounding } from '@renderer/utils/formats'
import { findCitationInChildren, sanitizeSchema } from '@renderer/utils/markdown' import { findCitationInChildren, sanitizeSchema } from '@renderer/utils/markdown'
import { isEmpty } from 'lodash' import { isEmpty } from 'lodash'
import { type FC, useMemo } from 'react' import { type FC, memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import ReactMarkdown, { type Components } from 'react-markdown' import ReactMarkdown, { type Components } from 'react-markdown'
import rehypeKatex from 'rehype-katex' import rehypeKatex from 'rehype-katex'
@ -22,6 +22,7 @@ import remarkCjkFriendly from 'remark-cjk-friendly'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math' import remarkMath from 'remark-math'
import InlineToolBlock from '../Messages/InlineToolBlock'
import EditableCodeBlock from './EditableCodeBlock' import EditableCodeBlock from './EditableCodeBlock'
import ImagePreview from './ImagePreview' import ImagePreview from './ImagePreview'
import Link from './Link' import Link from './Link'
@ -47,6 +48,87 @@ const Markdown: FC<Props> = ({ message }) => {
return [rehypeRaw, [rehypeSanitize, sanitizeSchema], mathEngine === 'KaTeX' ? rehypeKatex : rehypeMathjax] return [rehypeRaw, [rehypeSanitize, sanitizeSchema], mathEngine === 'KaTeX' ? rehypeKatex : rehypeMathjax]
}, [mathEngine]) }, [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 components = useMemo(() => {
const baseComponents = { const baseComponents = {
a: (props: any) => <Link {...props} citationData={parseJSON(findCitationInChildren(props.children))} />, 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} />, pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />,
// 自定义处理think标签 // 自定义处理think标签
think: (props: any) => { think: (props: any) => {
// 将think标签内容渲染为带样式的div // 将think标签内容渲染为带样式的span避免在p标签内使用div导致的hydration错误
return ( return (
<div <span
className="thinking-content" className="thinking-content"
style={{ style={{
display: 'block',
backgroundColor: 'rgba(0, 0, 0, 0.05)', backgroundColor: 'rgba(0, 0, 0, 0.05)',
padding: '10px 15px', padding: '10px 15px',
borderRadius: '8px', borderRadius: '8px',
@ -68,20 +151,101 @@ const Markdown: FC<Props> = ({ message }) => {
fontStyle: 'italic', fontStyle: 'italic',
color: 'var(--color-text-2)' color: 'var(--color-text-2)'
}}> }}>
<div style={{ fontWeight: 'bold', marginBottom: '5px' }}>:</div> <span style={{ display: 'block', fontWeight: 'bold', marginBottom: '5px' }}>:</span>
{props.children} {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> } as Partial<Components>
return baseComponents return baseComponents
}, []) }, [message?.metadata])
if (message.role === 'user' && !renderInputMessageAsMarkdown) { if (message.role === 'user' && !renderInputMessageAsMarkdown) {
return <p style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>{messageContent}</p> return <p style={{ marginBottom: 5, whiteSpace: 'pre-wrap' }}>{messageContent}</p>
} }
if (messageContent.includes('<style>')) { if (processedMessageContent.includes('<style>')) {
components.style = MarkdownShadowDOMRenderer as any components.style = MarkdownShadowDOMRenderer as any
} }
@ -96,9 +260,10 @@ const Markdown: FC<Props> = ({ message }) => {
footnoteLabelTagName: 'h4', footnoteLabelTagName: 'h4',
footnoteBackContent: ' ' footnoteBackContent: ' '
}}> }}>
{messageContent} {processedMessageContent}
</ReactMarkdown> </ReactMarkdown>
) )
} }
export default Markdown // 使用 memo 包装组件,避免不必要的重渲染
export default memo(Markdown)

View File

@ -10,7 +10,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { RootState } from '@renderer/store' import { RootState } from '@renderer/store'
import { selectCurrentTopicId } from '@renderer/store/messages' import { selectCurrentTopicId } from '@renderer/store/messages'
import { Button, Drawer, Tooltip } from 'antd' 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 { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import styled from 'styled-components' import styled from 'styled-components'
@ -77,107 +77,121 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
setHideTimer(timer) setHideTimer(timer)
}, []) }, [])
const handleChatHistoryClick = () => { // 使用 useCallback 记忆化 handleChatHistoryClick 函数,避免不必要的重新创建
const handleChatHistoryClick = useCallback(() => {
setShowChatHistory(true) setShowChatHistory(true)
resetHideTimer() resetHideTimer()
} }, [setShowChatHistory, resetHideTimer])
const handleDrawerClose = () => { // 使用 useCallback 记忆化 handleDrawerClose 函数,避免不必要的重新创建
const handleDrawerClose = useCallback(() => {
setShowChatHistory(false) setShowChatHistory(false)
} }, [setShowChatHistory])
const findUserMessages = () => { // 使用 useCallback 记忆化 findUserMessages 函数,避免不必要的重新创建
const findUserMessages = useCallback(() => {
const container = document.getElementById(containerId) const container = document.getElementById(containerId)
if (!container) return [] if (!container) return []
const userMessages = Array.from(container.getElementsByClassName('message-user')) const userMessages = Array.from(container.getElementsByClassName('message-user'))
return userMessages as HTMLElement[] return userMessages as HTMLElement[]
} }, [containerId])
const findAssistantMessages = () => { // 使用 useCallback 记忆化 findAssistantMessages 函数,避免不必要的重新创建
const findAssistantMessages = useCallback(() => {
const container = document.getElementById(containerId) const container = document.getElementById(containerId)
if (!container) return [] if (!container) return []
const assistantMessages = Array.from(container.getElementsByClassName('message-assistant')) const assistantMessages = Array.from(container.getElementsByClassName('message-assistant'))
return assistantMessages as HTMLElement[] return assistantMessages as HTMLElement[]
} }, [containerId])
const scrollToMessage = (element: HTMLElement) => { // 使用 useCallback 记忆化 scrollToMessage 函数,避免不必要的重新创建
const scrollToMessage = useCallback((element: HTMLElement) => {
element.scrollIntoView({ behavior: 'smooth', block: 'start' }) element.scrollIntoView({ behavior: 'smooth', block: 'start' })
} }, [])
const scrollToTop = () => { // 使用 useCallback 记忆化 scrollToTop 函数,避免不必要的重新创建
const scrollToTop = useCallback(() => {
const container = document.getElementById(containerId) const container = document.getElementById(containerId)
container && container.scrollTo({ top: -container.scrollHeight, behavior: 'smooth' }) container && container.scrollTo({ top: -container.scrollHeight, behavior: 'smooth' })
} }, [containerId])
const scrollToBottom = () => { // 使用 useCallback 记忆化 scrollToBottom 函数,避免不必要的重新创建
const scrollToBottom = useCallback(() => {
const container = document.getElementById(containerId) const container = document.getElementById(containerId)
container && container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }) container && container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' })
} }, [containerId])
const getCurrentVisibleIndex = (direction: 'up' | 'down') => { // 使用 useCallback 记忆化 getCurrentVisibleIndex 函数,避免不必要的重新创建
const userMessages = findUserMessages() const getCurrentVisibleIndex = useCallback(
const assistantMessages = findAssistantMessages() (direction: 'up' | 'down') => {
const container = document.getElementById(containerId) const userMessages = findUserMessages()
const assistantMessages = findAssistantMessages()
const container = document.getElementById(containerId)
if (!container) return -1 if (!container) return -1
const containerRect = container.getBoundingClientRect() const containerRect = container.getBoundingClientRect()
const visibleThreshold = containerRect.height * 0.1 const visibleThreshold = containerRect.height * 0.1
let visibleIndices: number[] = [] let visibleIndices: number[] = []
for (let i = 0; i < userMessages.length; i++) { for (let i = 0; i < userMessages.length; i++) {
const messageRect = userMessages[i].getBoundingClientRect() const messageRect = userMessages[i].getBoundingClientRect()
const visibleHeight = const visibleHeight =
Math.min(messageRect.bottom, containerRect.bottom) - Math.max(messageRect.top, containerRect.top) Math.min(messageRect.bottom, containerRect.bottom) - Math.max(messageRect.top, containerRect.top)
if (visibleHeight > 0 && visibleHeight >= Math.min(messageRect.height, visibleThreshold)) { if (visibleHeight > 0 && visibleHeight >= Math.min(messageRect.height, visibleThreshold)) {
visibleIndices.push(i) visibleIndices.push(i)
}
} }
}
if (visibleIndices.length > 0) { if (visibleIndices.length > 0) {
return direction === 'up' ? Math.max(...visibleIndices) : Math.min(...visibleIndices) 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) { visibleIndices = []
const assistantIndex = direction === 'up' ? Math.max(...visibleIndices) : Math.min(...visibleIndices) for (let i = 0; i < assistantMessages.length; i++) {
return assistantIndex < userMessages.length ? assistantIndex : userMessages.length - 1 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 函数 return -1
const handleCloseChatNavigation = () => { },
[containerId, findUserMessages, findAssistantMessages]
)
// 使用 useCallback 记忆化 handleCloseChatNavigation 函数,避免不必要的重新创建
const handleCloseChatNavigation = useCallback(() => {
setIsVisible(false) setIsVisible(false)
// 设置手动关闭状态1分钟内不响应鼠标靠近事件 // 设置手动关闭状态1分钟内不响应鼠标靠近事件
setManuallyClosedUntil(Date.now() + 60000) // 60000毫秒 = 1分钟 setManuallyClosedUntil(Date.now() + 60000) // 60000毫秒 = 1分钟
} }, [setIsVisible, setManuallyClosedUntil])
const handleScrollToTop = () => { // 使用 useCallback 记忆化 handleScrollToTop 函数,避免不必要的重新创建
const handleScrollToTop = useCallback(() => {
resetHideTimer() resetHideTimer()
scrollToTop() scrollToTop()
} }, [resetHideTimer, scrollToTop])
const handleScrollToBottom = () => { // 使用 useCallback 记忆化 handleScrollToBottom 函数,避免不必要的重新创建
const handleScrollToBottom = useCallback(() => {
resetHideTimer() resetHideTimer()
scrollToBottom() scrollToBottom()
} }, [resetHideTimer, scrollToBottom])
const handleNextMessage = () => { // 使用 useCallback 记忆化 handleNextMessage 函数,避免不必要的重新创建
const handleNextMessage = useCallback(() => {
resetHideTimer() resetHideTimer()
const userMessages = findUserMessages() const userMessages = findUserMessages()
const assistantMessages = findAssistantMessages() const assistantMessages = findAssistantMessages()
@ -202,9 +216,10 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
} }
scrollToMessage(userMessages[targetIndex]) scrollToMessage(userMessages[targetIndex])
} }, [resetHideTimer, findUserMessages, findAssistantMessages, scrollToBottom, getCurrentVisibleIndex, scrollToMessage])
const handlePrevMessage = () => { // 使用 useCallback 记忆化 handlePrevMessage 函数,避免不必要的重新创建
const handlePrevMessage = useCallback(() => {
resetHideTimer() resetHideTimer()
const userMessages = findUserMessages() const userMessages = findUserMessages()
const assistantMessages = findAssistantMessages() const assistantMessages = findAssistantMessages()
@ -228,7 +243,7 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
} }
scrollToMessage(userMessages[targetIndex]) scrollToMessage(userMessages[targetIndex])
} }, [resetHideTimer, findUserMessages, findAssistantMessages, scrollToTop, getCurrentVisibleIndex, scrollToMessage])
// Set up scroll event listener and mouse position tracking // Set up scroll event listener and mouse position tracking
useEffect(() => { useEffect(() => {
@ -441,4 +456,5 @@ const Divider = styled.div`
margin: 0; margin: 0;
` `
export default ChatNavigation // 使用 memo 包装组件,避免不必要的重渲染
export default memo(ChatNavigation)

View File

@ -1,7 +1,7 @@
import { InfoCircleOutlined } from '@ant-design/icons' import { InfoCircleOutlined } from '@ant-design/icons'
import Favicon from '@renderer/components/Icons/FallbackFavicon' import Favicon from '@renderer/components/Icons/FallbackFavicon'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
import React from 'react' import React, { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -22,23 +22,28 @@ const CitationsList: React.FC<CitationsListProps> = ({ citations }) => {
if (!citations || citations.length === 0) return null 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 ( return (
<CitationsContainer className="footnotes"> <CitationsContainer className="footnotes">
<CitationsTitle> <CitationsTitle>
{t('message.citations')} {t('message.citations')}
<InfoCircleOutlined style={{ fontSize: '14px', marginLeft: '4px', opacity: 0.6 }} /> <InfoCircleOutlined style={{ fontSize: '14px', marginLeft: '4px', opacity: 0.6 }} />
</CitationsTitle> </CitationsTitle>
{citations.map((citation) => ( {renderedCitations}
<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>
))}
</CitationsContainer> </CitationsContainer>
) )
} }
@ -78,4 +83,5 @@ const CitationLink = styled.a`
} }
` `
export default CitationsList // 使用 memo 包装组件,避免不必要的重渲染
export default memo(CitationsList)

View 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

View 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)

View 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)

View File

@ -16,6 +16,7 @@ import { Divider, Dropdown } from 'antd'
import { ItemType } from 'antd/es/menu/interface' import { ItemType } from 'antd/es/menu/interface'
import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { shallowEqual } from 'react-redux'
// import { useSelector } from 'react-redux'; // Removed unused import // import { useSelector } from 'react-redux'; // Removed unused import
import styled from 'styled-components' // Ensure styled-components is imported 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 [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null)
const [selectedQuoteText, setSelectedQuoteText] = useState<string>('') const [selectedQuoteText, setSelectedQuoteText] = useState<string>('')
const [selectedText, setSelectedText] = useState<string>('') const [selectedText, setSelectedText] = useState<string>('')
// 使用记忆化的上下文菜单项生成函数
const getContextMenuItems = useContextMenuItems(t, message)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const playTimeoutRef = useRef<NodeJS.Timeout | null>(null) const playTimeoutRef = useRef<NodeJS.Timeout | null>(null)
// --- Consolidated State Selection --- // --- Consolidated State Selection with shallowEqual for performance ---
const ttsEnabled = useAppSelector((state) => state.settings.ttsEnabled) const { ttsEnabled, voiceCallEnabled, isVoiceCallActive, lastPlayedMessageId, skipNextAutoTTS } = useAppSelector(
const voiceCallEnabled = useAppSelector((state) => state.settings.voiceCallEnabled) (state) => ({
const autoPlayTTSOutsideVoiceCall = useAppSelector((state) => state.settings.autoPlayTTSOutsideVoiceCall) ttsEnabled: state.settings.ttsEnabled,
const isVoiceCallActive = useAppSelector((state) => state.settings.isVoiceCallActive) voiceCallEnabled: state.settings.voiceCallEnabled,
const lastPlayedMessageId = useAppSelector((state) => state.settings.lastPlayedMessageId) isVoiceCallActive: state.settings.isVoiceCallActive,
const skipNextAutoTTS = useAppSelector((state) => state.settings.skipNextAutoTTS) lastPlayedMessageId: state.settings.lastPlayedMessageId,
skipNextAutoTTS: state.settings.skipNextAutoTTS
}),
shallowEqual
) // 使用 shallowEqual 比较函数避免不必要的重渲染
// --------------------------------- // ---------------------------------
const isLastMessage = index === 0 const isLastMessage = index === 0
@ -98,6 +106,7 @@ const MessageItem: FC<Props> = ({
const showMenubar = !isStreaming && !message.status.includes('ing') const showMenubar = !isStreaming && !message.status.includes('ing')
const fontFamily = useMemo(() => { const fontFamily = useMemo(() => {
// 优化:简化字符串操作,减少每次渲染时的计算开销
return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY return messageFont === 'serif' ? FONT_FAMILY.replace('sans-serif', 'serif').replace('Ubuntu, ', '') : FONT_FAMILY
}, [messageFont]) }, [messageFont])
@ -154,94 +163,100 @@ const MessageItem: FC<Props> = ({
} }
}, [generating, isLastMessage, isAssistantMessage, message.status, message.id, dispatch]) }, [generating, isLastMessage, isAssistantMessage, message.status, message.id, dispatch])
// --- Auto-play TTS Logic --- // --- 使用 useMemo 计算是否应该自动播放 TTS ---
useEffect(() => { const shouldAutoPlayTTS = useMemo(() => {
// 基本条件检查 // 基本条件检查
if (!isLastMessage || !isAssistantMessage || message.status !== 'success' || generating) { if (!isLastMessage) return false // 必须是最后一条消息
return if (!isAssistantMessage) return false // 必须是助手消息
} if (message.status !== 'success') return false // 消息状态必须是成功
if (!ttsEnabled) { if (generating) return false // 正在生成中时不播放
return if (!ttsEnabled) return false // TTS功能必须启用
}
// 语音通话相关条件检查 // 语音通话相关条件检查
if (voiceCallEnabled === false && autoPlayTTSOutsideVoiceCall === false) { if (!voiceCallEnabled || !isVoiceCallActive) {
// 简化日志输出 console.log('不自动播放TTS: 语音通话未开启或窗口未激活, ID:', message.id)
console.log('不自动播放TTS: 语音通话功能未启用 + 不允许在语音通话模式外自动播放') return false
return
}
if (voiceCallEnabled === true && isVoiceCallActive === false && autoPlayTTSOutsideVoiceCall === false) {
// 简化日志输出
console.log('不自动播放TTS: 语音通话窗口未打开 + 不允许在语音通话模式外自动播放')
return
} }
// 检查是否需要跳过自动TTS // 检查是否需要跳过自动TTS
if (skipNextAutoTTS === true) { if (skipNextAutoTTS === true) {
console.log('跳过自动TTS: skipNextAutoTTS = true, 消息ID:', message.id) console.log('跳过自动TTS: skipNextAutoTTS = true, 消息ID:', message.id)
return return false
} }
// 检查消息是否有内容,且消息是新的(不是上次播放过的消息) // 检查消息是否有内容,且消息是新的(不是上次播放过的消息)
if (message.content && message.content.trim() && message.id !== lastPlayedMessageId) { if (!message.content || !message.content.trim() || message.id === lastPlayedMessageId) {
// 简化日志输出 return false
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错误
} }
// 添加默认返回值,确保所有代码路径都有返回值 // 所有条件都满足,应该自动播放
return return true
}, [ }, [
isLastMessage, isLastMessage,
isAssistantMessage, isAssistantMessage,
message, message.status,
message.content,
message.id,
generating, generating,
ttsEnabled, ttsEnabled,
voiceCallEnabled, voiceCallEnabled,
autoPlayTTSOutsideVoiceCall,
isVoiceCallActive, isVoiceCallActive,
skipNextAutoTTS, skipNextAutoTTS,
lastPlayedMessageId, lastPlayedMessageId
dispatch
]) ])
// --- Highlight message on event --- // --- 简化后的 TTS 自动播放逻辑 ---
const messageHighlightHandler = useCallback((highlight: boolean = true) => { useEffect(() => {
if (messageContainerRef.current) { // 如果不应该自动播放,直接返回
messageContainerRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) if (!shouldAutoPlayTTS) return
if (highlight) {
const element = messageContainerRef.current console.log('准备自动播放TTS, 消息ID:', message.id)
element.classList.add('message-highlight')
setTimeout(() => { // 只有当没有设置过定时器时才设置
element?.classList.remove('message-highlight') if (!playTimeoutRef.current) {
}, 2500) 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(() => { useEffect(() => {
const eventName = `${EVENT_NAMES.LOCATE_MESSAGE}:${message.id}` const eventName = `${EVENT_NAMES.LOCATE_MESSAGE}:${message.id}`
@ -279,7 +294,7 @@ const MessageItem: FC<Props> = ({
{contextMenuPosition && ( {contextMenuPosition && (
<Dropdown <Dropdown
overlayStyle={{ position: 'fixed', left: contextMenuPosition.x, top: contextMenuPosition.y, zIndex: 1000 }} 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} open={true}
trigger={['contextMenu']}> trigger={['contextMenu']}>
{/* FIX 2: Use the styled component instead of inline style */} {/* FIX 2: Use the styled component instead of inline style */}
@ -324,75 +339,74 @@ const MessageItem: FC<Props> = ({
) )
} }
// Updated context menu items function // 使用 hook 封装上下文菜单项生成逻辑,便于在组件内使用
const getContextMenuItems = ( const useContextMenuItems = (t: (key: string) => string, message: Message) => {
t: (key: string) => string, return useMemo(() => {
selectedQuoteText: string, return (selectedQuoteText: string, selectedText: string): ItemType[] => {
selectedText: string, const items: ItemType[] = []
message: Message
): ItemType[] => {
const items: ItemType[] = []
if (selectedText) { if (selectedText) {
items.push({ items.push({
key: 'copy', key: 'copy',
label: t('common.copy'), label: t('common.copy'),
onClick: () => { onClick: () => {
navigator.clipboard navigator.clipboard
.writeText(selectedText) .writeText(selectedText)
.then(() => window.message.success({ content: t('message.copied'), key: 'copy-message' })) .then(() => window.message.success({ content: t('message.copied'), key: 'copy-message' }))
.catch((err) => console.error('Failed to copy text: ', err)) .catch((err) => console.error('Failed to copy text: ', err))
} }
}) })
items.push({ items.push({
key: 'quote', key: 'quote',
label: t('chat.message.quote'), label: t('chat.message.quote'),
onClick: () => { onClick: () => {
EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText) EventEmitter.emit(EVENT_NAMES.QUOTE_TEXT, selectedQuoteText)
} }
}) })
items.push({ items.push({
key: 'speak_selected', key: 'speak_selected',
label: t('chat.message.speak_selection') || '朗读选中部分', label: t('chat.message.speak_selection') || '朗读选中部分',
onClick: () => { onClick: () => {
// 首先手动关闭菜单 // 首先手动关闭菜单
document.dispatchEvent(new MouseEvent('click')) document.dispatchEvent(new MouseEvent('click'))
// 使用setTimeout确保菜单关闭后再执行TTS功能 // 使用setTimeout确保菜单关闭后再执行TTS功能
setTimeout(() => { setTimeout(() => {
import('@renderer/services/TTSService') import('@renderer/services/TTSService')
.then(({ default: TTSServiceInstance }) => { .then(({ default: TTSServiceInstance }) => {
let textToSpeak = selectedText let textToSpeak = selectedText
if (message.content) { if (message.content) {
const startIndex = message.content.indexOf(selectedText) const startIndex = message.content.indexOf(selectedText)
if (startIndex !== -1) { if (startIndex !== -1) {
textToSpeak = selectedText // Just speak selection textToSpeak = selectedText // Just speak selection
} }
} }
// 传递消息ID确保进度条和停止按钮正常工作 // 传递消息ID确保进度条和停止按钮正常工作
TTSServiceInstance.speak(textToSpeak, false, message.id) // 使用普通播放模式而非分段播放 TTSServiceInstance.speak(textToSpeak, false, message.id) // 使用普通播放模式而非分段播放
}) })
.catch((err) => console.error('Failed to load or use TTSService:', err)) .catch((err) => console.error('Failed to load or use TTSService:', err))
}, 100) }, 100)
}
})
items.push({ type: 'divider' })
} }
})
items.push({ type: 'divider' })
}
items.push({ items.push({
key: 'copy_id', key: 'copy_id',
label: t('message.copy_id') || '复制消息ID', label: t('message.copy_id') || '复制消息ID',
onClick: () => { onClick: () => {
navigator.clipboard navigator.clipboard
.writeText(message.id) .writeText(message.id)
.then(() => .then(() =>
window.message.success({ content: t('message.id_copied') || '消息ID已复制', key: 'copy-message-id' }) window.message.success({ content: t('message.id_copied') || '消息ID已复制', key: 'copy-message-id' })
) )
.catch((err) => console.error('Failed to copy message ID: ', err)) .catch((err) => console.error('Failed to copy message ID: ', err))
}
})
return items
} }
}) }, [t, message.id, message.content]) // 只依赖 t 和 message 的关键属性
return items
} }
// Styled components definitions // Styled components definitions

View File

@ -11,7 +11,7 @@ import { updateMessageThunk } from '@renderer/store/messages'
import type { Message } from '@renderer/types' import type { Message } from '@renderer/types'
import { isEmoji, removeLeadingEmoji } from '@renderer/utils' import { isEmoji, removeLeadingEmoji } from '@renderer/utils'
import { Avatar } from 'antd' 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 { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
interface MessageLineProps { interface MessageLineProps {
@ -54,7 +54,7 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
return () => { return () => {
window.removeEventListener('resize', updateHeight) window.removeEventListener('resize', updateHeight)
} }
}, [messages]) }, [])
// 函数用于计算根据距离的变化值 // 函数用于计算根据距离的变化值
const calculateValueByDistance = useCallback( const calculateValueByDistance = useCallback(
@ -130,6 +130,32 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
[setSelectedMessage] [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 scrollToBottom = useCallback(() => {
const messagesContainer = document.getElementById('messages') const messagesContainer = document.getElementById('messages')
if (messagesContainer) { 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 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 ( return (
<MessageLineContainer <MessageLineContainer
ref={containerRef} ref={containerRef}
@ -167,73 +258,7 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
$height={containerHeight}> $height={containerHeight}>
<MessagesList ref={messagesListRef} style={{ transform: `translateY(${listOffsetY}px)` }}> <MessagesList ref={messagesListRef} style={{ transform: `translateY(${listOffsetY}px)` }}>
<MessageItem {renderedItems}
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>
)
})}
</MessagesList> </MessagesList>
</MessageLineContainer> </MessageLineContainer>
) )
@ -321,4 +346,5 @@ const EmojiAvatar = styled.div<{ size: number }>`
border: 0.5px solid var(--color-border); border: 0.5px solid var(--color-border);
` `
export default MessageAnchorLine // 使用 memo 包装组件,避免不必要的重渲染
export default memo(MessageAnchorLine)

View File

@ -12,7 +12,7 @@ import FileManager from '@renderer/services/FileManager'
import { FileType, FileTypes, Message } from '@renderer/types' import { FileType, FileTypes, Message } from '@renderer/types'
import { download } from '@renderer/utils/download' import { download } from '@renderer/utils/download'
import { Image as AntdImage, Space, Upload } from 'antd' import { Image as AntdImage, Space, Upload } from 'antd'
import { FC } from 'react' import { FC, memo, useCallback, useMemo } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
interface Props { interface Props {
@ -20,12 +20,13 @@ interface Props {
} }
const MessageAttachments: FC<Props> = ({ message }) => { const MessageAttachments: FC<Props> = ({ message }) => {
const handleCopyImage = async (image: FileType) => { // 使用 useCallback 记忆化复制图片函数,避免不必要的重新创建
const handleCopyImage = useCallback(async (image: FileType) => {
const data = await FileManager.readFile(image) const data = await FileManager.readFile(image)
const blob = new Blob([data], { type: 'image/png' }) const blob = new Blob([data], { type: 'image/png' })
const item = new ClipboardItem({ [blob.type]: blob }) const item = new ClipboardItem({ [blob.type]: blob })
await navigator.clipboard.write([item]) await navigator.clipboard.write([item])
} }, [])
if (!message.files) { if (!message.files) {
return null return null
@ -34,50 +35,65 @@ const MessageAttachments: FC<Props> = ({ message }) => {
if (message?.files && message.files[0]?.type === FileTypes.IMAGE) { if (message?.files && message.files[0]?.type === FileTypes.IMAGE) {
return ( return (
<Container style={{ marginBottom: 8 }}> <Container style={{ marginBottom: 8 }}>
{message.files?.map((image) => ( {message.files?.map((image) => {
<Image // 使用 useCallback 记忆化工具栏渲染函数,避免不必要的重新创建
src={FileManager.getFileUrl(image)} const memoizedToolbarRender = useCallback(
key={image.id} (
width="33%" _,
preview={{ {
toolbarRender: ( transform: { scale },
_, actions: { onFlipY, onFlipX, onRotateLeft, onRotateRight, onZoomOut, onZoomIn, onReset }
{ }
transform: { scale }, ) => (
actions: { onFlipY, onFlipX, onRotateLeft, onRotateRight, onZoomOut, onZoomIn, onReset } <ToobarWrapper size={12} className="toolbar-wrapper">
} <SwapOutlined rotate={90} onClick={onFlipY} />
) => ( <SwapOutlined onClick={onFlipX} />
<ToobarWrapper size={12} className="toolbar-wrapper"> <RotateLeftOutlined onClick={onRotateLeft} />
<SwapOutlined rotate={90} onClick={onFlipY} /> <RotateRightOutlined onClick={onRotateRight} />
<SwapOutlined onClick={onFlipX} /> <ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} />
<RotateLeftOutlined onClick={onRotateLeft} /> <ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} />
<RotateRightOutlined onClick={onRotateRight} /> <UndoOutlined onClick={onReset} />
<ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} /> <CopyOutlined onClick={() => handleCopyImage(image)} />
<ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} /> <DownloadOutlined onClick={() => download(FileManager.getFileUrl(image))} />
<UndoOutlined onClick={onReset} /> </ToobarWrapper>
<CopyOutlined onClick={() => handleCopyImage(image)} /> ),
<DownloadOutlined onClick={() => download(FileManager.getFileUrl(image))} /> [image, handleCopyImage] // 依赖于当前循环的 image 对象和 handleCopyImage 函数
</ToobarWrapper> )
)
}} return (
/> <Image
))} src={FileManager.getFileUrl(image)}
key={image.id}
width="33%"
preview={{
toolbarRender: memoizedToolbarRender // 使用记忆化的函数
}}
/>
)
})}
</Container> </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 ( return (
<Container style={{ marginTop: 2, marginBottom: 8 }} className="message-attachments"> <Container style={{ marginTop: 2, marginBottom: 8 }} className="message-attachments">
<Upload <Upload listType="text" disabled fileList={memoizedFileList} />
listType="text"
disabled
fileList={message.files?.map((file) => ({
uid: file.id,
url: 'file://' + FileManager.getSafePath(file),
status: 'done',
name: FileManager.formatFileName(file)
}))}
/>
</Container> </Container>
) )
} }
@ -108,4 +124,5 @@ const ToobarWrapper = styled(Space)`
} }
` `
export default MessageAttachments // 使用 memo 包装组件,避免不必要的重渲染
export default memo(MessageAttachments)

View File

@ -20,7 +20,6 @@ import MessageAttachments from './MessageAttachments'
import MessageError from './MessageError' import MessageError from './MessageError'
import MessageImage from './MessageImage' import MessageImage from './MessageImage'
import MessageThought from './MessageThought' import MessageThought from './MessageThought'
import MessageTools from './MessageTools'
interface Props { interface Props {
message: Message message: Message
@ -235,7 +234,7 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
return ( return (
<Fragment> <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>)} {message.mentions?.map((model) => <MentionTag key={getModelUniqId(model)}>{'@' + model.name}</MentionTag>)}
</Flex> </Flex>
{message.referencedMessages && message.referencedMessages.length > 0 && ( {message.referencedMessages && message.referencedMessages.length > 0 && (
@ -329,25 +328,24 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
/> />
)} )}
<div className="message-content-tools"> <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} /> <MessageThought message={message} />
<MessageTools message={message} />
</div> </div>
{isSegmentedPlayback ? ( {isSegmentedPlayback ? (
// Apply regex replacement here for TTS // Apply regex replacement here for TTS
<TTSHighlightedText text={processedContent.replace(tagsToRemoveRegex, '')} /> <TTSHighlightedText text={processedContent.replace(tagsToRemoveRegex, '')} />
) : ( ) : (
// Apply regex replacement here for Markdown display // Don't remove XML tags, let Markdown component handle them
<Markdown message={{ ...message, content: processedContent.replace(tagsToRemoveRegex, '') }} /> <Markdown message={{ ...message, content: processedContent }} />
)} )}
{message.metadata?.generateImage && <MessageImage message={message} />} {message.metadata?.generateImage && <MessageImage message={message} />}
{message.translatedContent && ( {message.translatedContent && (
<Fragment> <Fragment>
<Divider style={{ margin: 0, marginBottom: 10 }}> <Divider style={{ margin: 0, marginBottom: 5 }}>
<TranslationOutlined /> <TranslationOutlined />
</Divider> </Divider>
{message.translatedContent === t('translate.processing') ? ( {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) // Render translated content (assuming it doesn't need tag removal, adjust if needed)
<Markdown message={{ ...message, content: message.translatedContent }} /> <Markdown message={{ ...message, content: message.translatedContent }} />
@ -428,10 +426,10 @@ const SearchingContainer = styled.div`
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
background-color: var(--color-background-mute); background-color: var(--color-background-mute);
padding: 10px; padding: 8px;
border-radius: 10px; border-radius: 8px;
margin-bottom: 10px; margin-bottom: 5px;
gap: 10px; gap: 8px;
` `
const MentionTag = styled.span` const MentionTag = styled.span`
@ -446,15 +444,15 @@ const SearchingText = styled.div`
` `
const SearchEntryPoint = styled.div` const SearchEntryPoint = styled.div`
margin: 10px 2px; margin: 5px 2px;
` `
// 引用消息样式 - 使用全局样式 // 引用消息样式 - 使用全局样式
const referenceStyles = ` const referenceStyles = `
.reference-collapse { .reference-collapse {
margin-bottom: 8px; margin-bottom: 5px;
border: 1px solid var(--color-border) !important; border: 1px solid var(--color-border) !important;
border-radius: 8px !important; border-radius: 6px !important;
overflow: hidden; overflow: hidden;
background-color: var(--color-bg-1) !important; background-color: var(--color-bg-1) !important;
@ -536,8 +534,8 @@ const referenceStyles = `
} }
.ant-collapse-content-box { .ant-collapse-content-box {
padding: 12px !important; padding: 8px !important;
padding-top: 8px !important; padding-top: 5px !important;
padding-bottom: 2px !important; padding-bottom: 2px !important;
} }
@ -554,7 +552,7 @@ const referenceStyles = `
} }
.reference-bottom-spacing { .reference-bottom-spacing {
height: 10px; height: 5px;
} }
} }
} }
@ -572,8 +570,8 @@ try {
referenceStyles + referenceStyles +
` `
.message-content-tools { .message-content-tools {
margin-top: 20px; /* Adjust as needed */ margin-top: 5px; /* 进一步减少顶部间距 */
margin-bottom: 10px; /* Add space before main content */ margin-bottom: 2px; /* 进一步减少底部间距 */
} }
` `
document.head.appendChild(styleElement) document.head.appendChild(styleElement)

View File

@ -1,7 +1,7 @@
import { Message } from '@renderer/types' import { Message } from '@renderer/types'
import { formatErrorMessage } from '@renderer/utils/error' import { formatErrorMessage } from '@renderer/utils/error'
import { Alert as AntdAlert } from 'antd' import { Alert as AntdAlert } from 'antd'
import { FC } from 'react' import { FC, memo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' 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 // Add more robust checks: ensure error is an object and status is a number before accessing/including
if ( if (
@ -64,7 +66,7 @@ const MessageErrorInfo: FC<{ message: Message }> = ({ message }) => {
} }
return <Alert description={t('error.chat.response')} type="error" /> return <Alert description={t('error.chat.response')} type="error" />
} })
const Alert = styled(AntdAlert)` const Alert = styled(AntdAlert)`
margin: 15px 0 8px; margin: 15px 0 8px;
@ -72,4 +74,5 @@ const Alert = styled(AntdAlert)`
font-size: 12px; font-size: 12px;
` `
export default MessageError // 使用 memo 包装组件,避免不必要的重渲染
export default memo(MessageError)

View File

@ -38,12 +38,27 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
return messages[0]?.id return messages[0]?.id
}, [messages]) }, [messages])
// 记录当前选中的消息 ID
const selectedMessageIdRef = useRef<string | null>(null)
const setSelectedMessage = useCallback( const setSelectedMessage = useCallback(
(message: Message) => { (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(() => { setTimeout(() => {
const messageElement = document.getElementById(`message-${message.id}`) const messageElement = document.getElementById(`message-${message.id}`)
if (messageElement) { if (messageElement) {
@ -51,7 +66,7 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
} }
}, 200) }, 200)
}, },
[editMessage, messages] [editMessage]
) )
const isGrouped = messageLength > 1 && messages.every((m) => m.role === 'assistant') 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [messageLength]) }, [messageLength])
// 添加对流程图节点点击事件的监听 // 使用 useMemo 记忆化流程图导航处理函数,减少重新创建
useEffect(() => { const handleFlowNavigate = useMemo(() => {
// 只在组件挂载和消息数组变化时添加监听器 return (event: CustomEvent) => {
if (!isGrouped || messageLength <= 1) return
const handleFlowNavigate = (event: CustomEvent) => {
const { messageId } = event.detail 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) document.addEventListener('flow-navigate-to-message', handleFlowNavigate as EventListener)
@ -106,17 +124,14 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
return () => { return () => {
document.removeEventListener('flow-navigate-to-message', handleFlowNavigate as EventListener) document.removeEventListener('flow-navigate-to-message', handleFlowNavigate as EventListener)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps }, [isGrouped, messageLength, handleFlowNavigate])
}, [messages, selectedIndex, isGrouped, messageLength])
// 添加对LOCATE_MESSAGE事件的监听 // 使用 useMemo 创建消息定位处理函数映射,减少重新创建
useEffect(() => { const messageLocateHandlers = useMemo(() => {
// 为每个消息注册一个定位事件监听器 const handlers: { [key: string]: () => void } = {}
const eventHandlers: { [key: string]: () => void } = {}
messages.forEach((message) => { messages.forEach((message) => {
const eventName = EVENT_NAMES.LOCATE_MESSAGE + ':' + message.id handlers[message.id] = () => {
const handler = () => {
// 检查消息是否处于可见状态 // 检查消息是否处于可见状态
const element = document.getElementById(`message-${message.id}`) const element = document.getElementById(`message-${message.id}`)
if (element) { 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 eventHandlers[eventName] = handler
EventEmitter.on(eventName, handler) EventEmitter.on(eventName, handler)
}) })
@ -143,7 +170,7 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
EventEmitter.off(eventName, handler) EventEmitter.off(eventName, handler)
}) })
} }
}, [messages, setSelectedMessage]) }, [messageLocateHandlers])
// 使用useMemo缓存消息渲染结果减少重复计算 // 使用useMemo缓存消息渲染结果减少重复计算
const renderedMessages = useMemo(() => { const renderedMessages = useMemo(() => {
@ -229,10 +256,16 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
<MessageGroupMenuBar <MessageGroupMenuBar
multiModelMessageStyle={multiModelMessageStyle} multiModelMessageStyle={multiModelMessageStyle}
setMultiModelMessageStyle={(style) => { setMultiModelMessageStyle={(style) => {
// 优化:使用批量更新消息样式,避免多次调用 editMessage
setMultiModelMessageStyle(style) 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} messages={messages}
selectMessageId={getSelectedMessageId()} selectMessageId={getSelectedMessageId()}

View File

@ -6,7 +6,7 @@ import { useAppDispatch } from '@renderer/store'
import { setFoldDisplayMode } from '@renderer/store/settings' import { setFoldDisplayMode } from '@renderer/store/settings'
import { Message, Model } from '@renderer/types' import { Message, Model } from '@renderer/types'
import { Avatar, Segmented as AntdSegmented, Tooltip } from 'antd' 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 { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -24,11 +24,59 @@ const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, selec
const { foldDisplayMode } = useSettings() const { foldDisplayMode } = useSettings()
const isCompact = foldDisplayMode === 'compact' 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 ( return (
<ModelsWrapper> <ModelsWrapper>
<DisplayModeToggle <DisplayModeToggle displayMode={foldDisplayMode} onClick={handleDisplayModeToggle}>
displayMode={foldDisplayMode}
onClick={() => dispatch(setFoldDisplayMode(isCompact ? 'expanded' : 'compact'))}>
<Tooltip <Tooltip
title={ title={
foldDisplayMode === 'compact' foldDisplayMode === 'compact'
@ -43,37 +91,13 @@ const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, selec
<ModelsContainer $displayMode={foldDisplayMode}> <ModelsContainer $displayMode={foldDisplayMode}>
{foldDisplayMode === 'compact' ? ( {foldDisplayMode === 'compact' ? (
/* Compact style display */ /* Compact style display */
<Avatar.Group className="avatar-group"> <Avatar.Group className="avatar-group">{compactModeAvatars}</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>
) : ( ) : (
/* Expanded style display */ /* Expanded style display */
<Segmented <Segmented
value={selectMessageId} value={selectMessageId}
onChange={(value) => { onChange={handleSegmentedChange}
const message = messages.find((message) => message.id === value) as Message options={expandedModeOptions}
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
}))}
size="small" size="small"
/> />
)} )}
@ -253,4 +277,5 @@ const ModelName = styled.span`
font-size: 12px; font-size: 12px;
` `
export default MessageGroupModelList // 使用 memo 包装组件,避免不必要的重渲染
export default memo(MessageGroupModelList)

View File

@ -6,7 +6,7 @@ import { useAppDispatch } from '@renderer/store'
import { setGridColumns, setGridPopoverTrigger } from '@renderer/store/settings' import { setGridColumns, setGridPopoverTrigger } from '@renderer/store/settings'
import { Col, Row, Select, Slider } from 'antd' import { Col, Row, Select, Slider } from 'antd'
import { Popover } from 'antd' import { Popover } from 'antd'
import { FC, useState } from 'react' import { FC, memo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const MessageGroupSettings: FC = () => { const MessageGroupSettings: FC = () => {
@ -56,4 +56,5 @@ const MessageGroupSettings: FC = () => {
) )
} }
export default MessageGroupSettings // 使用 memo 包装组件,避免不必要的重渲染
export default memo(MessageGroupSettings)

View File

@ -10,7 +10,7 @@ import {
} from '@ant-design/icons' } from '@ant-design/icons'
import { Message } from '@renderer/types' import { Message } from '@renderer/types'
import { Image as AntdImage, Space } from 'antd' import { Image as AntdImage, Space } from 'antd'
import { FC } from 'react' import { FC, memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -21,103 +21,118 @@ interface Props {
const MessageImage: FC<Props> = ({ message }) => { const MessageImage: FC<Props> = ({ message }) => {
const { t } = useTranslation() const { t } = useTranslation()
const onDownload = (imageBase64: string, index: number) => { // 使用 useCallback 记忆化下载函数,避免不必要的重新创建
try { const onDownload = useCallback(
const link = document.createElement('a') (imageBase64: string, index: number) => {
link.href = imageBase64 try {
link.download = `image-${Date.now()}-${index}.png` const link = document.createElement('a')
document.body.appendChild(link) link.href = imageBase64
link.click() link.download = `image-${Date.now()}-${index}.png`
document.body.removeChild(link) document.body.appendChild(link)
window.message.success(t('message.download.success')) link.click()
} catch (error) { document.body.removeChild(link)
console.error('下载图片失败:', error) window.message.success(t('message.download.success'))
window.message.error(t('message.download.failed')) } catch (error) {
} console.error('下载图片失败:', error)
} window.message.error(t('message.download.failed'))
}
},
[t]
)
// 复制图片到剪贴板 // 复制图片到剪贴板
const onCopy = async (type: string, image: string) => { const onCopy = useCallback(
try { async (type: string, image: string) => {
switch (type) { try {
case 'base64': { switch (type) {
// 处理 base64 格式的图片 case 'base64': {
const parts = image.split(';base64,') // 处理 base64 格式的图片
if (parts.length === 2) { const parts = image.split(';base64,')
const mimeType = parts[0].replace('data:', '') if (parts.length === 2) {
const base64Data = parts[1] const mimeType = parts[0].replace('data:', '')
const byteCharacters = atob(base64Data) const base64Data = parts[1]
const byteArrays: Uint8Array[] = [] const byteCharacters = atob(base64Data)
const byteArrays: Uint8Array[] = []
for (let offset = 0; offset < byteCharacters.length; offset += 512) { for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512) const slice = byteCharacters.slice(offset, offset + 512)
const byteNumbers = new Array(slice.length) const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++) { for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(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 图片格式')
} }
break
const blob = new Blob(byteArrays, { type: mimeType })
await navigator.clipboard.write([new ClipboardItem({ [mimeType]: blob })])
} else {
throw new Error('无效的 base64 图片格式')
} }
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([ window.message.success(t('message.copy.success'))
new ClipboardItem({ } catch (error) {
[blob.type]: blob console.error('复制图片失败:', error)
}) window.message.error(t('message.copy.failed'))
])
}
break
} }
},
window.message.success(t('message.copy.success')) [t]
} catch (error) { )
console.error('复制图片失败:', error)
window.message.error(t('message.copy.failed'))
}
}
return ( return (
<Container style={{ marginBottom: 8 }}> <Container style={{ marginBottom: 8 }}>
{message.metadata?.generateImage!.images.map((image, index) => ( {message.metadata?.generateImage!.images.map((image, index) => {
<Image // 使用 useCallback 记忆化工具栏渲染函数,避免不必要的重新创建
src={image} const memoizedToolbarRender = useCallback(
key={`image-${index}`} (
width="33%" _: any,
preview={{ {
toolbarRender: ( transform: { scale },
_, actions: { onFlipY, onFlipX, onRotateLeft, onRotateRight, onZoomOut, onZoomIn, onReset }
{ }: any
transform: { scale }, ) => (
actions: { onFlipY, onFlipX, onRotateLeft, onRotateRight, onZoomOut, onZoomIn, onReset } <ToobarWrapper size={12} className="toolbar-wrapper">
} <SwapOutlined rotate={90} onClick={onFlipY} />
) => ( <SwapOutlined onClick={onFlipX} />
<ToobarWrapper size={12} className="toolbar-wrapper"> <RotateLeftOutlined onClick={onRotateLeft} />
<SwapOutlined rotate={90} onClick={onFlipY} /> <RotateRightOutlined onClick={onRotateRight} />
<SwapOutlined onClick={onFlipX} /> <ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} />
<RotateLeftOutlined onClick={onRotateLeft} /> <ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} />
<RotateRightOutlined onClick={onRotateRight} /> <UndoOutlined onClick={onReset} />
<ZoomOutOutlined disabled={scale === 1} onClick={onZoomOut} /> <CopyOutlined onClick={() => onCopy(message.metadata?.generateImage?.type!, image)} />
<ZoomInOutlined disabled={scale === 50} onClick={onZoomIn} /> <DownloadOutlined onClick={() => onDownload(image, index)} />
<UndoOutlined onClick={onReset} /> </ToobarWrapper>
<CopyOutlined onClick={() => onCopy(message.metadata?.generateImage?.type!, image)} /> ),
<DownloadOutlined onClick={() => onDownload(image, index)} /> [image, index, onCopy, onDownload, message.metadata?.generateImage?.type]
</ToobarWrapper> )
)
}} return (
/> <Image
))} src={image}
key={`image-${index}`}
width="33%"
preview={{
toolbarRender: memoizedToolbarRender
}}
/>
)
})}
</Container> </Container>
) )
} }
@ -145,4 +160,5 @@ const ToobarWrapper = styled(Space)`
} }
` `
export default MessageImage // 使用 memo 包装组件,避免不必要的重渲染
export default memo(MessageImage)

View File

@ -339,25 +339,42 @@ const MessageMenubar: FC<Props> = (props) => {
].filter(Boolean) ].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) => { const onRegenerate = useCallback(
e?.stopPropagation?.() async (e: React.MouseEvent | undefined) => {
if (loading) return e?.stopPropagation?.()
const selectedModel = isGrouped ? model : assistantModel if (loading) return
const _message = resetAssistantMessage(message, selectedModel) const selectedModel = isGrouped ? model : assistantModel
editMessage(message.id, { ..._message }) const _message = resetAssistantMessage(message, selectedModel)
resendMessage(_message, assistant) editMessage(message.id, { ..._message })
} resendMessage(_message, assistant)
},
[loading, isGrouped, model, assistantModel, message, editMessage, resendMessage, assistant]
)
const onMentionModel = async (e: React.MouseEvent) => { const onMentionModel = useCallback(
e.stopPropagation() async (e: React.MouseEvent) => {
if (loading) return e.stopPropagation()
const selectedModel = await SelectModelPopup.show({ model }) if (loading) return
if (!selectedModel) return const selectedModel = await SelectModelPopup.show({ model })
resendMessage(message, { ...assistant, model: selectedModel }, true) if (!selectedModel) return
} resendMessage(message, { ...assistant, model: selectedModel }, true)
},
[loading, model, message, assistant, resendMessage]
)
const onUseful = useCallback( const onUseful = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {

View File

@ -1,5 +1,5 @@
import { useAppSelector } from '@renderer/store' 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 { Assistant, Message, Topic } from '@renderer/types'
import { memo, useMemo } from 'react' import { memo, useMemo } from 'react'
import styled from 'styled-components' 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 streamMessage = useAppSelector((state) => selectStreamMessage(state, _message.topicId, _message.id))
// 获取常规消息,使用选择器减少不必要的重新渲染 // 获取常规消息,使用记忆化选择器减少不必要的重新渲染
const regularMessage = useAppSelector((state) => { const regularMessage = useAppSelector((state) => selectRegularMessage(state, _message.topicId, _message.id, _message))
// 如果是用户消息直接使用传入的_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
})
// 使用useMemo缓存计算结果 // 使用useMemo缓存计算结果
const { isStreaming, message } = useMemo(() => { const { isStreaming, message } = useMemo(() => {
@ -70,13 +59,7 @@ const MessageStream: React.FC<MessageStreamProps> = ({
) )
} }
// 使用自定义比较函数的memo包装组件只在关键属性变化时重新渲染 // 使用 React.memo 包装组件,使用默认的浅层比较
export default memo(MessageStream, (prevProps, nextProps) => { // 这样可以确保所有属性变化都能触发重新渲染
// 只在关键属性变化时重新渲染 // 对于这种组件,默认的浅层比较通常更安全和简单
return ( export default memo(MessageStream)
prevProps.message.id === nextProps.message.id &&
prevProps.message.content === nextProps.message.content &&
prevProps.message.status === nextProps.message.status &&
prevProps.topic.id === nextProps.topic.id
)
})

View File

@ -2,7 +2,7 @@ import { CheckOutlined } from '@ant-design/icons'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { Message } from '@renderer/types' import { Message } from '@renderer/types'
import { Collapse, message as antdMessage, Tooltip } from 'antd' 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 { useTranslation } from 'react-i18next'
import BarLoader from 'react-spinners/BarLoader' import BarLoader from 'react-spinners/BarLoader'
import styled from 'styled-components' import styled from 'styled-components'
@ -33,57 +33,64 @@ const MessageThought: FC<Props> = ({ message }) => {
return null return null
} }
const copyThought = () => { // 使用 useCallback 记忆化 copyThought 函数,避免不必要的重新创建
const copyThought = useCallback(() => {
if (message.reasoning_content) { if (message.reasoning_content) {
navigator.clipboard.writeText(message.reasoning_content) navigator.clipboard.writeText(message.reasoning_content)
antdMessage.success({ content: t('message.copied'), key: 'copy-message' }) antdMessage.success({ content: t('message.copied'), key: 'copy-message' })
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} }
} }, [message.reasoning_content, t])
const thinkingTime = message.metrics?.time_thinking_millsec || 0 const thinkingTime = message.metrics?.time_thinking_millsec || 0
const thinkingTimeSeconds = (thinkingTime / 1000).toFixed(1) const thinkingTimeSeconds = (thinkingTime / 1000).toFixed(1)
const isPaused = message.status === 'paused' 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 ( return (
<CollapseContainer <CollapseContainer
activeKey={activeKey} activeKey={activeKey}
size="small" size="small"
onChange={() => setActiveKey((key) => (key ? '' : 'thought'))} onChange={() => setActiveKey((key) => (key ? '' : 'thought'))}
className="message-thought-container" className="message-thought-container"
items={[ items={collapseItems}
{
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>
)
}
]}
/> />
) )
} }
@ -132,4 +139,5 @@ const ActionButton = styled.button`
} }
` `
export default MessageThought // 使用 memo 包装组件,避免不必要的重渲染
export default memo(MessageThought)

View File

@ -1,12 +1,15 @@
import { CheckOutlined, ExpandOutlined, LoadingOutlined, WarningOutlined } from '@ant-design/icons' import { CheckOutlined, ExpandOutlined, LoadingOutlined, WarningOutlined } from '@ant-design/icons'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { Message } from '@renderer/types' import { Message } from '@renderer/types'
import { Collapse, message as antdMessage, Modal, Tooltip } from 'antd' import { message as antdMessage, Modal, Tooltip } from 'antd'
import { isEmpty } from 'lodash' import { FC, memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { FC, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
import CustomCollapse from './CustomCollapse'
import ExpandedResponseContent from './ExpandedResponseContent'
import ToolResponseContent from './ToolResponseContent'
interface Props { interface Props {
message: Message 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' : '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans","Helvetica Neue", sans-serif'
}, [messageFont]) }, [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 || [] const toolResponses = message.metadata?.mcpTools || []
if (isEmpty(toolResponses)) { // 预处理响应数据,避免在展开时计算
return null const responseStringsRef = useRef<Record<string, string>>({})
}
const copyContent = (content: string, toolId: string) => { // 使用 useLayoutEffect 在渲染前预处理数据
navigator.clipboard.writeText(content) useLayoutEffect(() => {
antdMessage.success({ content: t('message.copied'), key: 'copy-message' }) const strings: Record<string, string> = {}
setCopiedMap((prev) => ({ ...prev, [toolId]: true })) let hasChanges = false
setTimeout(() => setCopiedMap((prev) => ({ ...prev, [toolId]: false })), 2000)
}
const handleCollapseChange = (keys: string | string[]) => { for (const toolResponse of toolResponses) {
setActiveKeys(Array.isArray(keys) ? keys : [keys]) 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 try {
const getCollapseItems = () => { 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 }[] = [] const items: { key: string; label: React.ReactNode; children: React.ReactNode }[] = []
// Add tool responses // Add tool responses
for (const toolResponse of toolResponses) { for (const toolResponse of toolResponses) {
@ -79,8 +112,9 @@ const MessageTools: FC<Props> = ({ message }) => {
className="message-action-button" className="message-action-button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
// 使用预处理的响应数据
setExpandedResponse({ setExpandedResponse({
content: JSON.stringify(response, null, 2), content: responseStringsRef.current[id] || '',
title: tool.name title: tool.name
}) })
}} }}
@ -93,7 +127,9 @@ const MessageTools: FC<Props> = ({ message }) => {
className="message-action-button" className="message-action-button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
copyContent(JSON.stringify(result, null, 2), id) // 使用预处理的响应数据
const resultString = JSON.stringify(result, null, 2)
copyContent(resultString, id)
}} }}
aria-label={t('common.copy')}> aria-label={t('common.copy')}>
{!copiedMap[id] && <i className="iconfont icon-copy"></i>} {!copiedMap[id] && <i className="iconfont icon-copy"></i>}
@ -105,29 +141,38 @@ const MessageTools: FC<Props> = ({ message }) => {
</ActionButtonsContainer> </ActionButtonsContainer>
</MessageTitleLabel> </MessageTitleLabel>
), ),
children: isDone && result && ( children: isDone && result && <ToolResponseContent result={result} fontFamily={fontFamily} fontSize="12px" />
<ToolResponseContainer style={{ fontFamily, fontSize: '12px' }}>
<CodeBlock>{JSON.stringify(result, null, 2)}</CodeBlock>
</ToolResponseContainer>
)
}) })
} }
return items return items
}, [toolResponses, t, copiedMap, copyContent])
// 如果没有工具响应,则不渲染组件
if (toolResponses.length === 0) {
return null
} }
return ( return (
<> <>
<CollapseContainer <ToolsContainer className="message-tools-container">
activeKey={activeKeys} {collapseItems.map((item) => (
size="small" <CustomCollapse
onChange={handleCollapseChange} key={item.key}
className="message-tools-container" id={item.key as string}
items={getCollapseItems()} title={item.label}
expandIcon={({ isActive }) => ( isActive={activeKeys.includes(item.key as string)}
<CollapsibleIcon className={`iconfont ${isActive ? 'icon-chevron-down' : 'icon-chevron-right'}`} /> 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 <Modal
title={expandedResponse?.title} title={expandedResponse?.title}
@ -136,47 +181,36 @@ const MessageTools: FC<Props> = ({ message }) => {
footer={null} footer={null}
width="80%" width="80%"
centered 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 && ( {expandedResponse && (
<ExpandedResponseContainer style={{ fontFamily, fontSize }}> <ExpandedResponseContent
<ActionButton content={expandedResponse.content}
className="copy-expanded-button" fontFamily={fontFamily}
onClick={() => { fontSize={fontSize}
if (expandedResponse) { onCopy={() => {
navigator.clipboard.writeText(expandedResponse.content) navigator.clipboard.writeText(expandedResponse.content)
antdMessage.success({ content: t('message.copied'), key: 'copy-expanded' }) 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>
)} )}
</Modal> </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` const MessageTitleLabel = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -221,6 +255,14 @@ const ActionButtonsContainer = styled.div`
margin-left: auto; 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` const ActionButton = styled.button`
background: none; background: none;
border: none; border: none;
@ -251,51 +293,5 @@ const ActionButton = styled.button`
} }
` `
const CollapsibleIcon = styled.i` // 使用 memo 包装组件,避免不必要的重渲染
color: var(--color-text-2); export default memo(MessageTools)
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

View File

@ -57,15 +57,24 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
}, [messages]) }, [messages])
useEffect(() => { useEffect(() => {
const newDisplayMessages = computeDisplayMessages(messages, 0, displayCount) // 优化:使用 requestAnimationFrame 来延迟计算,避免阻塞主线程
setDisplayMessages(newDisplayMessages) const rafId = requestAnimationFrame(() => {
setHasMore(messages.length > displayCount) const newDisplayMessages = computeDisplayMessages(messages, 0, displayCount)
setDisplayMessages(newDisplayMessages)
setHasMore(messages.length > displayCount)
})
// 清理函数,取消未执行的 requestAnimationFrame
return () => cancelAnimationFrame(rafId)
}, [messages, displayCount]) }, [messages, displayCount])
const maxWidth = useMemo(() => { const maxWidth = useMemo(() => {
// 优化:缓存计算结果,减少字符串拼接
const showRightTopics = showTopics && topicPosition === 'right' const showRightTopics = showTopics && topicPosition === 'right'
const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : '' const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : ''
const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : '' const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : ''
// 使用模板字符串代替多次字符串拼接,提高可读性
return `calc(100vw - var(--sidebar-width) ${minusAssistantsWidth} ${minusRightTopicsWidth} - 5px)` return `calc(100vw - var(--sidebar-width) ${minusAssistantsWidth} ${minusRightTopicsWidth} - 5px)`
}, [showAssistants, showTopics, topicPosition]) }, [showAssistants, showTopics, topicPosition])
@ -201,15 +210,67 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
// 使用requestAnimationFrame代替setTimeout更好地与浏览器渲染周期同步 // 使用requestAnimationFrame代替setTimeout更好地与浏览器渲染周期同步
requestAnimationFrame(() => { requestAnimationFrame(() => {
const currentLength = displayMessages.length 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) setHasMore(currentLength + LOAD_MORE_COUNT < messages.length)
setIsLoadingMore(false) setIsLoadingMore(false)
}) })
}, [displayMessages.length, hasMore, isLoadingMore, messages]) }, [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', () => { useShortcut('copy_last_message', () => {
const lastMessage = last(messages) const lastMessage = last(messages)
if (lastMessage) { if (lastMessage) {
@ -258,8 +319,15 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
</InfiniteScroll> </InfiniteScroll>
<Prompt assistant={assistant} key={assistant.prompt} topic={topic} /> <Prompt assistant={assistant} key={assistant.prompt} topic={topic} />
</NarrowLayout> </NarrowLayout>
{messageNavigation === 'anchor' && <MessageAnchorLine messages={displayMessages} />} {useMemo(() => {
{messageNavigation === 'buttons' && <ChatNavigation containerId="messages" />} if (messageNavigation === 'anchor') {
return <MessageAnchorLine messages={displayMessages} />
}
if (messageNavigation === 'buttons') {
return <ChatNavigation containerId="messages" />
}
return null
}, [messageNavigation, displayMessages])}
<TTSStopButton /> <TTSStopButton />
</Container> </Container>
) )

View 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)

View File

@ -32,7 +32,13 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
const formattedKey = newKey.trim() const formattedKey = newKey.trim()
const keys = [...currentKeys, formattedKey] const keys = [...currentKeys, formattedKey]
const uniqueKeys = [...new Set(keys)] 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('') setNewKey('')
setIsAddKeyModalVisible(false) setIsAddKeyModalVisible(false)
} }
@ -48,7 +54,13 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
const allKeys = [...currentKeys, ...importedKeys] const allKeys = [...currentKeys, ...importedKeys]
const uniqueKeys = [...new Set(allKeys)] const uniqueKeys = [...new Set(allKeys)]
onApiKeyChange(uniqueKeys.join(',')) const newApiKey = uniqueKeys.join(',')
// 只有当值确实发生变化时才更新
if (newApiKey !== currentApiKey) {
onApiKeyChange(newApiKey)
}
setImportText('') setImportText('')
setIsImportModalVisible(false) setIsImportModalVisible(false)
} }
@ -102,11 +114,7 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
{currentKeys.map((key, index) => ( {currentKeys.map((key, index) => (
<KeyItem key={index}> <KeyItem key={index}>
<Text>{maskApiKey(key)}</Text> <Text>{maskApiKey(key)}</Text>
<Button <Button type="text" icon={<CopyOutlined />} onClick={() => copyKey(key)} />
type="text"
icon={<CopyOutlined />}
onClick={() => copyKey(key)}
/>
</KeyItem> </KeyItem>
))} ))}
</KeysListContainer> </KeysListContainer>
@ -121,7 +129,13 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
okButtonProps={{ disabled: !newKey.trim() }}> okButtonProps={{ disabled: !newKey.trim() }}>
<Input.Password <Input.Password
value={newKey} 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')} placeholder={t('settings.provider.gemini.enter_key')}
autoFocus autoFocus
/> />
@ -153,6 +167,18 @@ const GeminiKeyManager: React.FC<GeminiKeyManagerProps> = ({ currentApiKey, onAp
<Input.TextArea <Input.TextArea
value={importText} value={importText}
onChange={(e) => setImportText(e.target.value)} 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')} placeholder={t('settings.provider.gemini.enter_keys')}
rows={8} rows={8}
/> />

View File

@ -345,30 +345,34 @@ const ProviderSetting: FC<Props> = ({ provider: _provider }) => {
</SettingHelpTextRow> </SettingHelpTextRow>
)} )}
{/* 显示API密钥列表 */} {/* 显示API密钥列表 */}
{provider.id !== 'gemini' && provider.id !== 'ollama' && provider.id !== 'lmstudio' && provider.id !== 'copilot' && apiKey.includes(',') && ( {provider.id !== 'gemini' &&
<KeysListContainer> provider.id !== 'ollama' &&
{apiKey provider.id !== 'lmstudio' &&
.split(',') provider.id !== 'copilot' &&
.map((key) => key.trim()) apiKey.includes(',') && (
.filter((key) => key !== '') <KeysListContainer>
.map((key, index) => ( {apiKey
<KeyItem key={index}> .split(',')
<Typography.Text>{maskApiKey(key)}</Typography.Text> .map((key) => key.trim())
<Button .filter((key) => key !== '')
type="text" .map((key, index) => (
icon={<CopyOutlined />} <KeyItem key={index}>
onClick={() => { <Typography.Text>{maskApiKey(key)}</Typography.Text>
navigator.clipboard.writeText(key) <Button
window.message.success({ type="text"
content: t('common.copied'), icon={<CopyOutlined />}
duration: 2 onClick={() => {
}) navigator.clipboard.writeText(key)
}} window.message.success({
/> content: t('common.copied'),
</KeyItem> duration: 2
))} })
</KeysListContainer> }}
)} />
</KeyItem>
))}
</KeysListContainer>
)}
<SettingSubtitle>{t('settings.provider.api_host')}</SettingSubtitle> <SettingSubtitle>{t('settings.provider.api_host')}</SettingSubtitle>
<Space.Compact style={{ width: '100%', marginTop: 5 }}> <Space.Compact style={{ width: '100%', marginTop: 5 }}>
<Input <Input

View File

@ -105,8 +105,31 @@ class FileManager {
} }
static getFileUrl(file: FileType) { static getFileUrl(file: FileType) {
const filesPath = store.getState().runtime.filesPath try {
return 'file://' + filesPath + '/' + file.name 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) { static async updateFile(file: FileType) {

View File

@ -109,8 +109,7 @@ export class TTSService {
} }
// 更新最后播放的消息ID // 更新最后播放的消息ID
const dispatch = store.dispatch store.dispatch(setLastPlayedMessageId(message.id))
dispatch(setLastPlayedMessageId(message.id))
console.log('更新最后播放的消息ID:', message.id) console.log('更新最后播放的消息ID:', message.id)
// 记录当前正在播放的消息ID // 记录当前正在播放的消息ID
@ -121,7 +120,7 @@ export class TTSService {
console.log('TTS过滤前文本长度:', message.content.length, '过滤后:', filteredText.length) 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 latestSettings = store.getState().settings
const serviceType = latestSettings.ttsServiceType || 'openai' const serviceType = latestSettings.ttsServiceType || 'openai'
console.log('使用的TTS服务类型:', serviceType) console.log('使用的TTS服务类型:', serviceType)
@ -202,14 +200,13 @@ export class TTSService {
if (messageId) { if (messageId) {
this.playingMessageId = messageId this.playingMessageId = messageId
// 更新最后播放的消息ID // 更新最后播放的消息ID
const dispatch = store.dispatch store.dispatch(setLastPlayedMessageId(messageId))
dispatch(setLastPlayedMessageId(messageId))
console.log('更新最后播放的消息ID:', messageId) console.log('更新最后播放的消息ID:', messageId)
} }
if (segmented) { if (segmented) {
// 分段播放模式 // 分段播放模式
return await this.speakSegmented(text, serviceType, latestSettings) return await this.speakSegmented(text, serviceType, latestSettings, messageId)
} }
console.log('当前TTS设置详情:', { console.log('当前TTS设置详情:', {
@ -398,10 +395,16 @@ export class TTSService {
* *
* @param text * @param text
* @param serviceType TTS服务类型 * @param serviceType TTS服务类型
* @param settings * @param settings
* @param messageId ID
* @returns * @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 { try {
console.log('开始分段播放模式') console.log('开始分段播放模式')
@ -427,6 +430,11 @@ export class TTSService {
// 重置当前段落索引 // 重置当前段落索引
this.currentSegmentIndex = 0 this.currentSegmentIndex = 0
// 如果提供了messageId则设置playingMessageId
if (messageId) {
this.playingMessageId = messageId
}
// 触发分段播放事件 // 触发分段播放事件
this.emitSegmentedPlaybackEvent() this.emitSegmentedPlaybackEvent()
@ -455,9 +463,13 @@ export class TTSService {
* *
* @param index * @param index
* @param serviceType TTS服务类型 * @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) { if (index < 0 || index >= this.audioSegments.length) {
return return
} }
@ -625,11 +637,6 @@ export class TTSService {
* @param duration * @param duration
* @param progress 0-100 * @param progress 0-100
*/ */
// 记录上次输出日志的进度百分比 - 已禁用日志输出
// private lastLoggedProgress: number = -1;
// 记录上次日志输出时间,用于节流 - 已禁用日志输出
// private lastLogTime: number = 0;
private emitProgressUpdateEvent(currentTime: number, duration: number, progress: number): void { private emitProgressUpdateEvent(currentTime: number, duration: number, progress: number): void {
// 创建事件数据 // 创建事件数据
const eventData = { const eventData = {
@ -640,22 +647,6 @@ export class TTSService {
progress 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 })) window.dispatchEvent(new CustomEvent('tts-progress-update', { detail: eventData }))
} }

View File

@ -663,10 +663,38 @@ export const selectError = (state: RootState): string | null => {
return messagesState?.error || null return messagesState?.error || null
} }
export const selectStreamMessage = (state: RootState, topicId: string, messageId: string): Message | null => { // 使用 createSelector 记忆化流式消息选择器
const messagesState = state.messages as MessagesState // 这样可以避免在 Redux store 状态变化时不必要的重新计算
return messagesState.streamMessagesByTopic[topicId]?.[messageId] || null 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 { export const {
setTopicLoading, setTopicLoading,

View File

@ -367,45 +367,117 @@ export function parseToolUse(content: string, mcpTools: MCPTool[]): MCPToolRespo
if (!content || !mcpTools || mcpTools.length === 0) { if (!content || !mcpTools || mcpTools.length === 0) {
return [] 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 /<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[] = [] const tools: MCPToolResponse[] = []
let match
let idx = 0 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 toolName = match[2].trim()
const toolArgs = match[4].trim() const toolArgs = match[4].trim()
// Try to parse the arguments as JSON // 尝试解析参数为JSON
let parsedArgs let parsedArgs
try { try {
parsedArgs = JSON.parse(toolArgs) parsedArgs = JSON.parse(toolArgs)
} catch (error) { } catch (error) {
// If parsing fails, use the string as is // 如果解析失败,使用字符串原样
parsedArgs = toolArgs parsedArgs = toolArgs
} }
// console.log(`Parsed arguments for tool "${toolName}":`, parsedArgs)
const mcpTool = mcpTools.find((tool) => tool.id === toolName) const mcpTool = mcpTools.find((tool) => tool.id === toolName)
if (!mcpTool) { if (!mcpTool) {
console.error(`Tool "${toolName}" not found in MCP tools`) console.error(`Tool "${toolName}" not found in MCP tools`)
continue continue
} }
// Add to tools array // 添加到工具数组
tools.push({ tools.push({
id: `${toolName}-${idx++}`, // Unique ID for each tool use id: `${toolName}-${idx++}`, // 每个工具调用的唯一ID
tool: { tool: {
...mcpTool, ...mcpTool,
inputSchema: parsedArgs inputSchema: parsedArgs
}, },
status: 'pending' 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 return tools
} }

146
yarn.lock
View File

@ -479,7 +479,7 @@ __metadata:
languageName: node languageName: node
linkType: hard 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 version: 7.27.0
resolution: "@babel/runtime@npm:7.27.0" resolution: "@babel/runtime@npm:7.27.0"
dependencies: dependencies:
@ -4418,6 +4418,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@types/hast@npm:^3.0.0, @types/hast@npm:^3.0.4":
version: 3.0.4 version: 3.0.4
resolution: "@types/hast@npm:3.0.4" resolution: "@types/hast@npm:3.0.4"
@ -4625,6 +4634,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@types/react-transition-group@npm:^4.4.12":
version: 4.4.12 version: 4.4.12
resolution: "@types/react-transition-group@npm: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": "npm:^19.0.12"
"@types/react-dom": "npm:^19.0.4" "@types/react-dom": "npm:^19.0.4"
"@types/react-infinite-scroll-component": "npm:^5.0.0" "@types/react-infinite-scroll-component": "npm:^5.0.0"
"@types/react-syntax-highlighter": "npm:^15"
"@types/react-transition-group": "npm:^4.4.12" "@types/react-transition-group": "npm:^4.4.12"
"@types/tinycolor2": "npm:^1" "@types/tinycolor2": "npm:^1"
"@vitejs/plugin-react": "npm:^4.3.4" "@vitejs/plugin-react": "npm:^4.3.4"
@ -5059,6 +5078,7 @@ __metadata:
react-router: "npm:6" react-router: "npm:6"
react-router-dom: "npm:6" react-router-dom: "npm:6"
react-spinners: "npm:^0.14.1" react-spinners: "npm:^0.14.1"
react-syntax-highlighter: "npm:^15.6.1"
react-transition-group: "npm:^4.4.5" react-transition-group: "npm:^4.4.5"
redux: "npm:^5.0.1" redux: "npm:^5.0.1"
redux-persist: "npm:^6.0.0" redux-persist: "npm:^6.0.0"
@ -6583,6 +6603,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "comma-separated-tokens@npm:^2.0.0":
version: 2.0.3 version: 2.0.3
resolution: "comma-separated-tokens@npm:2.0.3" resolution: "comma-separated-tokens@npm:2.0.3"
@ -9139,6 +9166,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "fd-slicer@npm:~1.1.0":
version: 1.1.0 version: 1.1.0
resolution: "fd-slicer@npm:1.1.0" resolution: "fd-slicer@npm:1.1.0"
@ -9410,6 +9446,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "formdata-node@npm:^4.3.2":
version: 4.4.1 version: 4.4.1
resolution: "formdata-node@npm:4.4.1" resolution: "formdata-node@npm:4.4.1"
@ -10195,6 +10238,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "hast-util-parse-selector@npm:^4.0.0":
version: 4.0.0 version: 4.0.0
resolution: "hast-util-parse-selector@npm:4.0.0" resolution: "hast-util-parse-selector@npm:4.0.0"
@ -10314,6 +10364,19 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "hastscript@npm:^9.0.0":
version: 9.0.1 version: 9.0.1
resolution: "hastscript@npm:9.0.1" resolution: "hastscript@npm:9.0.1"
@ -10339,6 +10402,20 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "hmac-drbg@npm:^1.0.1":
version: 1.0.1 version: 1.0.1
resolution: "hmac-drbg@npm:1.0.1" resolution: "hmac-drbg@npm:1.0.1"
@ -11908,6 +11985,16 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "lru-cache@npm:^10.2.0, lru-cache@npm:^10.4.3":
version: 10.4.3 version: 10.4.3
resolution: "lru-cache@npm:10.4.3" resolution: "lru-cache@npm:10.4.3"
@ -14782,6 +14869,20 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "proc-log@npm:^2.0.1":
version: 2.0.1 version: 2.0.1
resolution: "proc-log@npm:2.0.1" resolution: "proc-log@npm:2.0.1"
@ -14852,6 +14953,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "property-information@npm:^6.0.0":
version: 6.5.0 version: 6.5.0
resolution: "property-information@npm:6.5.0" resolution: "property-information@npm:6.5.0"
@ -15770,6 +15880,22 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "react-transition-group@npm:^4.4.5":
version: 4.4.5 version: 4.4.5
resolution: "react-transition-group@npm:4.4.5" resolution: "react-transition-group@npm:4.4.5"
@ -15913,6 +16039,17 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "regenerator-runtime@npm:^0.13.3":
version: 0.13.11 version: 0.13.11
resolution: "regenerator-runtime@npm:0.13.11" resolution: "regenerator-runtime@npm:0.13.11"
@ -16966,6 +17103,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "space-separated-tokens@npm:^2.0.0":
version: 2.0.2 version: 2.0.2
resolution: "space-separated-tokens@npm:2.0.2" resolution: "space-separated-tokens@npm:2.0.2"