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

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 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<Props> = ({ 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<Props> = ({ block }) => {
size="small"
onChange={() => setActiveKey((key) => (key ? '' : 'thought'))}
className="message-thought-container"
expandIcon={({ isActive }) => (
<ChevronRight
color="var(--color-text-3)"
size={16}
strokeWidth={1.5}
style={{ transform: isActive ? 'rotate(90deg)' : 'rotate(0deg)' }}
/>
)}
expandIconPosition="end"
ghost
items={[
{
key: 'thought',
label: (
<MessageTitleLabel>
<motion.span
style={{ height: '18px' }}
variants={lightbulbVariants}
animate={isThinking ? 'active' : 'idle'}
initial="idle">
<Lightbulb size={18} />
</motion.span>
<ThinkingText>
<ThinkingEffect
expanded={activeKey === 'thought'}
isThinking={isThinking}
thinkingTimeText={
<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 && (
<Tooltip title={t('common.copy')} mouseEnterDelay={0.8}>
<ActionButton
@ -96,18 +90,10 @@ const ThinkingBlock: React.FC<Props> = ({ block }) => {
</ActionButton>
</Tooltip>
)}
</MessageTitleLabel>
),
children: (
// FIXME: 临时兼容
<div
style={{
fontFamily: messageFont === 'serif' ? 'var(--font-family-serif)' : 'var(--font-family)',
fontSize
}}>
<Markdown block={block} />
</div>
)
</ThinkingContent>
),
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;

View File

@ -63,12 +63,15 @@ vi.mock('lucide-react', () => ({
<span data-testid="lightbulb-icon" data-size={size}>
💡
</span>
)
),
ChevronRight: (props: any) => <svg data-testid="chevron-right-icon" {...props} />
}))
// Mock motion
vi.mock('motion/react', () => ({
AnimatePresence: ({ children }: any) => <div data-testid="animate-presence">{children}</div>,
motion: {
div: (props: any) => <div {...props} />,
span: ({ children, variants, animate, initial, style }: any) => (
<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', () => {
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(<ThinkingBlock block={completedBlock} />)
// Should be collapsed after thinking completes
// Should remain collapsed after thinking completes
expect(getThinkingContent()).not.toBeInTheDocument()
})
})

View File

@ -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;
}
<div
class="c0 message-thought-container"
data-active-key="thought"
data-expand-icon-position="end"
data-size="small"
data-testid="collapse-container"
>
@ -60,40 +66,15 @@ exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = `
data-testid="collapse-header-thought"
>
<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
data-animate="idle"
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"
<div
data-testid="thinking-time-text"
>
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>
@ -101,8 +82,23 @@ exports[`ThinkingBlock > basic rendering > should match snapshot 1`] = `
data-testid="collapse-content-thought"
>
<div
class="c1"
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
data-block-id="test-thinking-block-1"
data-testid="mock-markdown"