feat: thinking effect (#8081)

* 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
This commit is contained in:
Teo 2025-07-14 10:08:09 +08:00 committed by GitHub
parent 6952bea6e1
commit 7961ba87ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 288 additions and 106 deletions

View File

@ -136,17 +136,16 @@
} }
} }
.ant-collapse { .ant-collapse:not(.ant-collapse-ghost) {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
.ant-color-picker & { .ant-color-picker & {
border: none; border: none;
} }
} .ant-collapse-content {
border-top: 0.5px solid var(--color-border) !important;
.ant-collapse-content { .ant-color-picker & {
border-top: 0.5px solid var(--color-border) !important; border-top: none !important;
.ant-color-picker & { }
border-top: none !important;
} }
} }

View File

@ -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<Props> = ({ isThinking, thinkingTimeText, content, expanded }) => {
const [messages, setMessages] = useState<string[]>([])
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 (
<ThinkingContainer style={{ height: containerHeight }} className={expanded ? 'expanded' : ''}>
<LoadingContainer className={expanded || !messages.length ? 'expanded' : ''}>
<motion.div variants={lightbulbVariants} animate={isThinking ? 'active' : 'idle'} initial="idle">
<Lightbulb size={expanded || !messages.length ? 20 : 30} style={{ transition: 'width,height, 150ms' }} />
</motion.div>
</LoadingContainer>
<TextContainer>
<Title className={expanded || !messages.length ? 'expanded' : ''}>{thinkingTimeText}</Title>
{!expanded && (
<Content>
<AnimatePresence>
{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 (
<ContentLineMotion
key={`${index}-${message}`}
initial={{
opacity: 1,
y: index === messages.length - 1 ? containerHeight : finalY + lineHeight,
height: lineHeight
}}
animate={{
opacity,
y: finalY
}}
transition={{
duration: 0.15,
ease: 'linear'
}}>
{message}
</ContentLineMotion>
)
})}
</AnimatePresence>
</Content>
)}
</TextContainer>
<ArrowContainer className={expanded ? 'expanded' : ''}>
<ChevronRight size={20} color="var(--color-text-3)" strokeWidth={1.2} />
</ArrowContainer>
</ThinkingContainer>
)
}
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

View File

