# ADR-032: GenUI 動態渲染機制

> **狀態**: Accepted
> **日期**: 2026-03-27
> **決策者**: 首席架構師 (Claude Code)
> **審核者**: 統帥 (ogt)

## 背景

AWOOOI Phase 19 Omni-Terminal 需要「生成式介面 (GenUI)」能力，讓 AI 能夠根據分析結果動態渲染 UI 組件。例如：

- 顯示審批卡片 (ApprovalCard)
- 顯示指標摘要 (MetricsSummaryCard)
- 顯示執行進度 (ExecutionProgressCard)
- 顯示錯誤追蹤 (SentryErrorCard)

### 設計挑戰

1. **安全性** - 不能執行任意 JavaScript
2. **類型安全** - Props 必須有嚴格類型定義
3. **效能** - 避免運行時動態載入大量程式碼
4. **可維護性** - 新增卡片類型應該簡單

### 現有參考

- `ApprovalCard.tsx` 已存在，需要整合到 GenUI 系統
- 設計系統遵循 Nothing.tech 風格 (ADR-002)

## 決策

### 1. 採用預編譯 Registry 模式

```typescript
// genui/registry.ts
import { lazy } from 'react'

export const GENUI_REGISTRY = {
  ApprovalCard: lazy(() => import('./cards/ApprovalCard')),
  MetricsSummaryCard: lazy(() => import('./cards/MetricsSummaryCard')),
  ExecutionProgressCard: lazy(() => import('./cards/ExecutionProgressCard')),
  SentryErrorCard: lazy(() => import('./cards/SentryErrorCard')),
  LogViewerCard: lazy(() => import('./cards/LogViewerCard')),
  TimelineCard: lazy(() => import('./cards/TimelineCard')),
} as const

export type GenUICardType = keyof typeof GENUI_REGISTRY
```

### 2. SSE 事件格式

```typescript
// terminal_render_ui 事件
interface RenderUIEvent {
  type: 'terminal_render_ui'
  data: {
    component: GenUICardType  // 必須是 Registry 中的 key
    props: Record<string, unknown>
    position?: 'inline' | 'modal' | 'panel'
    id?: string  // 用於更新/移除
  }
}
```

### 3. 動態渲染器

```typescript
// genui/GenUIRenderer.tsx
import { Suspense } from 'react'
import { GENUI_REGISTRY, GenUICardType } from './registry'

interface GenUIRendererProps {
  component: GenUICardType
  props: Record<string, unknown>
}

export function GenUIRenderer({ component, props }: GenUIRendererProps) {
  const Component = GENUI_REGISTRY[component]

  if (!Component) {
    console.error(`[GenUI] Unknown component: ${component}`)
    return <UnknownCardFallback name={component} />
  }

  return (
    <Suspense fallback={<CardSkeleton />}>
      <Component {...props} />
    </Suspense>
  )
}
```

### 4. Props 類型定義

```typescript
// genui/types.ts
export interface ApprovalCardProps {
  approvalId: string
  riskLevel: 'LOW' | 'MEDIUM' | 'CRITICAL'
  kubectl: string
  title?: string
  description?: string
}

export interface MetricsSummaryCardProps {
  cpu: number
  memory: number
  pods: { running: number; total: number }
  timestamp: string
}

export interface ExecutionProgressCardProps {
  stepId: string
  steps: Array<{
    name: string
    status: 'pending' | 'running' | 'completed' | 'failed'
    output?: string
  }>
}

// ... 其他卡片類型

export type GenUIPropsMap = {
  ApprovalCard: ApprovalCardProps
  MetricsSummaryCard: MetricsSummaryCardProps
  ExecutionProgressCard: ExecutionProgressCardProps
  // ...
}
```

### 5. 6 張核心 GenUI 卡片

