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:
356
apps/web/src/components/agent/approval-card.tsx
Normal file
356
apps/web/src/components/agent/approval-card.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ApprovalCard - HITL 授權卡片
|
||||
* ============================
|
||||
* Phase 2.1: 人機協作核心組件
|
||||
* 設計風格: Nothing.tech (毛玻璃 + 極簡 + 風險色彩)
|
||||
*
|
||||
* i18n: 100% 使用 useTranslations,禁止任何寫死字串
|
||||
* 符合 AWOOOI 專案開發憲法 v2.0
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
type RiskLevel = 'low' | 'medium' | 'high' | 'critical'
|
||||
|
||||
interface DryRunCheck {
|
||||
name: string
|
||||
passed: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface BlastRadius {
|
||||
affectedPods: number
|
||||
estimatedDowntime: string
|
||||
relatedServices: string[]
|
||||
dataImpact: 'NONE' | 'READ_ONLY' | 'WRITE' | 'DESTRUCTIVE'
|
||||
}
|
||||
|
||||
interface ApprovalRequest {
|
||||
id: string
|
||||
action: string
|
||||
description: string
|
||||
riskLevel: RiskLevel
|
||||
blastRadius: BlastRadius
|
||||
dryRunChecks: DryRunCheck[]
|
||||
requiredSignatures: number
|
||||
currentSignatures: number
|
||||
requestedBy: string
|
||||
requestedAt: string
|
||||
}
|
||||
|
||||
interface ApprovalCardProps {
|
||||
request: ApprovalRequest
|
||||
onApprove?: (id: string) => void
|
||||
onReject?: (id: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
// ==================== Config ====================
|
||||
|
||||
const riskStyleConfig: Record<
|
||||
RiskLevel,
|
||||
{ color: string; bgColor: string; borderColor: string }
|
||||
> = {
|
||||
low: {
|
||||
color: 'text-status-healthy',
|
||||
bgColor: 'bg-status-healthy/10',
|
||||
borderColor: 'border-status-healthy/30',
|
||||
},
|
||||
medium: {
|
||||
color: 'text-status-warning',
|
||||
bgColor: 'bg-status-warning/10',
|
||||
borderColor: 'border-status-warning/30',
|
||||
},
|
||||
high: {
|
||||
color: 'text-nothing-red',
|
||||
bgColor: 'bg-nothing-red/10',
|
||||
borderColor: 'border-nothing-red/30',
|
||||
},
|
||||
critical: {
|
||||
color: 'text-nothing-red',
|
||||
bgColor: 'bg-nothing-red/20',
|
||||
borderColor: 'border-nothing-red/50',
|
||||
},
|
||||
}
|
||||
|
||||
const dataImpactStyleConfig: Record<
|
||||
BlastRadius['dataImpact'],
|
||||
{ color: string }
|
||||
> = {
|
||||
NONE: { color: 'text-nothing-gray-400' },
|
||||
READ_ONLY: { color: 'text-status-healthy' },
|
||||
WRITE: { color: 'text-status-warning' },
|
||||
DESTRUCTIVE: { color: 'text-nothing-red' },
|
||||
}
|
||||
|
||||
// ==================== Component ====================
|
||||
|
||||
export function ApprovalCard({
|
||||
request,
|
||||
onApprove,
|
||||
onReject,
|
||||
className,
|
||||
}: ApprovalCardProps) {
|
||||
const t = useTranslations('approval')
|
||||
const tRisk = useTranslations('risk')
|
||||
const tBlast = useTranslations('blastRadius')
|
||||
const tDryRun = useTranslations('dryRun')
|
||||
|
||||
const [confirmStep, setConfirmStep] = useState<0 | 1 | 2>(0)
|
||||
|
||||
const riskStyle = riskStyleConfig[request.riskLevel]
|
||||
const dataImpactStyle = dataImpactStyleConfig[request.blastRadius?.dataImpact ?? 'NONE']
|
||||
const allChecksPassed = request.dryRunChecks?.every((c) => c.passed) ?? true
|
||||
const needsMoreSignatures = request.currentSignatures < request.requiredSignatures
|
||||
const isDestructive = request.blastRadius?.dataImpact === 'DESTRUCTIVE'
|
||||
|
||||
// i18n: 風險等級標籤
|
||||
const riskLabel = tRisk(request.riskLevel)
|
||||
|
||||
// i18n: 資料影響標籤
|
||||
const dataImpactLabel = tBlast(
|
||||
request.blastRadius?.dataImpact === 'READ_ONLY' ? 'readOnly' :
|
||||
request.blastRadius?.dataImpact === 'WRITE' ? 'write' :
|
||||
request.blastRadius?.dataImpact === 'DESTRUCTIVE' ? 'destructive' : 'none'
|
||||
)
|
||||
|
||||
const handleApprove = () => {
|
||||
if (isDestructive && confirmStep < 2) {
|
||||
setConfirmStep((prev) => Math.min(prev + 1, 2) as 0 | 1 | 2)
|
||||
if (confirmStep === 1) {
|
||||
onApprove?.(request.id)
|
||||
setConfirmStep(0)
|
||||
}
|
||||
} else {
|
||||
onApprove?.(request.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = () => {
|
||||
setConfirmStep(0)
|
||||
onReject?.(request.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('glass-panel p-6 space-y-5', className)}>
|
||||
{/* Header: Risk Badge + Action */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Risk Badge */}
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 px-3 py-1 rounded-full border text-xs font-mono tracking-wider mb-3',
|
||||
riskStyle.bgColor,
|
||||
riskStyle.borderColor,
|
||||
riskStyle.color
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
request.riskLevel === 'critical' && 'animate-pulse',
|
||||
request.riskLevel === 'low' && 'bg-status-healthy',
|
||||
request.riskLevel === 'medium' && 'bg-status-warning',
|
||||
(request.riskLevel === 'high' || request.riskLevel === 'critical') && 'bg-nothing-red'
|
||||
)}
|
||||
/>
|
||||
{riskLabel}
|
||||
</div>
|
||||
|
||||
{/* Action Title */}
|
||||
<h3 className="font-display text-display-sm text-nothing-white truncate">
|
||||
{request.action}
|
||||
</h3>
|
||||
<p className="text-nothing-gray-400 text-sm mt-1">
|
||||
{request.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Signature Counter */}
|
||||
<div className="text-right shrink-0">
|
||||
<div className="font-mono text-xs text-nothing-gray-500 mb-1">
|
||||
{t('signatures')}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'font-mono text-lg',
|
||||
needsMoreSignatures ? 'text-status-warning' : 'text-status-healthy'
|
||||
)}
|
||||
>
|
||||
{request.currentSignatures}/{request.requiredSignatures}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-nothing-gray-800" />
|
||||
|
||||
{/* Blast Radius Section */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-mono text-xs text-nothing-gray-500 tracking-wider">
|
||||
{tBlast('title')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Affected Pods */}
|
||||
<div className="bg-nothing-gray-900 rounded-card p-3">
|
||||
<div className="font-mono text-xs text-nothing-gray-500 mb-1">
|
||||
{tBlast('affectedPods')}
|
||||
</div>
|
||||
<div className="font-mono text-lg text-nothing-white">
|
||||
{request.blastRadius?.affectedPods ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Estimated Downtime */}
|
||||
<div className="bg-nothing-gray-900 rounded-card p-3">
|
||||
<div className="font-mono text-xs text-nothing-gray-500 mb-1">
|
||||
{tBlast('estimatedDowntime')}
|
||||
</div>
|
||||
<div className="font-mono text-lg text-nothing-white">
|
||||
{request.blastRadius?.estimatedDowntime ?? '0'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related Services */}
|
||||
<div className="bg-nothing-gray-900 rounded-card p-3">
|
||||
<div className="font-mono text-xs text-nothing-gray-500 mb-1">
|
||||
{tBlast('relatedServices')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{(request.blastRadius?.relatedServices ?? []).map((service) => (
|
||||
<span
|
||||
key={service}
|
||||
className="px-2 py-0.5 bg-nothing-gray-800 rounded text-xs font-mono text-nothing-gray-300"
|
||||
>
|
||||
{service}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Impact */}
|
||||
<div className="bg-nothing-gray-900 rounded-card p-3">
|
||||
<div className="font-mono text-xs text-nothing-gray-500 mb-1">
|
||||
{tBlast('dataImpact')}
|
||||
</div>
|
||||
<div className={cn('font-mono text-lg', dataImpactStyle.color)}>
|
||||
{dataImpactLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-nothing-gray-800" />
|
||||
|
||||
{/* Dry-Run Checks */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-mono text-xs text-nothing-gray-500 tracking-wider">
|
||||
{tDryRun('validation')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{request.dryRunChecks.map((check) => (
|
||||
<div
|
||||
key={check.name}
|
||||
className="flex items-center justify-between bg-nothing-gray-900 rounded-card px-4 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'w-5 h-5 rounded-full flex items-center justify-center text-xs',
|
||||
check.passed
|
||||
? 'bg-status-healthy/20 text-status-healthy'
|
||||
: 'bg-nothing-red/20 text-nothing-red'
|
||||
)}
|
||||
>
|
||||
{check.passed ? '✓' : '✗'}
|
||||
</div>
|
||||
<span className="font-mono text-sm text-nothing-white">
|
||||
{check.name}
|
||||
</span>
|
||||
</div>
|
||||
{check.message && (
|
||||
<span className="font-mono text-xs text-nothing-gray-500">
|
||||
{check.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-nothing-gray-800" />
|
||||
|
||||
{/* Footer: Meta + Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Meta */}
|
||||
<div className="font-mono text-xs text-nothing-gray-600">
|
||||
<span>{t('requestedBy')} </span>
|
||||
<span className="text-nothing-gray-400">{request.requestedBy}</span>
|
||||
<span className="mx-2">|</span>
|
||||
<span>{request.requestedAt}</span>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Reject Button */}
|
||||
<button
|
||||
onClick={handleReject}
|
||||
className={cn(
|
||||
'px-5 py-2 rounded-button font-mono text-sm transition-all',
|
||||
'border border-nothing-gray-700 text-nothing-gray-400',
|
||||
'hover:border-nothing-red hover:text-nothing-red hover:bg-nothing-red/10'
|
||||
)}
|
||||
>
|
||||
{t('reject')}
|
||||
</button>
|
||||
|
||||
{/* Approve Button - with DESTRUCTIVE protection */}
|
||||
{isDestructive && confirmStep === 1 ? (
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
disabled={!allChecksPassed}
|
||||
className={cn(
|
||||
'px-5 py-2 rounded-button font-mono text-sm transition-all animate-pulse',
|
||||
'bg-nothing-red text-nothing-white border-2 border-nothing-red',
|
||||
'hover:bg-nothing-red/80'
|
||||
)}
|
||||
>
|
||||
{t('confirmDestructive')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleApprove}
|
||||
disabled={!allChecksPassed}
|
||||
className={cn(
|
||||
'px-5 py-2 rounded-button font-mono text-sm transition-all',
|
||||
allChecksPassed
|
||||
? isDestructive
|
||||
? 'bg-nothing-red/20 text-nothing-red border border-nothing-red/50 hover:bg-nothing-red/30'
|
||||
: 'bg-nothing-white text-nothing-black hover:bg-nothing-gray-200'
|
||||
: 'bg-nothing-gray-800 text-nothing-gray-600 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{needsMoreSignatures
|
||||
? t('needMore', { count: request.requiredSignatures - request.currentSignatures })
|
||||
: isDestructive
|
||||
? t('approveDestructive')
|
||||
: t('approve')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== Export Types ====================
|
||||
|
||||
export type { ApprovalRequest, DryRunCheck, BlastRadius, RiskLevel }
|
||||
237
apps/web/src/components/agent/data-pincer.tsx
Normal file
237
apps/web/src/components/agent/data-pincer.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DataPincer - AWOOOI 靈魂視覺元件
|
||||
* =================================
|
||||
* 數據鉗狀態燈:AI 代理的核心狀態指示器
|
||||
* 設計風格:Nothing.tech (極簡 + 呼吸燈 + 毛玻璃)
|
||||
*
|
||||
* i18n: 100% 使用 useTranslations,禁止任何寫死字串
|
||||
* 符合 AWOOOI 專案開發憲法 v2.0
|
||||
*
|
||||
* 狀態映射:
|
||||
* - idle: 灰色靜止
|
||||
* - thinking: 琥珀色呼吸
|
||||
* - executing: 綠色脈動
|
||||
* - waiting_approval: 紅色呼吸 (需要人類介入)
|
||||
* - error: 紅色閃爍
|
||||
*/
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
useAgentStore,
|
||||
selectAgentStatus,
|
||||
selectIsThinking,
|
||||
selectHasError,
|
||||
type AgentStatus,
|
||||
} from '@/stores/agent.store'
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
interface DataPincerProps {
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
showLabel?: boolean
|
||||
showPulse?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
// ==================== Config ====================
|
||||
|
||||
// 狀態樣式配置 (不含 label,label 由 i18n 提供)
|
||||
const statusStyleConfig: Record<
|
||||
AgentStatus,
|
||||
{
|
||||
color: string
|
||||
glowColor: string
|
||||
labelKey: string // i18n key
|
||||
animate: boolean
|
||||
pulseClass: string
|
||||
}
|
||||
> = {
|
||||
idle: {
|
||||
color: 'bg-nothing-gray-600',
|
||||
glowColor: 'shadow-none',
|
||||
labelKey: 'standby',
|
||||
animate: false,
|
||||
pulseClass: '',
|
||||
},
|
||||
thinking: {
|
||||
color: 'bg-status-thinking',
|
||||
glowColor: 'shadow-[0_0_30px_rgba(139,92,246,0.5)]',
|
||||
labelKey: 'analyzing',
|
||||
animate: true,
|
||||
pulseClass: 'animate-breathe',
|
||||
},
|
||||
executing: {
|
||||
color: 'bg-status-healthy',
|
||||
glowColor: 'shadow-[0_0_30px_rgba(34,197,94,0.5)]',
|
||||
labelKey: 'executing',
|
||||
animate: true,
|
||||
pulseClass: 'animate-pulse-slow',
|
||||
},
|
||||
waiting_approval: {
|
||||
color: 'bg-status-critical',
|
||||
glowColor: 'shadow-[0_0_30px_rgba(215,25,33,0.6)]',
|
||||
labelKey: 'waitingApproval',
|
||||
animate: true,
|
||||
pulseClass: 'animate-breathe',
|
||||
},
|
||||
error: {
|
||||
color: 'bg-status-critical',
|
||||
glowColor: 'shadow-[0_0_40px_rgba(215,25,33,0.8)]',
|
||||
labelKey: 'error',
|
||||
animate: true,
|
||||
pulseClass: 'animate-pulse',
|
||||
},
|
||||
}
|
||||
|
||||
const sizeConfig = {
|
||||
sm: {
|
||||
orb: 'w-8 h-8',
|
||||
ring: 'w-12 h-12',
|
||||
outerRing: 'w-16 h-16',
|
||||
label: 'text-xs',
|
||||
},
|
||||
md: {
|
||||
orb: 'w-16 h-16',
|
||||
ring: 'w-24 h-24',
|
||||
outerRing: 'w-32 h-32',
|
||||
label: 'text-sm',
|
||||
},
|
||||
lg: {
|
||||
orb: 'w-24 h-24',
|
||||
ring: 'w-36 h-36',
|
||||
outerRing: 'w-48 h-48',
|
||||
label: 'text-base',
|
||||
},
|
||||
xl: {
|
||||
orb: 'w-32 h-32',
|
||||
ring: 'w-48 h-48',
|
||||
outerRing: 'w-64 h-64',
|
||||
label: 'text-lg',
|
||||
},
|
||||
}
|
||||
|
||||
// ==================== Component ====================
|
||||
|
||||
export function DataPincer({
|
||||
size = 'lg',
|
||||
showLabel = true,
|
||||
showPulse = true,
|
||||
className,
|
||||
}: DataPincerProps) {
|
||||
const t = useTranslations('agent')
|
||||
const status = useAgentStore(selectAgentStatus)
|
||||
const hasError = useAgentStore(selectHasError)
|
||||
|
||||
const config = statusStyleConfig[status]
|
||||
const sizeClass = sizeConfig[size]
|
||||
|
||||
// i18n: 狀態標籤
|
||||
const statusLabel = t(config.labelKey)
|
||||
|
||||
return (
|
||||
<div className={cn('relative flex flex-col items-center', className)}>
|
||||
{/* Outer Pulse Ring (條件渲染) */}
|
||||
{showPulse && config.animate && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full opacity-20',
|
||||
sizeClass.outerRing,
|
||||
config.color,
|
||||
'animate-ping'
|
||||
)}
|
||||
style={{ animationDuration: '2s' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Middle Ring - Glass Effect */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute rounded-full',
|
||||
'bg-gradient-to-br from-white/10 to-transparent',
|
||||
'border border-white/10',
|
||||
'backdrop-blur-sm',
|
||||
sizeClass.ring,
|
||||
config.animate && config.pulseClass
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Core Orb */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-full z-10',
|
||||
'transition-all duration-500',
|
||||
sizeClass.orb,
|
||||
config.color,
|
||||
config.glowColor,
|
||||
config.animate && config.pulseClass
|
||||
)}
|
||||
>
|
||||
{/* Inner Highlight */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-2 rounded-full',
|
||||
'bg-gradient-to-br from-white/30 to-transparent'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Center Dot */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||
'w-1/4 h-1/4 rounded-full',
|
||||
'bg-white/50'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Label */}
|
||||
{showLabel && (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-6 font-mono tracking-wider uppercase',
|
||||
sizeClass.label,
|
||||
hasError ? 'text-status-critical' : 'text-nothing-gray-400',
|
||||
config.animate && 'animate-pulse'
|
||||
)}
|
||||
>
|
||||
{statusLabel}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decorative Lines (Nothing.tech 風格) */}
|
||||
<div className="absolute -inset-4 pointer-events-none">
|
||||
{/* Top Line */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-0 left-1/2 -translate-x-1/2 w-px h-4',
|
||||
'bg-gradient-to-b from-transparent to-nothing-gray-700'
|
||||
)}
|
||||
/>
|
||||
{/* Bottom Line */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-4',
|
||||
'bg-gradient-to-t from-transparent to-nothing-gray-700'
|
||||
)}
|
||||
/>
|
||||
{/* Left Line */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-0 top-1/2 -translate-y-1/2 h-px w-4',
|
||||
'bg-gradient-to-r from-transparent to-nothing-gray-700'
|
||||
)}
|
||||
/>
|
||||
{/* Right Line */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 top-1/2 -translate-y-1/2 h-px w-4',
|
||||
'bg-gradient-to-l from-transparent to-nothing-gray-700'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
apps/web/src/components/agent/index.ts
Normal file
3
apps/web/src/components/agent/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './data-pincer'
|
||||
export * from './thinking-terminal'
|
||||
export * from './approval-card'
|
||||
390
apps/web/src/components/agent/thinking-terminal.tsx
Normal file
390
apps/web/src/components/agent/thinking-terminal.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ThinkingTerminal - AI 思考流終端機 (Phase 4 升級版)
|
||||
*
|
||||
* 顯示 OpenClaw 的思考過程,Nothing.tech 終端機風格
|
||||
* 支援 GraphRAG (Blast Radius / Root Cause) 視覺化
|
||||
* 支援 FinOps 成本分析視覺化
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
useAgentStore,
|
||||
selectThinkingStream,
|
||||
selectAgentStatus,
|
||||
selectError,
|
||||
type ThinkingStep,
|
||||
} from '@/stores/agent.store'
|
||||
|
||||
interface ThinkingTerminalProps {
|
||||
className?: string
|
||||
maxHeight?: string
|
||||
}
|
||||
|
||||
// ==================== GraphRAG 關鍵字偵測 ====================
|
||||
|
||||
const GRAPH_RAG_KEYWORDS = {
|
||||
blast_radius: ['分析爆炸半徑', 'blast radius', 'affected services', '影響範圍'],
|
||||
root_cause: ['找到根本原因', 'root cause', 'probable root', '根本原因分析'],
|
||||
}
|
||||
|
||||
function detectGraphRAGType(content: string): 'blast_radius' | 'root_cause' | null {
|
||||
const lowerContent = content.toLowerCase()
|
||||
for (const keyword of GRAPH_RAG_KEYWORDS.blast_radius) {
|
||||
if (lowerContent.includes(keyword.toLowerCase())) return 'blast_radius'
|
||||
}
|
||||
for (const keyword of GRAPH_RAG_KEYWORDS.root_cause) {
|
||||
if (lowerContent.includes(keyword.toLowerCase())) return 'root_cause'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ==================== 依賴路徑視覺化 ====================
|
||||
|
||||
function DependencyPathVisualizer({
|
||||
paths,
|
||||
direction,
|
||||
}: {
|
||||
paths: string[]
|
||||
direction: 'upstream' | 'downstream'
|
||||
}) {
|
||||
if (paths.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mt-2 p-3 bg-nothing-gray-800 rounded border border-nothing-gray-700">
|
||||
<div className="text-xs text-nothing-gray-500 mb-2">
|
||||
{direction === 'upstream' ? '[ BLAST RADIUS ]' : '[ ROOT CAUSE CHAIN ]'}
|
||||
</div>
|
||||
<div className="font-mono text-sm space-y-1">
|
||||
{paths.map((path, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-nothing-gray-600">{i === 0 ? '>' : '|'}</span>
|
||||
<span
|
||||
className={cn(
|
||||
direction === 'upstream' ? 'text-status-warning' : 'text-status-critical'
|
||||
)}
|
||||
>
|
||||
{path}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceChainVisualizer({
|
||||
services,
|
||||
target,
|
||||
type,
|
||||
}: {
|
||||
services: string[]
|
||||
target: string
|
||||
type: 'blast_radius' | 'root_cause'
|
||||
}) {
|
||||
// 建構 ASCII 風格的依賴圖
|
||||
const isBlastRadius = type === 'blast_radius'
|
||||
|
||||
return (
|
||||
<div className="mt-2 p-3 bg-nothing-gray-800 rounded border border-nothing-gray-700">
|
||||
<div className="text-xs text-nothing-gray-500 mb-2">
|
||||
{isBlastRadius ? '[ UPSTREAM IMPACT ]' : '[ DOWNSTREAM DEPENDENCIES ]'}
|
||||
</div>
|
||||
|
||||
{/* ASCII Art 風格圖形 */}
|
||||
<div className="font-mono text-xs leading-relaxed">
|
||||
{isBlastRadius ? (
|
||||
// Blast Radius: 向上展示誰會受影響
|
||||
<>
|
||||
<div className="text-nothing-gray-600">{' ┌─────────────────────┐'}</div>
|
||||
<div className="text-status-warning">
|
||||
{' │ '}{services.slice(0, 3).join(', ').padEnd(19)}{'│'}
|
||||
</div>
|
||||
<div className="text-nothing-gray-600">{' └─────────┬───────────┘'}</div>
|
||||
<div className="text-nothing-gray-600">{' │ depends on'}</div>
|
||||
<div className="text-nothing-gray-600">{' ▼'}</div>
|
||||
<div className="text-nothing-gray-600">{' ┌─────────────────────┐'}</div>
|
||||
<div className="text-status-critical">
|
||||
{' │ '}{target.padEnd(19)}{'│ '}<span className="animate-pulse">X</span>
|
||||
</div>
|
||||
<div className="text-nothing-gray-600">{' └─────────────────────┘'}</div>
|
||||
</>
|
||||
) : (
|
||||
// Root Cause: 向下展示依賴誰
|
||||
<>
|
||||
<div className="text-nothing-gray-600">{' ┌─────────────────────┐'}</div>
|
||||
<div className="text-status-warning">
|
||||
{' │ '}{target.padEnd(19)}{'│ '}<span className="text-status-warning">!</span>
|
||||
</div>
|
||||
<div className="text-nothing-gray-600">{' └─────────┬───────────┘'}</div>
|
||||
<div className="text-nothing-gray-600">{' │ calls'}</div>
|
||||
<div className="text-nothing-gray-600">{' ▼'}</div>
|
||||
<div className="text-nothing-gray-600">{' ┌─────────────────────┐'}</div>
|
||||
<div className="text-status-critical">
|
||||
{' │ '}{services.slice(0, 3).join(', ').padEnd(19)}{'│ '}<span className="animate-pulse">X</span>
|
||||
</div>
|
||||
<div className="text-nothing-gray-600">{' └─────────────────────┘'}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 詳細清單 */}
|
||||
{services.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-nothing-gray-700">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{services.map((svc, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
'px-2 py-0.5 rounded text-xs',
|
||||
isBlastRadius
|
||||
? 'bg-status-warning/20 text-status-warning'
|
||||
: 'bg-status-critical/20 text-status-critical'
|
||||
)}
|
||||
>
|
||||
{svc}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== FinOps 視覺化 ====================
|
||||
|
||||
function FinOpsVisualizer({ data }: { data: NonNullable<ThinkingStep['finopsData']> }) {
|
||||
return (
|
||||
<div className="mt-2 p-3 bg-nothing-gray-800 rounded border border-nothing-gray-700">
|
||||
<div className="text-xs text-nothing-gray-500 mb-2">[ FINOPS ANALYSIS ]</div>
|
||||
|
||||
{/* 成本摘要 */}
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
<div className="text-center p-2 bg-nothing-gray-900 rounded">
|
||||
<div className="text-lg font-bold text-status-critical">
|
||||
${data.totalWastedUsd.toFixed(0)}
|
||||
</div>
|
||||
<div className="text-xs text-nothing-gray-500">Wasted/mo</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-nothing-gray-900 rounded">
|
||||
<div className="text-lg font-bold text-status-healthy">
|
||||
${data.realizableSavingsUsd.toFixed(0)}
|
||||
</div>
|
||||
<div className="text-xs text-nothing-gray-500">Realizable</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-nothing-gray-900 rounded">
|
||||
<div className="text-lg font-bold text-status-warning">
|
||||
${data.freedResourcesUsd.toFixed(0)}
|
||||
</div>
|
||||
<div className="text-xs text-nothing-gray-500">Freed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Actions */}
|
||||
{data.topActions.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{data.topActions.slice(0, 3).map((action, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between text-xs py-1 border-b border-nothing-gray-700 last:border-0"
|
||||
>
|
||||
<span className="text-nothing-gray-300 truncate max-w-[200px]">
|
||||
{action.action}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono',
|
||||
action.risk === 'low' && 'text-status-healthy',
|
||||
action.risk === 'medium' && 'text-status-warning',
|
||||
action.risk === 'high' && 'text-status-critical'
|
||||
)}
|
||||
>
|
||||
-${action.savings.toFixed(0)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== Step Renderer ====================
|
||||
|
||||
function ThinkingStepRenderer({ step }: { step: ThinkingStep }) {
|
||||
// 偵測 GraphRAG 相關內容
|
||||
const detectedType = useMemo(() => detectGraphRAGType(step.content), [step.content])
|
||||
|
||||
// 基礎渲染
|
||||
const baseContent = (
|
||||
<div
|
||||
className={cn(
|
||||
'py-0.5 animate-fade-in',
|
||||
step.type === 'result' && 'text-status-healthy',
|
||||
step.type === 'thinking' && 'text-nothing-gray-300',
|
||||
step.type === 'error' && 'text-status-critical',
|
||||
step.type === 'graph_rag' && 'text-status-thinking',
|
||||
step.type === 'finops' && 'text-status-warning'
|
||||
)}
|
||||
>
|
||||
<span className="text-nothing-gray-600 mr-2">
|
||||
[{step.type.toUpperCase()}]
|
||||
</span>
|
||||
{step.content}
|
||||
</div>
|
||||
)
|
||||
|
||||
// GraphRAG 結構化資料渲染
|
||||
if (step.graphData) {
|
||||
const { analysisType, targetService, affectedServices, probableRootCauses, criticalPath } =
|
||||
step.graphData
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{baseContent}
|
||||
{analysisType === 'blast_radius' && affectedServices && (
|
||||
<ServiceChainVisualizer
|
||||
services={affectedServices}
|
||||
target={targetService}
|
||||
type="blast_radius"
|
||||
/>
|
||||
)}
|
||||
{analysisType === 'root_cause' && probableRootCauses && (
|
||||
<ServiceChainVisualizer
|
||||
services={probableRootCauses}
|
||||
target={targetService}
|
||||
type="root_cause"
|
||||
/>
|
||||
)}
|
||||
{criticalPath && criticalPath.length > 0 && (
|
||||
<DependencyPathVisualizer
|
||||
paths={criticalPath}
|
||||
direction={analysisType === 'blast_radius' ? 'upstream' : 'downstream'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// FinOps 結構化資料渲染
|
||||
if (step.finopsData) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{baseContent}
|
||||
<FinOpsVisualizer data={step.finopsData} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 關鍵字偵測 (無結構化資料時的 fallback)
|
||||
if (detectedType && !step.graphData) {
|
||||
// 從文字內容嘗試解析服務名稱
|
||||
const servicePattern = /([a-z]+-[a-z]+(-[a-z]+)?)/gi
|
||||
const matches = step.content.match(servicePattern) || []
|
||||
|
||||
if (matches.length > 0) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{baseContent}
|
||||
<DependencyPathVisualizer
|
||||
paths={matches.slice(0, 5)}
|
||||
direction={detectedType === 'blast_radius' ? 'upstream' : 'downstream'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return baseContent
|
||||
}
|
||||
|
||||
export function ThinkingTerminal({
|
||||
className,
|
||||
maxHeight = '300px',
|
||||
}: ThinkingTerminalProps) {
|
||||
const thinkingStream = useAgentStore(selectThinkingStream)
|
||||
const status = useAgentStore(selectAgentStatus)
|
||||
const error = useAgentStore(selectError)
|
||||
const startThinkingStream = useAgentStore((s) => s.startThinkingStream)
|
||||
const stopThinkingStream = useAgentStore((s) => s.stopThinkingStream)
|
||||
|
||||
const isStreaming = status === 'thinking'
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopThinkingStream()
|
||||
}
|
||||
}, [stopThinkingStream])
|
||||
|
||||
return (
|
||||
<div className={cn('glass-panel p-6 w-full space-y-4', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-nothing-gray-800 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Terminal Icon */}
|
||||
<div className="flex gap-1">
|
||||
<div className="w-3 h-3 rounded-full bg-status-critical" />
|
||||
<div className="w-3 h-3 rounded-full bg-status-thinking" />
|
||||
<div className="w-3 h-3 rounded-full bg-status-healthy" />
|
||||
</div>
|
||||
<h2 className="font-display text-display-sm">AWOOOI Terminal</h2>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-nothing-gray-500">
|
||||
v0.1.0 | SSE
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Control Button */}
|
||||
<button
|
||||
onClick={() => startThinkingStream()}
|
||||
disabled={isStreaming}
|
||||
className={cn(
|
||||
'w-full py-3 px-4 rounded-button font-mono text-sm transition-all border',
|
||||
isStreaming
|
||||
? 'bg-nothing-gray-800 text-nothing-gray-500 border-nothing-gray-700 cursor-not-allowed'
|
||||
: 'bg-nothing-white text-nothing-black border-transparent hover:bg-nothing-gray-200'
|
||||
)}
|
||||
>
|
||||
{isStreaming ? '>_ EXECUTING...' : 'INITIATE SYNC'}
|
||||
</button>
|
||||
|
||||
{/* Terminal Output */}
|
||||
<div
|
||||
className="bg-nothing-gray-900 rounded-card p-4 overflow-y-auto font-mono text-sm"
|
||||
style={{ maxHeight, minHeight: '150px' }}
|
||||
>
|
||||
{thinkingStream.length === 0 && !isStreaming && !error && (
|
||||
<div className="text-nothing-gray-600">
|
||||
{'>'} Waiting for command...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{thinkingStream.map((step, index) => (
|
||||
<ThinkingStepRenderer key={index} step={step} />
|
||||
))}
|
||||
|
||||
{/* Cursor Animation */}
|
||||
{isStreaming && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="text-nothing-gray-500">{'>'}</span>
|
||||
<span className="w-2 h-4 bg-nothing-white animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-nothing-gray-800">
|
||||
<p className="font-mono text-xs text-nothing-gray-600">
|
||||
STREAM: /agent/thinking
|
||||
</p>
|
||||
<p className="font-mono text-xs text-nothing-gray-600">
|
||||
{thinkingStream.length} events
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user