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:
OG T
2026-03-22 18:57:44 +08:00
parent a840bf975b
commit 196d269b92
245 changed files with 42207 additions and 6 deletions

View 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 }

View 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 ====================
// 狀態樣式配置 (不含 labellabel 由 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>
)
}

View File

@@ -0,0 +1,3 @@
export * from './data-pincer'
export * from './thinking-terminal'
export * from './approval-card'

View 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>
)
}