Files
awoooi/docs/adr/ADR-031-omni-terminal-sse-architecture.md
OG T 22de22c989 refactor(phase-s): Phase S 技術債清理 - 五項架構改善
S-01: generate_alert_fingerprint() 移至 alert_analyzer_service (Router→Service)
S-02: 移除廢棄 USE_NEW_ENGINE config (Phase R 已完成歷史使命)
S-03: github_webhook.py linter 清理 (Field unused + delivery_id noqa)
S-04: Pydantic v2 遷移 - approval/incident models (class Config → ConfigDict)
S-05: Skill 09 v1.1 更新 (USE_NEW_ENGINE 廢棄說明)

測試: 393 passed, 零失敗

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 13:12:02 +08:00

10 KiB
Raw Blame History

ADR-031: Omni-Terminal SSE 架構

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

背景

AWOOOI Phase 19 計畫將平台從「傳統監控儀表板」轉型為「AI 代理人協作空間 (Agentic Workspace)」。核心組件 Omni-Terminal 需要即時雙向通訊能力,支援:

  1. AI 思考軌跡串流 - 展示 OpenClaw 分析過程
  2. 動態 UI 生成 (GenUI) - 根據 AI 回應動態掛載組件
  3. 核鑰授權請求 - 高風險操作的確認流程
  4. 斷線恢復 - 確保通訊穩定性

現有架構約束

  • apps/api/src/core/sse.py 已有 EventPublisher 實作
  • apps/api/src/api/v1/agents.py 使用混合 SSE 模式
  • 前端 terminal.store.ts 目前使用 Mock 實作
  • 必須符合 leWOOOgo 積木化原則

決策

1. 採用混合 SSE 模式 (POST Intent + GET Stream)

┌─────────┐     POST /terminal/intent     ┌─────────┐
│ Browser │ ────────────────────────────► │   API   │
│ (Web)   │ ◄──── { session_id: "xxx" } ─ │ Server  │
└─────────┘                               └─────────┘
     │
     │       GET /terminal/stream/{session_id}
     ▼
┌─────────┐     SSE (EventSource)         ┌─────────┐
│ Browser │ ◄─────────────────────────── │   API   │
│ (Web)   │                              │ Server  │
└─────────┘                               └─────────┘

2. API 端點設計

端點 方法 說明
/api/v1/terminal/intent POST 提交意圖,返回 session_id
/api/v1/terminal/stream/{session_id} GET SSE 訂閱 (EventSource 原生)
/api/v1/terminal/abort/{session_id} POST 中斷執行
/api/v1/terminal/status/{session_id} GET 查詢 Session 狀態

3. SSE 事件類型

class TerminalEventType(str, Enum):
    # 思考軌跡
    TERMINAL_THOUGHT = "terminal_thought"
    TERMINAL_TOOL_CALL = "terminal_tool_call"

    # GenUI
    TERMINAL_RENDER_UI = "terminal_render_ui"

    # 授權
    TERMINAL_ACTION_REQUEST = "terminal_action_request"
    TERMINAL_ACTION_RESULT = "terminal_action_result"

    # 控制
    TERMINAL_COMPLETE = "terminal_complete"
    TERMINAL_ERROR = "terminal_error"
    TERMINAL_HEARTBEAT = "terminal_heartbeat"

4. 前端 SSE 狀態機 (7 狀態)

type SSEConnectionState =
  | 'disconnected'   // 未連接
  | 'connecting'     // 正在連接
  | 'subscribing'    // 訂閱中
  | 'connected'      // 已連接 (空閒)
  | 'streaming'      // 串流中 (AI 回應)
  | 'reconnecting'   // 重連中
  | 'error'          // 錯誤

// 合法狀態轉換
const VALID_TRANSITIONS = {
  disconnected: ['connecting'],
  connecting: ['subscribing', 'error', 'disconnected'],
  subscribing: ['connected', 'error'],
  connected: ['streaming', 'reconnecting', 'disconnected'],
  streaming: ['connected', 'error'],
  reconnecting: ['connecting', 'error', 'disconnected'],
  error: ['reconnecting', 'disconnected'],
}

5. Session 儲存策略

  • 儲存位置: Redis (與 Multi-Sig 共用實例)
  • TTL: 5 分鐘 (無活動自動過期)
  • Key 格式: terminal:session:{session_id}
  • 資料結構:
class TerminalSession(BaseModel):
    session_id: str
    user_id: str
    intent: str
    context: SpatialContext
    status: Literal["pending", "processing", "completed", "aborted", "error"]
    created_at: datetime
    last_event_id: int = 0

6. 重連策略

  • 指數退避: 1s → 2s → 4s → 8s → 16s (最大)
  • Last-Event-ID: 支援斷點續傳
  • 最大重試: 5 次
  • 重連後: 自動恢復訂閱

7. 分層架構

apps/api/src/
├── models/
│   └── terminal.py              # Pydantic 模型
├── services/
│   ├── terminal_service.py      # ITerminalService + 實作
│   └── terminal_session.py      # Session 管理 (Redis)
├── api/v1/
│   └── terminal.py              # 純路由 (無業務邏輯)
└── core/
    └── sse.py                   # 新增 TerminalEventType

理由

為什麼選擇混合模式而非純 POST SSE

考量 POST SSE 混合模式
瀏覽器支援 fetch-event-source 原生 EventSource
與現有架構一致性 不一致 與 agents.py 一致
中斷/恢復能力 複雜 原生支援 Last-Event-ID
偵錯友善度 可直接 curl

