jim800121chen 8cd5751ce3 feat(local-tool): M8 重構 — Wails 控制台 + 瀏覽器 Web UI(R5 決策)
依 R5 五輪決策把 visionA-local 從「Wails 內嵌 Next.js」重構為「Wails
本機伺服器控制台 + 瀏覽器 Web UI」模式(類比 Docker Desktop / Ollama)。

程式碼變動
  - M8-1 砍 yt-dlp 全套(後端 resolver / URL handler / 前端 URL tab /
    Makefile vendor / installer / bootstrap / CI workflow,-555 行)
  - M8-2 砍 Mock 模式全套(driver/mock、mock_camera、Settings runtimeMode、
    VISIONA_MOCK 環境變數,-528 行)
  - M8-3 ffmpeg 從 GPL 切換到 LGPL 混合方案:Windows/Linux 用 BtbN 現成
    LGPL binary,macOS 自 build minimal decoder-only 進 git
    (vendor/ffmpeg/macos/ffmpeg 5.7MB + ffprobe 5.6MB,比 GPL 版省 85% 空間)
  - M8-4 Wails Server Controller:state machine、log ring buffer 2000 行、
    preferences.json atomic write、boot-id、Gin SkipPaths、shutdown 7+1 秒、
    notify_*.go 三平台 OS 通知、watchServer 改 Error state 不 os.Exit
  - M8-4b 啟動階段管線 R5-E:6 階段進度 event、20s soft / 60s hard timeout、
    stage 5/6 skip 規則、sentinel file、RestartStartupSequence 5 步驟
  - M8-5 Wails 控制台 vanilla HTML/JS/CSS(9 檔 ~2012 行)取代 M7-B splash:
    state 視覺、log panel、startup progress panel、Stage 6 manual CTA
    pulse、shutdown modal、Settings、Dark Mode、i18n 中英雙語
  - M8-6 上傳影片副檔名擴充(mp4/avi/mov/mpeg/mpg)
  - M8-7 Web UI Server Offline Overlay(role=alertdialog + focus trap +
    wsEverConnected 容錯 + Page Visibility)
  - M8-8 CORS middleware(127.0.0.1/localhost only + suffix attack 防護)+
    ws/origin.go 獨立 WebSocket CheckOrigin 避 package cycle
  - MAJ-4 server:shutdown-imminent WebSocket broadcast 機制
    (/ws/system endpoint + notifyShutdownImminent helper)
  - M8-9 Boot-ID + 瀏覽器 tab 自動重連(sessionStorage loop guard)

品質
  - ~105+ 新 unit test + race detector (-count=2) 全綠
  - 10 個 milestone 全部通過 Reviewer 審查
  - 三方 v2 + v2.1 文件(PRD / Design Spec / TDD)+ 交叉互審紀錄
    收錄在 .autoflow/

交付前待處理(M8-10)
  - 重跑 make payload-macos 把舊 GPL 77MB binary 換成新 LGPL
  - 三平台 end-to-end build 驗證

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:57:54 +08:00

14 KiB
Raw Permalink Blame History

Reviewer 審查 M8-5 兩個補丁2026-04-15

摘要

  • 補丁 AStage 6 Manual CTA 通過manualMode 狀態機、enterManualMode 觸發、onManualModeChange pub-sub、stage 6 manual hint description、stage 5 skipped label、CTA pulse、dark / reduced-motion fallback 全部對齊 Design Spec v2.1 §4.1 §7。i18n 用既有 key 無新增。
  • 補丁 BServerState + server:error payload 通過control-panel.js 匯出 6 個 lowercase 常數、data-state / dot class 不再 .toLowerCase()app.js import STATE_ERRORserver:errorpayload.reason 並附註 Go source of truth 註解。
  • 是否阻擋 M8-10不阻擋。M8-5 原 Reviewer 的 2 Critical 已完全修復M8-4b Reviewer 提醒的 stage 6 manual CTA UX 也補齊。全部 4 個改過的 JS node --check 乾淨;wails build -s -m -skipbindings 產出 build/bin/visiona-local.app6.272s。Minor 層級問題均為 Suggestion。

A. Stage 6 Manual CTA補丁 A

A1. manualMode 狀態機

