依 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>
14 KiB
Reviewer 審查 M8-5 兩個補丁(2026-04-15)
摘要
- 補丁 A(Stage 6 Manual CTA):✅ 通過。
manualMode狀態機、enterManualMode觸發、onManualModeChangepub-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 無新增。 - 補丁 B(ServerState + server:error payload):✅ 通過。
control-panel.js匯出 6 個 lowercase 常數、data-state/ dot class 不再.toLowerCase();app.jsimportSTATE_ERROR、server:error讀payload.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.app(6.272s)。Minor 層級問題均為 Suggestion。
A. Stage 6 Manual CTA(補丁 A)
A1. manualMode 狀態機
| 檢查項 | 結果 | 備註 |
|---|---|---|
enterManualMode() 在 stage=5 status="skipped" 時觸發 |
✅ | startup-panel.js:182-184「if (n === 5 && stages[5].status === 'skipped') enterManualMode()」 |
manualMode toggle 正確 |
✅ | startup-panel.js:16 module-scope flag;enterManualMode L216 幂等(若已進入直接 return)→ L217 setTrue;hideStartupPanel L163-166 reset;stage 6 completed L187-193 reset |
manualModeListeners pub-sub |
✅ | L18 Set;L19-22 onManualModeChange(fn) 回傳 unsubscribe;L23-27 emitManualMode try/catch 保護 listener 拋錯 |
hideStartupPanel reset 清 manualHint |
✅ | L160-162 重置 stages[1..6](含 manualHint: false),L163-166 若 manualMode===true 則 toggle 並 emit false |
A2. stage 6 manualHint description
| 檢查項 | 結果 | 備註 |
|---|---|---|
paintStageRow 偵測 stage===6 && manualHint |
✅ | startup-panel.js:88-89「else 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,69(zh-TW)+ i18n.js:140,143,148(en),與 Design §7 表格對齊 |
A4. CTA pulse
| 檢查項 | 結果 | 備註 |
|---|---|---|
setPrimaryCTAPulse(true/false) add/remove class |
✅ | control-panel.js:105-113;取 #btn-open-browser 後 classList.add/remove('pulse-cta') |
app.js 訂閱 onManualModeChange → 驅動 pulse |
✅ | app.js:32-33 import;app.js:42 import onManualModeChange;app.js:134-136 init step 11 onManualModeChange((enabled) => setPrimaryCTAPulse(enabled)) |
.pulse-cta CSS 正確 |
✅ | style.css:205-216 @keyframes ctaPulse 1.8s ease-in-out infinite,box-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 → 前端):
- 使用者按 Open in Browser →
OpenInBrowserbinding → 瀏覽器載入 Web UI - 瀏覽器 WebSocket 連線 → Hub register channel →
hub.go:112writeStartupSentinel(sync.Once) startup_pipeline.go:313-318watcher 每秒檢查 sentinel 檔案 →CompleteStage(6)startup_pipeline.go:230-251emitProgressgoroutine → Wails eventstartup:progress {stage:6, status:"completed"}app.js:318-324EventsOn('startup:progress')→updateStage(ev)startup-panel.js:187-193條件n === 6 && (status === 'completed' || 'done')→manualMode=false+stages[6].manualHint=false+emitManualMode(false)app.js:134-136listener →setPrimaryCTAPulse(false)→ classList.remove- 接著
startup_pipeline.go:223markReadyemitstartup:ready→app.js:331→hideStartupPanel()(二次保險)
✅ 路徑完整,pulse 在 stage 6 completed 瞬間即停,不等 panel unmount。
A6. Race 保護
情境 1:stage 5 skipped 先到,stage 6 running 後到
- L182-184
enterManualMode()→ L219stages[6].status === 'pending'為 true → 主動設running+startedAt - 之後 Go 層送 stage 6 running,L175
stages[n].status = ev.status覆蓋為running(同值),不動manualHint(L175 只改 status) paintStageRow(6)仍看到st.manualHint === true→ 顯示 manual hint description ✅
情境 2:stage 6 running 先到,stage 5 skipped 後到
- 第一個 event:
updateStageL175stages[6].status = 'running';L182 條件n===5 && ...不符合 → 不 enter manual mode - 第二個 event:
updateStageL175stages[5].status = 'skipped';L182 條件符合 →enterManualMode();L219stages[6].status === 'pending'為 false(已是 running),不重設 startedAt,但 L223stages[6].manualHint = true+ L225stages[6].slow = false+ L226 paint → manual hint 顯示 ✅
兩種 race 都正確。
情境 3:stage 6 running 與 stage 5 skipped 極短時間內雙飛
- Go emitProgress 用 goroutine(
startup_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 const;app.js:33 import { STATE_ERROR } from './control-panel.js' |
| 內部比對用常數 | ✅ | L20 STATE_IDLE;L26-34 switch case;L55 STATE_RUNNING;L82-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 已是 lowercase);L22 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-304「if (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 程式碼:
- 冷啟動 →
app.go:235a.ctrl.Start() server_control.go:200c.setState(ServerStateRunning, "")— Start 成功後立刻進入running- Start 返回 →
app.go:242a.runStartupStage5() app.go:253-255AutoOpenBrowser=false→a.startupPipeline.SkipStage(5)
關鍵結論:當前端收到 startup:progress stage=5 status=skipped 時,ctrl.state 早已是 running(在 Step 2 已 setState)。
前端路徑:
- Wails
server:state-changeevent 早一步送過state='running'payload app.js:300handleServerStatus→updatePrimaryControls→ L82openBtn.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 尚未到 running,disabled 按鈕也不會誤 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() 殘留 grep:control-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,但目前做法對齊既有 pattern(status-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) 後加一個 watchdog(30 秒沒收到 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 方便 iterate(如 Object.values(SERVER_STATES) 做 sanity check),非必要。 |
| s-3 | style.css:205 selector |
.btn.pulse-cta:not([disabled])) 選擇器優雅,但如果未來 Open in Browser 按鈕換 class 名稱(非 .btn),pulse 會失效。可改成單純 .pulse-cta:not([disabled])。 |
E. 結論
審查結果:✅ 通過(第 1 輪)
- 補丁 A(Stage 6 Manual CTA):對齊 Design Spec v2.1 §4.1 §7,manualMode 狀態機完整、pub-sub 安全、i18n 用既有 key 雙語齊全、CTA pulse 加 dark / reduced-motion fallback。M8-4b Reviewer 提醒的「AutoOpenBrowser=false 時 panel 永遠不淡出」UX 已補齊。
- 補丁 B(ServerState + server:error payload):M8-5 原 Critical 1 / 2 完全修復。
control-panel.js全 lowercase 對齊 Goserver_control.go:44-49;app.js讀payload.reason對齊server_control.go:184。 - 跨補丁交集 B4:親自追 Go → 前端路徑驗證 stage 5 skipped 時
ctrl.state已是running,Open in Browser 按鈕 enabled,pulse 引導使用者點擊,流程順暢。 - 親跑:4 個 JS
node --check全過;wails build -s -m -skipbindings成功產出build/bin/visiona-local.app(6.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