561 lines
21 KiB
TypeScript
561 lines
21 KiB
TypeScript
'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 { useCSRF } from '@/hooks/useCSRF'
|
|
import { OpenClawPanel, type OpenClawStatus } from './openclaw-panel'
|
|
import type { ThinkingStream as _ThinkingStream, DEFAULT_THINKING_MESSAGES as _DEFAULT_THINKING_MESSAGES } from './thinking-stream'
|
|
import { ApprovalCard, type ApprovalRequest } from '@/components/approval/approval-card'
|
|
import { ApprovalModal } from '@/components/ui/approval-modal'
|
|
import { RefreshCw, AlertCircle, CheckCircle2, AlertTriangle, ChevronRight } from 'lucide-react'
|
|
import { CURRENT_USER } from '@/lib/constants'
|
|
|
|
// =============================================================================
|
|
// 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()
|
|
const { csrfToken } = useCSRF()
|
|
|
|
// 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)
|
|
|
|
// Slide Panel 狀態
|
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
|
const selectedApproval = selectedIndex !== null ? pendingApprovals[selectedIndex] : null
|
|
|
|
// 2026-03-26: 簽核後保留內容顯示 (feedback_approval_preserve_content.md)
|
|
// 儲存剛簽核/拒絕的項目,顯示結果後再清除
|
|
const [processedApproval, setProcessedApproval] = useState<{
|
|
approval: ApprovalRequest
|
|
action: 'approved' | 'rejected'
|
|
timestamp: Date
|
|
} | null>(null)
|
|
|
|
// Timer refs for cleanup
|
|
const pollTimerRef = useRef<NodeJS.Timeout | null>(null)
|
|
|
|
// 導航
|
|
const handlePrevApproval = useCallback(() => {
|
|
if (selectedIndex !== null && selectedIndex > 0) {
|
|
setSelectedIndex(selectedIndex - 1)
|
|
}
|
|
}, [selectedIndex])
|
|
|
|
const handleNextApproval = useCallback(() => {
|
|
if (selectedIndex !== null && selectedIndex < pendingApprovals.length - 1) {
|
|
setSelectedIndex(selectedIndex + 1)
|
|
}
|
|
}, [selectedIndex, pendingApprovals.length])
|
|
|
|
// ==========================================================================
|
|
// API: Fetch Pending Approvals
|
|
// ==========================================================================
|
|
const fetchPendingApprovals = useCallback(async (): Promise<number> => {
|
|
const apiBaseUrl = getApiBaseUrl()
|
|
if (!apiBaseUrl) return 0
|
|
|
|
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)
|
|
return data.count
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
|
setError(message)
|
|
console.error('[OpenClaw] Fetch error:', message)
|
|
return 0
|
|
} 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 headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
if (csrfToken) headers['X-CSRF-Token'] = csrfToken
|
|
|
|
const response = await fetch(`${apiBaseUrl}/api/v1/approvals/${approvalId}/sign`, {
|
|
method: 'POST',
|
|
headers,
|
|
credentials: 'include',
|
|
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 and return new count
|
|
const newCount = await fetchPendingApprovals()
|
|
return newCount
|
|
} catch (err) {
|
|
console.error('[OpenClaw] Sign error:', err)
|
|
setError(err instanceof Error ? err.message : 'Sign failed')
|
|
return -1
|
|
}
|
|
}, [fetchPendingApprovals, csrfToken])
|
|
|
|
// ==========================================================================
|
|
// 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: CURRENT_USER.id,
|
|
rejector_name: CURRENT_USER.name,
|
|
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-body text-xs text-nothing-gray-500">
|
|
STATE:
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
'px-2 py-0.5 rounded text-[10px] font-body 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-body">{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-body',
|
|
'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 - 緊湊列表模式 */}
|
|
{pendingApprovals.length > 0 && (
|
|
<div className="space-y-2">
|
|
{/* 批次操作提示 */}
|
|
<div className="flex items-center justify-between px-1 mb-2">
|
|
<span className="text-[10px] font-body text-nothing-gray-400">
|
|
{pendingApprovals.length} 筆待簽核
|
|
</span>
|
|
<span className="text-[10px] font-body text-nothing-gray-400">
|
|
點擊查看詳情
|
|
</span>
|
|
</div>
|
|
|
|
{/* 緊湊列表 */}
|
|
{pendingApprovals.map((approval, index) => {
|
|
const isCritical = approval.riskLevel === 'critical'
|
|
const isHigh = approval.riskLevel === 'high'
|
|
const title = approval.action.includes('|')
|
|
? approval.action.split('|')[0].trim()
|
|
: approval.action
|
|
|
|
return (
|
|
<div
|
|
key={approval.id}
|
|
onClick={() => setSelectedIndex(index)}
|
|
className={cn(
|
|
'group flex items-center gap-3 p-3 rounded-xl cursor-pointer',
|
|
'bg-white border transition-all duration-200',
|
|
'hover:shadow-md hover:-translate-y-0.5',
|
|
isCritical && 'border-l-4 border-l-status-critical border-status-critical/30 bg-status-critical/5',
|
|
isHigh && 'border-l-4 border-l-status-warning border-status-warning/30',
|
|
!isCritical && !isHigh && 'border-nothing-gray-200 hover:border-nothing-gray-300',
|
|
selectedIndex === index && 'ring-2 ring-claw-blue'
|
|
)}
|
|
>
|
|
{/* 風險指示器 */}
|
|
<div className={cn(
|
|
'flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center',
|
|
isCritical && 'bg-status-critical/10',
|
|
isHigh && 'bg-status-warning/10',
|
|
approval.riskLevel === 'medium' && 'bg-status-warning/10',
|
|
approval.riskLevel === 'low' && 'bg-status-healthy/10'
|
|
)}>
|
|
{isCritical || isHigh ? (
|
|
<AlertTriangle className={cn(
|
|
'w-4 h-4',
|
|
isCritical ? 'text-status-critical' : 'text-status-warning'
|
|
)} />
|
|
) : (
|
|
<CheckCircle2 className="w-4 h-4 text-status-healthy" />
|
|
)}
|
|
</div>
|
|
|
|
{/* 標題 */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-body text-sm font-medium text-nothing-gray-900 truncate">
|
|
{title}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
<span className={cn(
|
|
'text-[10px] font-body font-bold uppercase px-1.5 py-0.5 rounded',
|
|
isCritical && 'bg-status-critical/10 text-status-critical',
|
|
isHigh && 'bg-status-warning/10 text-status-warning',
|
|
approval.riskLevel === 'medium' && 'bg-status-warning/10 text-status-warning',
|
|
approval.riskLevel === 'low' && 'bg-status-healthy/10 text-status-healthy'
|
|
)}>
|
|
{approval.riskLevel}
|
|
</span>
|
|
<span className="text-[10px] font-body text-nothing-gray-400">
|
|
{approval.currentSignatures}/{approval.requiredSignatures}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 箭頭 */}
|
|
<ChevronRight className="w-4 h-4 text-nothing-gray-400 flex-shrink-0 group-hover:translate-x-0.5 transition-transform" />
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Approval Modal - 全屏審核對話框 (Phase 17 UI/UX 修復) */}
|
|
<ApprovalModal
|
|
open={selectedIndex !== null || processedApproval !== null}
|
|
onClose={() => {
|
|
setSelectedIndex(null)
|
|
setProcessedApproval(null)
|
|
}}
|
|
title={processedApproval ? (processedApproval.action === 'approved' ? '已批准' : '已拒絕') : '審核詳情'}
|
|
onPrev={processedApproval ? undefined : handlePrevApproval}
|
|
onNext={processedApproval ? undefined : handleNextApproval}
|
|
current={selectedIndex !== null && !processedApproval ? selectedIndex + 1 : undefined}
|
|
total={!processedApproval ? pendingApprovals.length : undefined}
|
|
>
|
|
{/* 2026-03-26: 顯示剛處理的項目結果 */}
|
|
{processedApproval && (
|
|
<div className="relative">
|
|
{/* 結果覆蓋層 */}
|
|
<div className={cn(
|
|
"absolute inset-0 z-10 flex items-center justify-center rounded-xl",
|
|
processedApproval.action === 'approved'
|
|
? "bg-status-healthy/10"
|
|
: "bg-status-critical/10"
|
|
)}>
|
|
<div className={cn(
|
|
"text-center px-6 py-4 rounded-lg backdrop-blur-sm",
|
|
processedApproval.action === 'approved'
|
|
? "bg-status-healthy/20 text-status-healthy"
|
|
: "bg-status-critical/20 text-status-critical"
|
|
)}>
|
|
<div className="mb-2 flex justify-center">
|
|
{processedApproval.action === 'approved'
|
|
? <CheckCircle2 className="h-9 w-9" aria-hidden="true" />
|
|
: <AlertCircle className="h-9 w-9" aria-hidden="true" />
|
|
}
|
|
</div>
|
|
<div className="font-bold text-lg">
|
|
{processedApproval.action === 'approved' ? '已批准執行' : '已拒絕執行'}
|
|
</div>
|
|
<div className="text-sm opacity-75 mt-1">
|
|
{processedApproval.timestamp.toLocaleTimeString()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* 原始內容 (保留顯示) */}
|
|
<ApprovalCard
|
|
request={processedApproval.approval}
|
|
onApprove={async () => {}}
|
|
onReject={async () => {}}
|
|
holdDuration={1000}
|
|
fullHeight
|
|
/>
|
|
</div>
|
|
)}
|
|
{/* 正常待審核項目 */}
|
|
{selectedApproval && !processedApproval && (
|
|
<ApprovalCard
|
|
request={selectedApproval}
|
|
onApprove={async () => {
|
|
// 2026-03-26: 保留簽核後內容顯示 2 秒
|
|
setProcessedApproval({
|
|
approval: selectedApproval,
|
|
action: 'approved',
|
|
timestamp: new Date(),
|
|
})
|
|
|
|
const newCount = await handleApprove(selectedApproval.id) ?? 0
|
|
|
|
// 顯示結果 2 秒後導航
|
|
setTimeout(() => {
|
|
setProcessedApproval(null)
|
|
if (newCount > 0 && selectedIndex !== null) {
|
|
if (selectedIndex >= newCount) {
|
|
setSelectedIndex(newCount - 1)
|
|
}
|
|
} else {
|
|
setSelectedIndex(null)
|
|
}
|
|
}, 2000)
|
|
}}
|
|
onReject={async () => {
|
|
// 2026-03-26: 保留拒絕後內容顯示 2 秒
|
|
setProcessedApproval({
|
|
approval: selectedApproval,
|
|
action: 'rejected',
|
|
timestamp: new Date(),
|
|
})
|
|
|
|
await handleReject(selectedApproval.id)
|
|
|
|
// 顯示結果 2 秒後關閉
|
|
setTimeout(() => {
|
|
setProcessedApproval(null)
|
|
setSelectedIndex(null)
|
|
}, 2000)
|
|
}}
|
|
holdDuration={1000}
|
|
fullHeight
|
|
/>
|
|
)}
|
|
</ApprovalModal>
|
|
|
|
{/* 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-body text-xs text-nothing-gray-400">
|
|
{t('ai.standby')}
|
|
</p>
|
|
<p className="font-body 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-body text-xs text-status-warning">
|
|
Demo mode is enabled. Real API polling is disabled.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default OpenClawStateMachine
|