mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat: add rainbow sparkle text effect for ultrathink in user messages
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user