為什麼選擇 Redis 而非記憶體?

考量 記憶體 Redis
Pod 重啟 丟失 保留
水平擴展 黏性 Session 無狀態
複雜度
K3s 環境 不適用 必要

為什麼需要 7 狀態機?

現有 terminal.store.ts 使用布林標記 (isConnecting, isConnected),無法表達:

  • 訂閱中 vs 已連接
  • 串流中 vs 空閒
  • 重連中的不同階段

狀態機提供:

  • 明確的狀態轉換規則
  • 防止非法狀態 (如「未連接但串流中」)
  • 便於偵錯和可觀測性

後果

優點

  1. 與現有架構一致 - 複用 EventPublisher、與 agents.py 模式相同
  2. 原生瀏覽器支援 - 不需額外套件
  3. 斷點續傳 - Last-Event-ID 支援
  4. K3s 友善 - Redis Session 支援無狀態部署
  5. 可觀測 - 狀態機提供清晰的狀態追蹤

缺點

  1. 兩次請求 - POST + GET 比單一 POST SSE 多一次 roundtrip
  2. Redis 依賴 - 增加基礎設施依賴
  3. Session 管理 - 需處理 TTL、清理等

風險

風險 緩解策略
SSE 被 Nginx 緩衝 確認 X-Accel-Buffering: no Header
Redis 連線失敗 Fallback 到記憶體模式 + 告警
Session 過期競爭 串流中延長 TTL
前端狀態不一致 狀態機強制轉換驗證

實作指引

後端 (Phase 19.1)

# api/v1/terminal.py - Router 層
@router.post("/intent")
async def handle_intent(
    request: TerminalRequest,
    service: ITerminalService = Depends(get_terminal_service)
) -> IntentResponse:
    session_id = await service.process_intent(request)
    return IntentResponse(session_id=session_id, stream_url=f"/terminal/stream/{session_id}")

@router.get("/stream/{session_id}")
async def stream_response(
    session_id: str,
    service: ITerminalService = Depends(get_terminal_service)
) -> StreamingResponse:
    async def generate():
        publisher = await get_publisher()
        client = await publisher.subscribe(topics=[f"terminal:{session_id}"])
        async for event_str in publisher.stream(client):
            yield event_str

    return StreamingResponse(
        generate(),
        media_type="text/event-stream",
        headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"}
    )

前端 (Phase 19.2)

// stores/terminal.store.ts
const useTerminalStore = create<TerminalState>()((set, get) => ({
  connectionState: 'disconnected' as SSEConnectionState,

  async sendIntent(intent: string, context: SpatialContext) {
    const { transitionTo } = get()

    transitionTo('connecting')

    // Step 1: POST intent
    const { session_id, stream_url } = await fetch('/api/v1/terminal/intent', {
      method: 'POST',
      body: JSON.stringify({ intent, context }),
    }).then(r => r.json())

    transitionTo('subscribing')

    // Step 2: GET stream
    const es = new EventSource(stream_url)
    es.onopen = () => transitionTo('connected')
    es.onmessage = (e) => {
      transitionTo('streaming')
      // Handle event...
    }
    es.onerror = () => transitionTo('error')
  },
}))

實作紀錄

更新日期: 2026-03-31 (Phase 19.6 完成) 更新者: Claude Code (首席架構師) 首席架構師審查: Phase 19 審查 47/50 (SSE 狀態機 10/10 )

已完成項目

項目 檔案 狀態
後端 Router apps/api/src/api/v1/terminal.py
後端 Service apps/api/src/services/terminal_service.py
後端 Models apps/api/src/models/terminal.py
前端 Store apps/web/src/stores/terminal.store.ts
前端 UI apps/web/src/components/terminal/OmniTerminal.tsx
Telemetry apps/web/src/lib/telemetry/terminal-telemetry.ts
單元測試 (Service) apps/api/tests/test_terminal_service.py (54 項通過)
單元測試 (Router) apps/api/tests/test_terminal.py (Phase 19.6)
單元測試 (GenUI) apps/web/src/components/genui/__tests__/registry.test.ts (Phase 19.6, Vitest)
E2E 測試 apps/web/tests/e2e/terminal.spec.ts (Phase 19.6, Playwright)

P0-P2 修復紀錄

優先級 修復項目 說明
P0 Singleton → FastAPI Depends get_terminal_service() 依賴注入
P2 Slow Query 監控 5s 警告 / 10s 嚴重 + Sentry 告警

Phase 19.6 測試補全 (2026-03-31)

測試類型 檔案 覆蓋範圍
Router 層 test_terminal.py 4 端點 + 422/404 錯誤案例 + Session 流程
GenUI Zod registry.test.ts 7 組件 Schema + validateProps 錯誤碼分類
E2E API terminal.spec.ts POST/GET/Abort 完整流程 + 404/422 驗證

驗證結果

# Python 測試
cd apps/api && python -m pytest tests/test_terminal_service.py tests/test_terminal.py -v
# Service: 54 passed; Router: ~18 passed

# GenUI Zod 單元測試 (需先 pnpm install 安裝 vitest)
cd apps/web && pnpm vitest run

# E2E 測試
cd apps/web && pnpm playwright test tests/e2e/terminal.spec.ts

# 意圖分類覆蓋
# - 42 個分類測試案例
# - 9 種 IntentType 全覆蓋

參考