| 卡片 | 用途 | 觸發場景 |
|------|------|----------|
| **ApprovalCard** | 核鑰授權 | AI 提出高風險操作 |
| **MetricsSummaryCard** | 指標摘要 | 查詢系統狀態 |
| **ExecutionProgressCard** | 執行進度 | 執行 kubectl/腳本 |
| **SentryErrorCard** | 錯誤追蹤 | Sentry 事件分析 |
| **LogViewerCard** | 日誌查看 | 查詢 Pod 日誌 |
| **TimelineCard** | 事件時間軸 | 事件回顧/根因分析 |

### 6. 後端驗證

```python
# services/terminal_service.py
ALLOWED_COMPONENTS = {
    "ApprovalCard",
    "MetricsSummaryCard",
    "ExecutionProgressCard",
    "SentryErrorCard",
    "LogViewerCard",
    "TimelineCard",
}

def validate_render_ui_event(component: str, props: dict) -> bool:
    if component not in ALLOWED_COMPONENTS:
        raise ValueError(f"Unknown GenUI component: {component}")

    # Props 驗證使用 Pydantic
    # ...
```

## 理由

### 為什麼選擇預編譯而非運行時？

| 方案 | 安全性 | 效能 | 類型安全 | 複雜度 |
|------|--------|------|----------|--------|
| **預編譯 Registry** | ✅ 高 | ✅ 高 | ✅ 完整 | 低 |
| 運行時 eval | ❌ 危險 | ❌ 低 | ❌ 無 | 高 |
| 伺服器渲染 | 中 | 中 | 中 | 高 |
| MDX/Markdown | 中 | 高 | ❌ 有限 | 中 |

**預編譯優勢**:
1. **零 eval 風險** - 只能渲染已註冊的組件
2. **Code Splitting** - lazy() 自動分割
3. **類型安全** - GenUIPropsMap 強制 Props 類型
4. **首屏快** - 不需下載額外 runtime

### 為什麼限制 6 張卡片？

- **避免膨脹** - 每張卡片增加 bundle size
- **聚焦核心** - 覆蓋 80% 使用場景
- **可擴展** - 未來按需新增

## 後果

### 優點

1. **安全** - 不執行任意程式碼，只渲染預定義組件
2. **類型安全** - TypeScript 完整覆蓋
3. **效能** - React.lazy 自動 code splitting
4. **可預測** - 組件行為固定，易於測試
5. **可觀測** - 每張卡片有獨立的 Sentry error boundary

### 缺點

1. **靈活性受限** - 新卡片需要開發部署
2. **前後端同步** - 新增卡片需同時更新 Registry 和 ALLOWED_COMPONENTS
3. **Props 演進** - 舊版前端可能收到未知 props

### 風險

| 風險 | 緩解策略 |
|------|----------|
| 未知組件名稱 | UnknownCardFallback 優雅降級 |
| Props 類型不符 | Pydantic 後端驗證 + Zod 前端驗證 |
| Lazy 載入失敗 | ErrorBoundary 包裹 + 重試機制 |
| Bundle 過大 | 限制卡片數量 + 定期審計 |

## 實作指引

### 檔案結構

```
apps/web/src/components/genui/
├── registry.ts              # 組件註冊表
├── types.ts                 # Props 類型定義
├── GenUIRenderer.tsx        # 動態渲染器
├── CardSkeleton.tsx         # 載入骨架
├── UnknownCardFallback.tsx  # 未知組件 fallback
└── cards/
    ├── ApprovalCard.tsx         # 核鑰授權
    ├── MetricsSummaryCard.tsx   # 指標摘要
    ├── ExecutionProgressCard.tsx # 執行進度
    ├── SentryErrorCard.tsx      # 錯誤追蹤
    ├── LogViewerCard.tsx        # 日誌查看
    └── TimelineCard.tsx         # 事件時間軸
```

### 新增卡片流程

