45 個 component + 6 個 page 統一從舊 font-mono 遷移到 font-body (DM Mono),確保設計系統一致性。 font-body = DM Mono (等寬),視覺效果相同但走新設計 token。 保留: font-heading (Syne)、font-dot-matrix (VT323/DSEG7) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
270 lines
7.7 KiB
TypeScript
270 lines
7.7 KiB
TypeScript
'use client'
|
||
|
||
/**
|
||
* ThinkingStream - AI 思考流打字機動畫
|
||
* =====================================
|
||
* Phase 1: OpenClaw 靈魂注入
|
||
*
|
||
* Features:
|
||
* - 打字機效果 (Typewriter) 逐字顯示
|
||
* - VT323 點陣字體 + 思維紫色調
|
||
* - 極簡終端機風格 (Terminal Style)
|
||
* - 記憶體安全清理 (cleanup 必須清除所有計時器)
|
||
*
|
||
* 視覺規範:
|
||
* - 禁止 Chat Bubble 對話框
|
||
* - 純終端機文字流
|
||
* - 閃爍游標動畫
|
||
*
|
||
* i18n: 100% next-intl
|
||
*/
|
||
|
||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||
import { useTranslations } from 'next-intl'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
// =============================================================================
|
||
// Types
|
||
// =============================================================================
|
||
|
||
export interface ThinkingMessage {
|
||
id: string
|
||
prefix: '[SYS]' | '[AGENT]' | '[SCAN]' | '[CALC]'
|
||
messageKey: string // i18n key
|
||
delay?: number // 打字速度 (ms per char)
|
||
}
|
||
|
||
export interface ThinkingStreamProps {
|
||
messages: ThinkingMessage[]
|
||
onComplete?: () => void
|
||
className?: string
|
||
/** 是否顯示游標 */
|
||
showCursor?: boolean
|
||
/** 打字速度 (ms per char) */
|
||
typeSpeed?: number
|
||
}
|
||
|
||
// =============================================================================
|
||
// 預設思考訊息序列
|
||
// =============================================================================
|
||
|
||
export const DEFAULT_THINKING_MESSAGES: ThinkingMessage[] = [
|
||
{ id: '1', prefix: '[SYS]', messageKey: 'ai.intercepting' },
|
||
{ id: '2', prefix: '[AGENT]', messageKey: 'ai.analyzing' },
|
||
{ id: '3', prefix: '[CALC]', messageKey: 'ai.calculating' },
|
||
{ id: '4', prefix: '[SYS]', messageKey: 'ai.generating' },
|
||
{ id: '5', prefix: '[SYS]', messageKey: 'ai.complete' },
|
||
]
|
||
|
||
// =============================================================================
|
||
// Typewriter Hook (記憶體安全版)
|
||
// =============================================================================
|
||
|
||
function useTypewriter(
|
||
text: string,
|
||
speed: number = 35,
|
||
onComplete?: () => void
|
||
) {
|
||
const [displayText, setDisplayText] = useState('')
|
||
const [isComplete, setIsComplete] = useState(false)
|
||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||
const indexRef = useRef(0)
|
||
|
||
// Reset on text change
|
||
useEffect(() => {
|
||
setDisplayText('')
|
||
setIsComplete(false)
|
||
indexRef.current = 0
|
||
}, [text])
|
||
|
||
// Typewriter effect with cleanup
|
||
useEffect(() => {
|
||
if (!text || isComplete) return
|
||
|
||
const typeNextChar = () => {
|
||
if (indexRef.current < text.length) {
|
||
setDisplayText(text.slice(0, indexRef.current + 1))
|
||
indexRef.current++
|
||
timeoutRef.current = setTimeout(typeNextChar, speed)
|
||
} else {
|
||
setIsComplete(true)
|
||
onComplete?.()
|
||
}
|
||
}
|
||
|
||
// Start typing
|
||
timeoutRef.current = setTimeout(typeNextChar, speed)
|
||
|
||
// ⚠️ CRITICAL: 記憶體安全清理
|
||
// 必須在 unmount 時清除所有 setTimeout
|
||
// 否則會造成記憶體洩漏與 setState on unmounted component
|
||
return () => {
|
||
if (timeoutRef.current) {
|
||
clearTimeout(timeoutRef.current)
|
||
timeoutRef.current = null
|
||
}
|
||
}
|
||
}, [text, speed, isComplete, onComplete])
|
||
|
||
return { displayText, isComplete }
|
||
}
|
||
|
||
// =============================================================================
|
||
// Single Line Component
|
||
// =============================================================================
|
||
|
||
interface ThinkingLineProps {
|
||
prefix: string
|
||
message: string
|
||
typeSpeed: number
|
||
onComplete?: () => void
|
||
showCursor: boolean
|
||
}
|
||
|
||
function ThinkingLine({
|
||
prefix,
|
||
message,
|
||
typeSpeed,
|
||
onComplete,
|
||
showCursor,
|
||
}: ThinkingLineProps) {
|
||
const { displayText, isComplete } = useTypewriter(message, typeSpeed, onComplete)
|
||
|
||
// Phase 8.0 #16: 移除 setInterval cursor blink,改用 CSS animation
|
||
// 減少高頻 setState 造成的 DOM 重繪
|
||
|
||
return (
|
||
<div className="flex items-start gap-2 font-body text-sm leading-relaxed">
|
||
{/* Prefix */}
|
||
<span
|
||
className={cn(
|
||
'shrink-0 font-bold',
|
||
prefix === '[SYS]' && 'text-status-thinking',
|
||
prefix === '[AGENT]' && 'text-claw-blue',
|
||
prefix === '[SCAN]' && 'text-status-warning',
|
||
prefix === '[CALC]' && 'text-status-info'
|
||
)}
|
||
>
|
||
{prefix}
|
||
</span>
|
||
|
||
{/* Message with cursor (CSS animation blink) */}
|
||
<span className="text-nothing-gray-700">
|
||
{displayText}
|
||
{showCursor && !isComplete && (
|
||
<span
|
||
className="inline-block w-2 h-4 ml-0.5 -mb-0.5 bg-status-thinking animate-pulse"
|
||
/>
|
||
)}
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// =============================================================================
|
||
// Main Component
|
||
// =============================================================================
|
||
|
||
export function ThinkingStream({
|
||
messages,
|
||
onComplete,
|
||
className,
|
||
showCursor = true,
|
||
typeSpeed = 35,
|
||
}: ThinkingStreamProps) {
|
||
const t = useTranslations()
|
||
const [currentIndex, setCurrentIndex] = useState(0)
|
||
const [_completedLines, setCompletedLines] = useState<string[]>([])
|
||
const completedRef = useRef(false)
|
||
|
||
// Handle line completion
|
||
const handleLineComplete = useCallback(() => {
|
||
const nextIndex = currentIndex + 1
|
||
|
||
// Store completed line
|
||
if (messages[currentIndex]) {
|
||
setCompletedLines((prev) => [...prev, messages[currentIndex].id])
|
||
}
|
||
|
||
// Move to next line or complete
|
||
if (nextIndex < messages.length) {
|
||
// Small delay between lines
|
||
setTimeout(() => {
|
||
setCurrentIndex(nextIndex)
|
||
}, 300)
|
||
} else if (!completedRef.current) {
|
||
completedRef.current = true
|
||
setTimeout(() => {
|
||
onComplete?.()
|
||
}, 500)
|
||
}
|
||
}, [currentIndex, messages, onComplete])
|
||
|
||
// Reset when messages change
|
||
useEffect(() => {
|
||
setCurrentIndex(0)
|
||
setCompletedLines([])
|
||
completedRef.current = false
|
||
}, [messages])
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
// Terminal style container
|
||
'bg-nothing-gray-50/50 rounded-lg p-3',
|
||
'border border-nothing-gray-200/50',
|
||
// Subtle scan line effect
|
||
'relative overflow-hidden',
|
||
className
|
||
)}
|
||
>
|
||
{/* Scan line animation */}
|
||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||
<div
|
||
className="absolute h-[1px] w-full bg-gradient-to-r from-transparent via-status-thinking/30 to-transparent animate-scan"
|
||
style={{ animationDuration: '3s' }}
|
||
/>
|
||
</div>
|
||
|
||
{/* Terminal content */}
|
||
<div className="relative space-y-1.5">
|
||
{/* Completed lines */}
|
||
{messages.slice(0, currentIndex).map((msg) => (
|
||
<div
|
||
key={msg.id}
|
||
className="flex items-start gap-2 font-body text-sm leading-relaxed opacity-60"
|
||
>
|
||
<span
|
||
className={cn(
|
||
'shrink-0 font-bold',
|
||
msg.prefix === '[SYS]' && 'text-status-thinking',
|
||
msg.prefix === '[AGENT]' && 'text-claw-blue',
|
||
msg.prefix === '[SCAN]' && 'text-status-warning',
|
||
msg.prefix === '[CALC]' && 'text-status-info'
|
||
)}
|
||
>
|
||
{msg.prefix}
|
||
</span>
|
||
<span className="text-nothing-gray-600">
|
||
{t(msg.messageKey)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
|
||
{/* Current typing line */}
|
||
{messages[currentIndex] && (
|
||
<ThinkingLine
|
||
prefix={messages[currentIndex].prefix}
|
||
message={t(messages[currentIndex].messageKey)}
|
||
typeSpeed={messages[currentIndex].delay ?? typeSpeed}
|
||
onComplete={handleLineComplete}
|
||
showCursor={showCursor}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default ThinkingStream
|