Files
awoooi/apps/web/src/components/incident/thinking-terminal.tsx
OG T 196d269b92 feat: add all application source code
- apps/api: FastAPI backend with Dockerfile
- apps/web: Next.js frontend with Dockerfile
- apps/sensor: Signal collection agent
- packages: shared packages

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-22 18:57:44 +08:00

392 lines
12 KiB
TypeScript

'use client'
/**
* ThinkingTerminal - Phase 7 OpenClaw 思維終端機
* =============================================
*
* 專門渲染 Incident 的 decision_chain (AI 推論過程)
*
* Nothing.tech 視覺規範:
* - 深色背景 (bg-black text-white) - 全站唯一深色區域
* - 強制等寬字體 (font-mono)
* - 打字機效果展示推論步驟
*
* 統帥鐵律: 禁止假數據!
*/
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { Terminal, Play, Pause, RotateCcw, ChevronDown, ChevronUp } from 'lucide-react'
// =============================================================================
// Types
// =============================================================================
interface ReasoningStep {
step: string
reasoning: string
timestamp?: string
}
interface DecisionChain {
analysis_type: 'blast_radius' | 'root_cause' | 'action_suggestion'
target_service: string
reasoning_steps: ReasoningStep[]
conclusion: string
confidence: number
}
interface ThinkingTerminalProps {
/** 決策鏈資料 */
decisionChain?: DecisionChain | null
/** 事件 ID */
incidentId?: string
/** 自定義 CSS */
className?: string
/** 最大高度 */
maxHeight?: string
/** 是否自動播放打字效果 */
autoPlay?: boolean
/** 打字速度 (毫秒/字) */
typeSpeed?: number
}
// =============================================================================
// Component
// =============================================================================
export function ThinkingTerminal({
decisionChain,
incidentId,
className,
maxHeight = '400px',
autoPlay = true,
typeSpeed = 30,
}: ThinkingTerminalProps) {
const t = useTranslations('terminal')
const [displayedSteps, setDisplayedSteps] = useState<number>(0)
const [currentText, setCurrentText] = useState<string>('')
const [isPlaying, setIsPlaying] = useState(autoPlay)
const [isExpanded, setIsExpanded] = useState(true)
const containerRef = useRef<HTMLDivElement>(null)
const animationRef = useRef<NodeJS.Timeout | null>(null)
const steps = decisionChain?.reasoning_steps || []
const totalSteps = steps.length
// 打字機效果
const typeStep = useCallback((stepIndex: number) => {
if (stepIndex >= totalSteps) {
setIsPlaying(false)
return
}
const step = steps[stepIndex]
const fullText = `[${step.step}] ${step.reasoning}`
let charIndex = 0
const typeChar = () => {
if (charIndex < fullText.length) {
setCurrentText(fullText.slice(0, charIndex + 1))
charIndex++
animationRef.current = setTimeout(typeChar, typeSpeed)
} else {
// 完成當前步驟,進入下一步
setDisplayedSteps(stepIndex + 1)
setCurrentText('')
animationRef.current = setTimeout(() => {
if (isPlaying) {
typeStep(stepIndex + 1)
}
}, 500) // 步驟間暫停
}
}
typeChar()
}, [steps, totalSteps, typeSpeed, isPlaying])
// 控制播放
useEffect(() => {
if (isPlaying && displayedSteps < totalSteps) {
typeStep(displayedSteps)
}
return () => {
if (animationRef.current) {
clearTimeout(animationRef.current)
}
}
}, [isPlaying, typeStep, displayedSteps, totalSteps])
// 重置
const handleReset = () => {
if (animationRef.current) {
clearTimeout(animationRef.current)
}
setDisplayedSteps(0)
setCurrentText('')
setIsPlaying(true)
}
// 暫停/繼續
const handleTogglePlay = () => {
setIsPlaying(!isPlaying)
}
// 自動滾動到底部
useEffect(() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight
}
}, [displayedSteps, currentText])
return (
<div
className={cn(
// Nothing.tech 深色終端機風格 (全站唯一深色)
'bg-black',
'border border-gray-800',
'rounded-sm',
'overflow-hidden',
className
)}
>
{/* Terminal Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800 bg-gray-900">
<div className="flex items-center gap-3">
{/* Traffic Lights */}
<div className="flex gap-1.5">
<span className="w-3 h-3 rounded-full bg-red-500" />
<span className="w-3 h-3 rounded-full bg-yellow-500" />
<span className="w-3 h-3 rounded-full bg-green-500" />
</div>
{/* Title */}
<div className="flex items-center gap-2">
<Terminal className="w-4 h-4 text-gray-400" />
<span className="font-mono text-xs text-gray-400">
OpenClaw Terminal
</span>
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-2">
{incidentId && (
<span className="font-mono text-xs text-gray-600">
{incidentId}
</span>
)}
<button
onClick={handleTogglePlay}
className="p-1 hover:bg-gray-800 rounded transition-colors"
>
{isPlaying ? (
<Pause className="w-4 h-4 text-gray-400" />
) : (
<Play className="w-4 h-4 text-gray-400" />
)}
</button>
<button
onClick={handleReset}
className="p-1 hover:bg-gray-800 rounded transition-colors"
>
<RotateCcw className="w-4 h-4 text-gray-400" />
</button>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-gray-800 rounded transition-colors"
>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-gray-400" />
) : (
<ChevronDown className="w-4 h-4 text-gray-400" />
)}
</button>
</div>
</div>
{/* Terminal Body */}
{isExpanded && (
<div
ref={containerRef}
className="p-4 overflow-y-auto font-mono text-sm"
style={{ maxHeight, minHeight: '150px' }}
>
{/* No Data State */}
{!decisionChain && (
<div className="text-gray-500">
<span className="text-green-500">$</span> {t('waitingForData')}
<span className="animate-pulse ml-1">_</span>
</div>
)}
{/* Analysis Header */}
{decisionChain && (
<>
<div className="text-gray-500 mb-2">
<span className="text-green-500">$</span> openclaw analyze --type{' '}
<span className="text-cyan-400">
{decisionChain.analysis_type}
</span>{' '}
--target{' '}
<span className="text-yellow-400">
{decisionChain.target_service}
</span>
</div>
<div className="text-gray-600 mb-4">
{'─'.repeat(50)}
</div>
</>
)}
{/* Displayed Steps */}
{steps.slice(0, displayedSteps).map((step, index) => (
<ThinkingStepLine
key={index}
step={step}
isLast={index === displayedSteps - 1 && !currentText}
/>
))}
{/* Currently Typing */}
{currentText && (
<div className="text-gray-300 whitespace-pre-wrap">
{currentText}
<span className="animate-pulse text-white">_</span>
</div>
)}
{/* Conclusion */}
{displayedSteps >= totalSteps && decisionChain && (
<div className="mt-4 pt-4 border-t border-gray-800">
<div className="text-gray-500 mb-2">
{'─'.repeat(50)}
</div>
<div className="flex items-center gap-2 mb-2">
<span className="text-green-500">[CONCLUSION]</span>
<span className="text-green-400/50">
confidence: {(decisionChain.confidence * 100).toFixed(0)}%
</span>
</div>
<div className="text-green-400 pl-4">
{decisionChain.conclusion}
</div>
</div>
)}
{/* Cursor when idle */}
{!isPlaying && displayedSteps >= totalSteps && !currentText && (
<div className="mt-4 text-gray-500">
<span className="text-green-500">$</span>
<span className="animate-pulse ml-1">_</span>
</div>
)}
</div>
)}
{/* Terminal Footer */}
<div className="flex items-center justify-between px-4 py-2 border-t border-gray-800 bg-gray-900/50">
<span className="font-mono text-xs text-gray-600">
{t('steps')}: {displayedSteps}/{totalSteps}
</span>
<span className="font-mono text-xs text-gray-600">
{isPlaying ? t('streaming') : t('paused')}
</span>
</div>
</div>
)
}
// =============================================================================
// Sub-Components
// =============================================================================
interface ThinkingStepLineProps {
step: ReasoningStep
isLast: boolean
}
function ThinkingStepLine({ step, isLast }: ThinkingStepLineProps) {
// 根據步驟類型決定顏色
const getStepColor = (stepType: string): string => {
const lowerStep = stepType.toLowerCase()
if (lowerStep.includes('error') || lowerStep.includes('critical')) {
return 'text-red-400'
}
if (lowerStep.includes('warning') || lowerStep.includes('risk')) {
return 'text-yellow-400'
}
if (lowerStep.includes('success') || lowerStep.includes('complete')) {
return 'text-green-400'
}
if (lowerStep.includes('analysis') || lowerStep.includes('evaluate')) {
return 'text-cyan-400'
}
if (lowerStep.includes('action') || lowerStep.includes('execute')) {
return 'text-purple-400'
}
return 'text-blue-400'
}
return (
<div className={cn('mb-3', isLast && 'animate-fade-in')}>
<div className="flex items-start gap-2">
<span className={cn('font-bold', getStepColor(step.step))}>
[{step.step}]
</span>
</div>
<div className="text-gray-300 pl-4 whitespace-pre-wrap leading-relaxed">
{step.reasoning}
</div>
{step.timestamp && (
<div className="text-gray-600 text-xs pl-4 mt-1">
@ {step.timestamp}
</div>
)}
</div>
)
}
// =============================================================================
// Demo Data (for testing - will be removed in production)
// =============================================================================
export const DEMO_DECISION_CHAIN: DecisionChain = {
analysis_type: 'blast_radius',
target_service: 'api-gateway',
reasoning_steps: [
{
step: 'SIGNAL_RECEIVED',
reasoning: 'Received PodCrashLoopBackOff alert for api-gateway in production namespace.',
timestamp: '2026-03-22T16:41:18Z',
},
{
step: 'SEVERITY_EVALUATION',
reasoning: 'Alert severity: CRITICAL (P0). Requires immediate attention.',
},
{
step: 'BLAST_RADIUS_ANALYSIS',
reasoning: 'Analyzing upstream dependencies...\n- payment-service: AFFECTED\n- order-service: AFFECTED\n- user-service: AFFECTED\nTotal impact: 3 services, ~2000 requests/min',
},
{
step: 'ROOT_CAUSE_HYPOTHESIS',
reasoning: 'Possible causes:\n1. OOMKilled (memory limit exceeded)\n2. Liveness probe failure\n3. Application panic/crash',
},
{
step: 'ACTION_RECOMMENDATION',
reasoning: 'Recommended action: Restart deployment api-gateway\nRisk level: CRITICAL (requires 2 signatures)\nEstimated downtime: 5-15 min',
},
],
conclusion: 'Generate proposal: Restart deployment api-gateway with multi-sig approval.',
confidence: 0.87,
}
// =============================================================================
// Exports
// =============================================================================
export type { DecisionChain, ReasoningStep, ThinkingTerminalProps }