diff --git a/package.json b/package.json index 3b31abfcde..87ded6c80a 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,7 @@ "pdf-lib": "^1.17.1", "pdfjs-dist": "^5.1.91", "proxy-agent": "^6.5.0", + "react-syntax-highlighter": "^15.6.1", "react-transition-group": "^4.4.5", "tar": "^7.4.3", "turndown": "^7.2.0", @@ -170,6 +171,7 @@ "@types/react": "^19.0.12", "@types/react-dom": "^19.0.4", "@types/react-infinite-scroll-component": "^5.0.0", + "@types/react-syntax-highlighter": "^15", "@types/tinycolor2": "^1", "@vitejs/plugin-react": "^4.3.4", "analytics": "^0.8.16", diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 9721f36126..85e9810f1e 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -5,6 +5,7 @@ @use './animation.scss'; @import '../fonts/icon-fonts/iconfont.css'; @import '../fonts/ubuntu/ubuntu.css'; +@import './inline-tool-block.css'; :root { --color-white: #ffffff; @@ -29,6 +30,11 @@ --color-background-opacity: rgba(34, 34, 34, 0.7); --inner-glow-opacity: 0.3; // For the glassmorphism effect in the dropdown menu + /* 添加工具块背景色变量 */ + --color-bg-1: var(--color-black-soft); + --color-bg-2: var(--color-black-mute); + --color-bg-3: var(--color-gray-3); + --color-primary: #00b96b; --color-primary-soft: #00b96b99; --color-primary-mute: #00b96b33; @@ -101,6 +107,11 @@ body[theme-mode='light'] { --color-background-opacity: rgba(235, 235, 235, 0.7); --inner-glow-opacity: 0.1; + /* 添加工具块背景色变量 - 亮色主题 */ + --color-bg-1: var(--color-white); + --color-bg-2: var(--color-white-soft); + --color-bg-3: var(--color-white-mute); + --color-primary: #00b96b; --color-primary-soft: #00b96b99; --color-primary-mute: #00b96b33; diff --git a/src/renderer/src/assets/styles/inline-tool-block.css b/src/renderer/src/assets/styles/inline-tool-block.css new file mode 100644 index 0000000000..910cb2771f --- /dev/null +++ b/src/renderer/src/assets/styles/inline-tool-block.css @@ -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); +} diff --git a/src/renderer/src/components/MarkdownShadowDOMRenderer.tsx b/src/renderer/src/components/MarkdownShadowDOMRenderer.tsx index 9972d52139..986e6ad202 100644 --- a/src/renderer/src/components/MarkdownShadowDOMRenderer.tsx +++ b/src/renderer/src/components/MarkdownShadowDOMRenderer.tsx @@ -1,5 +1,5 @@ import { StyleProvider } from '@ant-design/cssinjs' -import React, { useEffect, useRef } from 'react' +import React, { memo, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { StyleSheetManager } from 'styled-components' @@ -60,4 +60,5 @@ const ShadowDOMRenderer: React.FC = ({ children }) => { ) } -export default ShadowDOMRenderer +// 使用 memo 包装组件,避免不必要的重渲染 +export default memo(ShadowDOMRenderer) diff --git a/src/renderer/src/components/TTSButton.tsx b/src/renderer/src/components/TTSButton.tsx index 085f630554..a91117618a 100644 --- a/src/renderer/src/components/TTSButton.tsx +++ b/src/renderer/src/components/TTSButton.tsx @@ -2,7 +2,7 @@ import { SoundOutlined } from '@ant-design/icons' import TTSService from '@renderer/services/TTSService' import { Message } from '@renderer/types' import { Tooltip } from 'antd' -import { useCallback, useEffect, useState } from 'react' +import { memo, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -11,69 +11,39 @@ interface TTSButtonProps { className?: string } -interface SegmentedPlaybackState { - isSegmentedPlayback: boolean - segments: { - text: string - isLoaded: boolean - isLoading: boolean - }[] - currentSegmentIndex: number - isPlaying: boolean -} +// 移除未使用的接口 const TTSButton: React.FC = ({ message, className }) => { const { t } = useTranslation() + // 只保留必要的状态 const [isSpeaking, setIsSpeaking] = useState(false) - // 分段播放状态 - const [, setSegmentedPlaybackState] = useState({ - isSegmentedPlayback: false, - segments: [], - currentSegmentIndex: 0, - isPlaying: false - }) - // 添加TTS状态变化事件监听器 - useEffect(() => { - const handleTTSStateChange = (event: CustomEvent) => { + // 使用 useCallback 记忆化事件处理函数,避免不必要的重新创建 + const handleTTSStateChange = useCallback( + (event: CustomEvent) => { const { isPlaying } = event.detail console.log('TTS按钮检测到TTS状态变化:', isPlaying) setIsSpeaking(isPlaying) - } + }, + [setIsSpeaking] + ) + // 添加TTS状态变化事件监听器 + useEffect(() => { // 添加事件监听器 window.addEventListener('tts-state-change', handleTTSStateChange as EventListener) + // 初始化时检查TTS状态 + const isCurrentlyPlaying = TTSService.isCurrentlyPlaying() + setIsSpeaking(isCurrentlyPlaying) + // 组件卸载时移除事件监听器 return () => { window.removeEventListener('tts-state-change', handleTTSStateChange as EventListener) } - }, []) + }, [handleTTSStateChange]) - // 监听分段播放状态变化 - useEffect(() => { - const handleSegmentedPlaybackUpdate = (event: CustomEvent) => { - console.log('检测到分段播放状态更新:', event.detail) - setSegmentedPlaybackState(event.detail) - } - - // 添加事件监听器 - window.addEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener) - - // 组件卸载时移除事件监听器 - return () => { - window.removeEventListener('tts-segmented-playback-update', handleSegmentedPlaybackUpdate as EventListener) - } - }, []) - - // 初始化时检查TTS状态 - useEffect(() => { - // 检查当前是否正在播放 - const isCurrentlyPlaying = TTSService.isCurrentlyPlaying() - if (isCurrentlyPlaying !== isSpeaking) { - setIsSpeaking(isCurrentlyPlaying) - } - }, [isSpeaking]) + // 移除未使用的分段播放状态事件监听器和冗余的初始化检查 const handleTTS = useCallback(async () => { if (isSpeaking) { @@ -139,4 +109,5 @@ const TTSActionButton = styled.div` } ` -export default TTSButton +// 使用 memo 包装组件,避免不必要的重渲染 +export default memo(TTSButton) diff --git a/src/renderer/src/components/TTSHighlightedText.tsx b/src/renderer/src/components/TTSHighlightedText.tsx index 24c05144d4..52998e2dbc 100644 --- a/src/renderer/src/components/TTSHighlightedText.tsx +++ b/src/renderer/src/components/TTSHighlightedText.tsx @@ -1,6 +1,6 @@ import { TextSegmenter } from '@renderer/services/tts/TextSegmenter' import TTSService from '@renderer/services/TTSService' -import React, { useEffect, useState } from 'react' +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react' import styled from 'styled-components' interface TTSHighlightedTextProps { @@ -53,26 +53,28 @@ const TTSHighlightedText: React.FC = ({ text }) => { }, []) // 处理段落点击 - const handleSegmentClick = (index: number) => { + // 使用 useCallback 记忆化函数,避免不必要的重新创建 + const handleSegmentClick = useCallback((index: number) => { TTSService.playFromSegment(index) - } + }, []) if (segments.length === 0) { return
{text}
} - return ( - - {segments.map((segment, index) => ( - handleSegmentClick(index)}> - {segment} - - ))} - - ) + // 使用 useMemo 记忆化列表渲染结果,避免不必要的重新计算 + const renderedSegments = useMemo(() => { + return segments.map((segment, index) => ( + handleSegmentClick(index)}> + {segment} + + )) + }, [segments, currentSegmentIndex, handleSegmentClick]) + + return {renderedSegments} } const TextContainer = styled.div` @@ -93,4 +95,5 @@ const TextSegment = styled.span` } ` -export default TTSHighlightedText +// 使用 memo 包装组件,避免不必要的重渲染 +export default memo(TTSHighlightedText) diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 07d89dedcb..861ef85a11 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -4,7 +4,7 @@ import { useSettings } from '@renderer/hooks/useSettings' import { useShowTopics } from '@renderer/hooks/useStore' import { Assistant, Topic } from '@renderer/types' import { Flex } from 'antd' -import { FC } from 'react' +import { FC, memo, useMemo } from 'react' import styled from 'styled-components' import Inputbar from './Inputbar/Inputbar' @@ -19,32 +19,55 @@ interface Props { } const Chat: FC = (props) => { + // 使用传入的 assistant 对象,避免重复获取 + // 如果 useAssistant 提供了额外的功能或状态更新,则保留此调用 const { assistant } = useAssistant(props.assistant.id) const { topicPosition, messageStyle } = useSettings() const { showTopics } = useShowTopics() + // 使用 useMemo 优化渲染,只有当相关依赖变化时才重新创建元素 + const messagesComponent = useMemo( + () => ( + + ), + [props.activeTopic.id, assistant, props.setActiveTopic] + ) + + const inputbarComponent = useMemo( + () => ( + + + + ), + [assistant, props.setActiveTopic, props.activeTopic] + ) + + const tabsComponent = useMemo(() => { + if (topicPosition !== 'right' || !showTopics) return null + + return ( + + ) + }, [topicPosition, showTopics, assistant, props.activeTopic, props.setActiveAssistant, props.setActiveTopic]) + return (
- - - - + {messagesComponent} + {inputbarComponent}
- {topicPosition === 'right' && showTopics && ( - - )} + {tabsComponent}
) } @@ -63,4 +86,4 @@ const Main = styled(Flex)` transform: translateZ(0); ` -export default Chat +export default memo(Chat) diff --git a/src/renderer/src/pages/home/Markdown/Markdown.tsx b/src/renderer/src/pages/home/Markdown/Markdown.tsx index f5afd39d15..50e4194073 100644 --- a/src/renderer/src/pages/home/Markdown/Markdown.tsx +++ b/src/renderer/src/pages/home/Markdown/Markdown.tsx @@ -9,7 +9,7 @@ import { parseJSON } from '@renderer/utils' import { escapeBrackets, removeSvgEmptyLines, withGeminiGrounding } from '@renderer/utils/formats' import { findCitationInChildren, sanitizeSchema } from '@renderer/utils/markdown' import { isEmpty } from 'lodash' -import { type FC, useMemo } from 'react' +import { type FC, memo, useMemo } from 'react' import { useTranslation } from 'react-i18next' import ReactMarkdown, { type Components } from 'react-markdown' import rehypeKatex from 'rehype-katex' @@ -22,6 +22,7 @@ import remarkCjkFriendly from 'remark-cjk-friendly' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' +import InlineToolBlock from '../Messages/InlineToolBlock' import EditableCodeBlock from './EditableCodeBlock' import ImagePreview from './ImagePreview' import Link from './Link' @@ -47,6 +48,87 @@ const Markdown: FC = ({ message }) => { return [rehypeRaw, [rehypeSanitize, sanitizeSchema], mathEngine === 'KaTeX' ? rehypeKatex : rehypeMathjax] }, [mathEngine]) + // 处理工具调用 - 采用通用方法 + const processToolUse = (content: string) => { + // 使用正则表达式匹配所有工具调用标签 + const toolUseRegex = /([\s\S]*?)<\/tool_use>|([\s\S]*?)(?:<\/tool|$)/g + + // 工具结果正则表达式 + const toolResultRegex = + /[\s\S]*?([\s\S]*?)<\/n>[\s\S]*?([\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 `
` + } catch (e) { + // 如果解析失败,使用原始文本 + console.error('Failed to parse tool args:', e) + return `
` + } + } else { + // 如果只有一行,则将整个内容作为工具调用 + return `
` + } + }) + + // 替换工具结果标签为自定义标记 + processedContent = processedContent.replace(toolResultRegex, (_, toolName, result) => { + return `
` + }) + + return processedContent + } + + // 处理后的消息内容 + const processedMessageContent = useMemo(() => { + return processToolUse(messageContent) + }, [messageContent]) + const components = useMemo(() => { const baseComponents = { a: (props: any) => , @@ -55,11 +137,12 @@ const Markdown: FC = ({ message }) => { pre: (props: any) =>
,
       // 自定义处理think标签
       think: (props: any) => {
-        // 将think标签内容渲染为带样式的div
+        // 将think标签内容渲染为带样式的span,避免在p标签内使用div导致的hydration错误
         return (
-          
= ({ message }) => { fontStyle: 'italic', color: 'var(--color-text-2)' }}> -
思考过程:
+ 思考过程: {props.children} -
+ ) + }, + // 处理工具调用标记 + 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 ( + + ) + } else { + // 如果没有找到工具调用结果,则显示完成状态和默认响应 + return ( + + ) + } + } else if (props.className === 'tool-result-marker') { + const toolName = props['data-tool-name'] + const result = props['data-result'] + return ( + + ) + } + return
} } as Partial return baseComponents - }, []) + }, [message?.metadata]) if (message.role === 'user' && !renderInputMessageAsMarkdown) { return

{messageContent}

} - if (messageContent.includes('