feat: add motion library for animations and enhance Spinner and Messa… (#5869)

feat: add motion library for animations and enhance Spinner and MessageBlock components

- Added 'motion' library to package.json and yarn.lock for animation support.
- Refactored Spinner component to utilize motion for animated effects.
- Introduced AnimatedBlockWrapper in MessageBlockRenderer for animated transitions.
- Updated ThinkingBlock to include animated lightbulb effect during thinking state.
This commit is contained in:
MyPrototypeWhat
2025-05-11 16:20:28 +08:00
committed by GitHub
parent d66e8cb4ec
commit 499fb306f6
5 changed files with 224 additions and 63 deletions

View File

@@ -178,6 +178,7 @@
"lru-cache": "^11.1.0",
"lucide-react": "^0.487.0",
"mime": "^4.0.4",
"motion": "^12.10.5",
"npx-scope-finder": "^1.2.0",
"openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch",
"p-queue": "^8.1.0",

View File

@@ -1,41 +1,68 @@
import { Search } from 'lucide-react'
import { motion } from 'motion/react'
import { useTranslation } from 'react-i18next'
import BarLoader from 'react-spinners/BarLoader'
import styled, { css } from 'styled-components'
import styled from 'styled-components'
interface Props {
text: string
}
// Define variants for the spinner animation
const spinnerVariants = {
defaultColor: {
color: '#2a2a2a'
},
dimmed: {
color: '#8C9296'
}
}
export default function Spinner({ text }: Props) {
const { t } = useTranslation()
return (
<Container>
<Search size={24} />
<StatusText>{t(text)}</StatusText>
<BarLoader color="#1677ff" />
</Container>
<Searching
variants={spinnerVariants}
initial="defaultColor"
animate={['defaultColor', 'dimmed']}
transition={{
duration: 0.8,
repeat: Infinity,
repeatType: 'reverse',
ease: 'easeInOut'
}}>
<Search size={16} style={{ color: 'unset' }} />
<span>{t(text)}</span>
</Searching>
)
}
const baseContainer = css`
// const baseContainer = css`
// display: flex;
// flex-direction: row;
// align-items: center;
// `
// const Container = styled.div`
// ${baseContainer}
// background-color: var(--color-background-mute);
// padding: 10px;
// border-radius: 10px;
// margin-bottom: 10px;
// gap: 10px;
// `
// const StatusText = styled.div`
// font-size: 14px;
// line-height: 1.6;
// text-decoration: none;
// color: var(--color-text-1);
// `
const SearchWrapper = styled.div`
display: flex;
flex-direction: row;
align-items: center;
`
const Container = styled.div`
${baseContainer}
background-color: var(--color-background-mute);
padding: 10px;
border-radius: 10px;
margin-bottom: 10px;
gap: 10px;
`
const StatusText = styled.div`
gap: 4px;
font-size: 14px;
line-height: 1.6;
text-decoration: none;
color: var(--color-text-1);
padding: 10px;
padding-left: 0;
`
const Searching = motion.create(SearchWrapper)

View File

@@ -2,12 +2,35 @@ import { CheckOutlined } from '@ant-design/icons'
import { useSettings } from '@renderer/hooks/useSettings'
import { MessageBlockStatus, type ThinkingMessageBlock } from '@renderer/types/newMessage'
import { Collapse, message as antdMessage, Tooltip } from 'antd'
import { Lightbulb } from 'lucide-react'
import { motion } from 'motion/react'
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import BarLoader from 'react-spinners/BarLoader'
import styled from 'styled-components'
import Markdown from '../../Markdown/Markdown'
// Define variants outside the component if they don't depend on component's props/state directly
// or inside if they do (though for this case, outside is fine).
const lightbulbVariants = {
thinking: {
opacity: [1, 0.2, 1],
transition: {
duration: 1.2,
ease: 'easeInOut',
times: [0, 0.5, 1],
repeat: Infinity
}
},
idle: {
opacity: 1,
transition: {
duration: 0.3, // Smooth transition to idle state
ease: 'easeInOut'
}
}
}
interface Props {
block: ThinkingMessageBlock
}
@@ -63,17 +86,25 @@ const ThinkingBlock: React.FC<Props> = ({ block }) => {
size="small"
onChange={() => setActiveKey((key) => (key ? '' : 'thought'))}
className="message-thought-container"
expandIconPosition="end"
items={[
{
key: 'thought',
label: (
<MessageTitleLabel>
<motion.span
style={{ height: '18px' }}
variants={lightbulbVariants}
animate={isThinking ? 'thinking' : 'idle'}
initial="idle">
<Lightbulb size={18} />
</motion.span>
<ThinkingText>
{t(isThinking ? 'chat.thinking' : 'chat.deeply_thought', {
seconds: thinkingTimeSeconds
})}
</ThinkingText>
{isThinking && <BarLoader color="#9254de" />}
{/* {isThinking && <BarLoader color="#9254de" />} */}
{!isThinking && (
<Tooltip title={t('common.copy')} mouseEnterDelay={0.8}>
<ActionButton
@@ -104,6 +135,7 @@ const ThinkingBlock: React.FC<Props> = ({ block }) => {
const CollapseContainer = styled(Collapse)`
margin-bottom: 15px;
max-width: 960px;
`
const MessageTitleLabel = styled.div`
@@ -111,7 +143,7 @@ const MessageTitleLabel = styled.div`
flex-direction: row;
align-items: center;
height: 22px;
gap: 15px;
gap: 4px;
`
const ThinkingText = styled.span`

View File

@@ -1,17 +1,8 @@
import type { RootState } from '@renderer/store'
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
import type {
ErrorMessageBlock,
FileMessageBlock,
ImageMessageBlock,
MainTextMessageBlock,
Message,
MessageBlock,
PlaceholderMessageBlock,
ThinkingMessageBlock,
TranslationMessageBlock
} from '@renderer/types/newMessage'
import type { ImageMessageBlock, MainTextMessageBlock, Message, MessageBlock } from '@renderer/types/newMessage'
import { MessageBlockStatus, MessageBlockType } from '@renderer/types/newMessage'
import { AnimatePresence, motion } from 'motion/react'
import React, { useMemo } from 'react'
import { useSelector } from 'react-redux'
import styled from 'styled-components'
@@ -26,8 +17,41 @@ import ThinkingBlock from './ThinkingBlock'
import ToolBlock from './ToolBlock'
import TranslationBlock from './TranslationBlock'
interface AnimatedBlockWrapperProps {
children: React.ReactNode
enableAnimation: boolean
}
const blockWrapperVariants = {
visible: {
opacity: 1,
x: 0,
transition: { duration: 0.3, type: 'spring', bounce: 0 }
},
hidden: {
opacity: 0,
x: 10
},
static: {
opacity: 1,
x: 0,
transition: { duration: 0 }
}
}
const AnimatedBlockWrapper: React.FC<AnimatedBlockWrapperProps> = ({ children, enableAnimation }) => {
return (
<motion.div
variants={blockWrapperVariants}
initial={enableAnimation ? 'hidden' : 'static'}
animate={enableAnimation ? 'visible' : 'static'}>
{children}
</motion.div>
)
}
interface Props {
blocks: MessageBlock[] | string[] // 可以接收块ID数组或MessageBlock数组
blocks: string[] // 可以接收块ID数组或MessageBlock数组
messageStatus?: Message['status']
message: Message
}
@@ -54,26 +78,30 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
// 根据blocks类型处理渲染数据
const renderedBlocks = blocks.map((blockId) => blockEntities[blockId]).filter(Boolean)
const groupedBlocks = useMemo(() => filterImageBlockGroups(renderedBlocks), [renderedBlocks])
return (
<>
<AnimatePresence mode="sync">
{groupedBlocks.map((block) => {
if (Array.isArray(block)) {
const groupKey = block.map((imageBlock) => imageBlock.id).join('-')
return (
<ImageBlockGroup key={block.map((imageBlock) => imageBlock.id).join('-')}>
{block.map((imageBlock) => (
<ImageBlock key={imageBlock.id} block={imageBlock as ImageMessageBlock} />
))}
</ImageBlockGroup>
<AnimatedBlockWrapper key={groupKey} enableAnimation={message.status.includes('ing')}>
<ImageBlockGroup>
{block.map((imageBlock) => (
<ImageBlock key={imageBlock.id} block={imageBlock as ImageMessageBlock} />
))}
</ImageBlockGroup>
</AnimatedBlockWrapper>
)
}
let blockComponent: React.ReactNode = null
switch (block.type) {
case MessageBlockType.UNKNOWN:
if (block.status === MessageBlockStatus.PROCESSING) {
return <PlaceholderBlock key={block.id} block={block as PlaceholderMessageBlock} />
blockComponent = <PlaceholderBlock key={block.id} block={block} />
}
return null
break
case MessageBlockType.MAIN_TEXT:
case MessageBlockType.CODE: {
const mainTextBlock = block as MainTextMessageBlock
@@ -82,7 +110,7 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
// No longer need to retrieve the full citation block here
// const citationBlock = citationBlockId ? (blockEntities[citationBlockId] as CitationMessageBlock) : undefined
return (
blockComponent = (
<MainTextBlock
key={block.id}
block={mainTextBlock}
@@ -91,30 +119,43 @@ const MessageBlockRenderer: React.FC<Props> = ({ blocks, message }) => {
role={message.role}
/>
)
break
}
case MessageBlockType.IMAGE:
return <ImageBlock key={block.id} block={block as ImageMessageBlock} />
blockComponent = <ImageBlock key={block.id} block={block} />
break
case MessageBlockType.FILE:
return <FileBlock key={block.id} block={block as FileMessageBlock} />
blockComponent = <FileBlock key={block.id} block={block} />
break
case MessageBlockType.TOOL:
return <ToolBlock key={block.id} block={block} />
blockComponent = <ToolBlock key={block.id} block={block} />
break
case MessageBlockType.CITATION:
return <CitationBlock key={block.id} block={block} />
blockComponent = <CitationBlock key={block.id} block={block} />
break
case MessageBlockType.ERROR:
return <ErrorBlock key={block.id} block={block as ErrorMessageBlock} />
blockComponent = <ErrorBlock key={block.id} block={block} />
break
case MessageBlockType.THINKING:
return <ThinkingBlock key={block.id} block={block as ThinkingMessageBlock} />
// case MessageBlockType.CODE:
// return <CodeBlock key={block.id} block={block as CodeMessageBlock} />
blockComponent = <ThinkingBlock key={block.id} block={block} />
break
case MessageBlockType.TRANSLATION:
return <TranslationBlock key={block.id} block={block as TranslationMessageBlock} />
blockComponent = <TranslationBlock key={block.id} block={block} />
break
default:
// Cast block to any for console.warn to fix linter error
console.warn('Unsupported block type in MessageBlockRenderer:', (block as any).type, block)
return null
break
}
return (
<AnimatedBlockWrapper
key={block.type === MessageBlockType.UNKNOWN ? 'placeholder' : block.id}
enableAnimation={message.status.includes('ing')}>
{blockComponent}
</AnimatedBlockWrapper>
)
})}
</>
</AnimatePresence>
)
}

View File

@@ -4488,6 +4488,7 @@ __metadata:
lucide-react: "npm:^0.487.0"
markdown-it: "npm:^14.1.0"
mime: "npm:^4.0.4"
motion: "npm:^12.10.5"
node-stream-zip: "npm:^1.15.0"
npx-scope-finder: "npm:^1.2.0"
officeparser: "npm:^4.1.1"
@@ -8559,6 +8560,28 @@ __metadata:
languageName: node
linkType: hard
"framer-motion@npm:^12.10.5":
version: 12.10.5
resolution: "framer-motion@npm:12.10.5"
dependencies:
motion-dom: "npm:^12.10.5"
motion-utils: "npm:^12.9.4"
tslib: "npm:^2.4.0"
peerDependencies:
"@emotion/is-prop-valid": "*"
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@emotion/is-prop-valid":
optional: true
react:
optional: true
react-dom:
optional: true
checksum: 10c0/a24a44b7a1b21e347f93f9ec3c1218b9ebf2b2bc2883c26ab9951e19a62fdc2e03f80a57d0c78eaf408d098ed6f0fbcae48207313921c1f5462eb04296adf55b
languageName: node
linkType: hard
"fresh@npm:^2.0.0":
version: 2.0.0
resolution: "fresh@npm:2.0.0"
@@ -12414,6 +12437,43 @@ __metadata:
languageName: node
linkType: hard
"motion-dom@npm:^12.10.5":
version: 12.10.5
resolution: "motion-dom@npm:12.10.5"
dependencies:
motion-utils: "npm:^12.9.4"
checksum: 10c0/2c362eb94c941bbbc42288a6738b8c7a11933687b3b20aa6c9f2c3dedc69e5c7995c7348499b535f8abe5ed9ea81d88f9eb2f98b69f5012bcd80b8f7a64a1c2c
languageName: node
linkType: hard
"motion-utils@npm:^12.9.4":
version: 12.9.4
resolution: "motion-utils@npm:12.9.4"
checksum: 10c0/b6783babfd1282ad320585f7cdac9fe7a1f97b39e07d12a500d3709534441bd9d49b556fa1cd838d1bde188570d4ab6b4c5aa9d297f7f5aa9dc16d600c17afdc
languageName: node
linkType: hard
"motion@npm:^12.10.5":
version: 12.10.5
resolution: "motion@npm:12.10.5"
dependencies:
framer-motion: "npm:^12.10.5"
tslib: "npm:^2.4.0"
peerDependencies:
"@emotion/is-prop-valid": "*"
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
"@emotion/is-prop-valid":
optional: true
react:
optional: true
react-dom:
optional: true
checksum: 10c0/d8f1755a565332e6122e2079e164026b945eda34827170f2615999d74d3df2ad77984ca55304d7682b97a2ccf83c33508d234af619b043cd18056047884396d1
languageName: node
linkType: hard
"mri@npm:1.1.4":
version: 1.1.4
resolution: "mri@npm:1.1.4"
@@ -12940,7 +13000,7 @@ __metadata:
"openai@patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch":
version: 4.96.0
resolution: "openai@patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch::version=4.96.0&hash=645779"
resolution: "openai@patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch::version=4.96.0&hash=6bc976"
dependencies:
"@types/node": "npm:^18.11.18"
"@types/node-fetch": "npm:^2.6.4"
@@ -12959,7 +13019,7 @@ __metadata:
optional: true
bin:
openai: bin/cli
checksum: 10c0/8c16fcf1812294220eddd4616e298c2af87398acb479287b7565548c8c1979c6d5c487fb7a9c25b0ac59f778de74c23d94ce1a34362c49260ae7a14acf22abc2
checksum: 10c0/e50e4b9b60e94fadaca541cf2c36a12c55221555dd2ce977738e13978b7187504263f2e31b4641f2b6e70fce562b4e1fa2affd68caeca21248ddfa8847eeb003
languageName: node
linkType: hard