@ -1,10 +1,8 @@
import { CheckOutlined } from '@ant-design/icons' import { CheckOutlined } from '@ant-design/icons'
import ThinkingEffect from '@renderer/components/ThinkingEffect'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage' import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage'
import { lightbulbVariants } from '@renderer/utils/motionVariants'
import { Collapse, message as antdMessage, Tooltip } from 'antd' 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 { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -24,7 +22,7 @@ const ThinkingBlock: React.FC<Props> = ({ block }) => {
const isThinking = useMemo(() => block.status === MessageBlockStatus.STREAMING, [block.status]) const isThinking = useMemo(() => block.status === MessageBlockStatus.STREAMING, [block.status])
useEffect(() => { useEffect(() => {
if (!isThinking && thoughtAutoCollapse) { if (thoughtAutoCollapse) {
setActiveKey('') setActiveKey('')
} else { } else {
setActiveKey('thought') setActiveKey('thought')
@ -57,31 +55,27 @@ const ThinkingBlock: React.FC<Props> = ({ block }) => {
size="small" size="small"
onChange={() => setActiveKey((key) => (key ? '' : 'thought'))} onChange={() => setActiveKey((key) => (key ? '' : 'thought'))}
className="message-thought-container" className="message-thought-container"
expandIcon={({ isActive }) => ( ghost
<ChevronRight
color="var(--color-text-3)"
size={16}
strokeWidth={1.5}
style={{ transform: isActive ? 'rotate(90deg)' : 'rotate(0deg)' }}
/>
)}
expandIconPosition="end"
items={[ items={[
{ {
key: 'thought', key: 'thought',
label: ( label: (
<MessageTitleLabel> <ThinkingEffect
<motion.span expanded={activeKey === 'thought'}
style={{ height: '18px' }} isThinking={isThinking}
variants={lightbulbVariants} thinkingTimeText={
animate={isThinking ? 'active' : 'idle'}
initial="idle">
<Lightbulb size={18} />
</motion.span>
<ThinkingText>
<ThinkingTimeSeconds blockThinkingTime={block.thinking_millsec} isThinking={isThinking} /> <ThinkingTimeSeconds blockThinkingTime={block.thinking_millsec} isThinking={isThinking} />
</ThinkingText> }
{/* {isThinking && <BarLoader color="#9254de" />} */} content={block.content}
/>
),
children: (
// FIXME: 临时兼容
<ThinkingContent
style={{
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
fontSize
}}>
{!isThinking && ( {!isThinking && (
<Tooltip title={t('common.copy')} mouseEnterDelay={0.8}> <Tooltip title={t('common.copy')} mouseEnterDelay={0.8}>
<ActionButton <ActionButton
@ -96,18 +90,10 @@ const ThinkingBlock: React.FC<Props> = ({ block }) => {
</ActionButton> </ActionButton>
</Tooltip> </Tooltip>
)} )}
</MessageTitleLabel>
),
children: (
// FIXME: 临时兼容
<div
style={{
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
fontSize
}}>
<Markdown block={block} /> <Markdown block={block} />
</div> </ThinkingContent>
) ),
showArrow: false
} }
]} ]}
/> />
@ -150,20 +136,22 @@ const ThinkingTimeSeconds = memo(
) )
const CollapseContainer = styled(Collapse)` const CollapseContainer = styled(Collapse)`
margin: 15px 0; margin-top: 15px;
margin-top: 5px; 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` const ThinkingContent = styled.div`
display: flex; position: relative;
flex-direction: row;
align-items: center;
height: 22px;
gap: 4px;
`
const ThinkingText = styled.span`
color: var(--color-text-2);
` `
const ActionButton = styled.button` const ActionButton = styled.button`
@ -178,6 +166,9 @@ const ActionButton = styled.button`
margin-left: auto; margin-left: auto;
opacity: 0.6; opacity: 0.6;
transition: all 0.3s; transition: all 0.3s;
position: absolute;
right: -12px;
top: -12px;
&:hover { &:hover {
opacity: 1; opacity: 1;

View File

@ -63,12 +63,15 @@ vi.mock('lucide-react', () => ({
<span data-testid="lightbulb-icon" data-size={size}> <span data-testid="lightbulb-icon" data-size={size}>
💡 💡
</span> </span>
) ),
ChevronRight: (props: any) => <svg data-testid="chevron-right-icon" {...props} />
})) }))
// Mock motion // Mock motion
vi.mock('motion/react', () => ({ vi.mock('motion/react', () => ({
AnimatePresence: ({ children }: any) => <div data-testid="animate-presence">{children}</div>,
motion: { motion: {
div: (props: any) => <div {...props} />,
span: ({ children, variants, animate, initial, style }: any) => ( span: ({ children, variants, animate, initial, style }: any) => (
<span <span
data-testid="motion-span" data-testid="motion-span"
@ -100,6 +103,20 @@ vi.mock('@renderer/pages/home/Markdown/Markdown', () => ({
) )
})) }))
// Mock ThinkingEffect component
vi.mock('@renderer/components/ThinkingEffect', () => ({
__esModule: true,
default: ({ isThinking, thinkingTimeText, content, expanded }: any) => (
<div
data-testid="mock-marquee-component"
data-is-thinking={isThinking}
data-expanded={expanded}
data-content={content}>
<div data-testid="thinking-time-text">{thinkingTimeText}</div>
</div>
)
}))
describe('ThinkingBlock', () => { describe('ThinkingBlock', () => {
beforeEach(async () => { beforeEach(async () => {
vi.useFakeTimers() vi.useFakeTimers()
@ -153,7 +170,7 @@ describe('ThinkingBlock', () => {
const getThinkingContent = () => screen.queryByText(/markdown:/i) const getThinkingContent = () => screen.queryByText(/markdown:/i)
const getCopyButton = () => screen.queryByRole('button', { name: /copy/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', () => { describe('basic rendering', () => {
it('should render thinking content when provided', () => { it('should render thinking content when provided', () => {
@ -162,7 +179,7 @@ describe('ThinkingBlock', () => {
// User should see the thinking content // User should see the thinking content
expect(screen.getByText('Markdown: Deep thoughts about AI')).toBeInTheDocument() 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', () => { it('should not render when content is empty', () => {
@ -332,14 +349,14 @@ describe('ThinkingBlock', () => {
const streamingBlock = createThinkingBlock({ status: MessageBlockStatus.STREAMING }) const streamingBlock = createThinkingBlock({ status: MessageBlockStatus.STREAMING })
const { rerender } = renderThinkingBlock(streamingBlock) const { rerender } = renderThinkingBlock(streamingBlock)
// Should be expanded while thinking // With thoughtAutoCollapse enabled, it should be collapsed even while thinking
expect(getThinkingContent()).toBeInTheDocument() expect(getThinkingContent()).not.toBeInTheDocument()
// Stop thinking // Stop thinking
const completedBlock = createThinkingBlock({ status: MessageBlockStatus.SUCCESS }) const completedBlock = createThinkingBlock({ status: MessageBlockStatus.SUCCESS })
rerender(<ThinkingBlock block={completedBlock} />) rerender(<ThinkingBlock block={completedBlock} />)
// Should be collapsed after thinking completes // Should remain collapsed after thinking completes
expect(getThinkingContent()).not.toBeInTheDocument() expect(getThinkingContent()).not.toBeInTheDocument()
}) })
}) })

View File

@ -2,23 +2,27 @@
exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = ` exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = `
.c0 { .c0 {
margin: 15px 0; margin-top: 15px;
margin-top: 5px; 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 { .c1 {
display: flex; position: relative;
flex-direction: row;
align-items: center;
height: 22px;
gap: 4px;
} }
.c2 { .c2 {
color: var(--color-text-2);
}
.c3 {
background: none; background: none;
border: none; border: none;
color: var(--color-text-2); color: var(--color-text-2);
@ -30,26 +34,28 @@ exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = `
margin-left: auto; margin-left: auto;
opacity: 0.6; opacity: 0.6;
transition: all 0.3s; transition: all 0.3s;
position: absolute;
right: -12px;
top: -12px;
} }
.c3:hover { .c2:hover {
opacity: 1; opacity: 1;
color: var(--color-text); color: var(--color-text);
} }
.c3:focus-visible { .c2:focus-visible {
outline: 2px solid var(--color-primary); outline: 2px solid var(--color-primary);
outline-offset: 2px; outline-offset: 2px;
} }
.c3 .iconfont { .c2 .iconfont {
font-size: 14px; font-size: 14px;
} }
<div <div
class="c0 message-thought-container" class="c0 message-thought-container"
data-active-key="thought" data-active-key="thought"
data-expand-icon-position="end"
data-size="small" data-size="small"
data-testid="collapse-container" data-testid="collapse-container"
> >
@ -60,40 +66,15 @@ exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = `
data-testid="collapse-header-thought" data-testid="collapse-header-thought"
> >
<div <div
class="c1" data-content="I need to think about this carefully..."
data-expanded="true"
data-is-thinking="false"
data-testid="mock-marquee-component"
> >
<span <div
data-animate="idle" data-testid="thinking-time-text"
data-initial="idle"
data-testid="motion-span"
data-variants="{"active":{"rotate":10,"scale":1.1},"idle":{"rotate":0,"scale":1}}"
style="height: 18px;"
>
<span
data-size="18"
data-testid="lightbulb-icon"
>
💡
</span>
</span>
<span
class="c2"
> >
Thought for 5.0s Thought for 5.0s
</span>
<div
data-mouse-enter-delay="0.8"
data-testid="tooltip"
title="Copy"
>
<button
aria-label="Copy"
class="c3 message-action-button"
>
<i
class="iconfont icon-copy"
/>
</button>
</div> </div>
</div> </div>
</div> </div>
@ -101,8 +82,23 @@ exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = `
data-testid="collapse-content-thought" data-testid="collapse-content-thought"
> >
<div <div
class="c1"
style="font-family: var(--font-family); font-size: 14px;" style="font-family: var(--font-family); font-size: 14px;"
> >
<div
data-mouse-enter-delay="0.8"
data-testid="tooltip"
title="Copy"
>
<button
aria-label="Copy"
class="c2 message-action-button"
>
<i
class="iconfont icon-copy"
/>
</button>
</div>
<div <div
data-block-id="test-thinking-block-1" data-block-id="test-thinking-block-1"
data-testid="mock-markdown" data-testid="mock-markdown"