檢查項 結果 備註
enterManualMode() 在 stage=5 status="skipped" 時觸發 startup-panel.js:182-184if (n === 5 && stages[5].status === 'skipped') enterManualMode()
manualMode toggle 正確 startup-panel.js:16 module-scope flagenterManualMode L216 幂等(若已進入直接 return→ L217 setTruehideStartupPanel L163-166 resetstage 6 completed L187-193 reset
manualModeListeners pub-sub L18 SetL19-22 onManualModeChange(fn) 回傳 unsubscribeL23-27 emitManualMode try/catch 保護 listener 拋錯
hideStartupPanel reset 清 manualHint L160-162 重置 stages[1..6](含 manualHint: falseL163-166 若 manualMode===true 則 toggle 並 emit false

A2. stage 6 manualHint description

檢查項 結果 備註
paintStageRow 偵測 stage===6 && manualHint startup-panel.js:88-89else if (stage === 6 && st.manualHint) labelSecondary.textContent = t('startup.stage.6.manualHint')
stage===5 && status==='skipped'startup.stage.5.skipped.label L86-87
markStageTimeout manualMode + stage 6 時忽略 soft timeout L242「if (n === 6 && stages[6].manualHint) return」;與 Design §4.1「不套 20 秒 retry hint」一致
paintStageRow 的 slow hint 也避開 manual hint L107「if (st.slow && st.status === 'running' && !st.manualHint)」雙重保險

A3. i18n key 使用

檢查項 結果 備註
無新增 key 全用既有 3 個:startup.stage.6.manualHint / startup.stage.5.skipped.label / startup.status.skipped
既有 key 存在雙語 i18n.js:61,64,69zh-TW+ i18n.js:140,143,148en與 Design §7 表格對齊

A4. CTA pulse

檢查項 結果 備註
setPrimaryCTAPulse(true/false) add/remove class control-panel.js:105-113;取 #btn-open-browserclassList.add/remove('pulse-cta')
app.js 訂閱 onManualModeChange → 驅動 pulse app.js:32-33 importapp.js:42 import onManualModeChangeapp.js:134-136 init step 11 onManualModeChange((enabled) => setPrimaryCTAPulse(enabled))
.pulse-cta CSS 正確 style.css:205-216 @keyframes ctaPulse 1.8s ease-in-out infinitebox-shadow 0px→8px 漸散(符合 material-style 脈衝)
:not([disabled]) 選擇器 style.css:205 .btn.pulse-cta:not([disabled]) — 按鈕 disabled 時不做動畫(避免 stage 5 skipped 但 server state 還沒到 running 的極短窗口裡 pulse disabled 按鈕)
dark mode 變體 style.css:217-222 @media (prefers-color-scheme: dark) 覆寫 @keyframes ctaPulse 為較亮藍(rgba(59,130,246,0.65)
prefers-reduced-motion fallback style.css:224-230 動畫關閉,改 outline: 2px solid var(--focus-ring)L676-682 全域 override 會把 animation-duration 壓成 0.01ms兩層不衝突outline 仍會套用

A5. stage 6 completed 離開 manualMode

路徑驗證(親自追 Go → 前端):

  1. 使用者按 Open in Browser → OpenInBrowser binding → 瀏覽器載入 Web UI
  2. 瀏覽器 WebSocket 連線 → Hub register channel → hub.go:112 writeStartupSentinelsync.Once
  3. startup_pipeline.go:313-318 watcher 每秒檢查 sentinel 檔案 → CompleteStage(6)
  4. startup_pipeline.go:230-251 emitProgress goroutine → Wails event startup:progress {stage:6, status:"completed"}
  5. app.js:318-324 EventsOn('startup:progress')updateStage(ev)
  6. startup-panel.js:187-193 條件 n === 6 && (status === 'completed' || 'done')manualMode=false + stages[6].manualHint=false + emitManualMode(false)
  7. app.js:134-136 listener → setPrimaryCTAPulse(false) → classList.remove
  8. 接著 startup_pipeline.go:223 markReady emit startup:readyapp.js:331hideStartupPanel()(二次保險)

路徑完整pulse 在 stage 6 completed 瞬間即停,不等 panel unmount。

A6. Race 保護

情境 1stage 5 skipped 先到stage 6 running 後到

  • L182-184 enterManualMode() → L219 stages[6].status === 'pending' 為 true → 主動設 running + startedAt
  • 之後 Go 層送 stage 6 runningL175 stages[n].status = ev.status 覆蓋為 running(同值),不動 manualHintL175 只改 status
  • paintStageRow(6) 仍看到 st.manualHint === true → 顯示 manual hint description

情境 2stage 6 running 先到stage 5 skipped 後到

  • 第一個 eventupdateStage L175 stages[6].status = 'running'L182 條件 n===5 && ... 不符合 → 不 enter manual mode
  • 第二個 eventupdateStage L175 stages[5].status = 'skipped'L182 條件符合 → enterManualMode()L219 stages[6].status === 'pending'false(已是 running不重設 startedAt但 L223 stages[6].manualHint = true + L225 stages[6].slow = false + L226 paint → manual hint 顯示

兩種 race 都正確。

情境 3stage 6 running 與 stage 5 skipped 極短時間內雙飛

  • Go emitProgress 用 goroutinestartup_pipeline.go:230-251),理論上可能亂序;但 Wails EventsEmit 單一 JS runtime 接收為序列 queue兩個 callback 接續執行,兩種順序均已驗證

A 小結

補丁 A 全部通過。manualMode 狀態機、pub-sub、stage 5/6 label 分流、CTA pulse、dark/reduced-motion 完整對齊 Design Spec v2.1 §4.1 §7無 Critical / Major 問題。


B. Critical 修復(補丁 B

B1. ServerState lowercase 統一

檢查項 結果 備註
control-panel.js 常數全 lowercase L7-12 定義 6 個常數(idle / starting / running / stopping / stopped / error),對齊 Go server_control.go:44-49
匯出給其他檔 import L7-12 每行都 export constapp.js:33 import { STATE_ERROR } from './control-panel.js'
內部比對用常數 L20 STATE_IDLEL26-34 switch caseL55 STATE_RUNNINGL82-86 primary controls
grep 無 PascalCase state 殘留 i18n.js:87,89,92,145 是顯示用 label value'Idle' / 'Running' / 'Stopped'),非 state 比對,符合預期
control-panel.js.toLowerCase() grep 零匹配

B2. data-state 屬性 + dot class

檢查項 結果 備註
不再 .toLowerCase() L21 root.setAttribute('data-state', s)(既然 s 已是 lowercaseL22 dot.className = 'status-dot state-' + s
CSS 選擇器匹配 快速檢查 style.css.status-dot.state-*[data-state="*"] 應全 lowercase原 M8-5 Review 已驗過 dot 顯示正常(因當時有 .toLowerCase() hack現在直接用 lowercase 字串,匹配相同且更乾淨

B3. server:error payload.reason

檢查項 結果 備註
改為 payload.reason app.js:301-304if (payload && payload.reason) showErrorBanner(payload.reason)
註解說明 Go source of truth L302「Go 端 server_control.go L184/L361 emit payload key 為 "reason"(不是 "error"
親自核對 Go 端 server_control.go:184-186 確認 emit map[string]any{"reason": err.Error()};未查 L361 但題目有註明

B4. Stage 5 skipped 情境驗證(補丁 A + B 交集 — 最關鍵懸念)

M8-5 原 Reviewer 最重要的懸而未決Open in Browser 按鈕在 stage 5 skipped 時是否 enabled

親自追 Go 程式碼:

  1. 冷啟動 → app.go:235 a.ctrl.Start()
  2. server_control.go:200 c.setState(ServerStateRunning, "")Start 成功後立刻進入 running
  3. Start 返回 → app.go:242 a.runStartupStage5()
  4. app.go:253-255 AutoOpenBrowser=falsea.startupPipeline.SkipStage(5)

關鍵結論:當前端收到 startup:progress stage=5 status=skipped 時,ctrl.state 早已是 running(在 Step 2 已 setState

前端路徑:

  • Wails server:state-change event 早一步送過 state='running' payload
  • app.js:300 handleServerStatusupdatePrimaryControls → L82 openBtn.disabled = s !== STATE_RUNNING = 'running' !== 'running' = false
  • Open in Browser 按鈕 enabled
  • 同時 startup:progress stage=5 skipped 觸發 enterManualMode → pulse 啟動
  • 使用者看到彈出 panel + pulse 的 Open in Browser 按鈕可點 → 體驗順暢

.pulse-cta:not([disabled]) 選擇器也守住了邊界:即便極短時間窗口內 state 尚未到 runningdisabled 按鈕也不會誤 pulse視覺正確

補丁 B 修復 Critical 1 之後stage 5 skipped → stage 6 manual CTA 的完整流程成立。 B4 通過。


C. 親跑驗證

== app.js ==           node --check OK
== control-panel.js == node --check OK
== startup-panel.js == node --check OK
== i18n.js ==          node --check OK
== style.css ==        (CSS, 跳過 node --check)
wails build -s -m -skipbindings
→ Compiling application: Done.
→ Packaging application: Done.
→ Self-signing application: Done.
→ Built build/bin/visiona-local.app (6.272s)

wails build 成功產出 .app,證明 go:embed all:frontend 正確吃進 4 個修改後的前端檔。

PascalCase state 殘留 grep

visiona-local/frontend/i18n.js:87:  'control.status.idle': 'Idle',
visiona-local/frontend/i18n.js:89:  'control.status.running': 'Running',
visiona-local/frontend/i18n.js:92:  'control.status.stopped': 'Stopped',
visiona-local/frontend/i18n.js:145: 'startup.status.running': 'Running',

僅 4 處,全是 i18n label 顯示文字(非 state 比對),符合題目允許範圍。

.toLowerCase() 殘留 grepcontrol-panel.js 零匹配。


D. 問題清單

Critical阻擋 M8-10

(無)

Major

(無)

Minor

# 檔案:行 問題描述 建議
m-1 style.css:209-222 dark variant 覆寫 @keyframes ctaPulse 把整個 keyframes 重新定義,而非用 CSS variable。若未來想調 pulse 顏色需改兩處。 可改用 --pulse-color CSS var + dark :root override但目前做法對齊既有 patternstatus-dot color variant 也是同法),不強制修
m-2 startup-panel.js:182-184 前端主動 enter manual mode 依 Design §4.1 流程Go 端理論上會先送 stage 5 skipped 再送 stage 6 running但前端這裡先行 paint 並設 stages[6].running,若 Go 層之後沒送 stage 6 running例如 pipeline 異常manualHint 狀態永遠停在「前端自造」的 running。目前 M8-4b 是必定送的,故不會發生;屬防禦性建議。 可在 emitManualMode(true) 後加一個 watchdog30 秒沒收到 stage 6 running 則 console.warn但非必要。
m-3 既有 Minor 沿用 — log-panel.js footer lines 英文硬編 M8-5 原 review 的 m-1本輪補丁 A/B 未涵蓋,仍然存在 保留為 M8-10 前收斂的項目

Suggestion

# 檔案 建議
s-1 startup-panel.js:16-27 pub-sub 若未來 manualMode 觀察者增多,可考慮用 EventTarget 替代 Set,標準 API 且可 dispatchEvent。目前單一訂閱者,Set 已足夠。
s-2 control-panel.js:7-12 常數 可再多 export 一個 SERVER_STATES frozen object 方便 iterateObject.values(SERVER_STATES) 做 sanity check非必要。
s-3 style.css:205 selector .btn.pulse-cta:not([disabled])) 選擇器優雅,但如果未來 Open in Browser 按鈕換 class 名稱(非 .btnpulse 會失效。可改成單純 .pulse-cta:not([disabled])

E. 結論

審查結果: 通過(第 1 輪)

  • 補丁 AStage 6 Manual CTA:對齊 Design Spec v2.1 §4.1 §7manualMode 狀態機完整、pub-sub 安全、i18n 用既有 key 雙語齊全、CTA pulse 加 dark / reduced-motion fallback。M8-4b Reviewer 提醒的「AutoOpenBrowser=false 時 panel 永遠不淡出」UX 已補齊。
  • 補丁 BServerState + server:error payloadM8-5 原 Critical 1 / 2 完全修復。control-panel.js 全 lowercase 對齊 Go server_control.go:44-49app.jspayload.reason 對齊 server_control.go:184
  • 跨補丁交集 B4:親自追 Go → 前端路徑驗證 stage 5 skipped 時 ctrl.state 已是 runningOpen in Browser 按鈕 enabledpulse 引導使用者點擊,流程順暢。
  • 親跑4 個 JS node --check 全過;wails build -s -m -skipbindings 成功產出 build/bin/visiona-local.app6.272s)。

不阻擋 M8-10。建議 Orchestrator 將本輪補丁 A/B 視為 M8-5 的最終交付。M8-5 原 review 的 Minor m-1~m-8 仍在 backlog不阻擋可由 M8-10 整理階段一併收斂。

給 Frontend Agent 的正面回饋

  • manualMode pub-sub 用 Set + try/catch 保護 listener簡潔且防禦性佳
  • enterManualMode / hideStartupPanel 兩處 reset 對稱,狀態機閉環
  • .pulse-cta:not([disabled]) 選擇器與 Go state 時序巧妙搭配(避免 pulse 空按鈕)
  • CSS dark mode 與 prefers-reduced-motion 兩層無障礙 fallback 齊全
  • B 補丁用 module-level 常數取代字面量 PascalCase未來再也不會發生大小寫 drift