feat: add rainbow sparkle text effect for ultrathink in user messages

This commit is contained in:
weishu
2025-12-17 18:09:25 +08:00
parent 72d7068c9d
commit e2ea9fbc45
4 changed files with 161 additions and 23 deletions
+2 -1
View File
@@ -3,6 +3,7 @@ import type { MessageStatus } from '@/types/api'
import type { ApiClient } from '@/api/client'
import type { SessionMetadataSummary } from '@/types/api'
import { MarkdownRenderer } from '@/components/MarkdownRenderer'
import { LazyRainbowText } from '@/components/LazyRainbowText'
import { ToolCard } from '@/components/ToolCard/ToolCard'
function ErrorIcon() {
@@ -99,7 +100,7 @@ export function ChatBlockList(props: {
<div key={`user:${block.id}`} className={userBubbleClass}>
<div className="flex items-end gap-2">
<div className="flex-1">
<MarkdownRenderer content={block.text} />
<LazyRainbowText text={block.text} />
</div>
{status ? (
<div className="shrink-0 self-end pb-0.5">
+104
View File
@@ -0,0 +1,104 @@
import React, { useRef, useState, useEffect, useMemo } from 'react'
import { MarkdownRenderer } from '@/components/MarkdownRenderer'
const ULTRATHINK_PATTERN = /\b(ultrathink)\b/gi
// Each letter gets a different delay for wave effect
function RainbowWord({ word, baseKey }: { word: string; baseKey: number }) {
const totalLetters = word.length
const cycleDuration = 2 // seconds for sparkle to travel across all letters
return (
<span>
{word.split('').map((letter, i) => {
// Each letter has a different delay to create wave effect
const colorDelay = (i / totalLetters) * 2 // stagger rainbow colors
const sparkleDelay = (i / totalLetters) * cycleDuration // sparkle wave
return (
<span
key={`${baseKey}-${i}`}
className="rainbow-letter"
style={{
animationDelay: `${-colorDelay}s, ${-sparkleDelay}s`,
}}
>
{letter}
</span>
)
})}
</span>
)
}
// Process text string to wrap "ultrathink" with RainbowWord
function processTextForRainbow(text: string): React.ReactNode {
ULTRATHINK_PATTERN.lastIndex = 0
const parts: React.ReactNode[] = []
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = ULTRATHINK_PATTERN.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index))
}
parts.push(<RainbowWord key={match.index} word={match[1]} baseKey={match.index} />)
lastIndex = match.index + match[0].length
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex))
}
return <>{parts}</>
}
// Process React children to apply rainbow to text nodes
function processChildrenForRainbow(children: React.ReactNode): React.ReactNode {
return React.Children.map(children, (child) => {
if (typeof child === 'string') {
return processTextForRainbow(child)
}
return child
})
}
export function LazyRainbowText({ text }: { text: string }) {
const ref = useRef<HTMLDivElement>(null)
const [hasBeenVisible, setHasBeenVisible] = useState(false)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setHasBeenVisible(true)
}
},
{ rootMargin: '100px' }
)
observer.observe(el)
return () => observer.disconnect()
}, [])
// Quick check: if no ultrathink, just render markdown
const hasUltrathink = text.toLowerCase().includes('ultrathink')
const rainbowComponents = useMemo(() => ({
p: ({ children }: { children?: React.ReactNode }) => (
<p>{processChildrenForRainbow(children)}</p>
),
}), [])
return (
<div ref={ref}>
<MarkdownRenderer
content={text}
components={hasUltrathink && hasBeenVisible ? rainbowComponents : undefined}
/>
</div>
)
}
+28 -22
View File
@@ -1,4 +1,4 @@
import ReactMarkdown from 'react-markdown'
import ReactMarkdown, { Components } from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { CodeBlock } from '@/components/CodeBlock'
@@ -6,36 +6,42 @@ function getLanguageFromClassName(className?: string): string | null {
if (!className) return null
for (const token of className.split(/\s+/g)) {
if (token.startsWith('language-')) {
const language = token.slice('language-'.length)
return language || null
return token.slice('language-'.length) || null
}
}
return null
}
export function MarkdownRenderer({ content }: { content: string }) {
const defaultComponents: Components = {
code({ className, children, ...props }) {
const language = getLanguageFromClassName(className) || 'text'
const code = String(children).replace(/\n$/, '')
if (language !== 'text' || code.includes('\n')) {
return (
<CodeBlock
code={code}
language={language}
showCopyButton={false}
/>
)
}
return <code className={className} {...props}>{children}</code>
},
}
interface MarkdownRendererProps {
content: string
components?: Components
}
export function MarkdownRenderer({ content, components }: MarkdownRendererProps) {
return (
<div className="markdown-content text-sm">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className, children, ...props }) {
const language = getLanguageFromClassName(className) || 'text'
const code = String(children).replace(/\n$/, '')
if (language !== 'text' || code.includes('\n')) {
return (
<CodeBlock
code={code}
language={language}
showCopyButton={false}
/>
)
}
return <code className={className} {...props}>{children}</code>
}
}}
components={{ ...defaultComponents, ...components }}
>
{content}
</ReactMarkdown>
+27
View File
@@ -85,3 +85,30 @@ body {
.markdown-content ul, .markdown-content ol { padding-left: 1.5rem; margin: 0.5rem 0; }
.markdown-content table { border-collapse: collapse; width: 100%; }
.markdown-content th, .markdown-content td { border: 1px solid var(--app-border); padding: 0.25rem 0.5rem; }
/* Ultrathink rainbow sparkle effect - per-letter wave animation */
@keyframes rainbow-wave {
0% { color: #ff5555; } /* red */
14% { color: #ffaa00; } /* orange */
28% { color: #ffff55; } /* yellow */
42% { color: #55ff55; } /* green */
57% { color: #55ffff; } /* cyan */
71% { color: #5555ff; } /* blue */
85% { color: #ff55ff; } /* magenta/pink */
100% { color: #ff5555; } /* red */
}
@keyframes sparkle-wave {
0%, 100% {
filter: brightness(1);
}
50% {
filter: brightness(0.7);
}
}
.rainbow-letter {
display: inline-block;
animation: rainbow-wave 2s linear infinite, sparkle-wave 2s ease-in-out infinite;
font-weight: 600;
}