Files
awoooi/apps/web/src/stores/terminal.store.ts
Your Name d0591c54b0
Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 35s
fix(security): 體健修復 — 7項 Critical/Major 安全問題全修
## Critical 修復 (C1-C5)
- C1: git rm --cached 03-secrets.yaml(CHANGE_ME 模板不再追蹤)
- C2: git rm --cached awoooi.db + .gitignore 加 *.db(SQLite HARD_RULES 違規)
- C3: sentry-tunnel SENTRY_HOST 改為 process.env fallback
- C4: config.py DATABASE_URL 移除 changeme default,改為必填
- C5: run_migration.py 改為 os.environ["DATABASE_URL"]

## Major 修復 (M1-M4)
- M1: auto_repair /execute 加 CSRF 保護 + AutoRepairPanel.tsx 同步
- M2: drift /rollback /adopt 加 CSRF 保護(/internal/scan 保持無 CSRF)
- M3: terminal /intent 加 CSRF 保護 + terminal.store.ts 同步
- M4: live-dashboard HOST_IPS + host-grid VIP 改為 env var

## 其他
- 新增 apps/web/.env.example(6 個 env var 說明)
- K8s deployment-web 補入 3 個新 env var
- 整合測試:新增 aider_event_repository + ai_router_feedback 真實 DB 測試
- test_terminal.py CSRF dependency override 修復

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 01:27:39 +08:00

628 lines
17 KiB
TypeScript

