// useRunStream 封装 run profile 的日志 WebSocket,结构照搬 useAcpStream: // - connect + 自动重连(指数退避 1s/3s,最多 2 次) // - ?since=lastLogID 续传 // - 把日志行累积进 ref 数组;state 帧更新 runStatus import { ref, onScopeDispose, type Ref } from 'vue' import { openRunLogsWS, type LogLine, type RunState } from '@/api/runs' import { useAuthStore } from '@/stores/auth' export type StreamStatus = 'idle' | 'connecting' | 'streaming' | 'closed' | 'error' export interface UseRunStreamReturn { status: Ref runStatus: Ref exitCode: Ref logs: Ref reconnect: () => void close: () => void clear: () => void } interface WireFrame { id?: number kind?: string level?: 'info' | 'error' text?: string ts?: string status?: RunState exit_code?: number } export function useRunStream(profileId: string): UseRunStreamReturn { const status = ref('idle') const runStatus = ref('stopped') const exitCode = ref(null) const logs = ref([]) const lastLogID = ref(0) let ws: WebSocket | null = null let retried = 0 let manualClose = false let reconnectTimer: ReturnType | null = null const auth = useAuthStore() function connect() { if (ws || manualClose) return status.value = 'connecting' const token = auth.token ?? '' const sock = openRunLogsWS(profileId, lastLogID.value, token) ws = sock sock.onopen = () => { status.value = 'streaming' retried = 0 } sock.onmessage = (ev: MessageEvent) => { let parsed: WireFrame try { parsed = JSON.parse(ev.data as string) as WireFrame } catch { return } if (!parsed || typeof parsed !== 'object') return if (parsed.kind === 'state') { if (parsed.status) runStatus.value = parsed.status exitCode.value = parsed.exit_code ?? null return } if (parsed.kind === 'slow_consumer_disconnect') { status.value = 'error' return } // 普通日志行 if (parsed.kind === 'log' && typeof parsed.id === 'number') { if (parsed.id > lastLogID.value) lastLogID.value = parsed.id logs.value.push({ id: parsed.id, level: parsed.level ?? 'info', text: parsed.text ?? '', ts: parsed.ts ?? '', }) } } sock.onerror = () => { status.value = 'error' } sock.onclose = () => { ws = null if (manualClose) { status.value = 'closed' return } if (retried < 2) { const delay = retried === 0 ? 1000 : 3000 retried += 1 reconnectTimer = setTimeout(connect, delay) } else { status.value = 'closed' } } } function reconnect() { if (ws) ws.close() retried = 0 manualClose = false connect() } function close() { manualClose = true if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null } if (ws) ws.close() status.value = 'closed' } function clear() { logs.value = [] } connect() onScopeDispose(close) return { status, runStatus, exitCode, logs, reconnect, close, clear } }