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

215 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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_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 resetstage 6 completed L187-193 reset |
| `manualModeListeners` pub-sub | ✅ | L18 `Set`L19-22 `onManualModeChange(fn)` 回傳 unsubscribeL23-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 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` `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 保護
**情境 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`(同值),**不動 `manualHint`**L175 只改 status
- `paintStageRow(6)` 仍看到 `st.manualHint === true` → 顯示 manual hint description ✅
**情境 2stage 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 都正確。
**情境 3stage 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 caseL55 `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` 已是 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-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 尚未到 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()` 殘留 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但目前做法對齊既有 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 方便 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 輪)**
- **補丁 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 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 按鈕 enabledpulse 引導使用者點擊,流程順暢。
- **親跑**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