依 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>
215 lines
14 KiB
Markdown
215 lines
14 KiB
Markdown
# Reviewer 審查 M8-5 兩個補丁(2026-04-15)
|
||
|
||
## 摘要
|
||
|
||
- **補丁 A(Stage 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 無新增。
|
||
- **補丁 B(ServerState + server:error payload)**:✅ **通過**。`control-panel.js` 匯出 6 個 lowercase 常數、`data-state` / dot class 不再 `.toLowerCase()`;`app.js` import `STATE_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 → 前端):
|
||
|
||
1. 使用者按 Open in Browser → `OpenInBrowser` binding → 瀏覽器載入 Web UI
|
||
2. 瀏覽器 WebSocket 連線 → Hub register channel → `hub.go:112` `writeStartupSentinel`(`sync.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:ready` → `app.js:331` → `hideStartupPanel()`(二次保險)
|
||
|
||
✅ 路徑完整,pulse 在 stage 6 completed 瞬間即停,不等 panel unmount。
|
||
|
||
### A6. Race 保護
|
||
|
||
**情境 1:stage 5 skipped 先到,stage 6 running 後到**
|
||
- L182-184 `enterManualMode()` → L219 `stages[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:`updateStage` L175 `stages[6].status = 'running'`;L182 條件 `n===5 && ...` 不符合 → 不 enter manual mode
|
||
- 第二個 event:`updateStage` 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 都正確。
|
||
|
||
**情境 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 程式碼:
|
||
|
||
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=false` → `a.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` `handleServerStatus` → `updatePrimaryControls` → 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 尚未到 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 對齊 Go `server_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
|