From 7961ba87edff48b585b5354a601403e1c6ecb28d Mon Sep 17 00:00:00 2001 From: Teo Date: Mon, 14 Jul 2025 10:08:09 +0800 Subject: [PATCH] feat: thinking effect (#8081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(i18n): add smooth stream output translations for multiple languages * feat(ThinkingBlock): integrate MarqueeComponent for enhanced message display * refactor(i18n): remove smooth stream output references from translations and components * refactor(typingOutput): enhance typing output logic and add debugging information * refactor(Markdown): consolidate markdown utility imports for cleaner code * feat(styles): add new styles for dropdown menus, popovers, and modals * test(ThinkingBlock): enhance tests for streaming status and content collapse behavior * refactor(typingOutput): remove debugging console log from outputNextChar function * refactor(MarqueeComponent): comment out blur effect for last marquee item and adjust ThinkingBlock margin * style(ThinkingBlock): update snapshot to include margin-top for improved layout * refactor(typingOutput): 修改流式输出逻辑以支持队列长度检查 * refactor(Markdown): simplify useTypingOutput by removing isStreaming parameter * test(Markdown): comment out re-render tests for content changes * test(Markdown): remove commented-out re-render tests for content changes * feat(ThinkingEffect): implement ThinkingEffect component for dynamic message display - Introduced ThinkingEffect component to enhance the visual representation of thinking states. - Integrated the new component into ThinkingBlock, replacing MarqueeComponent for improved functionality. - Added animations and dynamic height adjustments based on message content and expansion state. * test(ThinkingBlock): update mocks for ThinkingEffect and motion components in tests * fix: Delete unnecessary comments --- src/renderer/src/assets/styles/ant.scss | 13 +- .../src/components/ThinkingEffect.tsx | 179 ++++++++++++++++++ .../home/Messages/Blocks/ThinkingBlock.tsx | 85 ++++----- .../Blocks/__tests__/ThinkingBlock.test.tsx | 29 ++- .../__snapshots__/ThinkingBlock.test.tsx.snap | 88 ++++----- 5 files changed, 288 insertions(+), 106 deletions(-) create mode 100644 src/renderer/src/components/ThinkingEffect.tsx diff --git a/src/renderer/src/assets/styles/ant.scss b/src/renderer/src/assets/styles/ant.scss index 1c3a86037a..efd8fe3de2 100644 --- a/src/renderer/src/assets/styles/ant.scss +++ b/src/renderer/src/assets/styles/ant.scss @@ -136,17 +136,16 @@ } } -.ant-collapse { +.ant-collapse:not(.ant-collapse-ghost) { border: 1px solid var(--color-border); .ant-color-picker & { border: none; } -} - -.ant-collapse-content { - border-top: 0.5px solid var(--color-border) !important; - .ant-color-picker & { - border-top: none !important; + .ant-collapse-content { + border-top: 0.5px solid var(--color-border) !important; + .ant-color-picker & { + border-top: none !important; + } } } diff --git a/src/renderer/src/components/ThinkingEffect.tsx b/src/renderer/src/components/ThinkingEffect.tsx new file mode 100644 index 0000000000..8e55bf7eaf --- /dev/null +++ b/src/renderer/src/components/ThinkingEffect.tsx @@ -0,0 +1,179 @@ +import { lightbulbVariants } from '@renderer/utils/motionVariants' +import { isEqual } from 'lodash' +import { ChevronRight, Lightbulb } from 'lucide-react' +import { AnimatePresence, motion } from 'motion/react' +import React, { useEffect, useMemo, useState } from 'react' +import styled from 'styled-components' + +interface Props { + isThinking: boolean + thinkingTimeText: React.ReactNode + content: string + expanded: boolean +} + +const ThinkingEffect: React.FC = ({ isThinking, thinkingTimeText, content, expanded }) => { + const [messages, setMessages] = useState([]) + + useEffect(() => { + const allLines = (content || '').split('\n') + const newMessages = isThinking ? allLines.slice(0, -1) : allLines + const validMessages = newMessages.filter((line) => line.trim() !== '') + + if (!isEqual(messages, validMessages)) { + setMessages(validMessages) + } + }, [content, isThinking, messages]) + + const lineHeight = 16 + const containerHeight = useMemo(() => { + if (expanded) return lineHeight * 3 + return Math.min(80, Math.max(messages.length + 2, 3) * lineHeight) + }, [expanded, messages.length]) + + return ( + + + + + + + + + {thinkingTimeText} + + {!expanded && ( + + + {messages.map((message, index) => { + const finalY = containerHeight - (messages.length - index) * lineHeight - 4 + + if (index < messages.length - 5) return null + + const opacity = (() => { + const distanceFromLast = messages.length - 1 - index + if (distanceFromLast === 0) return 1 + if (distanceFromLast === 1) return 0.6 + if (distanceFromLast === 2) return 0.4 + return 0 + })() + + return ( + + {message} + + ) + })} + + + )} + + + + + + ) +} + +const ThinkingContainer = styled(motion.div)` + width: 100%; + border-radius: 12px; + overflow: hidden; + position: relative; + display: flex; + align-items: center; + border: 0.5px solid var(--color-border); + transition: height, border-radius, 150ms; + pointer-events: none; + user-select: none; + &.expanded { + border-radius: 12px 12px 0 0; + } +` + +const Title = styled.div` + position: absolute; + inset: 0 0 auto 0; + font-size: 14px; + font-weight: 500; + padding: 4px 0 30px; + z-index: 99; + transition: padding-top 150ms; + &.expanded { + padding-top: 14px; + } +` + +const LoadingContainer = styled.div` + width: 60px; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + flex-shrink: 0; + position: relative; + padding-left: 5px; + transition: width 150ms; + > div { + display: flex; + justify-content: center; + align-items: center; + } + &.expanded { + width: 40px; + } +` + +const TextContainer = styled.div` + flex: 1; + height: 100%; + overflow: hidden; + position: relative; +` + +const Content = styled(motion.div)` + width: 100%; + height: 100%; +` + +const ContentLineMotion = styled(motion.div)` + width: 100%; + line-height: 16px; + font-size: 12px; + color: var(--color-text-2); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + position: absolute; +` + +const ArrowContainer = styled.div` + width: 40px; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + flex-shrink: 0; + position: relative; + color: var(--color-border); + transition: transform 150ms; + &.expanded { + transform: rotate(90deg); + } +` + +export default ThinkingEffect diff --git a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx index bbde2ac75d..571f182a39 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ThinkingBlock.tsx @@ -1,10 +1,8 @@ import { CheckOutlined } from '@ant-design/icons' +import ThinkingEffect from '@renderer/components/ThinkingEffect' import { useSettings } from '@renderer/hooks/useSettings' import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage' -import { lightbulbVariants } from '@renderer/utils/motionVariants' import { Collapse, message as antdMessage, Tooltip } from 'antd' -import { ChevronRight, Lightbulb } from 'lucide-react' -import { motion } from 'motion/react' import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -24,7 +22,7 @@ const ThinkingBlock: React.FC = ({ block }) => { const isThinking = useMemo(() => block.status === MessageBlockStatus.STREAMING, [block.status]) useEffect(() => { - if (!isThinking && thoughtAutoCollapse) { + if (thoughtAutoCollapse) { setActiveKey('') } else { setActiveKey('thought') @@ -57,31 +55,27 @@ const ThinkingBlock: React.FC = ({ block }) => { size="small" onChange={() => setActiveKey((key) => (key ? '' : 'thought'))} className="message-thought-container" - expandIcon={({ isActive }) => ( - - )} - expandIconPosition="end" + ghost items={[ { key: 'thought', label: ( - - - - - + - - {/* {isThinking && } */} + } + content={block.content} + /> + ), + children: ( + // FIXME: 临时兼容 + {!isThinking && ( = ({ block }) => { )} - - ), - children: ( - // FIXME: 临时兼容 -
-
- ) + + ), + showArrow: false } ]} /> @@ -150,20 +136,22 @@ const ThinkingTimeSeconds = memo( ) const CollapseContainer = styled(Collapse)` - margin: 15px 0; - margin-top: 5px; + margin-top: 15px; + margin-bottom: 15px; + .ant-collapse-header { + padding: 0 !important; + } + .ant-collapse-content-box { + padding: 16px !important; + border-width: 0 0.5px 0.5px 0.5px; + border-style: solid; + border-color: var(--color-border); + border-radius: 0 0 12px 12px; + } ` -const MessageTitleLabel = styled.div` - display: flex; - flex-direction: row; - align-items: center; - height: 22px; - gap: 4px; -` - -const ThinkingText = styled.span` - color: var(--color-text-2); +const ThinkingContent = styled.div` + position: relative; ` const ActionButton = styled.button` @@ -178,6 +166,9 @@ const ActionButton = styled.button` margin-left: auto; opacity: 0.6; transition: all 0.3s; + position: absolute; + right: -12px; + top: -12px; &:hover { opacity: 1; diff --git a/src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx b/src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx index 6fe5448d5d..15088396df 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/__tests__/ThinkingBlock.test.tsx @@ -63,12 +63,15 @@ vi.mock('lucide-react', () => ({ 💡 - ) + ), + ChevronRight: (props: any) => })) // Mock motion vi.mock('motion/react', () => ({ + AnimatePresence: ({ children }: any) =>
{children}
, motion: { + div: (props: any) =>
, span: ({ children, variants, animate, initial, style }: any) => ( ({ ) })) +// Mock ThinkingEffect component +vi.mock('@renderer/components/ThinkingEffect', () => ({ + __esModule: true, + default: ({ isThinking, thinkingTimeText, content, expanded }: any) => ( +
+
{thinkingTimeText}
+
+ ) +})) + describe('ThinkingBlock', () => { beforeEach(async () => { vi.useFakeTimers() @@ -153,7 +170,7 @@ describe('ThinkingBlock', () => { const getThinkingContent = () => screen.queryByText(/markdown:/i) const getCopyButton = () => screen.queryByRole('button', { name: /copy/i }) - const getThinkingTimeText = () => screen.getByText(/thinking|thought/i) + const getThinkingTimeText = () => screen.getByTestId('thinking-time-text') describe('basic rendering', () => { it('should render thinking content when provided', () => { @@ -162,7 +179,7 @@ describe('ThinkingBlock', () => { // User should see the thinking content expect(screen.getByText('Markdown: Deep thoughts about AI')).toBeInTheDocument() - expect(screen.getByTestId('lightbulb-icon')).toBeInTheDocument() + expect(screen.getByTestId('mock-marquee-component')).toBeInTheDocument() }) it('should not render when content is empty', () => { @@ -332,14 +349,14 @@ describe('ThinkingBlock', () => { const streamingBlock = createThinkingBlock({ status: MessageBlockStatus.STREAMING }) const { rerender } = renderThinkingBlock(streamingBlock) - // Should be expanded while thinking - expect(getThinkingContent()).toBeInTheDocument() + // With thoughtAutoCollapse enabled, it should be collapsed even while thinking + expect(getThinkingContent()).not.toBeInTheDocument() // Stop thinking const completedBlock = createThinkingBlock({ status: MessageBlockStatus.SUCCESS }) rerender() - // Should be collapsed after thinking completes + // Should remain collapsed after thinking completes expect(getThinkingContent()).not.toBeInTheDocument() }) }) diff --git a/src/renderer/src/pages/home/Messages/Blocks/__tests__/__snapshots__/ThinkingBlock.test.tsx.snap b/src/renderer/src/pages/home/Messages/Blocks/__tests__/__snapshots__/ThinkingBlock.test.tsx.snap index 101e6bb644..579dbf279e 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/__tests__/__snapshots__/ThinkingBlock.test.tsx.snap +++ b/src/renderer/src/pages/home/Messages/Blocks/__tests__/__snapshots__/ThinkingBlock.test.tsx.snap @@ -2,23 +2,27 @@ exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = ` .c0 { - margin: 15px 0; - margin-top: 5px; + margin-top: 15px; + margin-bottom: 15px; +} + +.c0 .ant-collapse-header { + padding: 0!important; +} + +.c0 .ant-collapse-content-box { + padding: 16px!important; + border-width: 0 0.5px 0.5px 0.5px; + border-style: solid; + border-color: var(--color-border); + border-radius: 0 0 12px 12px; } .c1 { - display: flex; - flex-direction: row; - align-items: center; - height: 22px; - gap: 4px; + position: relative; } .c2 { - color: var(--color-text-2); -} - -.c3 { background: none; border: none; color: var(--color-text-2); @@ -30,26 +34,28 @@ exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = ` margin-left: auto; opacity: 0.6; transition: all 0.3s; + position: absolute; + right: -12px; + top: -12px; } -.c3:hover { +.c2:hover { opacity: 1; color: var(--color-text); } -.c3:focus-visible { +.c2:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; } -.c3 .iconfont { +.c2 .iconfont { font-size: 14px; }
@@ -60,40 +66,15 @@ exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = ` data-testid="collapse-header-thought" >
- - - 💡 - - - Thought for 5.0s - -
-
@@ -101,8 +82,23 @@ exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = ` data-testid="collapse-content-thought" >
+
+ +