Files
awoooi/apps/web/src/components/agent/thinking-terminal.tsx
OG T 196d269b92 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>
2026-03-22 18:57:44 +08:00

391 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)
}