/**
* AWOOOI Terminal Store
* ==========================
* Phase 19 - Omni-Terminal SSE State Machine
*
* 核心功能:
* 1. 7-State SSE 連線狀態機
* 2. 混合模式 SSE (POST intent + GET stream)
* 3. 指數退避重連
* 4. Last-Event-ID 斷點續傳
*
* @see ADR-031 Omni-Terminal SSE Architecture
* @see lib/constants/sse-states.ts
*
* @author Claude Code (首席架構師)
* @version 2.0.0
* @date 2026-03-28 (台北時間)
*/
import { create } from 'zustand'
import {
type SSEConnectionState,
type TerminalSession,
type TerminalEventType,
type SSEEventData,
isValidTransition,
getReconnectDelay,
shouldRetry,
createSession,
getStateInfo,
} from '@/lib/constants/sse-states'
import {
trackIntentSubmit,
trackIntentComplete,
trackSSEConnection,
setTerminalContext,
clearTerminalContext,
} from '@/lib/telemetry'
// =============================================================================
// Types
// =============================================================================
export type MessageRole = 'system' | 'user' | 'assistant'
export type MessageType = 'text' | 'tool_call' | 'thought' | 'render_ui'
export interface TerminalMessage {
id: string
role: MessageRole
type: MessageType
content: string
payload?: Record<string, unknown>
timestamp: Date
}
/** Ghost Payload - 空間感知上下文 */
export interface SpatialContext {
/** 當前頁面路由 */
currentPage: string
/** 聚焦的實體 ID (incident/approval) */
focusedEntityId?: string
}
/** Intent 請求回應 (snake_case from backend) */
interface IntentResponse {
session_id: string
stream_url: string
created_at: string
}
// =============================================================================
// Store State
// =============================================================================
interface TerminalState {
// UI 狀態
isOpen: boolean
// SSE 連線狀態機
connectionState: SSEConnectionState
session: TerminalSession | null
reconnectAttempt: number
// 訊息
messages: TerminalMessage[]
// 內部引用
_eventSource: EventSource | null
_abortController: AbortController | null
// Actions - UI
toggleTerminal: () => void
openTerminal: () => void
closeTerminal: () => void
// Actions - SSE 狀態機
transitionTo: (newState: SSEConnectionState) => void
connect: () => Promise<void>
disconnect: () => void
// Actions - 訊息
sendIntent: (text: string, context?: SpatialContext) => Promise<void>
appendMessage: (message: Omit<TerminalMessage, 'id' | 'timestamp'>) => void
updateLastMessage: (contentChunk: string) => void
clearMessages: () => void
// Actions - 內部
_subscribeToStream: (sessionId: string) => Promise<void>
_handleSSEEvent: (event: MessageEvent) => void
_handleSSEError: (error: Event) => void
_reconnect: () => void
// Selectors (computed)
getStateInfo: () => ReturnType<typeof getStateInfo>
}
// =============================================================================
// API Base URL
// =============================================================================
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || ''
// =============================================================================
// Store Implementation
// =============================================================================
export const useTerminalStore = create<TerminalState>((set, get) => ({
// ---------------------------------------------------------------------------
// Initial State
// ---------------------------------------------------------------------------
isOpen: false,
connectionState: 'disconnected',
session: null,
reconnectAttempt: 0,
messages: [
{
id: 'sys-init',
role: 'system',
type: 'text',
content: 'AWOOOI OS [Version 2.0.0]\n(c) WOOO Corporation. All rights reserved.',
timestamp: new Date(),
},
{
id: 'sys-ready',
role: 'system',
type: 'text',
content: 'System ready. Press ⌘J to toggle terminal.',
timestamp: new Date(),
},
],
_eventSource: null,
_abortController: null,
// ---------------------------------------------------------------------------
// UI Actions
// ---------------------------------------------------------------------------
toggleTerminal: () => set((state) => ({ isOpen: !state.isOpen })),
openTerminal: () => set({ isOpen: true }),
closeTerminal: () => {
get().disconnect()
set({ isOpen: false })
},
// ---------------------------------------------------------------------------
// SSE State Machine
// ---------------------------------------------------------------------------
transitionTo: (newState: SSEConnectionState) => {
const currentState = get().connectionState
// 驗證狀態轉換
if (!isValidTransition(currentState, newState)) {
console.warn(
`[Terminal] Invalid state transition: ${currentState}${newState}`
)
return
}
console.log(`[Terminal] State: ${currentState}${newState}`)
set({ connectionState: newState })
// 錯誤狀態自動觸發重連
if (newState === 'error') {
const { reconnectAttempt } = get()
if (shouldRetry(reconnectAttempt)) {
const delay = getReconnectDelay(reconnectAttempt)
console.log(`[Terminal] Reconnecting in ${delay}ms (attempt ${reconnectAttempt + 1})`)
setTimeout(() => get()._reconnect(), delay)
}
}
},
connect: async () => {
// Note: In Hybrid SSE mode, connect() is for reconnection scenarios only
// The actual connection happens in _subscribeToStream() after POST /intent
const state = get()
// 已連接則跳過
if (state.connectionState === 'connected' || state.connectionState === 'streaming') {
return
}
// 沒有有效 session 則無法連接
if (!state.session?.sessionId) {
console.warn('[Terminal] No valid session for reconnection')
return
}
// 嘗試重新訂閱現有 session
await get()._subscribeToStream(state.session.sessionId)
},
/**
* 訂閱 SSE 串流 (內部方法)
* ADR-031: Hybrid SSE 模式 - POST intent 後才訂閱
*/
_subscribeToStream: async (sessionId: string) => {
const state = get()
// 開始訂閱
state.transitionTo('subscribing')
// 追蹤 SSE 連線開始 (Phase 19.O)
trackSSEConnection({
state: 'connecting',
sessionId,
})
try {
const streamUrl = `${API_BASE_URL}/api/v1/terminal/stream/${sessionId}`
const lastEventId = state.session?.lastEventId
const eventSource = new EventSource(
lastEventId ? `${streamUrl}?last_event_id=${lastEventId}` : streamUrl
)
// 設定事件處理器
eventSource.onopen = () => {
console.log('[Terminal] SSE connected to', sessionId)
get().transitionTo('connected')
// 進入串流狀態 (正在處理)
get().transitionTo('streaming')
// 追蹤 SSE 連線成功 (Phase 19.O)
trackSSEConnection({
state: 'connected',
sessionId,
})
}
eventSource.onmessage = (event) => {
get()._handleSSEEvent(event)
}
eventSource.onerror = (error) => {
get()._handleSSEError(error)
}
// 監聽特定事件類型
const eventTypes: TerminalEventType[] = [
'terminal_thought',
'terminal_tool_call',
'terminal_render_ui',
'terminal_action_request',
'terminal_action_result',
'terminal_complete',
'terminal_error',
'terminal_heartbeat',
'connected',
'heartbeat',
]
eventTypes.forEach((type) => {
eventSource.addEventListener(type, (event) => {
get()._handleSSEEvent(event as MessageEvent)
})
})
set({ _eventSource: eventSource })
} catch (error) {
console.error('[Terminal] SSE subscription error:', error)
get().transitionTo('error')
}
},
disconnect: () => {
const { _eventSource, _abortController } = get()
if (_eventSource) {
_eventSource.close()
}
if (_abortController) {
_abortController.abort()
}
set({
connectionState: 'disconnected',
_eventSource: null,
_abortController: null,
session: null,
reconnectAttempt: 0,
})
console.log('[Terminal] Disconnected')
},
// ---------------------------------------------------------------------------
// Message Actions
// ---------------------------------------------------------------------------
sendIntent: async (text: string, context?: SpatialContext) => {
if (!text.trim()) return
const state = get()
const intentStartTime = Date.now()
// 樂觀更新 UI - 顯示使用者輸入
state.appendMessage({
role: 'user',
type: 'text',
content: text,
})
// 關閉現有 SSE 連線 (每次 intent 是新的 session)
if (state._eventSource) {
state._eventSource.close()
set({ _eventSource: null })
}
// 進入連接狀態
state.transitionTo('connecting')
// 追蹤 Intent 提交 (Phase 19.O 可觀測性)
trackIntentSubmit({
intentType: 'user_input',
inputLength: text.length,
})
try {
const abortController = new AbortController()
set({ _abortController: abortController })
// Step 1: POST intent 到後端建立 session
// Phase 20: CSRF Protection — 先取得 token 再提交
const csrfRes = await fetch(`${API_BASE_URL}/api/v1/csrf/token`, { method: 'GET', credentials: 'include' })
if (!csrfRes.ok) throw new Error(`CSRF fetch failed: ${csrfRes.status}`)
const csrfData = await csrfRes.json()
const csrfHeaders: Record<string, string> = csrfData.token ? { 'X-CSRF-Token': String(csrfData.token) } : {}
const response = await fetch(`${API_BASE_URL}/api/v1/terminal/intent`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...csrfHeaders,
},
body: JSON.stringify({
intent: text,
context: {
current_page: context?.currentPage ??
(typeof window !== 'undefined' ? window.location.pathname : '/'),
focused_entity_id: context?.focusedEntityId,
},
}),
signal: abortController.signal,
})
if (!response.ok) {
throw new Error(`Intent failed: ${response.status}`)
}
const data: IntentResponse = await response.json()
// Step 2: 建立 session 並訂閱 SSE
set({
session: createSession(data.session_id),
reconnectAttempt: 0,
})
// 設定 Sentry 上下文 (Phase 19.O)
setTerminalContext(data.session_id, 'user_input')
// Step 3: 訂閱 SSE 串流
await get()._subscribeToStream(data.session_id)
// 追蹤 Intent 完成 (Phase 19.O)
trackIntentComplete({
intentType: 'user_input',
inputLength: text.length,
sessionId: data.session_id,
duration: Date.now() - intentStartTime,
success: true,
})
} catch (error) {
if ((error as Error).name === 'AbortError') {
console.log('[Terminal] Intent aborted')
set({ connectionState: 'disconnected' })
clearTerminalContext()
return
}
console.error('[Terminal] Intent error:', error)
get().appendMessage({
role: 'system',
type: 'text',
content: `Error: ${(error as Error).message}`,
})
// 追蹤 Intent 失敗 (Phase 19.O)
trackIntentComplete({
intentType: 'user_input',
inputLength: text.length,
duration: Date.now() - intentStartTime,
success: false,
})
// 進入錯誤狀態 (會觸發自動重連)
set({ connectionState: 'error' })
}
},
appendMessage: (msg) =>
set((state) => ({
messages: [
...state.messages,
{
...msg,
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
timestamp: new Date(),
},
],
})),
updateLastMessage: (contentChunk: string) =>
set((state) => {
const messages = [...state.messages]
if (messages.length === 0) return state
const lastMsg = messages[messages.length - 1]
if (lastMsg.role === 'assistant') {
lastMsg.content += contentChunk
return { messages }
}
return state
}),
clearMessages: () =>
set({
messages: [
{
id: 'sys-cleared',
role: 'system',
type: 'text',
content: 'Terminal cleared.',
timestamp: new Date(),
},
],
}),
// ---------------------------------------------------------------------------
// Internal Handlers
// ---------------------------------------------------------------------------
_handleSSEEvent: (event: MessageEvent) => {
const state = get()
try {
const data: SSEEventData = JSON.parse(event.data)
// 更新 lastEventId
if (data.id && state.session) {
set((s) => ({
session: s.session ? { ...s.session, lastEventId: data.id! } : null,
}))
}
// 根據事件類型處理
switch (data.type) {
case 'terminal_thought':
state.appendMessage({
role: 'assistant',
type: 'thought',
content: (data.data.msg as string) || '',
payload: data.data,
})
break
case 'terminal_tool_call':
state.appendMessage({
role: 'assistant',
type: 'tool_call',
content: `[Tool] ${data.data.tool}`,
payload: data.data,
})
break
case 'terminal_render_ui':
state.appendMessage({
role: 'assistant',
type: 'render_ui',
content: '',
payload: data.data,
})
break
case 'terminal_action_request':
state.appendMessage({
role: 'assistant',
type: 'render_ui',
content: '',
payload: {
component: 'ApprovalCard',
props: data.data,
},
})
break
case 'terminal_complete':
// 串流完成,回到 connected 狀態
if (state.connectionState === 'streaming') {
state.transitionTo('connected')
}
break
case 'terminal_error':
state.appendMessage({
role: 'system',
type: 'text',
content: `Error: ${data.data.message || 'Unknown error'}`,
})
state.transitionTo('error')
break
case 'terminal_heartbeat':
case 'heartbeat':
// 心跳,更新活動時間
if (state.session) {
set((s) => ({
session: s.session
? { ...s.session, lastActivityAt: new Date() }
: null,
}))
}
break
default:
console.log('[Terminal] Unknown event type:', data.type)
}
} catch (error) {
console.error('[Terminal] Failed to parse SSE event:', error)
}
},
_handleSSEError: (error: Event) => {
const state = get()
console.error('[Terminal] SSE error:', error)
// 追蹤 SSE 錯誤 (Phase 19.O)
trackSSEConnection({
state: 'error',
sessionId: state.session?.sessionId,
error: 'SSE connection error',
retryCount: state.reconnectAttempt,
})
// 關閉現有連接
if (state._eventSource) {
state._eventSource.close()
set({ _eventSource: null })
}
// 進入錯誤狀態 (會自動觸發重連)
state.transitionTo('error')
},
_reconnect: () => {
const state = get()
// 更新重連計數
set((s) => ({ reconnectAttempt: s.reconnectAttempt + 1 }))
// 追蹤 SSE 重連 (Phase 19.O)
trackSSEConnection({
state: 'reconnecting',
sessionId: state.session?.sessionId,
retryCount: state.reconnectAttempt + 1,
})
// 進入重連狀態
state.transitionTo('reconnecting')
// 嘗試重新連接
state.connect()
},
// ---------------------------------------------------------------------------
// Selectors
// ---------------------------------------------------------------------------
getStateInfo: () => getStateInfo(get().connectionState),
}))
// =============================================================================
// Selector Hooks (Optimized Re-renders)
// =============================================================================
/** 取得連線狀態 */
export const useConnectionState = () =>
useTerminalStore((s) => s.connectionState)
/** 取得狀態資訊 (label, color, etc.) */
export const useConnectionStateInfo = () =>
useTerminalStore((s) => getStateInfo(s.connectionState))
/** 是否已連接 (可發送訊息) */
export const useIsConnected = () =>
useTerminalStore((s) => {
const info = getStateInfo(s.connectionState)
return info.isConnected
})
/** 是否正在串流 */
export const useIsStreaming = () =>
useTerminalStore((s) => s.connectionState === 'streaming')
/** Terminal 是否開啟 */
export const useIsTerminalOpen = () =>
useTerminalStore((s) => s.isOpen)
/** 訊息列表 */
export const useTerminalMessages = () =>
useTerminalStore((s) => s.messages)