1. **定義 Props 類型** - `genui/types.ts`
2. **建立卡片組件** - `genui/cards/NewCard.tsx`
3. **註冊到 Registry** - `genui/registry.ts`
4. **後端允許清單** - `terminal_service.py`
5. **測試** - Storybook + 整合測試

### Props 驗證範例 (Zod)

```typescript
// genui/validation.ts
import { z } from 'zod'

export const ApprovalCardPropsSchema = z.object({
  approvalId: z.string(),
  riskLevel: z.enum(['LOW', 'MEDIUM', 'CRITICAL']),
  kubectl: z.string(),
  title: z.string().optional(),
  description: z.string().optional(),
})

export function validateGenUIProps<T extends GenUICardType>(
  component: T,
  props: unknown
): GenUIPropsMap[T] {
  const schema = SCHEMA_MAP[component]
  return schema.parse(props)
}
```

## 實作紀錄

> **更新日期**: 2026-03-28
> **更新者**: Claude Code (首席架構師)
> **首席架構師審查**: Phase 19 審查 47/50 (GenUI 架構 9/10)

### 已完成項目

| 項目 | 檔案 | 狀態 |
|------|------|------|
| Registry (Lazy Loading) | `apps/web/src/components/genui/registry.ts` | ✅ |
| 動態渲染器 | `apps/web/src/components/genui/GenUIRenderer.tsx` | ✅ |
| ApprovalCard | `apps/web/src/components/genui/ApprovalCard.tsx` | ✅ |
| MetricsSummaryCard | `apps/web/src/components/genui/MetricsSummaryCard.tsx` | ✅ |
| SentryErrorCard | `apps/web/src/components/genui/SentryErrorCard.tsx` | ✅ |
| IncidentTimelineCard | `apps/web/src/components/genui/IncidentTimelineCard.tsx` | ✅ |
| K8sPodStatusCard | `apps/web/src/components/genui/K8sPodStatusCard.tsx` | ✅ |
| TraceWaterfallCard | `apps/web/src/components/genui/TraceWaterfallCard.tsx` | ✅ |
| NuclearKeyButton | `apps/web/src/components/genui/NuclearKeyButton.tsx` | ✅ |
| Telemetry 整合 | `apps/web/src/lib/telemetry/terminal-telemetry.ts` | ✅ |

### P1 修復紀錄 (Zod Schema 升級)

| Schema | 驗證內容 |
|--------|----------|
| `ApprovalCardSchema` | riskLevel enum 驗證 |
| `MetricsSummaryCardSchema` | 百分比/時間格式驗證 (regex) |
| `K8sPodStatusCardSchema` | 巢狀物件結構驗證 |
| `NuclearKeyButtonSchema` | risk level enum 驗證 |
| `SentryErrorCardSchema` | errorId/title 必填 |
| `IncidentTimelineCardSchema` | events 陣列 + status enum |
| `TraceWaterfallCardSchema` | spans 陣列 + duration 數值 |

### 錯誤分類碼 (Sentry 聚合)

```typescript
errorCode?:
  | 'NOT_REGISTERED'      // 組件未註冊
  | 'DEF_NOT_FOUND'       // 定義找不到
  | 'ZOD_VALIDATION_FAILED' // Zod 驗證失敗
  | 'LEGACY_TYPE_MISMATCH'  // 舊版類型不符
  | 'RENDER_ERROR'        // 渲染錯誤
```

---

## 參考

- [ADR-002 Nothing.tech Design System](./ADR-002-nothing-tech-design-system.md) - 設計規範
- [ADR-031 Omni-Terminal SSE Architecture](./ADR-031-omni-terminal-sse-architecture.md) - SSE 事件格式
- [ApprovalCard.tsx](../../apps/web/src/components/genui/ApprovalCard.tsx) - 現有實作
- [React.lazy 文件](https://react.dev/reference/react/lazy)
- [Phase 19 工作規格書](../../.claude/projects/-Users-ogt-awoooi/memory/project_phase19_omni_terminal.md)
