- 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>
391 lines
13 KiB
TypeScript
391 lines
13 KiB
TypeScript
'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>
|
||
)
|
||
}
|