- 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>
392 lines
12 KiB
TypeScript
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 }
|