/** * useFlashProgress — flash 進度 WS 訂閱 + 順序保證的 POST 觸發(塊:flash UI) * * 移植自 POC edge-ai-platform/frontend/src/hooks/use-flash-progress.ts(唯讀參考、非搬 code)。 * * ⚠️ 最關鍵的契約差異(見 flash-model-load-mapping.md §2.3-B,採「選項 2」): * - POC `useFlashProgress` 提供 imperative `connectAndWait(): Promise`, * dialog 先 `await connectAndWait()`(開 WS 並等 open)**再** `startFlash()`(POST)。 * - visionA `useWebSocket(path, { enabled, onMessage, onOpen })` 是 **declarative** * (靠 `enabled` false→true 觸發連線,無法回傳「已 open」的 promise)。 * 若 `setEnabled(true)` 後立刻 POST → race(POST 已送、WS 還沒 open,漏早期 progress)。 * * **本 hook 的解法(選項 2,複用最多、無 race)**: * 用 `useWebSocket` 的 `onOpen` callback 觸發 POST。呼叫端 `beginFlash(modelId)` 只設 * `enabled=true` + 記下 modelId;WS 真正 open 後才在 `onOpen` 內 `startFlash(serialNumber, modelId)`, * 天然保證「先 open WS 再 POST」的順序(flash 很快就送 progress,順序錯會漏早期 percent)。 * * **重連防重複 POST(`hasStartedRef`)**: * `onOpen` 在**每次連線成功**(含斷線重連)都會觸發。若不防護,重連會重複 POST flash。 * 故用 `hasStartedRef` 記錄「本次 flash 已 POST 過」,重連時的 onOpen 不再 POST,只續收 progress。 * * 認證:same-origin cookie(visiona_session HttpOnly)自動帶,**不放 token 到 URL** * (對齊 use-websocket.ts 安全決策 + security 對 token-in-URL 的 Critical 否決)。 */ "use client"; import { useCallback, useRef, useState } from "react"; import { useWebSocket } from "@/hooks/use-websocket"; import { type FlashProgress, useFlashStore } from "@/stores/flash-store"; interface UseFlashProgress { /** * 開始 flash:啟用 WS(`enabled=true`),WS open 後才 POST flash(順序保證)。 * @param modelId 要 flash 的模型 id */ beginFlash: (modelId: string) => void; /** 停止:關閉 WS、重置狀態(不再重連、不再 POST)。 */ stop: () => void; /** 目前是否已啟用 WS(供除錯 / 測試觀察)。 */ isActive: boolean; } /** * @param serialNumber 目標裝置的硬體序號(kn_number)。 * * ⚠️ ADR-018 serial 路由:WS path 與 flash POST 的識別值都必須用 serialNumber, * 兩者要一致——local agent 端 flash 進度是 broadcast 到以「請求帶入的識別值」 * 為 key 的 room,POST 帶 serial、WS 帶 UUID 會收不到進度。 * serial 為空的裝置不可 beginFlash(呼叫端 FlashDialog disable)。 */ export function useFlashProgress(serialNumber: string): UseFlashProgress { const updateProgress = useFlashStore((s) => s.updateProgress); const startFlash = useFlashStore((s) => s.startFlash); // enabled 用 state 讓 useWebSocket 收到 false→true 變化 → 觸發連線。 const [enabled, setEnabled] = useState(false); // 本次 flash 要送的 modelId(beginFlash 設定、onOpen 讀取)。 const modelIdRef = useRef(null); // 防重連重複 POST:本次 flash 已 POST 過就不再送。 const hasStartedRef = useRef(false); const handleOpen = useCallback(() => { // 只在「本次 flash 尚未 POST 過」時觸發 POST(重連時的 onOpen 會跳過)。 if (hasStartedRef.current) return; const modelId = modelIdRef.current; if (!modelId) return; hasStartedRef.current = true; void startFlash(serialNumber, modelId); }, [serialNumber, startFlash]); const handleMessage = useCallback( (data: unknown) => { const p = data as Partial; // 防呆:非預期形狀(percent 非數字且無 error)直接忽略,不污染 store。 if (typeof p?.percent !== "number" && typeof p?.error !== "string") { return; } updateProgress({ percent: typeof p.percent === "number" ? p.percent : 0, stage: typeof p.stage === "string" ? p.stage : "", message: typeof p.message === "string" ? p.message : undefined, error: typeof p.error === "string" ? p.error : undefined, }); }, [updateProgress], ); useWebSocket(`/ws/devices/${encodeURIComponent(serialNumber)}/flash-progress`, { enabled, onOpen: handleOpen, onMessage: handleMessage, }); const beginFlash = useCallback((modelId: string) => { modelIdRef.current = modelId; hasStartedRef.current = false; // 新一輪 flash,重置「已 POST」旗標 setEnabled(true); }, [setEnabled]); const stop = useCallback(() => { modelIdRef.current = null; hasStartedRef.current = false; setEnabled(false); }, [setEnabled]); return { beginFlash, stop, isActive: enabled }; }