Files
awoooi/apps/web/src/components/ai/thinking-stream.tsx
OG T 4d46e6b9a7
Some checks failed
E2E Health Check / e2e-health (push) Successful in 17s
CD Pipeline / build-and-deploy (push) Has been cancelled
style(web): 全站 font-mono → font-body (DM Mono 設計系統套用)
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>
2026-04-02 09:37:03 +08:00

270 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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