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>
This commit is contained in:
123
apps/web/src/components/ai/ai-command-panel.tsx
Normal file
123
apps/web/src/components/ai/ai-command-panel.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AICommandPanel - 戰情室右側 AI 指揮面板
|
||||
* ==========================================
|
||||
* Phase 1: OpenClaw + HITL 授權卡片整合
|
||||
*
|
||||
* Features:
|
||||
* - OpenClaw AI 視覺化 (頂部)
|
||||
* - 待授權卡片列表 (底部)
|
||||
* - 即時輪詢後端待簽核
|
||||
* - Nothing.tech 純白極簡風格
|
||||
*
|
||||
* i18n: 100% next-intl
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { OpenClawPanel, type OpenClawStatus } from './openclaw-panel'
|
||||
import { ApprovalCard } from '@/components/approval/approval-card'
|
||||
import {
|
||||
useApprovalStore,
|
||||
usePendingApprovals,
|
||||
toFrontendApproval,
|
||||
} from '@/stores/approval.store'
|
||||
import { ShieldCheck, Bell } from 'lucide-react'
|
||||
|
||||
// =============================================================================
|
||||
// Props
|
||||
// =============================================================================
|
||||
|
||||
interface AICommandPanelProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function AICommandPanel({ className }: AICommandPanelProps) {
|
||||
const t = useTranslations()
|
||||
const tApproval = useTranslations('approval')
|
||||
|
||||
// Store
|
||||
const { fetchPending, signApproval, startPolling, stopPolling } = useApprovalStore()
|
||||
const pendingApprovals = usePendingApprovals()
|
||||
|
||||
// Start polling on mount
|
||||
useEffect(() => {
|
||||
startPolling(5000) // Poll every 5 seconds
|
||||
return () => stopPolling()
|
||||
}, [startPolling, stopPolling])
|
||||
|
||||
// Handle approval
|
||||
const handleApprove = async (id: string) => {
|
||||
await signApproval(id, 'demo-user', 'War Room User', 'Approved via Command Center')
|
||||
await fetchPending()
|
||||
}
|
||||
|
||||
// Handle rejection
|
||||
const handleReject = async (id: string) => {
|
||||
// TODO: Implement rejection API
|
||||
console.log('[AICommandPanel] Reject:', id)
|
||||
await fetchPending()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-4', className)}>
|
||||
{/* OpenClaw AI Section */}
|
||||
<OpenClawPanel status="patrolling" />
|
||||
|
||||
{/* Pending Approvals Section */}
|
||||
{pendingApprovals.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-claw-blue" />
|
||||
<span className="font-mono text-xs text-nothing-gray-600">
|
||||
{tApproval('pendingApprovals')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Bell className="w-3 h-3 text-status-warning animate-pulse" />
|
||||
<span className="font-mono text-xs font-bold text-status-warning">
|
||||
{pendingApprovals.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Approval Cards */}
|
||||
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-1 scrollbar-thin">
|
||||
{pendingApprovals.map((approval) => {
|
||||
const frontendApproval = toFrontendApproval(approval)
|
||||
return (
|
||||
<ApprovalCard
|
||||
key={approval.id}
|
||||
request={frontendApproval}
|
||||
onApprove={handleApprove}
|
||||
onReject={handleReject}
|
||||
holdDuration={approval.risk_level === 'critical' ? 2000 : 1000}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State - No pending approvals */}
|
||||
{pendingApprovals.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<ShieldCheck className="w-8 h-8 text-nothing-gray-300 mb-2" />
|
||||
<p className="font-mono text-xs text-nothing-gray-400">
|
||||
{tApproval('noApprovals')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AICommandPanel
|
||||
248
apps/web/src/components/ai/ai-thinking-panel.tsx
Normal file
248
apps/web/src/components/ai/ai-thinking-panel.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AIThinkingPanel - OpenClaw AI 思考狀態面板
|
||||
* ==========================================
|
||||
* Phase 1: 視覺靈魂注入 (Frontend AI UX Integration)
|
||||
*
|
||||
* Features:
|
||||
* - 思維紫 (--status-thinking) 色彩
|
||||
* - 點陣字體打字機特效
|
||||
* - 脈衝呼吸燈動畫
|
||||
* - Slide Up 動畫過渡到 ApprovalCard
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Brain, Sparkles, Zap, Shield, AlertTriangle } from 'lucide-react'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type ThinkingPhase =
|
||||
| 'intercepting' // [SYS] 攔截異常...
|
||||
| 'analyzing' // OpenClaw 正在分析爆炸半徑...
|
||||
| 'calculating' // 計算風險矩陣...
|
||||
| 'generating' // 生成修復建議...
|
||||
| 'complete' // 分析完成
|
||||
|
||||
export interface AIThinkingPanelProps {
|
||||
isActive: boolean
|
||||
phase?: ThinkingPhase
|
||||
alertType?: string
|
||||
onComplete?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Typewriter Messages (i18n keys or fallback)
|
||||
// =============================================================================
|
||||
|
||||
const PHASE_MESSAGES: Record<ThinkingPhase, string> = {
|
||||
intercepting: '[SYS] 攔截異常訊號...',
|
||||
analyzing: 'OpenClaw 正在分析爆炸半徑...',
|
||||
calculating: '計算風險矩陣與簽核門檻...',
|
||||
generating: '生成修復腳本建議...',
|
||||
complete: '分析完成,待簽核卡片已建立',
|
||||
}
|
||||
|
||||
const PHASE_ICONS: Record<ThinkingPhase, typeof Brain> = {
|
||||
intercepting: AlertTriangle,
|
||||
analyzing: Brain,
|
||||
calculating: Zap,
|
||||
generating: Shield,
|
||||
complete: Sparkles,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function AIThinkingPanel({
|
||||
isActive,
|
||||
phase = 'intercepting',
|
||||
alertType,
|
||||
onComplete,
|
||||
className,
|
||||
}: AIThinkingPanelProps) {
|
||||
const t = useTranslations('ai')
|
||||
const [displayText, setDisplayText] = useState('')
|
||||
const [cursorVisible, setCursorVisible] = useState(true)
|
||||
const [currentPhase, setCurrentPhase] = useState<ThinkingPhase>('intercepting')
|
||||
|
||||
// Typewriter effect
|
||||
const typeText = useCallback((text: string, callback?: () => void) => {
|
||||
let index = 0
|
||||
setDisplayText('')
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (index < text.length) {
|
||||
setDisplayText(text.slice(0, index + 1))
|
||||
index++
|
||||
} else {
|
||||
clearInterval(interval)
|
||||
callback?.()
|
||||
}
|
||||
}, 50) // 50ms per character
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Phase progression
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
setCurrentPhase('intercepting')
|
||||
setDisplayText('')
|
||||
return
|
||||
}
|
||||
|
||||
const phases: ThinkingPhase[] = ['intercepting', 'analyzing', 'calculating', 'generating', 'complete']
|
||||
let phaseIndex = 0
|
||||
|
||||
const progressPhase = () => {
|
||||
if (phaseIndex < phases.length) {
|
||||
const currentPhase = phases[phaseIndex]
|
||||
setCurrentPhase(currentPhase)
|
||||
|
||||
const cleanup = typeText(PHASE_MESSAGES[currentPhase], () => {
|
||||
phaseIndex++
|
||||
if (phaseIndex < phases.length) {
|
||||
setTimeout(progressPhase, 800) // Wait before next phase
|
||||
} else {
|
||||
onComplete?.()
|
||||
}
|
||||
})
|
||||
|
||||
return cleanup
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = progressPhase()
|
||||
return () => cleanup?.()
|
||||
}, [isActive, typeText, onComplete])
|
||||
|
||||
// Cursor blink
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCursorVisible((v) => !v)
|
||||
}, 500)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
if (!isActive) return null
|
||||
|
||||
const PhaseIcon = PHASE_ICONS[currentPhase]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-xl',
|
||||
'bg-gradient-to-br from-status-thinking/5 to-status-thinking/10',
|
||||
'border border-status-thinking/20',
|
||||
'p-6',
|
||||
'animate-fade-in',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Animated scan line */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute h-[2px] w-full bg-gradient-to-r from-transparent via-status-thinking/50 to-transparent animate-scan" />
|
||||
</div>
|
||||
|
||||
{/* Sparkle decorations */}
|
||||
<div className="absolute top-3 right-3">
|
||||
<Sparkles className="w-5 h-5 text-status-thinking/40 animate-pulse" />
|
||||
</div>
|
||||
<div className="absolute bottom-3 left-3">
|
||||
<Sparkles className="w-4 h-4 text-status-thinking/30 animate-pulse" style={{ animationDelay: '0.5s' }} />
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Brain icon with glow */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className={cn(
|
||||
'w-14 h-14 rounded-xl flex items-center justify-center',
|
||||
'bg-gradient-to-br from-status-thinking/20 to-status-thinking/30',
|
||||
'border border-status-thinking/30',
|
||||
'shadow-glow-purple'
|
||||
)}>
|
||||
<PhaseIcon className={cn(
|
||||
'w-7 h-7 text-status-thinking',
|
||||
currentPhase !== 'complete' && 'animate-pulse'
|
||||
)} />
|
||||
</div>
|
||||
{/* Breathing indicator */}
|
||||
<div className="absolute -bottom-1 -right-1 w-4 h-4">
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-status-thinking opacity-75 animate-ping" />
|
||||
<span className="relative inline-flex rounded-full h-4 w-4 bg-status-thinking" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xs font-mono text-status-thinking uppercase tracking-widest">
|
||||
OpenClaw AI
|
||||
</span>
|
||||
{alertType && (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono bg-status-thinking/10 text-status-thinking border border-status-thinking/20">
|
||||
{alertType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Typewriter text - Dot Matrix Style */}
|
||||
<div className="font-mono text-lg text-nothing-gray-800 tracking-wide">
|
||||
<span className="whitespace-pre-wrap">{displayText}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block w-2 h-5 bg-status-thinking ml-1 -mb-0.5',
|
||||
cursorVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Progress dots */}
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
{(['intercepting', 'analyzing', 'calculating', 'generating', 'complete'] as ThinkingPhase[]).map((p, i) => (
|
||||
<div
|
||||
key={p}
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full transition-all duration-300',
|
||||
currentPhase === p
|
||||
? 'bg-status-thinking scale-125 shadow-glow-purple'
|
||||
: i < ['intercepting', 'analyzing', 'calculating', 'generating', 'complete'].indexOf(currentPhase)
|
||||
? 'bg-status-thinking/50'
|
||||
: 'bg-nothing-gray-300'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom status bar */}
|
||||
<div className="mt-4 pt-4 border-t border-status-thinking/10">
|
||||
<div className="flex items-center justify-between text-xs font-mono text-nothing-gray-500">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-status-thinking animate-pulse" />
|
||||
{currentPhase === 'complete' ? t('analysisComplete') : t('processingAlert')}
|
||||
</span>
|
||||
<span className="text-status-thinking">
|
||||
AI Fallback: Ollama → Gemini → Claude
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Export
|
||||
// =============================================================================
|
||||
|
||||
export default AIThinkingPanel
|
||||
439
apps/web/src/components/ai/clawbot-panel.tsx
Normal file
439
apps/web/src/components/ai/clawbot-panel.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* OpenClawPanel - 賽博維運風格 AI 面板
|
||||
* =====================================
|
||||
* Phase 1: 視覺靈魂注入 (Cyber-Shell Visual Identity)
|
||||
*
|
||||
* Features:
|
||||
* - 3D 骨架機械爪視覺化 (CSS Art)
|
||||
* - 核心藍色 LED 脈衝動畫
|
||||
* - 點陣字體狀態顯示
|
||||
* - AI 思考流過渡動畫
|
||||
* - 高通透度 awoooi-glass 效果
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type OpenClawStatus =
|
||||
| 'patrolling' // [AGENT] patrolling...
|
||||
| 'intercepting' // [SYS] 攔截異常...
|
||||
| 'analyzing' // [SYS] Analyzing blast radius...
|
||||
| 'generating' // [SYS] Generating proposed action...
|
||||
| 'complete' // [SYS] Analysis complete
|
||||
|
||||
export interface OpenClawPanelProps {
|
||||
status?: OpenClawStatus
|
||||
alertType?: string
|
||||
onAnalysisComplete?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Status Messages (Dot Matrix Style)
|
||||
// =============================================================================
|
||||
|
||||
const STATUS_MESSAGES: Record<OpenClawStatus, string> = {
|
||||
patrolling: '[AGENT] patrolling...',
|
||||
intercepting: '[SYS] Intercepting anomaly...',
|
||||
analyzing: '[SYS] Analyzing blast radius...',
|
||||
generating: '[SYS] Generating proposed action...',
|
||||
complete: '[SYS] Analysis complete',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NemoClaw 3D Ceramic SVG Component (Lab-White Style)
|
||||
// =============================================================================
|
||||
|
||||
function NemoClaw({ isActive, isPulsing }: { isActive: boolean; isPulsing: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 140 140"
|
||||
className="w-full h-full drop-shadow-lg"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
{/* 3D Ceramic gradient - white/cream tones */}
|
||||
<linearGradient id="ceramic3d" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#FFFFFF" />
|
||||
<stop offset="40%" stopColor="#F8F8F8" />
|
||||
<stop offset="70%" stopColor="#E8E8E8" />
|
||||
<stop offset="100%" stopColor="#D8D8D8" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Core glow filter - stronger */}
|
||||
<filter id="coreGlow" x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feGaussianBlur stdDeviation="6" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
|
||||
{/* Pulse glow animation filter */}
|
||||
<filter id="pulseGlow" x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feGaussianBlur stdDeviation="8" result="blur">
|
||||
<animate attributeName="stdDeviation" values="6;10;6" dur="1.5s" repeatCount="indefinite" />
|
||||
</feGaussianBlur>
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
|
||||
{/* Shadow for 3D effect */}
|
||||
<filter id="shadow3d" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="2" dy="4" stdDeviation="3" floodOpacity="0.15" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Base shadow */}
|
||||
<ellipse cx="70" cy="125" rx="35" ry="8" fill="rgba(0,0,0,0.08)" />
|
||||
|
||||
{/* Main body - 3D ceramic sphere */}
|
||||
<circle
|
||||
cx="70"
|
||||
cy="70"
|
||||
r="32"
|
||||
fill="url(#ceramic3d)"
|
||||
filter="url(#shadow3d)"
|
||||
stroke="#E0E0E0"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{/* Inner ring - depth effect */}
|
||||
<circle
|
||||
cx="70"
|
||||
cy="70"
|
||||
r="26"
|
||||
fill="none"
|
||||
stroke="#D0D0D0"
|
||||
strokeWidth="1"
|
||||
opacity="0.5"
|
||||
/>
|
||||
|
||||
{/* Core LED - Blue pulsing (the eye) */}
|
||||
<circle
|
||||
cx="70"
|
||||
cy="70"
|
||||
r="16"
|
||||
fill={isActive ? '#4A90D9' : '#B0B0B0'}
|
||||
filter={isPulsing ? 'url(#pulseGlow)' : 'url(#coreGlow)'}
|
||||
className="transition-all duration-500"
|
||||
>
|
||||
{isPulsing && (
|
||||
<animate
|
||||
attributeName="r"
|
||||
values="14;17;14"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
)}
|
||||
</circle>
|
||||
|
||||
{/* Core highlight */}
|
||||
<circle
|
||||
cx="70"
|
||||
cy="70"
|
||||
r="8"
|
||||
fill="white"
|
||||
opacity={isActive ? 0.9 : 0.4}
|
||||
className="transition-opacity duration-300"
|
||||
/>
|
||||
|
||||
{/* Claw arms - ceramic white 3D style */}
|
||||
{/* Top arm */}
|
||||
<g filter="url(#shadow3d)">
|
||||
<path
|
||||
d="M 70 38 L 70 18 L 58 6 M 70 18 L 82 6"
|
||||
stroke="url(#ceramic3d)"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M 70 38 L 70 18 L 58 6 M 70 18 L 82 6"
|
||||
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
/>
|
||||
{/* Claw tips */}
|
||||
<circle cx="58" cy="6" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
|
||||
<circle cx="82" cy="6" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
|
||||
</g>
|
||||
|
||||
{/* Left arm */}
|
||||
<g filter="url(#shadow3d)">
|
||||
<path
|
||||
d="M 38 70 L 18 70 L 6 58 M 18 70 L 6 82"
|
||||
stroke="url(#ceramic3d)"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M 38 70 L 18 70 L 6 58 M 18 70 L 6 82"
|
||||
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<circle cx="6" cy="58" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
|
||||
<circle cx="6" cy="82" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
|
||||
</g>
|
||||
|
||||
{/* Right arm */}
|
||||
<g filter="url(#shadow3d)">
|
||||
<path
|
||||
d="M 102 70 L 122 70 L 134 58 M 122 70 L 134 82"
|
||||
stroke="url(#ceramic3d)"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M 102 70 L 122 70 L 134 58 M 122 70 L 134 82"
|
||||
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<circle cx="134" cy="58" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
|
||||
<circle cx="134" cy="82" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
|
||||
</g>
|
||||
|
||||
{/* Bottom left arm */}
|
||||
<g filter="url(#shadow3d)">
|
||||
<path
|
||||
d="M 48 92 L 28 112 L 16 116"
|
||||
stroke="url(#ceramic3d)"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M 48 92 L 28 112 L 16 116"
|
||||
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<circle cx="16" cy="116" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
|
||||
</g>
|
||||
|
||||
{/* Bottom right arm */}
|
||||
<g filter="url(#shadow3d)">
|
||||
<path
|
||||
d="M 92 92 L 112 112 L 124 116"
|
||||
stroke="url(#ceramic3d)"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M 92 92 L 112 112 L 124 116"
|
||||
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<circle cx="124" cy="116" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
|
||||
</g>
|
||||
|
||||
{/* Orbit ring when active */}
|
||||
{isActive && (
|
||||
<circle
|
||||
cx="70"
|
||||
cy="70"
|
||||
r="42"
|
||||
fill="none"
|
||||
stroke="#4A90D9"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="6 6"
|
||||
opacity="0.4"
|
||||
className="animate-spin"
|
||||
style={{ animationDuration: '8s', transformOrigin: '70px 70px' }}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Typewriter Hook
|
||||
// =============================================================================
|
||||
|
||||
function useTypewriter(text: string, speed: number = 50) {
|
||||
const [displayText, setDisplayText] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let index = 0
|
||||
setDisplayText('')
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (index < text.length) {
|
||||
setDisplayText(text.slice(0, index + 1))
|
||||
index++
|
||||
} else {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, speed)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [text, speed])
|
||||
|
||||
return displayText
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function OpenClawPanel({
|
||||
status = 'patrolling',
|
||||
alertType,
|
||||
onAnalysisComplete,
|
||||
className,
|
||||
}: OpenClawPanelProps) {
|
||||
const t = useTranslations('ai')
|
||||
const [cursorVisible, setCursorVisible] = useState(true)
|
||||
|
||||
const isActive = status !== 'patrolling'
|
||||
const isPulsing = status === 'intercepting' || status === 'analyzing'
|
||||
|
||||
const statusMessage = STATUS_MESSAGES[status]
|
||||
const displayText = useTypewriter(statusMessage, 40)
|
||||
|
||||
// Cursor blink
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCursorVisible((v) => !v)
|
||||
}, 500)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Notify when complete
|
||||
useEffect(() => {
|
||||
if (status === 'complete') {
|
||||
const timeout = setTimeout(() => {
|
||||
onAnalysisComplete?.()
|
||||
}, 1000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [status, onAnalysisComplete])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-xl',
|
||||
// awoooi-glass effect
|
||||
'bg-white/70 backdrop-blur-[20px]',
|
||||
// 骨架細框線
|
||||
'border border-nothing-gray-200/50',
|
||||
// 輕柔陰影
|
||||
'shadow-[0_4px_24px_rgba(0,0,0,0.06)]',
|
||||
'p-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Scan line animation when active */}
|
||||
{isActive && (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute h-[1px] w-full bg-gradient-to-r from-transparent via-claw-blue/50 to-transparent animate-scan" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header - Dot Matrix Style */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-dot-matrix text-sm text-nothing-gray-700 tracking-wider">
|
||||
AWOOOI v1.0.0
|
||||
</span>
|
||||
<span className="font-dot-matrix text-xs text-nothing-gray-400">
|
||||
| Production
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="w-2 h-2 rounded-full bg-claw-blue animate-ping" />
|
||||
)}
|
||||
</div>
|
||||
{alertType && (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-dot-matrix bg-status-critical/10 text-status-critical border border-status-critical/20">
|
||||
{alertType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* NemoClaw 3D Ceramic Visualization */}
|
||||
<div className="relative w-32 h-32 mx-auto mb-4">
|
||||
<NemoClaw isActive={isActive} isPulsing={isPulsing} />
|
||||
|
||||
{/* Sparkle effects when active */}
|
||||
{isActive && (
|
||||
<>
|
||||
<Sparkles className="absolute -top-1 -right-1 w-4 h-4 text-claw-blue/60 animate-pulse" />
|
||||
<Sparkles className="absolute -bottom-1 -left-1 w-3 h-3 text-claw-blue/40 animate-pulse" style={{ animationDelay: '0.3s' }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Display - VT323 Dot Matrix Font */}
|
||||
<div className="text-center bg-nothing-gray-50/50 rounded-lg py-2 px-3">
|
||||
<div className={cn(
|
||||
'font-dot-matrix text-base tracking-wide',
|
||||
isActive ? 'text-claw-blue' : 'text-nothing-gray-600'
|
||||
)}>
|
||||
<span>{displayText}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block w-2 h-4 ml-1 -mb-0.5',
|
||||
isActive ? 'bg-claw-blue' : 'bg-nothing-gray-400',
|
||||
cursorVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator when analyzing */}
|
||||
{(status === 'analyzing' || status === 'generating') && (
|
||||
<div className="mt-3 flex justify-center gap-1">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1.5 h-1.5 rounded-full bg-claw-blue animate-pulse"
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Complete indicator */}
|
||||
{status === 'complete' && (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<span className="px-2 py-0.5 rounded text-[9px] font-mono bg-status-healthy/10 text-status-healthy border border-status-healthy/20">
|
||||
READY
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OpenClawPanel
|
||||
558
apps/web/src/components/ai/clawbot-state-machine.tsx
Normal file
558
apps/web/src/components/ai/clawbot-state-machine.tsx
Normal file
@@ -0,0 +1,558 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* OpenClawStateMachine - 戰情室 AI 狀態機整合
|
||||
* ==========================================
|
||||
* Phase 2: 真實 API 數據整合 (禁止 Mock)
|
||||
*
|
||||
* Features:
|
||||
* - 三態狀態機 (idle / thinking / awaiting_approval)
|
||||
* - 真實 API 輪詢 (/api/v1/approvals/pending)
|
||||
* - ThinkingStream 打字機動畫
|
||||
* - ApprovalCard 滑入動畫
|
||||
* - 記憶體安全清理
|
||||
*
|
||||
* 真實性條款: 禁止任何 Mock Data
|
||||
* i18n: 100% next-intl
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { OpenClawPanel, type OpenClawStatus } from './openclaw-panel'
|
||||
import { ThinkingStream, DEFAULT_THINKING_MESSAGES } from './thinking-stream'
|
||||
import { ApprovalCard, type ApprovalRequest } from '@/components/approval/approval-card'
|
||||
import { RefreshCw, AlertCircle, CheckCircle2, Clock, Archive } from 'lucide-react'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { useTimelineStore, useStartSmartPolling } from '@/stores/timeline.store'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type MachineState = 'idle' | 'thinking' | 'awaiting_approval'
|
||||
|
||||
export interface OpenClawStateMachineProps {
|
||||
/** 啟用 Demo 模式 (僅供開發測試) */
|
||||
demoMode?: boolean
|
||||
/** API 輪詢間隔 (ms) */
|
||||
pollInterval?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface PendingApprovalsResponse {
|
||||
count: number
|
||||
approvals: ApprovalRequest[]
|
||||
}
|
||||
|
||||
// 歷史紀錄擴展類型
|
||||
interface HistoryApproval extends ApprovalRequest {
|
||||
status: 'approved' | 'rejected' | 'executed' | 'execution_failed'
|
||||
resolvedAt?: string
|
||||
}
|
||||
|
||||
type TabType = 'pending' | 'history'
|
||||
|
||||
// =============================================================================
|
||||
// API Helper
|
||||
// =============================================================================
|
||||
|
||||
const getApiBaseUrl = (): string => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
// 統帥鐵律: 禁止任何 Fallback IP
|
||||
const url = process.env.NEXT_PUBLIC_API_URL
|
||||
if (!url) {
|
||||
console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL')
|
||||
return ''
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// API response uses snake_case, frontend uses camelCase
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function transformApiResponse(apiApproval: any): ApprovalRequest {
|
||||
return {
|
||||
id: apiApproval.id,
|
||||
action: apiApproval.action,
|
||||
description: apiApproval.description,
|
||||
riskLevel: apiApproval.risk_level,
|
||||
blastRadius: {
|
||||
affectedPods: apiApproval.blast_radius?.affected_pods ?? 0,
|
||||
estimatedDowntime: apiApproval.blast_radius?.estimated_downtime ?? '0',
|
||||
relatedServices: apiApproval.blast_radius?.related_services ?? [],
|
||||
dataImpact: ((apiApproval.blast_radius?.data_impact ?? 'none').toUpperCase()) as 'NONE' | 'READ_ONLY' | 'WRITE' | 'DESTRUCTIVE',
|
||||
},
|
||||
dryRunChecks: apiApproval.dry_run_checks ?? [],
|
||||
requiredSignatures: apiApproval.required_signatures ?? 2,
|
||||
currentSignatures: apiApproval.current_signatures ?? 0,
|
||||
requestedBy: apiApproval.requested_by ?? 'OpenClaw',
|
||||
requestedAt: apiApproval.created_at ?? new Date().toISOString(),
|
||||
hitCount: apiApproval.hit_count,
|
||||
lastSeenAt: apiApproval.last_seen_at,
|
||||
fingerprint: apiApproval.fingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function OpenClawStateMachine({
|
||||
demoMode = false,
|
||||
pollInterval = 5000,
|
||||
className,
|
||||
}: OpenClawStateMachineProps) {
|
||||
const t = useTranslations()
|
||||
|
||||
// State
|
||||
const [machineState, setMachineState] = useState<MachineState>('idle')
|
||||
const [openclawStatus, setClawbotStatus] = useState<OpenClawStatus>('patrolling')
|
||||
const [pendingApprovals, setPendingApprovals] = useState<ApprovalRequest[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastFetch, setLastFetch] = useState<Date | null>(null)
|
||||
|
||||
// 歷史紀錄狀態
|
||||
const [activeTab, setActiveTab] = useState<TabType>('pending')
|
||||
const [historyApprovals, setHistoryApprovals] = useState<HistoryApproval[]>([])
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false)
|
||||
|
||||
// Timer refs for cleanup
|
||||
const pollTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// ==========================================================================
|
||||
// API: Fetch Pending Approvals
|
||||
// ==========================================================================
|
||||
const fetchPendingApprovals = useCallback(async () => {
|
||||
const apiBaseUrl = getApiBaseUrl()
|
||||
if (!apiBaseUrl) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1/approvals/pending`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status}`)
|
||||
}
|
||||
|
||||
const rawData = await response.json()
|
||||
const data: PendingApprovalsResponse = {
|
||||
count: rawData.count,
|
||||
approvals: (rawData.approvals ?? []).map(transformApiResponse),
|
||||
}
|
||||
setPendingApprovals(data.approvals)
|
||||
setLastFetch(new Date())
|
||||
|
||||
// Update machine state based on approvals
|
||||
if (data.count > 0) {
|
||||
setMachineState('awaiting_approval')
|
||||
setClawbotStatus('complete')
|
||||
} else {
|
||||
setMachineState('idle')
|
||||
setClawbotStatus('patrolling')
|
||||
}
|
||||
|
||||
console.log('[OpenClaw] Fetched approvals:', data.count)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
setError(message)
|
||||
console.error('[OpenClaw] Fetch error:', message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ==========================================================================
|
||||
// API: Fetch History Approvals (歷史歸檔)
|
||||
// ==========================================================================
|
||||
const fetchHistoryApprovals = useCallback(async () => {
|
||||
const apiBaseUrl = getApiBaseUrl()
|
||||
if (!apiBaseUrl) return
|
||||
|
||||
setIsLoadingHistory(true)
|
||||
|
||||
try {
|
||||
// 取得已處理的歷史紀錄
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1/approvals?status=approved,executed,rejected,execution_failed&limit=20`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status}`)
|
||||
}
|
||||
|
||||
const rawData = await response.json()
|
||||
const items = (rawData.items ?? rawData.approvals ?? []).map((item: any) => ({
|
||||
...transformApiResponse(item),
|
||||
status: item.status,
|
||||
resolvedAt: item.resolved_at,
|
||||
}))
|
||||
|
||||
setHistoryApprovals(items)
|
||||
console.log('[OpenClaw] Fetched history:', items.length)
|
||||
} catch (err) {
|
||||
console.error('[OpenClaw] History fetch error:', err)
|
||||
} finally {
|
||||
setIsLoadingHistory(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 當切換到歷史標籤時自動載入
|
||||
useEffect(() => {
|
||||
if (activeTab === 'history') {
|
||||
fetchHistoryApprovals()
|
||||
}
|
||||
}, [activeTab, fetchHistoryApprovals])
|
||||
|
||||
// ==========================================================================
|
||||
// Timeline Refresh + Smart Polling
|
||||
// ==========================================================================
|
||||
const fetchTimeline = useTimelineStore((s) => s.fetchEvents)
|
||||
const startSmartPolling = useTimelineStore((s) => s.startSmartPolling)
|
||||
|
||||
// ==========================================================================
|
||||
// Signer rotation for Multi-Sig
|
||||
// ==========================================================================
|
||||
const signerCounter = useRef(0)
|
||||
const SIGNERS = [
|
||||
{ id: 'cto-ogt', name: '統帥 (CTO)' },
|
||||
{ id: 'ciso-security', name: '資安長 (CISO)' },
|
||||
{ id: 'cpo-product', name: '產品長 (CPO)' },
|
||||
]
|
||||
|
||||
// ==========================================================================
|
||||
// API: Submit Signature (with Toast & Timeline Refresh)
|
||||
// ==========================================================================
|
||||
const handleApprove = useCallback(async (approvalId: string) => {
|
||||
const apiBaseUrl = getApiBaseUrl()
|
||||
if (!apiBaseUrl) return
|
||||
|
||||
// Show loading toast
|
||||
const loadingId = toast.loading('[AWOOOI] 統帥已授權,K8s 指令發送中...')
|
||||
|
||||
try {
|
||||
// Rotate through signers for Multi-Sig
|
||||
const signer = SIGNERS[signerCounter.current % SIGNERS.length]
|
||||
signerCounter.current++
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1/approvals/${approvalId}/sign`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
signer_id: signer.id,
|
||||
signer_name: signer.name,
|
||||
comment: 'Approved via AWOOOI Dashboard',
|
||||
}),
|
||||
})
|
||||
|
||||
// Dismiss loading toast
|
||||
if (loadingId) toast.dismiss(loadingId)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Sign failed: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log('[OpenClaw] Approval signed:', approvalId, result)
|
||||
|
||||
// Show success toast
|
||||
if (result.execution_triggered) {
|
||||
toast.success(`[AWOOOI] 簽核完成!K8s Executor 已啟動執行`)
|
||||
// 執行觸發後的 Toast
|
||||
toast.info('[AWOOOI] K8s 執行完成,正在更新戰情時間軸...')
|
||||
// 啟動 Smart Polling (每秒輪詢直到看到 EXEC 事件)
|
||||
startSmartPolling()
|
||||
} else {
|
||||
toast.info(`[AWOOOI] ${signer.name} 簽核成功 (${result.approval?.current_signatures}/${result.approval?.required_signatures})`)
|
||||
// 非最終簽核也刷新 Timeline
|
||||
fetchTimeline()
|
||||
}
|
||||
|
||||
// Refresh approvals list
|
||||
await fetchPendingApprovals()
|
||||
|
||||
} catch (err) {
|
||||
if (loadingId) toast.dismiss(loadingId)
|
||||
console.error('[OpenClaw] Sign error:', err)
|
||||
const errorMsg = err instanceof Error ? err.message : 'Sign failed'
|
||||
setError(errorMsg)
|
||||
toast.error(`[AWOOOI] 簽核失敗: ${errorMsg}`)
|
||||
}
|
||||
}, [fetchPendingApprovals, fetchTimeline, startSmartPolling])
|
||||
|
||||
// ==========================================================================
|
||||
// API: Reject Request (with Toast & Timeline Refresh)
|
||||
// ==========================================================================
|
||||
const handleReject = useCallback(async (approvalId: string) => {
|
||||
const apiBaseUrl = getApiBaseUrl()
|
||||
if (!apiBaseUrl) return
|
||||
|
||||
const loadingId = toast.loading('[AWOOOI] 拒絕請求處理中...')
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1/approvals/${approvalId}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
rejector_id: 'cto-ogt',
|
||||
rejector_name: '統帥 (CTO)',
|
||||
reason: 'Rejected via AWOOOI Dashboard',
|
||||
}),
|
||||
})
|
||||
|
||||
if (loadingId) toast.dismiss(loadingId)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Reject failed: ${response.status}`)
|
||||
}
|
||||
|
||||
console.log('[OpenClaw] Approval rejected:', approvalId)
|
||||
toast.success('[AWOOOI] 請求已拒絕')
|
||||
|
||||
// Refresh approvals list
|
||||
await fetchPendingApprovals()
|
||||
|
||||
// Refresh Timeline (拒絕不需要 Smart Polling)
|
||||
fetchTimeline()
|
||||
|
||||
} catch (err) {
|
||||
if (loadingId) toast.dismiss(loadingId)
|
||||
console.error('[OpenClaw] Reject error:', err)
|
||||
const errorMsg = err instanceof Error ? err.message : 'Reject failed'
|
||||
setError(errorMsg)
|
||||
toast.error(`[AWOOOI] 拒絕失敗: ${errorMsg}`)
|
||||
}
|
||||
}, [fetchPendingApprovals, fetchTimeline])
|
||||
|
||||
// ==========================================================================
|
||||
// Polling Effect
|
||||
// ==========================================================================
|
||||
useEffect(() => {
|
||||
// Skip polling in demo mode
|
||||
if (demoMode) return
|
||||
|
||||
// Initial fetch
|
||||
fetchPendingApprovals()
|
||||
|
||||
// Start polling
|
||||
pollTimerRef.current = setInterval(fetchPendingApprovals, pollInterval)
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current)
|
||||
pollTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [demoMode, pollInterval, fetchPendingApprovals])
|
||||
|
||||
// ==========================================================================
|
||||
// Render
|
||||
// ==========================================================================
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Status Bar */}
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-nothing-gray-500">
|
||||
STATE:
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 rounded text-[10px] font-mono font-bold uppercase',
|
||||
machineState === 'idle' && 'bg-nothing-gray-100 text-nothing-gray-600',
|
||||
machineState === 'thinking' && 'bg-status-thinking/10 text-status-thinking',
|
||||
machineState === 'awaiting_approval' && 'bg-status-warning/10 text-status-warning'
|
||||
)}
|
||||
>
|
||||
{machineState}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Loading indicator */}
|
||||
{isLoading && (
|
||||
<RefreshCw className="w-3 h-3 text-nothing-gray-400 animate-spin" />
|
||||
)}
|
||||
|
||||
{/* Error indicator */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-1 text-status-critical">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
<span className="text-[10px] font-mono">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual refresh button */}
|
||||
<button
|
||||
onClick={fetchPendingApprovals}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2 py-1 rounded text-[10px] font-mono',
|
||||
'bg-nothing-gray-100 text-nothing-gray-600',
|
||||
'hover:bg-nothing-gray-200 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
REFRESH
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OpenClaw Visual */}
|
||||
<OpenClawPanel
|
||||
status={openclawStatus}
|
||||
alertType={pendingApprovals.length > 0 ? 'POD_CRASH' : undefined}
|
||||
/>
|
||||
|
||||
{/* Nothing.tech 風格標籤切換 */}
|
||||
<div className="flex items-center gap-1 p-1 bg-nothing-gray-50 rounded-lg border border-nothing-gray-200">
|
||||
<button
|
||||
onClick={() => setActiveTab('pending')}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-md font-mono text-xs font-semibold',
|
||||
'transition-all duration-200',
|
||||
activeTab === 'pending'
|
||||
? 'bg-white text-nothing-gray-900 shadow-sm border border-nothing-gray-200'
|
||||
: 'text-nothing-gray-500 hover:text-nothing-gray-700 hover:bg-white/50'
|
||||
)}
|
||||
>
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
待處理
|
||||
{pendingApprovals.length > 0 && (
|
||||
<span className="ml-1 px-1.5 py-0.5 bg-status-warning/10 text-status-warning rounded text-[10px]">
|
||||
{pendingApprovals.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('history')}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-md font-mono text-xs font-semibold',
|
||||
'transition-all duration-200',
|
||||
activeTab === 'history'
|
||||
? 'bg-white text-nothing-gray-900 shadow-sm border border-nothing-gray-200'
|
||||
: 'text-nothing-gray-500 hover:text-nothing-gray-700 hover:bg-white/50'
|
||||
)}
|
||||
>
|
||||
<Archive className="w-3.5 h-3.5" />
|
||||
歷史紀錄
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ========== 待處理 Tab ========== */}
|
||||
{activeTab === 'pending' && (
|
||||
<>
|
||||
{/* Pending Approvals List (REAL DATA) */}
|
||||
{pendingApprovals.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{pendingApprovals.map((approval) => (
|
||||
<div key={approval.id} className="animate-slide-in-up">
|
||||
<ApprovalCard
|
||||
request={approval}
|
||||
onApprove={() => handleApprove(approval.id)}
|
||||
onReject={() => handleReject(approval.id)}
|
||||
holdDuration={1000}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Idle state - no pending approvals */}
|
||||
{machineState === 'idle' && pendingApprovals.length === 0 && !isLoading && (
|
||||
<div className="text-center py-6 border border-dashed border-nothing-gray-200 rounded-lg">
|
||||
<CheckCircle2 className="w-8 h-8 mx-auto mb-2 text-status-healthy/50" />
|
||||
<p className="font-mono text-xs text-nothing-gray-400">
|
||||
{t('ai.standby')}
|
||||
</p>
|
||||
<p className="font-mono text-[10px] text-nothing-gray-300 mt-1">
|
||||
{lastFetch
|
||||
? `Last check: ${lastFetch.toLocaleTimeString()}`
|
||||
: 'Waiting for first fetch...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ========== 歷史紀錄 Tab ========== */}
|
||||
{activeTab === 'history' && (
|
||||
<div className="space-y-3">
|
||||
{/* Loading 狀態 */}
|
||||
{isLoadingHistory && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="w-5 h-5 text-nothing-gray-400 animate-spin" />
|
||||
<span className="ml-2 font-mono text-xs text-nothing-gray-400">載入歷史紀錄...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 歷史卡片列表 */}
|
||||
{!isLoadingHistory && historyApprovals.length > 0 && (
|
||||
<>
|
||||
{historyApprovals.map((approval) => (
|
||||
<div key={approval.id} className="opacity-80">
|
||||
<ApprovalCard
|
||||
request={approval}
|
||||
readOnly
|
||||
finalStatus={
|
||||
approval.status === 'executed' ? 'executed' :
|
||||
approval.status === 'execution_failed' ? 'failed' :
|
||||
approval.status === 'rejected' ? 'rejected' : 'approved'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 空狀態 */}
|
||||
{!isLoadingHistory && historyApprovals.length === 0 && (
|
||||
<div className="text-center py-8 border border-dashed border-nothing-gray-200 rounded-lg">
|
||||
<Archive className="w-8 h-8 mx-auto mb-2 text-nothing-gray-300" />
|
||||
<p className="font-mono text-xs text-nothing-gray-400">
|
||||
尚無歷史紀錄
|
||||
</p>
|
||||
<p className="font-mono text-[10px] text-nothing-gray-300 mt-1">
|
||||
已處理的授權請求將會顯示在這裡
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 刷新按鈕 */}
|
||||
<div className="flex justify-center pt-2">
|
||||
<button
|
||||
onClick={fetchHistoryApprovals}
|
||||
disabled={isLoadingHistory}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-lg font-mono text-xs',
|
||||
'bg-nothing-gray-50 text-nothing-gray-600 border border-nothing-gray-200',
|
||||
'hover:bg-nothing-gray-100 transition-colors',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<RefreshCw className={cn('w-3 h-3', isLoadingHistory && 'animate-spin')} />
|
||||
重新載入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Demo mode warning */}
|
||||
{demoMode && (
|
||||
<div className="bg-status-warning/10 border border-status-warning/20 rounded-lg p-3">
|
||||
<p className="font-mono text-xs text-status-warning">
|
||||
⚠️ Demo mode is enabled. Real API polling is disabled.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OpenClawStateMachine
|
||||
498
apps/web/src/components/ai/hitl-section.tsx
Normal file
498
apps/web/src/components/ai/hitl-section.tsx
Normal file
@@ -0,0 +1,498 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* HITLSection - 人機協作審批區域
|
||||
* ================================
|
||||
* Phase 1: AI 思考 → 動態卡片對接
|
||||
*
|
||||
* Features:
|
||||
* - OpenClaw AI 思考流視覺化
|
||||
* - 動態 Approval Card 渲染 (100% 後端資料)
|
||||
* - Slide Up 動畫過渡
|
||||
* - 即時輪詢後端待簽核列表
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { OpenClawPanel, type OpenClawStatus } from './openclaw-panel'
|
||||
import { ApprovalCard, type RiskLevel } from '@/components/approval/approval-card'
|
||||
import {
|
||||
useApprovalStore,
|
||||
usePendingApprovals,
|
||||
toFrontendApproval,
|
||||
} from '@/stores/approval.store'
|
||||
import { useTimelineStore } from '@/stores/timeline.store'
|
||||
import { ActionTimeline } from '@/components/timeline'
|
||||
import { GlassCard, GlassCardTitle, GlassCardContent, GlassCardHeader } from '@/components/ui/glass-card'
|
||||
import { ShieldCheck, Plus, Loader2, Lock, ShieldX, AlertTriangle } from 'lucide-react'
|
||||
|
||||
// =============================================================================
|
||||
// API Configuration (統帥鐵律: 禁止任何 Fallback IP)
|
||||
// =============================================================================
|
||||
|
||||
const getApiBaseUrl = (): string => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
const url = process.env.NEXT_PUBLIC_API_URL
|
||||
if (!url) {
|
||||
console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL')
|
||||
return ''
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl()
|
||||
|
||||
// =============================================================================
|
||||
// Phase 3: RBAC Permission System (企業護城河)
|
||||
// =============================================================================
|
||||
|
||||
type UserRole = 'viewer' | 'developer' | 'devops' | 'admin' | 'cto' | 'ciso' | 'ceo'
|
||||
|
||||
const REQUIRED_ROLE_FOR_RISK: Record<RiskLevel, UserRole[]> = {
|
||||
low: ['developer', 'devops', 'admin', 'cto', 'ciso', 'ceo'],
|
||||
medium: ['devops', 'admin', 'cto', 'ciso', 'ceo'],
|
||||
high: ['admin', 'cto', 'ciso', 'ceo'],
|
||||
critical: ['cto', 'ciso', 'ceo'], // Critical 需要 CTO/CISO 等級
|
||||
}
|
||||
|
||||
function canSignApproval(userRole: UserRole, riskLevel: RiskLevel): boolean {
|
||||
const allowedRoles = REQUIRED_ROLE_FOR_RISK[riskLevel]
|
||||
return allowedRoles.includes(userRole)
|
||||
}
|
||||
|
||||
function getRequiredRolesDisplay(riskLevel: RiskLevel): string {
|
||||
const roles = REQUIRED_ROLE_FOR_RISK[riskLevel]
|
||||
return roles.map((r) => r.toUpperCase()).join(' / ')
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
interface HITLSectionProps {
|
||||
locale: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function HITLSection({ locale, className }: HITLSectionProps) {
|
||||
const t = useTranslations('demo')
|
||||
const tApproval = useTranslations('approval')
|
||||
|
||||
// Store
|
||||
const { fetchPending, signApproval, startPolling, stopPolling } = useApprovalStore()
|
||||
const pendingApprovals = usePendingApprovals()
|
||||
const addTimelineEvent = useTimelineStore((state) => state.addEvent)
|
||||
|
||||
// AI thinking state
|
||||
const [openclawStatus, setClawbotStatus] = useState<OpenClawStatus>('patrolling')
|
||||
const [currentAlertType, setCurrentAlertType] = useState<string | undefined>()
|
||||
const [showCards, setShowCards] = useState(true)
|
||||
const [isSimulating, setIsSimulating] = useState(false)
|
||||
|
||||
// Phase 3: 模擬當前登入者角色 (可切換測試權限)
|
||||
const [currentUserRole, setCurrentUserRole] = useState<UserRole>('devops')
|
||||
const currentUserName = currentUserRole === 'cto' ? 'CTO Admin' : 'Demo DevOps'
|
||||
|
||||
// Phase 3: Access Denied 模態框狀態
|
||||
const [accessDeniedModal, setAccessDeniedModal] = useState<{
|
||||
show: boolean
|
||||
riskLevel: RiskLevel
|
||||
requiredRoles: string
|
||||
} | null>(null)
|
||||
|
||||
// Start polling on mount
|
||||
useEffect(() => {
|
||||
startPolling(5000) // Poll every 5 seconds
|
||||
return () => stopPolling()
|
||||
}, [startPolling, stopPolling])
|
||||
|
||||
// Simulate webhook alert (triggers AI thinking flow)
|
||||
const simulateAlert = useCallback(async (alertType: string, severity: 'critical' | 'warning') => {
|
||||
setIsSimulating(true)
|
||||
setShowCards(false)
|
||||
setCurrentAlertType(alertType)
|
||||
|
||||
// Phase 1: Intercepting
|
||||
setClawbotStatus('intercepting')
|
||||
await new Promise((r) => setTimeout(r, 800))
|
||||
|
||||
// Phase 2: Analyzing
|
||||
setClawbotStatus('analyzing')
|
||||
await new Promise((r) => setTimeout(r, 1200))
|
||||
|
||||
// Phase 3: Generating
|
||||
setClawbotStatus('generating')
|
||||
|
||||
// Call actual webhook API
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/webhooks/alerts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
alert_type: alertType,
|
||||
severity,
|
||||
source: 'demo-ui',
|
||||
target_resource: alertType === 'k8s_pod_crash' ? 'harbor-core-7d4b8c9f5-xk2m3' : 'nginx-ingress-abc123',
|
||||
namespace: 'default',
|
||||
message: `Demo alert: ${alertType}`,
|
||||
metrics: { cpu_percent: 95, sigma_deviation: 3.5 },
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log('[HITL] Webhook response:', result)
|
||||
|
||||
// Phase 4: Log SYSTEM alert received
|
||||
addTimelineEvent({
|
||||
type: 'system',
|
||||
status: 'warning',
|
||||
title: `接收到 ${alertType.replace('_', ' ')} 告警`,
|
||||
riskLevel: severity === 'critical' ? 'critical' : 'medium',
|
||||
})
|
||||
|
||||
// Phase 4: Log AGENT analysis complete
|
||||
addTimelineEvent({
|
||||
type: 'agent',
|
||||
status: 'info',
|
||||
title: `OpenClaw 分析完成,提出 ${severity.toUpperCase()} 提案`,
|
||||
actor: 'OpenClaw',
|
||||
actorRole: 'ai',
|
||||
riskLevel: severity === 'critical' ? 'critical' : 'medium',
|
||||
approvalId: result.approval_id,
|
||||
})
|
||||
|
||||
// Phase 4: Complete
|
||||
setClawbotStatus('complete')
|
||||
await new Promise((r) => setTimeout(r, 800))
|
||||
|
||||
// Refresh approvals list
|
||||
await fetchPending()
|
||||
|
||||
// Show cards with animation
|
||||
setShowCards(true)
|
||||
|
||||
} catch (error) {
|
||||
console.error('[HITL] Webhook failed:', error)
|
||||
setClawbotStatus('patrolling')
|
||||
} finally {
|
||||
setIsSimulating(false)
|
||||
setTimeout(() => {
|
||||
setClawbotStatus('patrolling')
|
||||
setCurrentAlertType(undefined)
|
||||
}, 2000)
|
||||
}
|
||||
}, [fetchPending, addTimelineEvent])
|
||||
|
||||
// Handle approval sign with RBAC check (Phase 3) + Timeline Logging (Phase 4)
|
||||
const handleApprove = useCallback(async (id: string, riskLevel?: RiskLevel) => {
|
||||
// Phase 3: 權限檢查
|
||||
const level = riskLevel || 'medium'
|
||||
if (!canSignApproval(currentUserRole, level)) {
|
||||
// 顯示 Access Denied 模態框
|
||||
setAccessDeniedModal({
|
||||
show: true,
|
||||
riskLevel: level,
|
||||
requiredRoles: getRequiredRolesDisplay(level),
|
||||
})
|
||||
|
||||
// Phase 4: Log SECURITY Access Denied (紅色微光)
|
||||
addTimelineEvent({
|
||||
type: 'security',
|
||||
status: 'error',
|
||||
title: `${currentUserRole.toUpperCase()} 嘗試簽核,觸發 Access Denied`,
|
||||
description: `需要 ${getRequiredRolesDisplay(level)} 角色`,
|
||||
actor: currentUserName,
|
||||
actorRole: currentUserRole,
|
||||
riskLevel: level,
|
||||
approvalId: id,
|
||||
})
|
||||
|
||||
console.warn('[HITL] Access Denied:', {
|
||||
user: currentUserName,
|
||||
role: currentUserRole,
|
||||
riskLevel: level,
|
||||
requiredRoles: getRequiredRolesDisplay(level),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 4: Log HUMAN signature (綠色微光)
|
||||
addTimelineEvent({
|
||||
type: 'human',
|
||||
status: 'success',
|
||||
title: `${currentUserRole.toUpperCase()} 成功簽核批准`,
|
||||
actor: currentUserName,
|
||||
actorRole: currentUserRole,
|
||||
riskLevel: level,
|
||||
approvalId: id,
|
||||
})
|
||||
|
||||
const result = await signApproval(id, 'demo-user', currentUserName, 'Approved via demo UI')
|
||||
|
||||
// Check if approval is now complete - Log EXEC
|
||||
if (result?.execution_triggered) {
|
||||
addTimelineEvent({
|
||||
type: 'exec',
|
||||
status: 'success',
|
||||
title: '多重簽章完成,指令已執行',
|
||||
actor: 'OpenClaw',
|
||||
actorRole: 'ai',
|
||||
riskLevel: level,
|
||||
approvalId: id,
|
||||
})
|
||||
}
|
||||
|
||||
await fetchPending()
|
||||
}, [signApproval, fetchPending, currentUserRole, addTimelineEvent])
|
||||
|
||||
// Handle rejection
|
||||
const handleReject = useCallback(async (id: string) => {
|
||||
// For demo, just refresh
|
||||
await fetchPending()
|
||||
}, [fetchPending])
|
||||
|
||||
return (
|
||||
<section className={cn('space-y-6', className)}>
|
||||
{/* Header - Lab-White Style */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className="w-6 h-6 text-claw-blue" />
|
||||
<h2 className="font-dot-matrix text-2xl text-nothing-gray-800 tracking-wide">
|
||||
{t('hitlRealApi')}
|
||||
</h2>
|
||||
<span className="px-2 py-0.5 rounded text-xs font-dot-matrix bg-claw-blue/10 text-claw-blue border border-claw-blue/20">
|
||||
Multi-Sig
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Trigger Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => simulateAlert('k8s_pod_crash', 'critical')}
|
||||
disabled={isSimulating}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg',
|
||||
'font-mono text-xs font-medium',
|
||||
'bg-status-critical/10 text-status-critical border border-status-critical/20',
|
||||
'hover:bg-status-critical/20 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isSimulating ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Plus className="w-3 h-3" />
|
||||
)}
|
||||
{t('addCritical')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => simulateAlert('high_cpu', 'warning')}
|
||||
disabled={isSimulating}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg',
|
||||
'font-mono text-xs font-medium',
|
||||
'bg-status-warning/10 text-status-warning border border-status-warning/20',
|
||||
'hover:bg-status-warning/20 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isSimulating ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Plus className="w-3 h-3" />
|
||||
)}
|
||||
{t('addMedium')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
{/* OpenClaw Panel (Left) */}
|
||||
<div className="lg:col-span-1">
|
||||
<OpenClawPanel
|
||||
status={openclawStatus}
|
||||
alertType={currentAlertType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Approval Cards (Center) */}
|
||||
<div className="lg:col-span-3">
|
||||
{pendingApprovals.length === 0 ? (
|
||||
<GlassCard className="flex flex-col items-center justify-center py-12">
|
||||
<ShieldCheck className="w-12 h-12 text-nothing-gray-300 mb-4" />
|
||||
<GlassCardTitle className="text-nothing-gray-500">
|
||||
{tApproval('noApprovals')}
|
||||
</GlassCardTitle>
|
||||
<p className="text-sm text-nothing-gray-400 mt-2 font-mono">
|
||||
[SYS] All clear. Awaiting alerts...
|
||||
</p>
|
||||
</GlassCard>
|
||||
) : (
|
||||
<div className={cn(
|
||||
'space-y-4',
|
||||
showCards ? 'animate-slide-in-up' : 'opacity-0'
|
||||
)}>
|
||||
{pendingApprovals.map((approval) => {
|
||||
const frontendApproval = toFrontendApproval(approval)
|
||||
const hasPermission = canSignApproval(currentUserRole, frontendApproval.riskLevel)
|
||||
|
||||
return (
|
||||
<div key={approval.id} className="relative">
|
||||
<ApprovalCard
|
||||
request={frontendApproval}
|
||||
onApprove={(id) => handleApprove(id, frontendApproval.riskLevel)}
|
||||
onReject={handleReject}
|
||||
holdDuration={approval.risk_level === 'critical' ? 2000 : 1000}
|
||||
/>
|
||||
|
||||
{/* Phase 3: Permission Warning Badge */}
|
||||
{!hasPermission && (
|
||||
<div className="absolute top-3 right-3 flex items-center gap-1 px-2 py-1 bg-status-critical/10 border border-status-critical/30 rounded text-[10px] font-mono text-status-critical z-10">
|
||||
<Lock className="w-3 h-3" />
|
||||
需要 {getRequiredRolesDisplay(frontendApproval.riskLevel)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Timeline (Right) - Phase 4 */}
|
||||
<div className="lg:col-span-1">
|
||||
<GlassCard className="h-full" padding="md">
|
||||
<ActionTimeline maxDisplay={8} />
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase 3 + 4: Current User Role Display with Switcher */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Current User */}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-nothing-gray-100 rounded-lg">
|
||||
<Lock className="w-3.5 h-3.5 text-nothing-gray-500" />
|
||||
<span className="font-mono text-xs text-nothing-gray-600">
|
||||
登入身份: <span className="font-bold text-nothing-gray-800">{currentUserName}</span>
|
||||
</span>
|
||||
<span className={cn(
|
||||
'px-2 py-0.5 rounded text-[10px] font-mono font-bold uppercase',
|
||||
currentUserRole === 'cto' || currentUserRole === 'ciso' || currentUserRole === 'ceo'
|
||||
? 'bg-status-healthy/10 text-status-healthy border border-status-healthy/30'
|
||||
: currentUserRole === 'admin'
|
||||
? 'bg-claw-blue/10 text-claw-blue border border-claw-blue/30'
|
||||
: 'bg-status-warning/10 text-status-warning border border-status-warning/30'
|
||||
)}>
|
||||
{currentUserRole}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Role Switcher (Demo Only) */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[10px] text-nothing-gray-400">切換:</span>
|
||||
<button
|
||||
onClick={() => setCurrentUserRole('devops')}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded text-[10px] font-mono font-medium transition-colors',
|
||||
currentUserRole === 'devops'
|
||||
? 'bg-status-warning/20 text-status-warning'
|
||||
: 'bg-nothing-gray-100 text-nothing-gray-500 hover:bg-nothing-gray-200'
|
||||
)}
|
||||
>
|
||||
DevOps
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentUserRole('cto')}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded text-[10px] font-mono font-medium transition-colors',
|
||||
currentUserRole === 'cto'
|
||||
? 'bg-status-healthy/20 text-status-healthy'
|
||||
: 'bg-nothing-gray-100 text-nothing-gray-500 hover:bg-nothing-gray-200'
|
||||
)}
|
||||
>
|
||||
CTO
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Debug Info */}
|
||||
<div className="text-[10px] font-mono text-nothing-gray-400">
|
||||
Pending: {pendingApprovals.length} | Status: {openclawStatus}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase 3: Access Denied Modal (Nothing.tech Style) */}
|
||||
{accessDeniedModal?.show && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in">
|
||||
<GlassCard className="w-full max-w-md mx-4" variant="elevated" padding="lg">
|
||||
<div className="text-center py-6">
|
||||
{/* Icon */}
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-status-critical/10 flex items-center justify-center mb-4">
|
||||
<ShieldX className="w-8 h-8 text-status-critical" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-dot-matrix text-2xl text-status-critical mb-2">
|
||||
ACCESS DENIED
|
||||
</h3>
|
||||
|
||||
{/* Risk Level Badge */}
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-status-critical/10 border border-status-critical/30 mb-4">
|
||||
<AlertTriangle className="w-4 h-4 text-status-critical" />
|
||||
<span className="font-mono text-sm text-status-critical font-semibold uppercase">
|
||||
{accessDeniedModal.riskLevel} RISK
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<p className="text-nothing-gray-600 font-mono text-sm mb-2">
|
||||
此操作需要更高權限簽核
|
||||
</p>
|
||||
<p className="text-nothing-gray-500 font-mono text-xs mb-6">
|
||||
您的角色: <span className="text-status-warning font-bold">{currentUserRole.toUpperCase()}</span>
|
||||
</p>
|
||||
|
||||
{/* Required Roles */}
|
||||
<div className="bg-nothing-gray-50 rounded-lg p-4 mb-6">
|
||||
<p className="text-[10px] text-nothing-gray-500 font-mono uppercase tracking-wider mb-2">
|
||||
需要以下角色之一
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{accessDeniedModal.requiredRoles.split(' / ').map((role) => (
|
||||
<span
|
||||
key={role}
|
||||
className="px-2 py-1 rounded bg-status-healthy/10 text-status-healthy font-mono text-xs font-bold border border-status-healthy/30"
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Button */}
|
||||
<button
|
||||
onClick={() => setAccessDeniedModal(null)}
|
||||
className="w-full px-6 py-3 rounded-xl font-mono text-sm font-semibold bg-nothing-gray-100 text-nothing-gray-700 hover:bg-nothing-gray-200 transition-colors"
|
||||
>
|
||||
了解,返回
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default HITLSection
|
||||
21
apps/web/src/components/ai/index.ts
Normal file
21
apps/web/src/components/ai/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* AI Components - OpenClaw Visual Integration
|
||||
* ===========================================
|
||||
* Phase 5: OpenClaw 實體化升級 (2026-03-21)
|
||||
*/
|
||||
|
||||
export { AIThinkingPanel, type ThinkingPhase, type AIThinkingPanelProps } from './ai-thinking-panel'
|
||||
export { HITLSection } from './hitl-section'
|
||||
export { AICommandPanel } from './ai-command-panel'
|
||||
export { ThinkingStream, DEFAULT_THINKING_MESSAGES, type ThinkingMessage, type ThinkingStreamProps } from './thinking-stream'
|
||||
|
||||
// =============================================================================
|
||||
// OpenClaw Components (Phase 5)
|
||||
// =============================================================================
|
||||
export { OpenClawPanel, type OpenClawStatus, type OpenClawPanelProps } from './openclaw-panel'
|
||||
export { OpenClawStateMachine, type MachineState, type OpenClawStateMachineProps } from './openclaw-state-machine'
|
||||
|
||||
// =============================================================================
|
||||
// Phase 5 完工: OpenClaw 過渡別名已移除
|
||||
// 全專案已 100% 使用 OpenClaw
|
||||
// =============================================================================
|
||||
439
apps/web/src/components/ai/openclaw-panel.tsx
Normal file
439
apps/web/src/components/ai/openclaw-panel.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* OpenClawPanel - 賽博維運風格 AI 面板
|
||||
* =====================================
|
||||
* Phase 5: OpenClaw 實體化升級
|
||||
*
|
||||
* Features:
|
||||
* - 3D 骨架機械爪視覺化 (CSS Art)
|
||||
* - 核心藍色 LED 脈衝動畫
|
||||
* - 點陣字體狀態顯示
|
||||
* - AI 思考流過渡動畫
|
||||
* - 高通透度 awoooi-glass 效果
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type OpenClawStatus =
|
||||
| 'patrolling' // [AGENT] patrolling...
|
||||
| 'intercepting' // [SYS] 攔截異常...
|
||||
| 'analyzing' // [SYS] Analyzing blast radius...
|
||||
| 'generating' // [SYS] Generating proposed action...
|
||||
| 'complete' // [SYS] Analysis complete
|
||||
|
||||
export interface OpenClawPanelProps {
|
||||
status?: OpenClawStatus
|
||||
alertType?: string
|
||||
onAnalysisComplete?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Status Messages (Dot Matrix Style)
|
||||
// =============================================================================
|
||||
|
||||
const STATUS_MESSAGES: Record<OpenClawStatus, string> = {
|
||||
patrolling: '[AGENT] patrolling...',
|
||||
intercepting: '[SYS] Intercepting anomaly...',
|
||||
analyzing: '[SYS] Analyzing blast radius...',
|
||||
generating: '[SYS] Generating proposed action...',
|
||||
complete: '[SYS] Analysis complete',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// NemoClaw 3D Ceramic SVG Component (Lab-White Style)
|
||||
// =============================================================================
|
||||
|
||||
function NemoClaw({ isActive, isPulsing }: { isActive: boolean; isPulsing: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 140 140"
|
||||
className="w-full h-full drop-shadow-lg"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
{/* 3D Ceramic gradient - white/cream tones */}
|
||||
<linearGradient id="ceramic3d" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#FFFFFF" />
|
||||
<stop offset="40%" stopColor="#F8F8F8" />
|
||||
<stop offset="70%" stopColor="#E8E8E8" />
|
||||
<stop offset="100%" stopColor="#D8D8D8" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Core glow filter - stronger */}
|
||||
<filter id="coreGlow" x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feGaussianBlur stdDeviation="6" result="blur" />
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
|
||||
{/* Pulse glow animation filter */}
|
||||
<filter id="pulseGlow" x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feGaussianBlur stdDeviation="8" result="blur">
|
||||
<animate attributeName="stdDeviation" values="6;10;6" dur="1.5s" repeatCount="indefinite" />
|
||||
</feGaussianBlur>
|
||||
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||
</filter>
|
||||
|
||||
{/* Shadow for 3D effect */}
|
||||
<filter id="shadow3d" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="2" dy="4" stdDeviation="3" floodOpacity="0.15" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Base shadow */}
|
||||
<ellipse cx="70" cy="125" rx="35" ry="8" fill="rgba(0,0,0,0.08)" />
|
||||
|
||||
{/* Main body - 3D ceramic sphere */}
|
||||
<circle
|
||||
cx="70"
|
||||
cy="70"
|
||||
r="32"
|
||||
fill="url(#ceramic3d)"
|
||||
filter="url(#shadow3d)"
|
||||
stroke="#E0E0E0"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{/* Inner ring - depth effect */}
|
||||
<circle
|
||||
cx="70"
|
||||
cy="70"
|
||||
r="26"
|
||||
fill="none"
|
||||
stroke="#D0D0D0"
|
||||
strokeWidth="1"
|
||||
opacity="0.5"
|
||||
/>
|
||||
|
||||
{/* Core LED - Blue pulsing (the eye) */}
|
||||
<circle
|
||||
cx="70"
|
||||
cy="70"
|
||||
r="16"
|
||||
fill={isActive ? '#4A90D9' : '#B0B0B0'}
|
||||
filter={isPulsing ? 'url(#pulseGlow)' : 'url(#coreGlow)'}
|
||||
className="transition-all duration-500"
|
||||
>
|
||||
{isPulsing && (
|
||||
<animate
|
||||
attributeName="r"
|
||||
values="14;17;14"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
)}
|
||||
</circle>
|
||||
|
||||
{/* Core highlight */}
|
||||
<circle
|
||||
cx="70"
|
||||
cy="70"
|
||||
r="8"
|
||||
fill="white"
|
||||
opacity={isActive ? 0.9 : 0.4}
|
||||
className="transition-opacity duration-300"
|
||||
/>
|
||||
|
||||
{/* Claw arms - ceramic white 3D style */}
|
||||
{/* Top arm */}
|
||||
<g filter="url(#shadow3d)">
|
||||
<path
|
||||
d="M 70 38 L 70 18 L 58 6 M 70 18 L 82 6"
|
||||
stroke="url(#ceramic3d)"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M 70 38 L 70 18 L 58 6 M 70 18 L 82 6"
|
||||
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
/>
|
||||
{/* Claw tips */}
|
||||
<circle cx="58" cy="6" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
|
||||
<circle cx="82" cy="6" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
|
||||
</g>
|
||||
|
||||
{/* Left arm */}
|
||||
<g filter="url(#shadow3d)">
|
||||
<path
|
||||
d="M 38 70 L 18 70 L 6 58 M 18 70 L 6 82"
|
||||
stroke="url(#ceramic3d)"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M 38 70 L 18 70 L 6 58 M 18 70 L 6 82"
|
||||
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<circle cx="6" cy="58" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
|
||||
<circle cx="6" cy="82" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
|
||||
</g>
|
||||
|
||||
{/* Right arm */}
|
||||
<g filter="url(#shadow3d)">
|
||||
<path
|
||||
d="M 102 70 L 122 70 L 134 58 M 122 70 L 134 82"
|
||||
stroke="url(#ceramic3d)"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M 102 70 L 122 70 L 134 58 M 122 70 L 134 82"
|
||||
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<circle cx="134" cy="58" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
|
||||
<circle cx="134" cy="82" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
|
||||
</g>
|
||||
|
||||
{/* Bottom left arm */}
|
||||
<g filter="url(#shadow3d)">
|
||||
<path
|
||||
d="M 48 92 L 28 112 L 16 116"
|
||||
stroke="url(#ceramic3d)"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M 48 92 L 28 112 L 16 116"
|
||||
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<circle cx="16" cy="116" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
|
||||
</g>
|
||||
|
||||
{/* Bottom right arm */}
|
||||
<g filter="url(#shadow3d)">
|
||||
<path
|
||||
d="M 92 92 L 112 112 L 124 116"
|
||||
stroke="url(#ceramic3d)"
|
||||
strokeWidth="6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M 92 92 L 112 112 L 124 116"
|
||||
stroke={isActive ? '#4A90D9' : '#C0C0C0'}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<circle cx="124" cy="116" r="4" fill="url(#ceramic3d)" stroke={isActive ? '#4A90D9' : '#D0D0D0'} strokeWidth="1.5" />
|
||||
</g>
|
||||
|
||||
{/* Orbit ring when active */}
|
||||
{isActive && (
|
||||
<circle
|
||||
cx="70"
|
||||
cy="70"
|
||||
r="42"
|
||||
fill="none"
|
||||
stroke="#4A90D9"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="6 6"
|
||||
opacity="0.4"
|
||||
className="animate-spin"
|
||||
style={{ animationDuration: '8s', transformOrigin: '70px 70px' }}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Typewriter Hook
|
||||
// =============================================================================
|
||||
|
||||
function useTypewriter(text: string, speed: number = 50) {
|
||||
const [displayText, setDisplayText] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let index = 0
|
||||
setDisplayText('')
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (index < text.length) {
|
||||
setDisplayText(text.slice(0, index + 1))
|
||||
index++
|
||||
} else {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, speed)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [text, speed])
|
||||
|
||||
return displayText
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function OpenClawPanel({
|
||||
status = 'patrolling',
|
||||
alertType,
|
||||
onAnalysisComplete,
|
||||
className,
|
||||
}: OpenClawPanelProps) {
|
||||
const t = useTranslations('ai')
|
||||
const [cursorVisible, setCursorVisible] = useState(true)
|
||||
|
||||
const isActive = status !== 'patrolling'
|
||||
const isPulsing = status === 'intercepting' || status === 'analyzing'
|
||||
|
||||
const statusMessage = STATUS_MESSAGES[status]
|
||||
const displayText = useTypewriter(statusMessage, 40)
|
||||
|
||||
// Cursor blink
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCursorVisible((v) => !v)
|
||||
}, 500)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Notify when complete
|
||||
useEffect(() => {
|
||||
if (status === 'complete') {
|
||||
const timeout = setTimeout(() => {
|
||||
onAnalysisComplete?.()
|
||||
}, 1000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [status, onAnalysisComplete])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-xl',
|
||||
// awoooi-glass effect
|
||||
'bg-white/70 backdrop-blur-[20px]',
|
||||
// 骨架細框線
|
||||
'border border-nothing-gray-200/50',
|
||||
// 輕柔陰影
|
||||
'shadow-[0_4px_24px_rgba(0,0,0,0.06)]',
|
||||
'p-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Scan line animation when active */}
|
||||
{isActive && (
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute h-[1px] w-full bg-gradient-to-r from-transparent via-claw-blue/50 to-transparent animate-scan" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header - Dot Matrix Style */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-dot-matrix text-sm text-nothing-gray-700 tracking-wider">
|
||||
AWOOOI v1.0.0
|
||||
</span>
|
||||
<span className="font-dot-matrix text-xs text-nothing-gray-400">
|
||||
| Production
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="w-2 h-2 rounded-full bg-claw-blue animate-ping" />
|
||||
)}
|
||||
</div>
|
||||
{alertType && (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-dot-matrix bg-status-critical/10 text-status-critical border border-status-critical/20">
|
||||
{alertType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* NemoClaw 3D Ceramic Visualization */}
|
||||
<div className="relative w-32 h-32 mx-auto mb-4">
|
||||
<NemoClaw isActive={isActive} isPulsing={isPulsing} />
|
||||
|
||||
{/* Sparkle effects when active */}
|
||||
{isActive && (
|
||||
<>
|
||||
<Sparkles className="absolute -top-1 -right-1 w-4 h-4 text-claw-blue/60 animate-pulse" />
|
||||
<Sparkles className="absolute -bottom-1 -left-1 w-3 h-3 text-claw-blue/40 animate-pulse" style={{ animationDelay: '0.3s' }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Display - VT323 Dot Matrix Font */}
|
||||
<div className="text-center bg-nothing-gray-50/50 rounded-lg py-2 px-3">
|
||||
<div className={cn(
|
||||
'font-dot-matrix text-base tracking-wide',
|
||||
isActive ? 'text-claw-blue' : 'text-nothing-gray-600'
|
||||
)}>
|
||||
<span>{displayText}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block w-2 h-4 ml-1 -mb-0.5',
|
||||
isActive ? 'bg-claw-blue' : 'bg-nothing-gray-400',
|
||||
cursorVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator when analyzing */}
|
||||
{(status === 'analyzing' || status === 'generating') && (
|
||||
<div className="mt-3 flex justify-center gap-1">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1.5 h-1.5 rounded-full bg-claw-blue animate-pulse"
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Complete indicator */}
|
||||
{status === 'complete' && (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<span className="px-2 py-0.5 rounded text-[9px] font-mono bg-status-healthy/10 text-status-healthy border border-status-healthy/20">
|
||||
READY
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OpenClawPanel
|
||||
354
apps/web/src/components/ai/openclaw-state-machine.tsx
Normal file
354
apps/web/src/components/ai/openclaw-state-machine.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* OpenClawStateMachine - 戰情室 AI 狀態機整合
|
||||
* ==========================================
|
||||
* Phase 5: OpenClaw 實體化升級
|
||||
*
|
||||
* Features:
|
||||
* - 三態狀態機 (idle / thinking / awaiting_approval)
|
||||
* - 真實 API 輪詢 (/api/v1/approvals/pending)
|
||||
* - ThinkingStream 打字機動畫
|
||||
* - ApprovalCard 滑入動畫
|
||||
* - 記憶體安全清理
|
||||
*
|
||||
* 真實性條款: 禁止任何 Mock Data
|
||||
* i18n: 100% next-intl
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { OpenClawPanel, type OpenClawStatus } from './openclaw-panel'
|
||||
import { ThinkingStream, DEFAULT_THINKING_MESSAGES } from './thinking-stream'
|
||||
import { ApprovalCard, type ApprovalRequest } from '@/components/approval/approval-card'
|
||||
import { RefreshCw, AlertCircle, CheckCircle2 } from 'lucide-react'
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type MachineState = 'idle' | 'thinking' | 'awaiting_approval'
|
||||
|
||||
export interface OpenClawStateMachineProps {
|
||||
/** 啟用 Demo 模式 (僅供開發測試) */
|
||||
demoMode?: boolean
|
||||
/** API 輪詢間隔 (ms) */
|
||||
pollInterval?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface PendingApprovalsResponse {
|
||||
count: number
|
||||
approvals: ApprovalRequest[]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Helper
|
||||
// =============================================================================
|
||||
|
||||
const getApiBaseUrl = (): string => {
|
||||
if (typeof window === 'undefined') return ''
|
||||
// 統帥鐵律: 禁止任何 Fallback IP
|
||||
const url = process.env.NEXT_PUBLIC_API_URL
|
||||
if (!url) {
|
||||
console.error('[AWOOOI ERROR] Missing NEXT_PUBLIC_API_URL')
|
||||
return ''
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
// API response uses snake_case, frontend uses camelCase
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function transformApiResponse(apiApproval: any): ApprovalRequest {
|
||||
return {
|
||||
id: apiApproval.id,
|
||||
action: apiApproval.action,
|
||||
description: apiApproval.description,
|
||||
riskLevel: apiApproval.risk_level,
|
||||
blastRadius: {
|
||||
affectedPods: apiApproval.blast_radius?.affected_pods ?? 0,
|
||||
estimatedDowntime: apiApproval.blast_radius?.estimated_downtime ?? '0',
|
||||
relatedServices: apiApproval.blast_radius?.related_services ?? [],
|
||||
dataImpact: ((apiApproval.blast_radius?.data_impact ?? 'none').toUpperCase()) as 'NONE' | 'READ_ONLY' | 'WRITE' | 'DESTRUCTIVE',
|
||||
},
|
||||
dryRunChecks: apiApproval.dry_run_checks ?? [],
|
||||
requiredSignatures: apiApproval.required_signatures ?? 2,
|
||||
currentSignatures: apiApproval.current_signatures ?? 0,
|
||||
requestedBy: apiApproval.requested_by ?? 'OpenClaw',
|
||||
requestedAt: apiApproval.created_at ?? new Date().toISOString(),
|
||||
hitCount: apiApproval.hit_count,
|
||||
lastSeenAt: apiApproval.last_seen_at,
|
||||
fingerprint: apiApproval.fingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function OpenClawStateMachine({
|
||||
demoMode = false,
|
||||
pollInterval = 5000,
|
||||
className,
|
||||
}: OpenClawStateMachineProps) {
|
||||
const t = useTranslations()
|
||||
|
||||
// State
|
||||
const [machineState, setMachineState] = useState<MachineState>('idle')
|
||||
const [openclawStatus, setOpenclawStatus] = useState<OpenClawStatus>('patrolling')
|
||||
const [pendingApprovals, setPendingApprovals] = useState<ApprovalRequest[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastFetch, setLastFetch] = useState<Date | null>(null)
|
||||
|
||||
// Timer refs for cleanup
|
||||
const pollTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// ==========================================================================
|
||||
// API: Fetch Pending Approvals
|
||||
// ==========================================================================
|
||||
const fetchPendingApprovals = useCallback(async () => {
|
||||
const apiBaseUrl = getApiBaseUrl()
|
||||
if (!apiBaseUrl) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1/approvals/pending`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API Error: ${response.status}`)
|
||||
}
|
||||
|
||||
const rawData = await response.json()
|
||||
const data: PendingApprovalsResponse = {
|
||||
count: rawData.count,
|
||||
approvals: (rawData.approvals ?? []).map(transformApiResponse),
|
||||
}
|
||||
setPendingApprovals(data.approvals)
|
||||
setLastFetch(new Date())
|
||||
|
||||
// Update machine state based on approvals
|
||||
if (data.count > 0) {
|
||||
setMachineState('awaiting_approval')
|
||||
setOpenclawStatus('complete')
|
||||
} else {
|
||||
setMachineState('idle')
|
||||
setOpenclawStatus('patrolling')
|
||||
}
|
||||
|
||||
console.log('[OpenClaw] Fetched approvals:', data.count)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
setError(message)
|
||||
console.error('[OpenClaw] Fetch error:', message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ==========================================================================
|
||||
// API: Submit Signature
|
||||
// ==========================================================================
|
||||
// 🔧 修復 Multi-Sig Bug: 每次簽核使用不同的 signer_id
|
||||
const signerCounter = useRef(0)
|
||||
|
||||
const handleApprove = useCallback(async (approvalId: string) => {
|
||||
const apiBaseUrl = getApiBaseUrl()
|
||||
if (!apiBaseUrl) return
|
||||
|
||||
// Multi-Sig: 輪替簽核者身份
|
||||
const signers = [
|
||||
{ id: 'cto-ogt', name: '統帥 (CTO)' },
|
||||
{ id: 'ciso-security', name: '資安長 (CISO)' },
|
||||
{ id: 'cpo-product', name: '產品長 (CPO)' },
|
||||
]
|
||||
const signer = signers[signerCounter.current % signers.length]
|
||||
signerCounter.current++
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1/approvals/${approvalId}/sign`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
signer_id: signer.id,
|
||||
signer_name: signer.name,
|
||||
comment: 'Approved via AWOOOI Dashboard',
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.detail || `Sign failed: ${response.status}`)
|
||||
}
|
||||
|
||||
console.log('[OpenClaw] Approval signed by', signer.name, ':', approvalId)
|
||||
// Refresh approvals list
|
||||
await fetchPendingApprovals()
|
||||
} catch (err) {
|
||||
console.error('[OpenClaw] Sign error:', err)
|
||||
setError(err instanceof Error ? err.message : 'Sign failed')
|
||||
}
|
||||
}, [fetchPendingApprovals])
|
||||
|
||||
// ==========================================================================
|
||||
// API: Reject Request
|
||||
// ==========================================================================
|
||||
const handleReject = useCallback(async (approvalId: string) => {
|
||||
const apiBaseUrl = getApiBaseUrl()
|
||||
if (!apiBaseUrl) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/api/v1/approvals/${approvalId}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
rejector_id: 'war-room-user',
|
||||
rejector_name: 'War Room User',
|
||||
reason: 'Rejected via AWOOOI Dashboard',
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Reject failed: ${response.status}`)
|
||||
}
|
||||
|
||||
console.log('[OpenClaw] Approval rejected:', approvalId)
|
||||
// Refresh approvals list
|
||||
await fetchPendingApprovals()
|
||||
} catch (err) {
|
||||
console.error('[OpenClaw] Reject error:', err)
|
||||
setError(err instanceof Error ? err.message : 'Reject failed')
|
||||
}
|
||||
}, [fetchPendingApprovals])
|
||||
|
||||
// ==========================================================================
|
||||
// Polling Effect
|
||||
// ==========================================================================
|
||||
useEffect(() => {
|
||||
// Skip polling in demo mode
|
||||
if (demoMode) return
|
||||
|
||||
// Initial fetch
|
||||
fetchPendingApprovals()
|
||||
|
||||
// Start polling
|
||||
pollTimerRef.current = setInterval(fetchPendingApprovals, pollInterval)
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current)
|
||||
pollTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [demoMode, pollInterval, fetchPendingApprovals])
|
||||
|
||||
// ==========================================================================
|
||||
// Render
|
||||
// ==========================================================================
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Status Bar */}
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-nothing-gray-500">
|
||||
STATE:
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 rounded text-[10px] font-mono font-bold uppercase',
|
||||
machineState === 'idle' && 'bg-nothing-gray-100 text-nothing-gray-600',
|
||||
machineState === 'thinking' && 'bg-status-thinking/10 text-status-thinking',
|
||||
machineState === 'awaiting_approval' && 'bg-status-warning/10 text-status-warning'
|
||||
)}
|
||||
>
|
||||
{machineState}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Loading indicator */}
|
||||
{isLoading && (
|
||||
<RefreshCw className="w-3 h-3 text-nothing-gray-400 animate-spin" />
|
||||
)}
|
||||
|
||||
{/* Error indicator */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-1 text-status-critical">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
<span className="text-[10px] font-mono">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual refresh button */}
|
||||
<button
|
||||
onClick={fetchPendingApprovals}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2 py-1 rounded text-[10px] font-mono',
|
||||
'bg-nothing-gray-100 text-nothing-gray-600',
|
||||
'hover:bg-nothing-gray-200 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
REFRESH
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OpenClaw Visual */}
|
||||
<OpenClawPanel
|
||||
status={openclawStatus}
|
||||
alertType={pendingApprovals.length > 0 ? 'POD_CRASH' : undefined}
|
||||
/>
|
||||
|
||||
{/* Pending Approvals List (REAL DATA) */}
|
||||
{pendingApprovals.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{pendingApprovals.map((approval) => (
|
||||
<div key={approval.id} className="animate-slide-in-up">
|
||||
<ApprovalCard
|
||||
request={approval}
|
||||
onApprove={() => handleApprove(approval.id)}
|
||||
onReject={() => handleReject(approval.id)}
|
||||
holdDuration={1000}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Idle state - no pending approvals */}
|
||||
{machineState === 'idle' && pendingApprovals.length === 0 && !isLoading && (
|
||||
<div className="text-center py-6 border border-dashed border-nothing-gray-200 rounded-lg">
|
||||
<CheckCircle2 className="w-8 h-8 mx-auto mb-2 text-status-healthy/50" />
|
||||
<p className="font-mono text-xs text-nothing-gray-400">
|
||||
{t('ai.standby')}
|
||||
</p>
|
||||
<p className="font-mono text-[10px] text-nothing-gray-300 mt-1">
|
||||
{lastFetch
|
||||
? `Last check: ${lastFetch.toLocaleTimeString()}`
|
||||
: 'Waiting for first fetch...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Demo mode warning */}
|
||||
{demoMode && (
|
||||
<div className="bg-status-warning/10 border border-status-warning/20 rounded-lg p-3">
|
||||
<p className="font-mono text-xs text-status-warning">
|
||||
Demo mode is enabled. Real API polling is disabled.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OpenClawStateMachine
|
||||
282
apps/web/src/components/ai/thinking-stream.tsx
Normal file
282
apps/web/src/components/ai/thinking-stream.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
'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)
|
||||
const [cursorVisible, setCursorVisible] = useState(true)
|
||||
|
||||
// Cursor blink animation
|
||||
useEffect(() => {
|
||||
if (!showCursor) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCursorVisible((v) => !v)
|
||||
}, 530)
|
||||
|
||||
// ⚠️ 記憶體安全清理
|
||||
return () => clearInterval(interval)
|
||||
}, [showCursor])
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2 font-mono 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 */}
|
||||
<span className="text-nothing-gray-700">
|
||||
{displayText}
|
||||
{showCursor && !isComplete && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block w-2 h-4 ml-0.5 -mb-0.5 bg-status-thinking',
|
||||
cursorVisible ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</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-mono 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
|
||||
Reference in New Issue
Block a user