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>
This commit is contained in:
jim800121chen 2026-04-15 17:57:54 +08:00
parent a6b71ea908
commit 8cd5751ce3
113 changed files with 23145 additions and 1372 deletions

9
.gitignore vendored
View File

@ -15,9 +15,16 @@ Thumbs.db
# ─────────────────────────────────────
# 不進 git 的依賴與產物(決策 R4-D2
local-tool/vendor/
# 注意:使用 /** 而非 /,否則後面的 ! 例外無法 re-includegit 規則:
# 父目錄被排除時無法 re-include 檔案)— R5-6b 需要 macOS ffmpeg binary 進 git
local-tool/vendor/**
local-tool/dist/
local-tool/payload/
# R5-6bmacOS LGPL ffmpeg binary 進 git沒有現成 LGPL binary 來源,自 build 成本高,
# commit 後開發者 clone 即可用,不必每次重 build ~15 分鐘)
!local-tool/vendor/ffmpeg/
!local-tool/vendor/ffmpeg/macos/
!local-tool/vendor/ffmpeg/macos/**
# Go server 的 embed 與 build 產物
local-tool/server/web/out/

View File

@ -0,0 +1,419 @@
# PM 第一輪分析 — visionA-local 方向變更2026-04-14
> 作者PM Agent
> 性質:**第一輪分析筆記,非正式 PRD 修訂**
> 觸發事件:使用者在 M7 收尾階段提出三項重大方向變更
> 對應使用者訊息:「推論只需包含 camera / image / video upload」「模型只預設 + 只能上傳」「介面希望是網頁不是包在應用程式中」「Wails 負責顯示 server log / 啟停重啟 / 打開 localhost 網頁」
---
## 摘要3 行)
1. **這是一個 L 級變更,不是 S 級**:三項變更疊加起來等於把 visionA-local 從「Wails 內嵌式桌面 app」重新定位為「Ollama / LM Studio / Stable Diffusion WebUI 式的 server 管理器 + 獨立 web UI」PRD 第 1 章(定位)、第 2 章(使用情境)、第 4 章(功能清單)、第 6 章(非功能需求)、第 8 章(風險)全部要改,競品類比也要換。
2. **最大的哲學衝突**:原 PRD 的 北極星指標是「內部 FAE 在客戶現場 5 分鐘內跑出第一次推論 ≥ 95%」,現在多一步「要使用者先在桌面 app 按 Start Server → 再點 Open Browser → 瀏覽器才看到 UI」這對「零摩擦 3 分鐘 demo」體驗是**退步而非進步**PM 必須先釐清使用者動機,否則會做出一個「技術上更乾淨、但使用體驗更差」的產品。
3. **目前開發階段是最糟的時機點**M1-M6 已經做完dmg 220MB 可跑M7 Windows build 在收尾。此時做這種方向轉彎代價很高——前端 Sidebar / 4 主導航 / Workspace / Settings 全部要重新思考「誰住在 Wails 裡、誰住在瀏覽器裡」。但如果使用者的動機夠強(見 C1延後改比現在改更貴。**必須先釐清動機再做決定,不要為了「使用者說了就做」而跳過第一輪分析**。
---
## A. 變更解讀
### A1. 影片來源變更(砍 URL / yt-dlp
**新要求**:推論只包含 `camera / image / upload 影片(avi, mpeg, mp4 瀏覽器能吃的格式)`
**PM 解讀**
- 使用者明確砍掉 `/api/media/url`YouTube / yt-dlp 相關)。
- 「瀏覽器能吃的格式」是一個**關鍵措辭**——暗示使用者的 mental model 從「我用桌面 app 解影片」轉向「我用瀏覽器播影片」。這跟 A3 介面變更是同一件事的兩面。
- 但這裡有個**語意陷阱**:瀏覽器能播的 `<video>` 格式(主要是 MP4 H.264/AAC、WebM VP9/Opus、Ogg**不等於** Kneron 推論管線能吃的格式。推論仍然需要 ffmpeg decode 成 frame 餵給模型。使用者可能沒意識到這一點。**這題必須回去澄清**(見 C1
- 原本 `/api/media/upload/video` 已經在保留清單,所以「上傳影片」不是新功能,**真正被砍的只是 URL 模式**。
**影響範圍**(具體):
- 後端:`POST /api/media/url` 整個 endpoint 拔掉;`server/internal/media/url_handler.go` 或類似檔案整包刪。
- Bundle**yt-dlp 35MB 可以拔掉**dmg 從 220MB 降到約 185MB符合 6.2 節 macOS ≤ 220MB 的原目標值(目前是壓著上限走)。**這是個體積紅利**。
- 前端:`媒體上傳`頁面的「URL 貼上」輸入框、「從 YouTube 下載」tab、相關 Loading 狀態(對應 recent commit `a6b71ea fix(local-tool): URL 影片載入提示「正在解析,請耐心等待」`)全部拔掉。
- 授權宣告:`4.8 第三方授權宣告`的 yt-dlp 條目可以移除LICENSE-yt-dlp.txt 不用打包。
- RiskP2FAE 不知道有新版)不受影響;但原 R11發佈通路如果本來要放 yt-dlp 需要高流量下載則壓力降低。
**還沒確認的疑問**
- 「camera / image / upload 影片」這個措辭漏掉了**批次影像** `POST /api/media/upload/batch`。使用者是忘了、還是也要砍?(見 C2
- 砍 URL 模式是否連帶砍 `US-8`User Story我傾向砍但要使用者確認。
### A2. 模型管理變更(只預設 + 只能上傳)
**新要求**:模型除了預設的幾種只能用上傳的。
**PM 解讀**
- 現況:預設 8 個 .nef73MB 內嵌)+ 支援使用者上傳自己的 .nef。
- 新要求:跟現況幾乎一樣,**重點是明確否定「從 URL / Model Zoo 下載」這類未來可能性**。
- 這有兩個可能讀法:
- **讀法 A保守**:使用者只是確認「不要做 Model Zoo / 遠端下載功能」,目前的預設 + 上傳就是最終狀態。
- **讀法 B更激進**:使用者可能想重新檢視預設清單——是要維持 8 個?還是想精簡到 3-4 個最有代表性的?
- 我**傾向讀法 A**,因為原 PRD 本來就沒規劃 Model Zoo這句話更像是「再次確認非目標」而非新增要求。但要回去跟使用者確認見 C3
**還沒確認的疑問**
- 使用者上傳 .nef 後,是否需要命名、分類、加說明文字、刪除?原 feature-inventory `model-upload-dialog` 應該已經涵蓋,要請前端 lead 回看現況。
- 預設模型的「重置」按鈕是否需要?(使用者不小心刪掉預設模型後能不能還原)
**影響範圍**
- 若讀法 A 正確,**這項變更幾乎不影響程式碼**PRD 只需在 4.1 / 4.2 / 4.5 章節明確寫「只預設 + 只上傳,不支援任何形式的遠端模型下載」,並把它列入第 3 章非目標。
- 若讀法 B 正確,需要重新評估預設模型清單,但這是 Architect / KneronPLUS 範疇的事。
### A3. 介面架構巨變Wails 內嵌 → Wails server 控制台 + 瀏覽器 UI
**這是最大的、最需要深挖的變更。**
**新要求**(逐字擷取):
> 我想像中的是 visionA local 安裝完 啟動後 應用程式介面會有可以顯示 local server log 的地方
> 有可以啟動/停止 重啟 local server 的介面 有打開 localhost 網頁的介面
> 網頁上會有 scan/connect device 的介面 選模型/上傳模型 推論的介面
**PM 解讀**
這實質上是把 visionA-local 從「Wails webview 直接顯示 Next.js UI」退化成**「兩個 app」**
1. **Wails 桌面 appserver 控制台)**:只負責
- 顯示 Go server 的 stdout / log類似 Docker Desktop 的 Logs 面板)
- 啟動 / 停止 / 重啟 local server 的按鈕
- 「Open in Browser」按鈕點了開瀏覽器到 `http://localhost:{port}`
- 可能還要顯示server 狀態running / stopped / error、port 號、資料目錄路徑、版本號
- **不顯示推論 UI**
2. **瀏覽器網頁(真正的 UI**:跟現在的 Next.js 前端幾乎一樣(但要適應沒有 Wails bridge 的情況)
- Scan / Connect Device
- 選模型 / 上傳模型
- 推論camera / image / video upload
**這是什麼定位?競品類比換人**
- 原本的類比:**Photo Booth、Kneron 官方 demo app**——桌面 app打開就用。
- 新的類比:**Ollamaserver 跑在背景 + 獨立 web UI / CLI、LM Studio有桌面 app 也有 server 模式、Stable Diffusion WebUI點 bat 啟動 + 瀏覽器打開 127.0.0.1:7860、Docker Desktop桌面 app 只管 daemon真正的操作在 CLI / Dashboard**。
- 這個類比轉換會影響**所有對外文案**
- 原本「裝起來就能跑」→ 現在更像「一鍵起 local server用瀏覽器操作」。
- 原本「像一般桌面 app」→ 現在更像「像 Ollama 一樣輕量伺服器」。
**為什麼使用者會這樣想PM 的猜測(必須回去問,見 C1**
我沒有資料支持,只能猜。列出可能動機讓使用者確認:
| # | 可能動機 | 我的評估 |
|---|---------|---------|
| a | **想要多視窗 / 多分頁**,一邊看推論結果一邊看裝置管理 | 合理。Wails 單視窗架構確實限制了這件事 |
| b | **想要瀏覽器的開發者工具**inspect / network / console除錯方便 | 合理。Wails webview 也能開 devtools 但比瀏覽器難找 |
| c | **想要分享 localhost 連結給同網段其他人**(例如客戶的手機開 `http://192.168.1.10:port` 看 demo | **高度懷疑這一點**——原 PRD 6.6 明確寫「絕對不 bind 0.0.0.0」。若這是動機,等於安全模型也要改 |
| d | **不想被 Wails 視窗鎖住**——想讓 server 在背景跑、瀏覽器關掉也沒差,下次要用再開瀏覽器 | 合理。這是 Docker Desktop / Ollama 的體驗 |
| e | **熟悉瀏覽器操作習慣** | 合理但薄弱。單一動機不足以支撐這種大改 |
| f | **想要能用手機 / 平板當第二螢幕看推論**(同上 c | 同 c安全模型風險 |
| g | **看不慣 Wails webview 的某些 bug**(例如 splash regression見 progress.md M7| 合理。但這是解 bug 不是改架構 |
| h | **想把 web UI 獨立出來**,未來可能部署到別處(不綁 Wails | 合理長期考量,但目前內部工具用不到 |
| i | **單純覺得「server + web UI」架構比較乾淨** | 工程師審美考量PM 角度會挑戰 |
**不同動機導出不同產品方向**——a, b, d, g 導向「架構輕度重整,還是桌面 app 為主」c, f 導向「server 要能被同網段連線安全模型大改」h 導向「web UI 要獨立可部署,跟 Wails 解耦」。**必須先知道是哪一個,不能一把梭**。
**PM 的擔憂(一個句子)**
> 這個變更方向在技術上更乾淨,但在使用者體驗上可能是**退步**——因為它把原本「雙擊 app → 看到 UI」的 1 步流程變成「雙擊 app → 按 Start → 按 Open Browser → 看到 UI」的 3 步流程,違反 P1 Persona Arthur 的核心願望「不要為了環境浪費客戶時間」。
### A3.5 多使用者情境:其他使用者是 FAE 還是外部客戶?
第四輪決策是「內部工具」,所以沒處理「分享給外部」。但 A3 如果動機是 c分享 localhost 給同網段),會把產品定位從「內部 FAE 個人工具」推向「內部 FAE 向客戶 demo 的 server」**這會改變威脅模型**
- 內部 FAE 個人工具:威脅模型 = 本機127.0.0.1 + 無認證即可)
- 向客戶 demo 的 server威脅模型 = 區網(可能需要密碼 / token / 限制連線 IP / HTTPS 自簽)
---
## B. 影響範圍
### B1. PRD 要改的章節(按影響嚴重度排序)
| 章節 | 子檔案 | 變更性質 | 嚴重度 |
|------|--------|---------|--------|
| 1. 產品策略與定位 | `strategy.md` | **重寫**。競品類比從 Photo Booth 換成 Ollama / LM Studio價值主張要重述「本機 server + 瀏覽器 UI」而非「桌面 app」北極星指標的 5 分鐘門檻是否還合理需要重評 | 🔴 高 |
| 2. 目標使用者與使用情境 | `user-research.md` | **修訂 + 重寫 US**。US-1第一次安裝驗收標準 AC-1.3 要加「App 啟動後顯示 server 控制台」US-2 / US-3 / US-4 / US-5 全部要重寫流程(因為真正操作在瀏覽器而非 Wails 視窗US-6 原本已砍US-7上傳影片要保留但砍掉 URLUS-8 砍掉User Journey Map「第一次成功」階段要重畫 | 🔴 高 |
| 3. 產品願景與非目標 | `vision-and-non-goals.md` | **補充非目標**。明確寫「不支援遠端模型下載 / Model Zoo」「不支援影片 URL 推論 / yt-dlp」「不 bind 0.0.0.0(除非使用者確認 A3 動機 c/f」 | 🟡 中 |
| 4. 功能清單與 API 對照 | `features/feature-inventory.md` | **大幅修訂**。4.1 API`/api/media/url`4.2 前端路由重新切分「Wails shell 顯示什麼 / 瀏覽器顯示什麼」4.3 元件:把 cluster UI、relay-token-sync 一樣明確刪,同時新增 server 控制台元件start/stop 按鈕、log viewer、open browser 按鈕4.5 新增功能表加「server 生命週期控制面板」4.6 要刪的檔案:加 yt-dlp 相關4.8 授權宣告:刪 yt-dlp 條目 | 🔴 高 |
| 5. 使用者流程 | `features/user-flows.md` | **重寫**。First-Run / 日常使用 / 離開的流程都要重寫Wails app 變成 server 管理器,使用者第一次啟動要引導「點 Open Browser」 | 🔴 高 |
| 6. 非功能需求 | `nonfunctional.md` | **修訂量化值**。6.1 首次推論時間要拆得更細Wails shell 啟動 / server 啟動 / 瀏覽器開啟 / 第一幀6.2 安裝檔大小目標可以降低yt-dlp 35MB 拔掉6.6 安全性需求要根據 A3 動機c/f決定是否改 bind IP6.8 主題跟隨系統這一條在瀏覽器中是「跟隨使用者 OS prefers-color-scheme」而非「Wails webview 繼承 OS」 | 🔴 高 |
| 7. 發佈與交付策略 | `release-strategy.md` | 輕微影響。dmg 變小、發佈說明要寫「啟動後點 Open Browser」 | 🟢 低 |
| 8. 風險與相依性 | `risks.md` | **新增風險**。見 D 章 | 🟡 中 |
### B2. 要砍的功能(具體清單)
| # | 項目 | 出處 | 動作 |
|---|------|------|------|
| 1 | `POST /api/media/url` endpoint | `feature-inventory.md` 4.1 | 刪除 |
| 2 | yt-dlp 內嵌35MB | `feature-inventory.md` 4.8 / `progress.md` M6-2 | 從 vendor / payload / installer 全部拔掉 |
| 3 | 前端「URL 貼上」UI | `frontend/src/app/media/` 或類似 | 刪除 |
| 4 | US-8YouTube / URL 推論) | `user-research.md` 2.3 | 刪除 |
| 5 | 授權宣告中的 yt-dlp 條目 | `feature-inventory.md` 4.8 | 刪除 |
| 6 | 「模型從 URL 下載」類功能(若存在)| 需確認 | 確認無後標為非目標 |
| 7 | **(待確認)** Wails webview 內嵌 Next.js 前端的架構 | `server/main.go` + Wails config | 改為只 serve server 控制台介面 |
| 8 | **(待確認)** Wails 首頁 iframe / navigate 到 Next.js build | 同上 | 改為 server 控制台 UI |
### B3. 要重寫的 user story
| US | 原標題 | 變更性質 |
|----|--------|---------|
| US-1 | 第一次安裝 | **大幅重寫**。AC-1.3「安裝完成後系統中出現 app icon」不變但要加 AC-1.7「第一次開啟 app 看到 server 控制台,不是 Dashboard」AC-1.8「控制台有明確引導『按這裡在瀏覽器打開 UI』」 |
| US-2 | Mock 模式試玩 | **重寫**。進入 Mock 模式的入口從「First-Run 選擇 Mock」變成「先在控制台啟動 server選 Mock 模式) → 開瀏覽器 → 看到 Mock UI」 |
| US-3 | 連實體 Kneron 裝置 | **中度重寫**。偵測流程不變,但「看到裝置」的 UI 在瀏覽器,不在 Wails 視窗。邊緣情況若瀏覽器沒開USB 插入事件要不要 push 到 Wails 控制台 toast |
| US-4 | 切換 / 上傳模型 | **中度重寫**。「拖放 .nef 到視窗任何地方」—— **哪個視窗**Wails 控制台?瀏覽器?兩邊都要?這是體驗面 Design 要回答的關鍵問題(見 E 章) |
| US-5 | 跑即時攝影機推論 | **中度重寫**。Workspace 頁在瀏覽器。這裡有個**瀏覽器特有的新限制**:瀏覽器 `navigator.mediaDevices.getUserMedia()` 取 webcam 需要 HTTPS 或 localhost目前 localhost 勉強可過但 Chrome 可能提示 permission跟原 Wails 直接走 Go + AVFoundation / DirectShow 的模型不同。**這是關鍵技術風險**(見 E 章)|
| US-7 | 上傳影片 / 圖片做離線推論 | 輕微修訂,格式限定「瀏覽器能吃的」 |
| US-8 | YouTube / URL 推論 | **砍掉** |
| US-9 | 查看 server 日誌 | **升級**。原本是 Settings 進階分頁的次要功能,現在變成 **Wails 控制台的一級功能**,要獨立一個 AC |
### B4. 原本架構中「為了 Wails 內嵌」做的設計(請 Architect 補完整清單)
PM 只能從 PRD / progress.md 看到的表層:
1. **server/web/out 內嵌**M1 收尾的 `build-embed` target把 Next.js `frontend/out` 同步到 `server/web/out`,再 embed 進 Go binary。**這個設計在新架構下仍然有效**——但要考慮 Wails 控制台是用什麼技術?是 Wails 自己的 UIGo template / Wails 原生 webview 顯示一個極簡 HTML還是另一份 Next.js build
2. **Wails `/ipc/raise` endpoint + wails-ipc-port 檔案**M7 L-3原本用來讓 server 崩潰後可以叫回 Wails 視窗。新架構下這個還有意義嗎server 崩潰時需要通知的是**誰**——Wails 控制台?還是瀏覽器?兩邊?
3. **Single-instance 保護**PRD 5.5,第四輪決策):使用者第二次雙擊 app 時 raise 既有視窗。新架構下 single-instance 還是要做,但 raise 什麼——Wails 控制台?還是 focus 既有的瀏覽器 tab瀏覽器 tab focus 跨瀏覽器很難做)
4. **⌘Shift+R 重啟服務**(第四輪 R4-6這個快捷鍵是給 Wails webview 用的。新架構下快捷鍵要住在哪——Wails 控制台用 Wails menu bar瀏覽器網頁如果要也要的話得自己用 JS 監聽(還會被瀏覽器預設快捷鍵衝突)。
5. **Mock 模式的 sidebar 標記 / 主視窗標題列標記**4.5現在「主視窗」有兩個——Wails 控制台、瀏覽器 tab。兩邊都要標嗎
6. **Wails tray 已砍、改用主視窗 menu bar**(第三輪 Q-A新架構下 menu bar 屬於 Wails 控制台。那「上傳模型」這種動作,從 Wails menu bar 按下去是要直接處理,還是要切到瀏覽器?
7. **Fatal 原生對話框**L-2這個維持有效。
8. **First-Run 歡迎流程在哪**:原本是 Wails webview 載入 Next.js 前端的第一個畫面。新架構下——是 Wails 控制台放一個「首次啟動引導」(講解控制台怎麼用),還是瀏覽器打開後才看到 First-Run跟以前一樣還是兩邊都要Wails 講控制台、瀏覽器講功能)?
**請 Architect 補充:** 現有 `local-tool/server/main.go`、Wails `wails.json`、前端 `next.config.js` 裡面哪些設定是為了內嵌架構而存在、新架構下哪些要改、哪些可以直接沿用。
---
## C. 待使用者決策的問題
> 每題都是「必須先問才能繼續」——不是可以延後的。
### C1. 🔴【最關鍵】介面架構變更的動機是什麼?
**為什麼問**A3 章列出 9 種可能動機a ~ i每一種導向不同的產品方向。不問清楚就做會做錯。
**選項**
- **A**:多視窗 / 多分頁的便利性(可以一邊看推論、一邊看裝置)→ 方向是「架構輕度重整Wails 只是 server 生命週期管理器UI 主體仍在本機瀏覽器,**127.0.0.1 不變**」
- **B**:想要瀏覽器開發者工具 / 熟悉瀏覽器操作 → 同 A方向相同
- **C**:要分享 localhost 給同網段的其他裝置(客戶的手機、平板)→ 方向是「架構重整 + 安全模型大改 + 要加認證 / token / IP 限制」**這會是另一個 L 級工作**
- **D**:想讓 server 在背景跑、瀏覽器關掉也沒差,下次要用再開瀏覽器 → 同 A方向相同但要加「視窗關閉不終止 server」
- **E**:單純看不慣 Wails webview 的某些 bug例如 splash 問題)→ **建議不要為此改架構**,先解 bug
- **F**:想讓 web UI 可獨立部署(未來不綁 Wails→ 方向是「把 web UI 做得跟 Wails 完全解耦,未來可以 deploy 到別處」
- **其他 / 混合**:請使用者直接說
**PM 建議**
> 我強烈建議使用者選 A / B / D或混合理由是這三種動機都能在**不改安全模型**的情況下達成,改動範圍可控。
>
> 如果選 C 或 F**我會要求暫停 M7 Windows build**,重新開一輪三方聯合討論,因為這相當於改變產品的核心定位。
>
> 如果選 E**我會直接挑戰使用者**:解 bug 比改架構便宜一個數量級,先把 bug 修掉再說。
### C2. 🟡「camera / image / 上傳影片」有涵蓋批次影像上傳嗎?
**為什麼問**:原 `/api/media/upload/batch`(批次多張圖片上傳)在 feature-inventory 是保留的,新要求措辭漏掉。
**選項**
- **A**:保留批次上傳(一次處理多張圖片)
- **B**:砍掉,只保留單張圖片 + 單支影片
- **C**:保留但降級為 Phase 2
**PM 建議**
> 保留(選 A。批次上傳是既有功能砍掉無理由對 FAE 用批次跑回歸測試有價值。
### C3. 🟡 模型管理:「只預設 + 只能上傳」的解讀是 A 還是 B
**為什麼問**:見 A2 章節。
**選項**
- **A**:維持現況的 8 個預設 .nef + 使用者上傳,只是確認「不要做 Model Zoo / 遠端下載」
- **B**:重新評估預設清單,可能精簡到 3-4 個
- **C**:把預設也砍掉,只留上傳
**PM 建議**
> 選 A。原 PRD 本來就沒規劃 Model Zoo這句話更像是再次確認非目標。選 B 會增加決策成本(「要留哪 3 個?」),選 C 會破壞 Mock / 即時體驗Persona P3 Solution Architect 的 demo 場景沒模型就完蛋)。
### C4. 🔴 Wails 控制台的 scope 究竟有多小?
**為什麼問**:使用者說「顯示 log」「啟動/停止/重啟」「打開 localhost」這是**最小集合**。但還有很多「邊緣功能」不知道要不要也住在 Wails 控制台:
**選項**(每項都要獨立回答):
| 功能 | 要住在 Wails 控制台嗎? | PM 預設建議 |
|------|----------------------|------------|
| 顯示目前 server port | 是 ✅ | 是(使用者需要知道 URL 是 127.0.0.1:XXXX|
| 顯示資料目錄路徑macOS `~/Library/Application Support/visiona-local/`| 是 ✅ | 是(除錯時常用)|
| 顯示版本號 / 建置資訊 | 是 ✅ | 是 |
| 顯示 server 狀態running / stopped / error| 是 ✅ | 是 |
| Mock 模式切換按鈕 | **待決定** ⚠️ | 是Mock 是 server 模式,不是業務模式,住控制台合理)|
| 硬體偵測結果(已接上幾台 Kneron| **待決定** ⚠️ | **不要**(屬於業務資訊,住瀏覽器 UI|
| 上傳模型 | **待決定** ⚠️ | **不要**(業務功能)|
| 匯入 / 匯出設定 | **待決定** ⚠️ | 是(屬於「工具維護」)|
| 清除 log / 查看歷史 log 檔 | **待決定** ⚠️ | 是 |
| 第三方授權宣告 About | **待決定** ⚠️ | 是Wails About 對話框)|
| Settings > 一般 / 語言 | **待決定** ⚠️ | **不要**(屬於瀏覽器 UI 的 Settings 頁)|
| 崩潰後的原生對話框通知 | 是 ✅ | 是 |
請使用者逐項確認,或直接接受 PM 建議。
### C5. 🔴 Wails 視窗關閉行為
**為什麼問**:原 PRD Q7 決定「關閉視窗 = 結束程式」(所以砍 tray。新架構下這個語義要重新定義因為現在視窗關閉影響兩個東西server、瀏覽器 UI。
**選項**
- **A**:關閉 Wails 控制台 = 停 server = 瀏覽器 UI 失效。**最簡單**。但使用者若關了控制台忘了 server 還在跑,不會發生這種狀況。
- **B**:關閉 Wails 控制台 ≠ 停 server。關閉後 server 仍在背景跑,下次重開 Wails 控制台 attach 回去。**像 Docker Desktop**。但在 macOS / Windows / Linux 實作有差異,且 single-instance 保護要重寫。
- **C**:關閉 Wails 控制台時彈確認對話框問使用者「要不要一起停 server」。
**PM 建議**
> 初步偏 **A最簡單**,除非使用者在 C1 選了動機 D想讓 server 在背景跑)。若選 D 則必須是 B。
>
> 這題的決定直接影響 B4 第 3 項 single-instance 設計。
### C6. 🟡 使用者要不要有機會直接雙擊開瀏覽器(不經過 Wails 控制台)?
**為什麼問**:既然 UI 在瀏覽器,使用者其實不需要每次都打開 Wails 控制台——他可能想要像 Ollama 那樣「server 背景跑著,直接打開 `http://localhost:XXXX`」。但這跟 C5 的 B 方案強相關。
**選項**
- **A**:必須先開 Wails 控制台Wails 控制台負責起 server然後才能用瀏覽器
- **B**:提供 macOS 的 LaunchAgent / Windows 的啟動項 / Linux systemd user unit讓 server 可以背景常駐(類似 Docker Desktop / Ollama
- **C**:不背景常駐,但提供 CLI 啟動模式(使用者可以自己 `open -a visiona-local``visiona-local serve`
**PM 建議**
> 選 **A** + 未來考慮 C。這是 MVP先把最小路徑做對。背景常駐會引入 autostart / tray / 開機啟動等一整套複雜度(而 tray 才剛被砍)。
### C7. 🟡 瀏覽器選擇:使用者預設瀏覽器 or 指定 Chromium-based
**為什麼問**webcamUS-5在 Safari / Firefox / Chrome 的權限流程和支援度不同。攝影機 overlay 效能也不同。
**選項**
- **A**:用使用者的 OS 預設瀏覽器Safari on macOS / Edge on Win / Firefox / Chrome 依系統設定)
- **B**:強制 Chrome / Edge 系列,其他瀏覽器警告
- **C**:內嵌 Chromium如 Electron但整包會變很大 100MB+
**PM 建議**
> 選 **A** 但在 README / First-Run 控制台提示「推薦 Chrome / EdgeSafari 可能有攝影機權限限制」。選 C 違反整個 MVP 的瘦身原則dmg 會從 220MB 變 400MB+)。
### C8. 🔴 Wails 控制台的首次啟動引導如何設計?
**為什麼問**:原 PRD 的 First-Run 流程(歡迎 → 選擇模式 → 硬體偵測)是長在 Next.js 前端裡的。新架構下這個流程要**搬家**——是搬去 Wails 控制台、還是維持在瀏覽器但等「使用者第一次開瀏覽器」才看到?
**選項**
- **A**:整套 First-Run 仍在瀏覽器端Wails 控制台的首次啟動只是提示「按 Start Server 然後按 Open Browser」
- **B**First-Run 拆成兩段Wails 控制台引導「怎麼用這個控制台」,瀏覽器端引導「怎麼用 visionA-local 的功能」
- **C**First-Run 整套搬到 Wails 控制台,瀏覽器打開後直接進 Dashboard
**PM 建議**
> 選 **A**。Wails 控制台是次要視窗,不應該搶 First-Run 戲份。但要確保「按 Open Browser 後」瀏覽器會自動打開到正確的 `http://localhost:XXXX/onboarding` 路由。
### C9. 🟢 這次變更是否要重做 `progress.md` M7 的 Windows build
**為什麼問**M7 正在收尾,若這輪變更砍了 yt-dlp + 改前端架構Windows build 可能要等改完再跑。
**選項**
- **A**:先把 M7 Windows 收完(拿到 Windows 能跑的 baseline再基於 baseline 套這輪變更
- **B**M7 停擺,本輪變更優先
- **C**M7 和本輪變更平行做(高風險)
**PM 建議**
> 選 **A**。理由Windows 能跑的 baseline 是品質保險,即使這輪變更改掉 70% 前端「Windows 能跑 server + 可以打包」這件事本身不會白費。
---
## D. 風險觀察
### D1. 【新】「零摩擦安裝」承諾被打破
- **描述**:原 PRD 北極星指標是「5 分鐘內跑第一次推論 ≥ 95%」P1 Persona Arthur 的一句話是「雙擊 app、插 USB就能 demo」。新架構多一步「點 Open Browser」對 Arthur 的心智負擔是 +30%。
- **可能性**:高(結構性變更,不是實作品質問題)
- **影響**:中-高(可能違反北極星承諾)
- **緩解**(1) Wails 控制台啟動時自動 `Start Server`(不用手動按),(2) Server 就緒後自動 `Open Browser`(不用手動按),(3) Wails 控制台第一次啟動後可以「隱藏到背景」但不終止 server。**這樣 UX 上仍是「雙擊 → 看到 UI」但底層是新架構**。
- **等級****P0 阻斷項**——若沒做到這三點緩解,我會建議使用者回到原架構或放棄變更。
### D2. 【新】Safari / Firefox webcam 相容性
- **描述**:瀏覽器 `getUserMedia` 在 Safari 17+ 雖然支援 localhost HTTP 的 camera permission但跟使用者的直覺不同Firefox 也有類似 permission flow 問題。原 Wails 內嵌架構下,直接走 Go + AVFoundation / DirectShow / V4L2使用者感知不到 permission。
- **可能性**:高(每個 macOS 預設 Safari 使用者都會碰到)
- **影響**US-5 攝影機推論的起步體驗變差)
- **緩解**(1) README / 首次引導推薦 Chrome / Edge(2) 在 Wails 控制台的「Open Browser」按鈕旁邊標示「如使用 Safari 需額外允許攝影機權限」,(3) 攝影機啟動失敗時提供具體錯誤訊息。
- **等級****P1 追蹤項**。
### D3. 【新】Server 只聽 127.0.0.1 vs 同網段需求的衝突
- **描述**:原 PRD 6.6 明寫「絕對不 bind 0.0.0.0」。若使用者在 C1 選動機 C / F就得 bind 0.0.0.0 並加認證,這是另一個 L 級工作。
- **可能性**:未知,取決於 C1 答案
- **影響**:大(安全模型改寫)
- **緩解**C1 先問清楚,動機若非 C / F 則維持 127.0.0.1
- **等級**:待 C1 回答後定級
### D4. 【新】Wails 控制台的跨平台 UI 一致性風險
- **描述**現在的架構下Wails 只是 webviewUI 本身是 Next.js 跨三平台一致。若控制台改用 Wails 原生 menu + 原生 button會碰到三平台原生元件差異重回「跨平台原生 UI」的泥沼。若控制台還是用 webview + HTML等於又多一份前端 build。
- **可能性**:高
- **影響**:中(開發成本)
- **緩解**Architect 要在 TDD 裡明確說明「控制台 UI 是用什麼技術」——我建議仍然用 webview + 極簡 HTML 而非 Wails 原生 UI避免三平台 UI 分岔)。
- **等級**P1
### D5. 【新】使用者關閉瀏覽器後 server 是否仍在跑的心智模型不清
- **描述**:跟 C5 相關。Docker Desktop / Ollama 的體驗是「server 是常駐的」,使用者不會覺得怪;但 visionA-local 若採 C5-A關 Wails 等於關 server使用者關了瀏覽器再開瀏覽器發現 UI 404 會困惑。
- **可能性**:中
- **影響**:中
- **緩解**:瀏覽器端檢測 server 不可達時顯示「server 已停止,請回到 visionA-local 控制台重新啟動」的友善錯誤頁
- **等級**P2
### D6. 【新】「單一動機不足,但 sunk cost 足」的心理陷阱
- **描述**:這是 PM 自己的 meta 風險提醒。使用者花了大量時間做 M1-M6現在提出巨變。若動機薄弱例如 E「看 Wails splash 不順眼」),**改架構的成本遠大於解 bug 的成本**。PM 有責任挑戰這個決定,而不是順從。
- **緩解**C1 是保護機制。若 C1 答案是「沒特別理由」或 EPM 應該建議**不要改**。
- **等級**:管理風險
### D7. 【既有】P2「FAE 不知道有新版」依然成立
- **描述**:不受這輪變更影響,但在新架構下「版本號顯示」變得更重要,因為 Wails 控制台是版本資訊的第一個 touchpoint。
- **緩解**Wails 控制台顯眼處(例如視窗標題列)顯示版本號。
- **等級**P2
---
## E. 給 Design 和 Architect 的議題
### E1. 給 Design 的議題(體驗面)
1. **Wails 控制台的資訊架構**(對應 C4要放哪些資訊排版色調這是一個全新頁面不能從既有 Next.js sidebar 直接搬。建議 Design Agent 畫 wireframe。
2. **「Open Browser」按鈕的視覺層級**:這是新架構下**最重要的單一 CTA**。是不是該放在 Wails 控制台的正中央、像 Photo Booth 按拍照鈕那樣顯眼?還是像 Docker Desktop 那樣比較低調?
3. **First-Run 的分段**(對應 C8若選 A整套 First-Run 在瀏覽器Wails 控制台的首次引導要怎麼設計才不會打擾到「使用者只是想趕快開 UI」的急迫感。
4. **拖放 .nef 的目標視窗**US-4使用者從 Finder 拖 .nef 檔,應該拖到 Wails 控制台還是瀏覽器兩邊都要支援Design 要決定。
5. **Mock 模式的視覺標記**4.5):現在「主視窗」有兩個。兩邊都要打 Mock 標?還是只有其中一邊?
6. **Server 啟動中的 loading 體驗**:原本 Wails webview 直接載 Next.js有個 splash 就結束了。新架構下有兩段等待:(a) Wails 啟動後到 server ready、(b) 瀏覽器打開後到頁面渲染完。兩段都要設計。
7. **Wails 控制台的視窗大小**:原本 Wails 視窗是 1280x800主 UI。新架構下控制台要小得多可能 600x400 就夠),而且可能不需要 resize。
8. **錯誤狀態的分佈**server 崩潰時要通知誰——Wails 控制台瀏覽器兩邊通知形式toast / modal / native dialog如何分
### E2. 給 Architect 的議題(技術面)
1. **Wails 控制台是用什麼實作**(對應 D4最務實的選擇是 Wails webview + 極簡 HTML不用 Next.js 那份 build另一個選擇是用 Wails 原生 menu / button三平台原生 UI 不一致);還有第三個選擇是 Next.js 產出兩份 build主 UI + 控制台。Architect 要提方案並分析利弊。
2. **Next.js build 是否要解耦 Wails**:原本 build 會 embed 進 Go binary新架構下如果 UI 主體跑在瀏覽器,還要 embed 嗎embed 的意義是什麼?可以改成 server 啟動時直接 `http.Handler` serve `static/` 目錄。
3. **Server 啟停的 IPC**Wails 控制台按下 Start 要叫 server 起來;按 Stop 要 graceful shutdown。目前 server 是透過 Wails 生命週期管理M7 L-1 的 watchServer新架構下要重新設計 IPC 協定。
4. **Single-instance 保護**(對應 C5 + B4-3新架構下「second instance」的定義變了。需要重新設計。
5. **Server port 的分配**:目前是隨機 port + wails-ipc-port 檔案L-3。新架構下要讓使用者在瀏覽器打開「固定 URL」還是「每次不同 port」固定 port 方便但有衝突風險,隨機 port 要從 Wails 控制台讀取。建議是「固定 port 優先,衝突時降級為隨機並顯示」。
6. **Wails App 的 Dock icon / taskbar 行為**macOS 的 Dock icon 一直亮著、使用者一定會發現有個 app 在跑。跟 Ollama背景 tray + 沒有 Dock icon不同。Architect 要決定要不要做 NSApplicationActivationPolicyAccessorymacOS、hidden from taskbarWindows等。
7. **瀏覽器打開方式**`open http://...`macOS/ `start`Win/ `xdg-open`Linux。Architect 要寫跨平台的 OS helper注意防呆例如使用者沒設預設瀏覽器時的 fallback
8. **攝影機推論的新通路**(對應 D2原本 Go server 直接用 AVFoundation / DirectShow / V4L2 抓 frame 做推論。新架構下如果 UI 跑在瀏覽器,是**維持原路徑**server 抓 frame、MJPEG 推給瀏覽器)還是**改走瀏覽器路徑**(瀏覽器 getUserMedia → WebRTC / ImageCapture → POST 給 server**強烈建議維持原路徑**(伺服器抓 frame + MJPEG效能和權限都簡單。但 Architect 要確認。
9. **server 被同網段連線的防護**:即使使用者選 C1-A/B/D也要確認新架構下 server 真的只聽 127.0.0.1 沒意外 bind 0.0.0.0。這點要在 TDD 加驗收。
10. **CORS / CSRF**:瀏覽器發 request 到 `localhost:XXXX` 是同 origin瀏覽器也在 localhost 開),理論上不需要 CORS。但如果使用者把 URL 複製到別台機器的瀏覽器(跟 C1-C/F 相關),會跨 origin。這題跟 D3 綁在一起。
11. **現有 L-3Wails /ipc/raise是否仍需要**:原本 server 崩潰後 raise Wails 視窗是為了顯示 UI。新架構下 server 崩潰後要 raise Wails 控制台(顯示錯誤),瀏覽器端只會顯示 offline 頁面。IPC 協定仍有效但意義變了。
---
## 結尾PM 的立場聲明
作為 PM我對這輪變更有三個**明確的立場**
1. **C1動機釐清是這次變更能不能做的前提**。我不建議在沒有 C1 答案之前啟動任何實作。
2. **D1零摩擦承諾是不能妥協的紅線**。若新架構無法透過「自動 Start Server + 自動 Open Browser」達成「雙擊就看到 UI」我會建議使用者回到原架構。
3. **我不建議現在就跳去改前端**。正確順序是:(1) 使用者回答 C1-C9(2) PM 根據答案更新 PRD 相關章節;(3) 三方PM / Design / Architect重新聯合審閱(4) 才進實作。這整個循環我估大約要 1-2 個工作日。
**下一步(交給 Orchestrator**
- 將本分析文件呈現給使用者
- 請使用者回答 C1-C9 九個問題C1、C4、C5、C8 為必答;其他可接受 PM 建議)
- 答案到位後,請 Design Agent 和 Architect Agent 同步做第一輪分析(對應 E1、E2
- 三方第一輪分析完成後再召開聯合討論,決定是否要做、怎麼做、對 M7 的影響
---
## 附錄:本分析文件的資料來源
- `/Users/jimchen/visionA/local-tool/.autoflow/02-prd/PRD.md`(索引)
- `/Users/jimchen/visionA/local-tool/.autoflow/02-prd/features/feature-inventory.md`
- `/Users/jimchen/visionA/local-tool/.autoflow/02-prd/user-research.md`
- `/Users/jimchen/visionA/local-tool/.autoflow/02-prd/nonfunctional.md`
- `/Users/jimchen/visionA/local-tool/.autoflow/02-prd/risks.md`
- `/Users/jimchen/visionA/local-tool/.autoflow/progress.md`M1-M7 進度)
- 使用者第四輪變更訊息2026-04-14

View File

@ -0,0 +1,500 @@
# visionA-local — 產品需求文件PRD v2
> 版本:**v2.12026-04-14**
> 作者PM Agent
> 任務等級L 級(重構方向變更)
> 狀態:**v2.1 補丁:吸收 R5-D1/D2/D3 + R5-E + Design 交叉審閱 4 Major / 4 Minor**
> 前版:`PRD-v2.md` v2.02026-04-14/ [`PRD.md`](./PRD.md) v1.22026-04-11
---
## 0. v2 變更摘要(和 v1.2 的差異)
本版本因應 2026-04-14 使用者提出的三項重大方向變更(介面架構、影片來源、模型管理),基於 R5 第五輪決策重新定位產品。本文件採「只更新受影響章節」策略,未影響的章節以「同 v1.2」標註並指向原子檔。
**最關鍵 5 點差異:**
1. **產品定位轉向**從「Wails 桌面 app 內嵌 Next.js UI」改為「本機服務Wails 控制台)+ 網頁控制台(瀏覽器 Web UI競品類比從 Photo Booth 換成 **Ollama / LM Studio / Docker Desktop / Stable Diffusion WebUI**R5-1
2. **砍 Mock 模式**:完全刪除,沒插硬體就讓 UI 空白R5-5a。Persona P3 Solution Architect 的使用情境被迫降級US-2 整條砍掉。
3. **砍 yt-dlp 全套 + URL 影片推論**:媒體上傳只剩 camera / image / upload 影片(`.mp4 / .avi / .mov / .mpeg / .mpg`)。砍 35MB vendor binary。
4. **ffmpeg 授權從 GPL blocker 升級為 LGPL 方案 B 混合**Windows/Linux 用 BtbN LGPL binarymacOS 自 build decoder-only~20MBcommit 到 repoR5-6 / R5-6a / R5-6b / R5-6c
5. **啟動時自動開瀏覽器(每次啟動)**Wails 控制台 server 就緒後**每次啟動**都自動開系統預設瀏覽器Settings 可關R5-4 / R5-D3。平台預設**macOS / Windows ONLinux OFF**`xdg-open` 在極簡 WM 行為不穩R5-D2。用於抵消「多一步摩擦」對北極星指標的傷害。
### v2.1 補丁摘要(相對 v2.0
v2.1 為 Design 交叉審閱發回的小版本修正Major 13 + 4 個 Minor+ R5-D / R5-E 決策落地,**未改動產品定位與核心決策**,只補齊語意與驗收標準。
| 變更 | 內容 | 依據 |
|------|------|------|
| M1 | 「首次啟動自動開」→「每次啟動自動開」(全文 replace | R5-D3 |
| M2 | auto-open 平台預設差異macOS / Windows ONLinux OFF | R5-D2 |
| M3 | US-7 Server 崩潰時 **除控制台 banner 外仍發 OS 原生通知** | R5-D1 |
| M4 | auto-open toggle 住 Wails 控制台Design 仲裁採 PRD 原案) | Design review §G Major 4 |
| **R5-E 大改** | **AC-1.3 從「10 秒硬指標」翻轉為「60 秒 + 階段化進度 + perceived performance」** | R5-E1E6 |
| Minor 1 | US-2 明確:日常啟動(非首次)同樣 auto-open | Design review Minor 1 |
| Minor 2 | Offline Overlay 硬阻斷(`role="alertdialog"` + focus trap + 不可關閉 + 純色卡片) | Design Spec v2 `server-offline-overlay.md` |
| Minor 3 | Wails 控制台 Footer **持久警示**取代單次 confirm / Error state **三動作按鈕**Restart / View log / Report | Design Spec v2 `control-panel.md` |
| Minor 4 | First-Run wizard **從 3 步縮為 2 步**(砍模式選擇),無硬體可按「稍後再設定」 | Design Spec v2 `first-run-update.md` / R5-5a |
**行數目標**v2.1 新增約 40 行、精簡約 20 行,總量仍 ≤ 500 行。
**保留 vs 重寫對照表:**
| 章節 | 子檔 | v2 處理 |
|------|------|---------|
| 1. 產品策略與定位 | `strategy.md` | 🔴 重寫(見本檔 §1 |
| 2. 目標使用者與情境 | `user-research.md` | 🔴 重寫 US見本檔 §2 / §5 |
| 3. 產品願景與非目標 | `vision-and-non-goals.md` | 🟡 修訂(見本檔 §3 |
| 4. 功能清單與 API 對照 | `features/feature-inventory.md` | 🔴 大幅修訂(見本檔 §4 |
| 5. 使用者流程 | `features/user-flows.md` | 🔴 重寫(見本檔 §5 |
| 6. 非功能需求與驗收 | `nonfunctional.md` | 🟡 修訂(見本檔 §6 |
| 7. 發佈與交付策略 | `release-strategy.md` | 🟢 同 v1.2(安裝檔數字需更新見 §6 |
| 8. 風險與相依性 | `risks.md` | 🔴 大幅修訂(見本檔 §8 |
| 4.8 第三方授權 | `feature-inventory.md §4.8` | 🔴 改寫(見本檔 §7 |
---
## 1. 產品策略與定位(重寫)
### 1.1 新定位
visionA-local 是一個**本機服務 + 網頁控制台**架構的 Kneron AI 邊緣推論工具:
- **Wails 桌面 app = 本機 server 控制台**類比Docker Desktop 的 Dashboard、Ollama 的 tray app只負責 server 生命週期、log、版本資訊。
- **瀏覽器 Web UI = 真正的工作區**類比Stable Diffusion WebUI、LM Studio 的 web mode所有「選裝置、選模型、推論」在瀏覽器完成。
- 兩者只在本機、只綁 `127.0.0.1`、不做 LAN、不做背景 daemonR5-1 / R5-2
### 1.2 一句話價值主張
> **「雙擊 app → 自動開瀏覽器 → 插 USB → 5 分鐘內跑第一次推論。熟悉的瀏覽器操作、熟悉的 devtools離線可用、零依賴。」**
### 1.3 競品類比更新
| v1.2 類比 | v2.0 類比 |
|-----------|-----------|
| Photo Booth、Kneron 官方 demo app | **Ollama**server + web UI / CLI、**Docker Desktop**(桌面 app 管 daemon、Dashboard 管操作)、**LM Studio**(桌面 app + server 模式)、**Stable Diffusion WebUI**(點 bat → 瀏覽器打開 127.0.0.1 |
### 1.4 北極星指標(更新)
**v1.2 原指標**:內部 FAE 在客戶現場首次安裝後 **5 分鐘內**順利跑出第一次推論的比例 ≥ 95%。
**v2.0 新指標**(處理「多一步開瀏覽器」紅線):
> **內部 FAE 在客戶現場首次安裝後 5 分鐘內,瀏覽器 Web UI 跑出第一次推論的比例 ≥ 95%。**
> 起點雙擊安裝檔終點瀏覽器顯示第一幀推論結果。自動開瀏覽器R5-4 / R5-D3是必要條件 — 使用者不需手動點「Open in Browser」除非關過 Settings。比 v1.2 更嚴格(把瀏覽器冷啟動也納入 5 分鐘)。
### 1.5 策略取捨紀錄
- **砍 Mock 模式的代價**R5-5aP3 Persona Sam 的「零摩擦 demo」情境無法再被支援使用者明確接受此取捨P3 實質降級為非目標§2
- **雙視窗的代價**Wails 控制台 + 瀏覽器比 v1.2 單視窗多一層「誰是主角」的心智負擔。緩解:控制台 UI 極簡4 按鈕 + 1 log panel所有業務操作裝置、模型、Settings > 語言)強制住瀏覽器。
---
## 2. 目標使用者(修訂)
### 2.1 Persona 變動
- **P1 — Arthur內部 FAE**:同 v1.2。核心使用情境「帶筆電到客戶現場跑 demo」在 v2.0 下**多一步自動開瀏覽器**,但 R5-4 / R5-D3 決定每次啟動都自動開,對 Arthur 幾乎無感。
- **P2 — Dora外部開發者**:同 v1.2。她會欣賞「瀏覽器 devtools」R5-1 動機 B這是 v2.0 相對於 v1.2 的潛在加分項。
- **P3 — SamSolution Architect / PM****實質降級為非目標使用者**。R5-5a 砍 Mock 模式後Sam「沒硬體也能 demo」的訴求無法被滿足。保留 Persona 定義只是為了文件完整性,不再為他優化任何 MVP 功能。若未來要重新支援 Sam應提 L 級功能重新討論。
### 2.2 Persona 對新架構的反應PM 預測)
| Persona | 對「Wails 控制台 + 瀏覽器」的反應 | 對「砍 Mock」的反應 |
|---------|---------------------------------|--------------------|
| P1 Arthur | 🟡 中性偏好(第一次會愣一下,但每次啟動自動開瀏覽器大幅緩解) | ✅ 無感(他本來就帶硬體) |
| P2 Dora | ✅ 正面(瀏覽器 devtools 正中下懷) | ✅ 無感(她本來就在評估自家硬體) |
| P3 Sam | 🟡 中性(多一步開瀏覽器) | 🔴 **負面**(他本來就沒硬體) |
---
## 3. 非目標補充(修訂)
相對於 v1.2 `vision-and-non-goals.md`v2.0 明確**新增以下非目標**
- ❌ **Mock 模式 / 假資料 / 假裝置**R5-5a。沒插硬體就讓 UI 空白。
- ❌ **影片 URL 推論 / yt-dlp / YouTube / Vimeo / Dailymotion 整合**R5 共識 2
- ❌ **Model Zoo / 遠端模型下載**R5 共識 9同 v1.2 但再次確認)。
- ❌ **LAN / 區網分享 localhost**R5-1使用者明確放棄動機 C/F
- ❌ **背景 daemon / tray 常駐**R5-3 維持砍 trayR5-2 維持視窗關閉=結束 server
- ❌ **雙 Next.js build**(共識 5Wails 控制台用 vanilla HTML/JS/CSS不搞兩份前端
**保留的非目標**(同 v1.2cluster、relay、tunnel、auto-update、程式碼簽章、telemetry、韌體燒錄、多架構、商店上架、license 啟用機制、WCAG 2.2 AA 正式合規。
---
## 4. 功能清單與 API 對照(大幅修訂)
本節列出**相對於 v1.2 的變動**。v2.0 完整的功能清單後續由 PM Agent 同步更新到 `features/feature-inventory.md` 子檔。
### 4.1 保留(沿用 v1.2
- Device scan / connect`GET /api/devices``POST /api/devices/scan``POST /api/devices/:id/connect|disconnect``POST /api/devices/:id/inference/start|stop`
- 模型管理8 個預設 .nef + 使用者上傳(`GET /api/models``POST /api/models/upload``DELETE /api/models/:id``GET /api/models/:id/download`
- 批次影像上傳(`POST /api/media/upload/batch`R5 共識 10
- 推論引擎camera / image / 上傳影片
- MJPEG 串流(`GET /api/camera/:id/stream`
- WebSocket 推論結果 stream`/ws/devices/*``/ws/server-logs`
- 系統 API`GET /api/system/health|info|deps|metrics``POST /api/system/restart`
- i18n 中英雙語(住瀏覽器 Web UI
- Dark Mode 跟隨系統(住瀏覽器 Web UI`prefers-color-scheme`
- Log 檔案持久化macOS`~/Library/Application Support/visiona-local/logs/`
### 4.2 砍除v2.0 明確刪除)
| # | 項目 | 理由 | R5 依據 |
|---|------|------|---------|
| 1 | `POST /api/media/url` endpoint | 砍 URL 推論 | R5 共識 2 |
| 2 | `server/internal/media/url_handler.go`(或類似檔案) | 同上 | R5 共識 2 |
| 3 | yt-dlp 內嵌35MB binary + `vendor/ytdlp/` + `installer/payload-*` 相關 stage | 砍 URL 推論 | R5 共識 2 |
| 4 | 前端「URL 貼上」tab、source-selector 中的 URL 選項 | 砍 URL 推論 | R5 共識 2 / R5 共識 4 |
| 5 | **Mock 模式全部**Mock 裝置資料、Mock 推論結果 mock、Mock mode 切換按鈕、Mock 視覺標記、`server/internal/mock/`(若存在)、前端 Mock 入口 | 使用者明確砍 | **R5-5a** |
| 6 | Wails webview 載入 Next.js 前端的路由(原 `server/web/out` 不再被 Wails 直接 serve 作為主要 UI | 轉為 server 控制台 | R5-1 |
| 7 | Wails tray 相關(維持 v1.2 Q-A 決策) | 沒意義 | R5-3 |
| 8 | First-Run 流程中的「模式選擇(真實 / Mock」畫面 | Mock 砍了 | R5-5a |
| 9 | v1.2 US-2Mock 模式試玩)整條 user story | Mock 砍了 | R5-5a |
### 4.3 新增v2.0 明確增加)
| # | 項目 | 描述 | R5 依據 |
|---|------|------|---------|
| N1 | **Wails 控制台 UI** | vanilla HTML/JS/CSS 的獨立桌面視窗。包含Start / Stop / Restart Server 按鈕、Open in Browser 按鈕、Server 狀態指示running / stopped / error、Server port 號、資料目錄路徑、版本號、Clear log 按鈕、Log panel自動捲動 + 手動捲動解鎖、Settings menu含 auto-open toggle見 N2。**Footer 持久警示**:右下角固定顯示 `⚠ 關閉此視窗會停止 Local Server`12 px muted隨時可見取代單次 confirm dialog 作為主要提醒)。**Error state 面板**(對應 N6log panel 上方浮動 banner 含三個動作按鈕 `Restart Server` / `View log` / `Report issue`(見 Design Spec v2 `v2/control-panel.md §5`)。**不含**裝置管理、推論、模型、Settings > 語言等任何業務操作。 | R5-1 / R5-5 / Design review Minor 3 |
| N2 | **啟動時自動開瀏覽器(每次啟動)** | Wails 控制台啟動、server 就緒後,**每次啟動**都自動開系統預設瀏覽器到 `http://127.0.0.1:{port}`不是只在首次R5-D3。**auto-open toggle 住 Wails 控制台** 的 Settings menu讀取時機在 Wails `main.go` 啟動階段Web UI 尚未 load已與 Design 取得共識採此位置)。**平台預設**macOS / Windows `autoOpenBrowser = true`**Linux `autoOpenBrowser = false`**(因 `xdg-open` 在極簡 WM 行為不穩定R5-D2。使用者可隨時切換。 | R5-4 / R5-D2 / R5-D3 |
| N3 | **Web UI Server Offline Overlay** | 瀏覽器 Web UI 偵測 server 斷線heartbeat / WebSocket 斷)時,顯示**全螢幕半透明 backdrop`rgba(0,0,0,0.4-0.5)`+ 置中純色卡片**(卡片本體**不是**半透明,以確保 WCAG 4.5:1 對比。卡片內訊息「Local Server 已離線」+「請回到 visionA-local 桌面視窗重新啟動 Server」+「重試連線」Primary 按鈕 +「了解更多 ↓」Ghost 按鈕。**A11y 硬阻斷特性**`role="alertdialog"` + `aria-modal="true"` + focus trap + **不可由使用者手動關閉**(無 ✕ 按鈕,只能 retry 成功或使用者自行 ⌘W / Ctrl+W 關 tab / 重開 app 後自動恢復)。覆蓋層下方 UI 凍結不可操作。 | R5-2 / Design review Minor 2 |
| N4 | **server boot-id 機制** | Go server 啟動時產生 UUID boot-id放進 `/api/system/info` 回應。瀏覽器 Web UI 連上後記下第一個 boot-id後續 poll 發現 boot-id 變了就代表 server 重啟,前端自動 reload state。**Restart Server 按鈕**(住 Wails 控制台)依賴此機制讓瀏覽器 tab 不用手動重載。 | R5 共識 14 |
| N5 | **CORS 中介層** | Go server 加 CORS middleware`Access-Control-Allow-Origin` 只允許 `http://127.0.0.1:*` / `http://localhost:*`,其他 origin 一律拒絕。 | R5 共識 6 |
| N6 | **watchServer() 改為 Error state** | v1.2 watchServer 連續 3 次健檢失敗會 `os.Exit(1)`v2.0 改為把 Wails 控制台的 Server 狀態切為「Error」並在 log panel 顯示錯誤,不強制退出 app。Error state 面板同時顯示三個動作按鈕(見 N1。 | R5 共識 8 |
| N7 | **Server 崩潰 OS 原生通知** | 當 `watchServer()` 偵測連續 3 次健檢失敗進入 Error state 時,**除了**控制台 Error banner 外,**同步發一次 OS 原生通知**macOS Notification Center / Windows Toast / Linux `libnotify`)。使用者可能正在使用瀏覽器 tab靠 OS 通知把他拉回控制台。去重策略(避免 Error 狀態期間重複發通知)由 Architect 在 TDD v2 決定。 | R5-D1 |
| N8 | **啟動階段化進度顯示6 階段)** | Wails 控制台在啟動過程全程顯示階段化進度:`1. 初始化控制台 → 2. 檢查 Python → 3. 啟動 server → 4. 偵測裝置 → 5. 開瀏覽器 → 6. 瀏覽器就緒`。每階段有編號 + 使用者可理解的動作描述 + 視覺回饋spinner / 進度條)+ 中英雙語文案。第 6 階段「ready」的偵測訊號 = Web UI 透過 WebSocket hub 建立第一個 client 連線(不做新 endpointR5-E6。階段文案由 Design Agent 最終決定R5-E5。啟動總預算見 §6.1 AC-1.3 系列。 | R5-E1E6 |
### 4.4 功能對 Persona 對照表(更新)
| 功能 | 對應 Persona | 住哪裡 |
|------|-------------|--------|
| Wails 控制台Start/Stop/Restart/Log| P1, P2 | Wails 桌面視窗 |
| Device scan / connect | P1, P2 | 瀏覽器 Web UI |
| 模型管理 | P1, P2 | 瀏覽器 Web UI |
| Workspace推論| P1, P2 | 瀏覽器 Web UI |
| Settings > 語言 / 深色模式 | P1, P2 | 瀏覽器 Web UI |
| Settings > 自動開瀏覽器 | P1, P2 | **Wails 控制台**(因為它控制的是 Wails 啟動行為)|
| Server Offline Overlay | 全體 | 瀏覽器 Web UI |
| ~~Mock 模式~~ | ~~P3~~ | ~~砍~~ |
---
## 5. User Stories重寫受影響的
### US-1第一次安裝 → 首次看到推論 UI**重寫**,取代 v1.2 US-1 + 部分 US-5
**身份**:任意目標 Persona主力 P1 Arthur
**情境**:剛拿到 `.dmg` / `.exe` / `.AppImage`,全新機器、客戶現場
**敘述**
> 身為 FAE我希望雙擊安裝檔、按幾下「下一步」、等進度條跑完**視窗自動打開**Wails 控制台)、**瀏覽器自動打開**Web UI插上 Kneron USB 後瀏覽器立刻顯示裝置我按一下「Start」就看到第一幀推論結果。整個流程不超過 5 分鐘。
**驗收標準**
- AC-1.1:下載安裝檔,無 terminal 操作可完成安裝(同 v1.2
- AC-1.2:整個安裝過程 ≤ 3 分鐘(目標)/ ≤ 5 分鐘(上限)(同 v1.2R4-4
- **AC-1.3R5-E 改寫)**:安裝完成後首次啟動,從 Wails 控制台視窗出現到瀏覽器 Web UI 顯示第一幀,**全程 ≤ 60 秒**R5-E1原 10 秒硬指標取消)。啟動過程全程可見、不可出現白畫面或無回饋狀態 — 採 Nielsen Norman perceived performance 原則,使用者感覺進度在推進比硬時間指標重要
- **AC-1.3a(新)**啟動全程顯示階段化進度6 階段,見 §4 N8每階段有編號 + 使用者可理解的動作描述 + 視覺回饋spinner 或進度條)+ 中英雙語文案。階段文案由 Design Agent 決定R5-E2 / R5-E5
- **AC-1.3b(新)**:任一啟動階段卡超過 **20 秒**UI 必須顯示「正在重試」或「這步比預期久」類提示,**不可停留在白畫面或無變化狀態**R5-E3
- **AC-1.3c(新)**:總啟動時間超過 **60 秒仍未進入「就緒」狀態**Wails 控制台進入 Error state顯示「Restart Server」「View log」「Report issue」三個動作按鈕對應 §4 N1 / N6與 watchServer 3 次失敗後行為一致R5-E4
- **AC-1.3d(新)**:「瀏覽器就緒」的定義 = **Web UI 建立 WebSocket 連線到 server**hub 收到第一個 client不是瀏覽器視窗出現時。不做新 endpoint、不做固定延遲R5-E6
- AC-1.4:瀏覽器 Web UI 首次顯示時,若**沒插硬體**Dashboard 顯示空白狀態 + 「請插入 Kneron 裝置」提示文字R5-5a不是 Mock 資料)。**First-Run wizard 從 v1.2 的 3 步縮為 2 步**砍模式選擇R5-5aStep 1 歡迎 → Step 2 硬體偵測。Step 2 在無硬體情況下可按 `稍後再設定` 略過,直接進 Dashboard 空白狀態Design review Minor 4
- AC-1.5Mac 第一次開啟若出現 Gatekeeper 警告Wails 控制台和安裝說明頁提供「右鍵 → 開啟」引導(同 v1.2
- AC-1.6auto-open 可在 **Wails 控制台的 Settings menu** 切換(不在 Web UI Settings見 §4 N2。**平台預設**macOS / Windows ON**Linux OFF**R5-D2。關閉後下次啟動只開 Wails 控制台、不開瀏覽器使用者可手動按「Open in Browser」
### US-2日常啟動**新增**,取代 v1.2 US-2 Mock
**身份**P1, P2
**情境**:第二次以後的日常啟動(非首次安裝,但「啟動時自動開」對首次 / 日常**同樣生效**R5-D3
**敘述**
> 身為日常使用者,我希望雙擊 app icon 後Wails 控制台和瀏覽器都自動開啟(除非我關過 auto-openserver 狀態顯示 running瀏覽器顯示上次的 Dashboard 狀態。
**驗收標準**
- AC-2.1:日常啟動時,若 Wails 控制台 Settings `autoOpenBrowser === true`**仍會自動開瀏覽器**和首次啟動行為一致R5-D3 / Design review Minor 1。AC-1.3 系列的 60 秒預算與階段化進度在日常啟動同樣生效(通常遠低於 60 秒)
- AC-2.2:瀏覽器 Web UI 保留上次的 i18n 語言偏好、深色模式偏好
- AC-2.3:若使用者關過 auto-open或 Linux 平台預設 OFF 且使用者未手動開),啟動時只開 Wails 控制台使用者可點「Open in Browser」手動開
### US-3連實體 Kneron 裝置(**微調**
**身份**P1, P2
**敘述**:同 v1.2,但 UI 住瀏覽器
**驗收標準**
- AC-3.1 ~ AC-3.6:同 v1.2(插 USB → 3 秒內瀏覽器 Devices 頁顯示裝置)
- AC-3.7**新**):若使用者關閉瀏覽器但 Wails 控制台還在server 仍跑),插入 USB 的事件不會通知使用者(不做 toast下次使用者打開瀏覽器會看到裝置。不發原生 OS 通知(維持 v1.2 R4-8 OS 通知策略)。
### US-4切換 / 上傳模型(**微調**
**身份**P1, P2
**敘述**:同 v1.2,但 UI 住瀏覽器
**驗收標準**
- AC-4.1 ~ AC-4.4:同 v1.2
- AC-4.5**新**):拖放 .nef **只在瀏覽器視窗內有效**,拖到 Wails 控制台視窗無反應(不處理跨視窗拖放,避免混淆)
- AC-4.6**新**):預設模型清單**維持 8 個 .nef**,使用者可上傳自己的 .nef但**沒有**任何 Model Zoo / URL download 入口R5 共識 9
### US-5跑即時攝影機推論**重寫,瀏覽器情境**
**身份**P1
**敘述**
> 身為 FAE我希望在瀏覽器 Workspace 頁選一顆 webcam、選一個模型、按 Start看到 MJPEG 即時串流和推論 overlay。
**驗收標準**
- AC-5.1Workspace 頁能列出所有可用 webcam透過後端 `/api/camera`,而非瀏覽器 `navigator.mediaDevices`;這點對 Chrome / Edge / Safari 行為一致)
- AC-5.2:選 webcam + 模型後按 Start≤ 3 秒看到第一幀
- AC-5.3:推論 overlay方框 / 標籤 / 信心度)正確顯示
- AC-5.4:即時 FPS / 延遲 / CPU / RAM 顯示
- AC-5.5:按 Stop 即時停止
- AC-5.6**新****不使用**瀏覽器 `getUserMedia()` API避開 Chrome/Edge/Safari 權限差異 + HTTPS 限制)。所有攝影機存取走後端 AVFoundation / DirectShow / V4L2 pipeline和 v1.2 架構相同)
### US-6上傳影片推論**修訂**,取代 v1.2 US-7 / US-8
**身份**P1, P2
**敘述**
> 身為使用者,我希望能把一段 .mp4 / .avi / .mov / .mpeg / .mpg 影片拖進瀏覽器 Workspace跑離線推論、看到逐幀結果。
**驗收標準**
- AC-6.1:瀏覽器 Workspace 頁支援拖放或 file picker 上傳影片
- AC-6.2:支援副檔名:`.mp4 / .avi / .mov / .mpeg / .mpg`R5 共識 11
- AC-6.3:上傳後後端走 ffmpeg decode → Kneron 推論 → 前端顯示結果
- AC-6.4**不支援** URL 推論 / YouTube / Vimeo / DailymotionR5 共識 2
### US-7Server 崩潰 / 意外中斷(**新增**
**身份**P1, P2
**情境**Go server 子程序因某種原因崩潰OOM、panic、依賴錯誤或使用者關閉 Wails 視窗
**敘述**
> 身為使用者,我希望當 server 斷線時我在瀏覽器看到清楚的「Local Server 已離線」訊息,而不是無聲的網頁卡住。我也希望 Wails 控制台能告訴我發生什麼、我能按 Restart 重啟。
**驗收標準**
- AC-7.1:瀏覽器 Web UI 偵測 server 斷線health check 失敗 or WebSocket 斷 + retry 失敗)→ 顯示全螢幕 Server Offline OverlayR5-2 / N3。**Overlay 無 ✕ 按鈕、不可手動關閉**`role="alertdialog"` + focus trapDesign review Minor 2
- AC-7.2Overlay 卡片內訊息「Local Server 已離線,請回到 visionA-local 桌面視窗重新啟動 Server」+「重試連線」Primary 按鈕 +「了解更多 ↓」Ghost 按鈕。卡片背景純色(非 backdrop 半透明),確保 WCAG 4.5:1 對比
- AC-7.3Wails 控制台 Server 狀態顯示變為「Error」紅色log panel 上方浮現 Error banner 面板,含 **三個動作按鈕**`Restart Server` / `View log` / `Report issue`(對應 §4 N1 / N6Design Spec v2 `v2/control-panel.md §5`Design review Minor 3
- AC-7.4:使用者在 Wails 控制台按 Restart Server 後server 重啟、取得新 boot-id瀏覽器 Web UI 透過 Retry 重連、偵測到新 boot-id、自動 reload stateR5 共識 14 / N4
- AC-7.5:使用者直接關閉 Wails 視窗 → **server 結束**R5-2 維持 v1.2 Q7→ 瀏覽器 Web UI 顯示相同的 Offline Overlay + 提示「請重新啟動 visionA-local」
- AC-7.6:為避免使用者誤解「關閉視窗 = 程式還在背景跑」,**雙保險警示**(a) Wails 控制台 Footer 右下角**持久警示文字** `⚠ 關閉此視窗會停止 Local Server`12 px muted始終可見§4 N1(b) 標題列 / menu bar 的「Close」按鈕 tooltip / confirm dialog 同樣寫明。(a) 是主要手段(避免使用者盲目點 confirm(b) 為輔
- **AC-7.7R5-D1 新)**Server 崩潰事件watchServer 連續 3 次失敗進入 Error state 的瞬間),**除控制台 Error banner 外,同步發一次 OS 原生通知**macOS Notification Center / Windows Toast / Linux `libnotify`)。使用者可能正在使用瀏覽器 tab 而未看控制台OS 通知負責把他拉回來。去重策略Error 狀態期間避免重複發)由 Architect 在 TDD v2 決定
### 砍掉的 User Stories
| US | v1.2 標題 | v2.0 處理 |
|----|----------|-----------|
| v1.2 US-2 | Mock 模式試玩 | **砍**R5-5a |
| v1.2 US-6 | 從 Tray 快速控制 | 維持 v1.2 已砍(第三輪 Q-A |
| v1.2 US-8 | YouTube / URL 推論 | **砍**R5 共識 2 |
| v1.2 US-9 | 查看 server 日誌 | **升級**:住 Wails 控制台成為一級功能N1 |
---
## 6. 非功能需求(更新)
### 6.1 效能指標
| 指標 | v1.2 | v2.0 | 變化說明 |
|------|------|------|---------|
| 安裝時間 | ≤ 3 分 / 上限 5 分 | **同 v1.2** | R4-4 沿用 |
| 首次推論時間(首次啟動)| ≤ 20s / 上限 30s至 Mock 第一幀)| **≤ 20s / 上限 30s至瀏覽器第一幀** | 終點改為瀏覽器 Web UI 顯示第一幀真實推論結果(因 Mock 砍。R5-4 的自動開瀏覽器是達成此指標的必要條件。Architect TDD v2 需驗證預算 |
| 首次推論時間(回訪)| ≤ 10s / 上限 15s | **同 v1.2**(但終點改為瀏覽器)| 同上 |
| 實機接入時間 | ≤ 5s / 上限 10s | 同 v1.2 | — |
| 攝影機串流延遲(首次)| ≤ 200ms / 上限 250ms | 同 v1.2R4-2| — |
| 攝影機串流延遲(穩定後)| ≤ 120ms / 上限 150ms | 同 v1.2R4-2| — |
| idle CPU無硬體狀態| ≤ 3% / 上限 5%Mock 模式)| **≤ 3% / 上限 5%(無硬體狀態)** | Mock 改為「無硬體空白狀態」。CPU 估計值不變,因為 v1.2 Mock 本來就不 spawn Python |
| idle RAM無硬體狀態| ≤ 500 MB / 上限 600 MB | **≤ 450 MB / 上限 550 MB** | 因無 Mock state、無 Python sidecar、無 yt-dlp 子程序,可略降。**此指標不含瀏覽器 tab 記憶體**(由 OS 系統瀏覽器負責,不是 Wails app 的責任範圍。Architect 實測Wails + Go server + Python 樂觀估 275405 MB達標 |
| 真實推論 RAM | ≤ 1 GB / 上限 1.5 GB | 同 v1.2 | — |
### 6.2 安裝檔大小(更新)
| 平台 | v1.2 上限 | v2.0 目標 | 組成變化 |
|------|-----------|-----------|---------|
| macOS `.dmg` | ≤ 220 MB | **≤ 185 MB**(目標)/ 上限 ≤ 220 MB | -35MB yt-dlp、ffmpeg 從 77MB GPL build 換成 ~20MB LGPL decoder-only = 淨 -92MB |
| Windows `.exe` | ≤ 200 MB | **≤ 165 MB**(目標)/ 上限 ≤ 220 MB | -35MB yt-dlp、ffmpeg 換 BtbN LGPL ~40MB差異較小但仍略降 |
| Ubuntu `.AppImage` | ≤ 200 MB | **≤ 165 MB**(目標)/ 上限 ≤ 220 MB | 同 Windows |
> **根據**R5-6 / R5-6a / R5-6b / R5-6c ffmpeg LGPL 混合方案,砍 yt-dlp 全套。實際數字需 Architect 在 TDD v2 實測確認。
### 6.3 瀏覽器相容性(新)
- **預設**:開啟使用者 OS 預設瀏覽器macOSSafari/Chrome/Edge 依 OS 設定WindowsEdgeLinux`xdg-settings`
- **auto-open 平台預設**(見 §4 N2macOS / Windows `autoOpenBrowser = true`**Linux `autoOpenBrowser = false`**`xdg-open` 在極簡 WM 下行為不穩定R5-D2
- **推薦**Chrome 或 EdgeMJPEG decode 穩定性最好、devtools 最完整、WebSocket 穩定)
- **可用但不保證**SafariMJPEG decode 可能較慢;因為 US-5 AC-5.6 決定不走 `getUserMedia`Safari 的攝影機權限流程差異不構成問題)
- **不支援**IE、舊版 Firefox ESR< 115)、行動版瀏覽器不是使用情境
- **WebSocket 就緒偵測**R5-E6 / AC-1.3d):依賴瀏覽器支援 WebSocket — 所有現代瀏覽器原生支援,風險低
> **注意**v1.2 靠 Wails webviewWebView2 / WKWebView是 Chromium/Safari 核心的一個已知固定版本v2.0 改用使用者系統瀏覽器後**多了一維瀏覽器版本差異**。這是 Architect TDD v2 和 Testing Agent 要接受的新測試矩陣。
### 6.4 安全性需求(修訂)
- ✅ Server 只監聽 `127.0.0.1`**絕對不 bind 0.0.0.0**(同 v1.2R5 共識 7 再次確認)
- ✅ **CORS 中介層**`Access-Control-Allow-Origin` 只允許 `http://127.0.0.1:*` / `http://localhost:*`**v2.0 新增**R5 共識 6
- ✅ 使用者資料存在 OS 慣例目錄(同 v1.2
- ❌ 無 TLSlocalhost 不需要)(同 v1.2
- ❌ 無認證(單人工具)(同 v1.2
- ⚠️ 瀏覽器 DevTools 暴露v2.0 使用者可以用 F12 看到所有 API 呼叫、WebSocket 流量。這不是安全問題(因為是單人本機),但要在 FAE 訓練文件提醒「不要把瀏覽器分頁截圖給外部,可能含硬體序號」
### 6.5 其他非功能需求
其餘章節(相容性、可維護性、可打包、可用性、日誌)**同 v1.2**,詳見 [`nonfunctional.md`](./nonfunctional.md)。
---
## 7. 第三方授權宣告(大改,取代 v1.2 §4.8
### 7.1 依賴對照表v2.0
| 依賴 | 用途 | 授權 | 宣告要求 | v1.2 → v2.0 變動 |
|------|------|------|---------|-----------------|
| **ffmpeg LGPL buildBtbN** | Windows / Linux 的攝影機 / 影片 decode | **LGPL v2.1+** | 必須宣告LGPL 條文、動態連結、About 對話框列 `Powered by FFmpeg (LGPL)` + 連結) | v1.2 是 GPL build 標 `under legal review` 🔴v2.0 改 LGPL 方案 B 解除 blockerR5-6 |
| **ffmpeg LGPL buildmacOS 自 build** | macOS 的攝影機 / 影片 decode | **LGPL v2.1+** | 同上;額外說明「本 binary 由 Innovedus 在 macOS 上自 ffmpeg 官方 source 以 `./configure --enable-shared --disable-gpl` 等 flag build 出,源碼可取得」 | **v2.0 新增**。Decoder-only ~20MB只含 mp4/avi/mov/mpeg/mpg 五種 decoderR5-6acommit 到 `vendor/ffmpeg/macos/`R5-6b。ffprobe 一起包R5-6c|
| **KneronPLUS SDK**wheel| 裝置 / 推論 | Kneron 專有 | 同 v1.2(發佈前 gateR5 風險維持)| 同 v1.2 |
| **Python runtime**python-build-standalone| 執行 KneronPLUS | PSF 2.0 | 同 v1.2 | 同 v1.2 |
| **Python wheels**numpy / opencv / pyusb 等)| 推論依賴 | BSD / MIT / Apache 2.0 為主 | 同 v1.2 | 同 v1.2 |
| **Wails v2** | Wails 控制台殼 | MIT | 同 v1.2 | 角色改變v1.2 是前端 shellv2.0 是控制台 shell|
| **Next.js / React / shadcn / Radix / Tailwind / Zustand** | 瀏覽器 Web UI | MIT 為主 | 同 v1.2 | 同 v1.2 |
| ~~**yt-dlp**~~ | ~~URL 影片下載~~ | ~~Unlicense~~ | **v2.0 刪除** | 整個 vendor 拔掉 |
### 7.2 macOS ffmpeg 自 build 的長期責任
Architect TDD v2 必須產出 `scripts/build-ffmpeg-macos.sh`(記錄 configure flags / source tarball URL / checksum 驗證。LGPL ffmpeg 幾乎不需更新R5-6b~20MB binary commit 到 repo 可接受2-3 年後 build 系統變化時需 ~0.5 人天重 build。
### 7.3 授權檔案落點
同 v1.2 `<APPDATA>/licenses/`,但:❌ 刪 `LICENSE-yt-dlp.txt`;✅ `LICENSE-ffmpeg.txt` 從 GPL 換成 LGPL v2.1 + v3✅ 新增 `ffmpeg-macos-build-notes.md` 說明 macOS binary 來源。
---
## 8. 風險與相依性(大幅修訂)
### 8.1 技術風險變動
| 風險 ID | v1.2 狀態 | v2.0 處理 |
|---------|-----------|-----------|
| R1KneronPLUS Linux wheel glibc| 高可能 / 高影響 | 同 v1.2(不變)|
| R2Windows WinUSB UAC| 中 / 高 | 同 v1.2 |
| R3KneronPLUS macOS x86_64| 中 / 高,已被 Q4 降級 | 同 v1.2 |
| R4Linux venv| 中 / 中 | 同 v1.2 |
| R5Kneron .nef re-distribution| 發佈前 gate | 同 v1.2 |
| **R6ffmpeg GPL blocker** | 🔴 release blocker | **✅ 解除**R5-6 走 LGPL 方案 B|
| R11發佈通路| P2 追蹤 | 同 v1.2 |
| R12CI runner| P2 追蹤 | 同 v1.2 |
### 8.2 新增風險v2.0
#### N-R1 — 瀏覽器相容性差異(新)
| 項目 | 內容 |
|------|------|
| 描述 | v2.0 改用使用者系統瀏覽器後MJPEG decode 效能、WebSocket 穩定性、深色模式判斷都會因瀏覽器而異。Safari 的 MJPEG 延遲可能超過 150ms 上限 |
| 可能性 | 中 |
| 影響 | 中Safari 使用者體驗可能變差,但 P1 FAE 多用 Chrome|
| 等級 | **P2 追蹤項** |
| 緩解 | 1) 預設推薦 Chrome / Edge2) Safari 列為「可用但不保證」3) Testing Agent 測試矩陣加 Safari4) 發佈說明寫「若遇延遲過高,請改用 Chrome / Edge」 |
| PM 行動 | Testing 階段實測 Safari若嚴重超標則在 Wails 控制台自動開瀏覽器時優先選 Chrome / Edge |
#### N-R2 — macOS 自 build ffmpeg 的長期維護成本(新)
| 項目 | 內容 |
|------|------|
| 描述 | macOS 上沒有現成的 LGPL decoder-only ffmpeg binary需要 Innovedus 自 buildR5-6a / R5-6b。2-3 年後若 ffmpeg build 系統變化 / configure flag 調整 / source tarball 結構變化,可能需要重新 build |
| 可能性 | 中(長期)|
| 影響 | 低—中 |
| 等級 | **P3 追蹤項**(不緊急)|
| 緩解 | 1) TDD v2 產出 `scripts/build-ffmpeg-macos.sh`,記錄完整 build 步驟2) commit 時把 source tarball checksum、configure flags、build 環境一起寫進 commit message3) 每 2 年做一次 build 演練確保 script 還能跑 |
| PM 行動 | 交付時在 delivery docs 加「ffmpeg rebuild SOP」 |
#### N-R3 — 使用者誤解「關閉視窗 ≠ 結束 server」
| 項目 | 內容 |
|------|------|
| 描述 | R5-2 決定維持「關閉 Wails 視窗 = 結束 server」但因為 v2.0 多了瀏覽器視窗這個「真正的操作介面」,使用者可能誤以為「我關掉 Wails 視窗沒關係、瀏覽器 tab 還在、應該還能用」。這種誤解會導致他在瀏覽器看到 Offline Overlay 時很困惑 |
| 可能性 | 高 |
| 影響 | 中(體驗面混亂,不危險)|
| 等級 | **P2 追蹤項** |
| 緩解 | 1) Wails 控制台 Close 按鈕的 tooltip / confirm dialog 明確寫「關閉此視窗會結束 Local Server」AC-7.62) Web UI 的 Offline Overlay 文案引導「請回到 visionA-local 桌面視窗重新啟動」3) First-Run 歡迎頁加一句說明 |
| PM 行動 | Design Agent 在 Design Spec v2 確認這三處文案都到位 |
#### N-R4 — 砍 Mock 後 CI / E2E 測試困難(新)
| 項目 | 內容 |
|------|------|
| 描述 | v1.2 有 Mock 模式CI runner 沒插硬體也能跑 E2E 測試(跑 Mock pipeline。v2.0 砍 Mock 後CI runner 要跑裝置管理 / 模型切換 / 推論 E2E 時,若沒硬體就測不了 |
| 可能性 | 高 |
| 影響 | 中(測試覆蓋率下降)|
| 等級 | **P2 追蹤項** |
| 緩解 | 1) E2E 測試分層:「不需硬體的 UI 測試」載入、i18n、Settings、Offline Overlay、Wails 控制台 start/stop button可在無硬體 CI 跑;「需要硬體的測試」(裝置偵測、推論)只能在有硬體的 runner 跑2) 用 HTTP fixture mock 後端 API前端 unit 測試層級),但不碰 Go server 的推論 pipeline3) Testing Agent 在測試計畫明確區分兩層 |
| PM 行動 | 與 Testing Agent / DevOps 討論是否要建「有硬體 CI runner」M5+ 規劃)|
### 8.3 PM 需要進一步確認的事項(寫入 progress.md 未解決問題)
沿用 v1.2 三項R5 授權 / R11 發佈通路 / R12 CI runner新增 **N-R1 瀏覽器相容性**Testing 驗證 Safari/Chrome/Edge/Firefox MJPEG + WebSocket、**N-R4 CI/E2E 測試分層**Testing + DevOps 於 TDD v2 提出分層方案)。
---
## 9. 策略性路線圖(更新)
- **Now本 sprint重構實作**PRD v2.1 / Design Spec v2 / TDD v2 三方互審 → Architect 產 ffmpeg LGPL vendor script + Wails 控制台實作 + CORS middleware + 刪檔清單 → Design 產控制台 wireframe + Offline Overlay + source-selector 拔 URL → 拆 milestone 進開發
- **Next重構完成後**M7 Windows build 重驗R5-7v1.0.0 release gateKneron re-distribution 授權R5 發佈前 gate三平台 smoke test
- **Laterv1.0.0 發佈後)**:視回饋決定是否放回 v1.2 功能Mock / URL 推論 / tray視需求升級 LAN 模式(重走 R5-1 動機 C
---
## 10. 變更追蹤(和 v1.2 的完整差異清單)
| 區域 | v1.2 | v2.0 | R5 依據 |
|------|------|------|---------|
| 產品定位 | Wails 桌面 app 內嵌 Next.js UI | 本機服務 + 網頁控制台 | R5-1 |
| 競品類比 | Photo Booth | Ollama / Docker Desktop / LM Studio / SD WebUI | R5-1 |
| 一句話價值主張 | 「裝起來就能跑、離線可用、零依賴」 | 「雙擊 → 自動開瀏覽器 → 插 USB → 5 分鐘跑第一次推論」 | R5-1 / R5-4 |
| 北極星指標終點 | Mock 第一幀 | 瀏覽器第一幀(真實硬體)| R5-5a |
| Mock 模式 | MVP 必達 | **砍** | R5-5a |
| URL 影片推論 | US-8 / `/api/media/url` / yt-dlp 內嵌 | **砍** | R5 共識 2 |
| yt-dlp vendor35MB| 打包 | **砍** | R5 共識 2 |
| ffmpeg | GPL buildblocker 🔴)| **LGPL 方案 B 混合**Win/Linux BtbN、macOS 自 build decoder-only ~20MB commit| R5-6 / R5-6a / R5-6b / R5-6c |
| 安裝檔大小macOS 目標)| ≤ 220 MB | **≤ 185 MB** | R5-6 |
| idle RAM無硬體| ≤ 500 MB / 上限 600 MB | ≤ 450 MB / 上限 550 MB | 砍 Mock / yt-dlp |
| Wails 視窗關閉 | 結束程式 | **同 v1.2**R5-2 復議後維持)| R5-2 |
| Tray | 砍v1.2 Q-A| **同 v1.2**R5-3 復議後維持)| R5-3 |
| 啟動時自動開瀏覽器 | 無 | **新增,每次啟動都開**R5-D3macOS/Win 預設 ON、**Linux 預設 OFF**R5-D2toggle 住 Wails 控制台 Settings已與 Design 取得共識) | R5-4 / R5-D2 / R5-D3 |
| AC-1.3 啟動預算 | — | **≤ 60 秒**(原 v2.0 草案 10 秒硬指標被 R5-E1 取代)+ 階段化進度6 階段R5-E2+ 20 秒卡住提示R5-E3+ 60 秒超時進 Error stateR5-E4+ WebSocket 就緒偵測R5-E6 | **R5-E1E6** |
| Server 崩潰 OS 通知 | 無 | **新增**(控制台 Error banner 並存R5-D1| R5-D1 |
| First-Run wizard 步數 | 3 步(歡迎 / 模式選擇 / 硬體偵測) | **2 步**(歡迎 / 硬體偵測,砍模式選擇;無硬體可按「稍後再設定」略過) | R5-5a / Design review Minor 4 |
| Offline Overlay 硬阻斷 | — | **新增驗收特性**`role="alertdialog"` + focus trap + 無 ✕ 按鈕 + 純色卡片) | Design review Minor 2 |
| Wails 控制台 Footer 持久警示 | 無 | **新增**「⚠ 關閉此視窗會停止 Local Server」取代單次 confirm 作為主要提醒 | Design review Minor 3 |
| Error state 三動作按鈕 | 無 | **新增** `Restart Server` / `View log` / `Report issue`Wails 控制台 Error banner 內) | Design review Minor 3 |
| Wails 控制台 | 無webview 載 Next.js| **新增 vanilla HTML/JS/CSS**Start/Stop/Restart/Open Browser/log panel/status/port/dir/version | R5-5 / R5 共識 5 |
| Server Offline Overlay | 無 | **新增**Web UI 全螢幕覆蓋層)| R5-2 |
| CORS middleware | 無 | **新增**127.0.0.1 / localhost only| R5 共識 6 |
| watchServer 連 3 次失敗 | `os.Exit(1)` | **改為 Error state不退出** | R5 共識 8 |
| Server boot-id | 無 | **新增**(支援 Restart Server 按鈕的 tab 自動重連)| R5 共識 14 |
| US-1 | 3 分鐘看到 Dashboard | 5 分鐘瀏覽器跑第一次推論 | R5-1 / R5-4 |
| US-2 | Mock 試玩 | 日常啟動(取代)| R5-5a |
| US-5 | Wails webview 攝影機 | 瀏覽器 Workspace + 後端攝影機 pipeline不走 `getUserMedia`| R5-1 |
| US-7 / US-8 | 影片 / URL 上傳 | 合併為 US-6砍 URL | R5 共識 2 |
| US-9 | Settings 進階分頁次要功能 | **升級為 Wails 控制台一級功能** | R5-1 / R5-5 |
| 新 US-7server 崩潰 / 離線)| 無 | **新增** | R5-2 |
| 上傳影片副檔名 | mp4 / avi / mov / webm瀏覽器能吃| **mp4 / avi / mov / mpeg / mpg** | R5 共識 11 |
| Persona P3 Sam | 三順位目標 | **實質降級為非目標** | R5-5a |
| 新風險 | — | N-R1 瀏覽器相容 / N-R2 macOS ffmpeg 維護 / N-R3 關窗誤解 / N-R4 CI 測試分層 | — |
| 解除風險 | R6 ffmpeg GPL blocker | **解除** | R5-6 |
---
## 11. 給 Orchestrator / 下輪審閱的問題
| # | 問題 | v2.1 狀態 |
|---|------|---------|
| 11-1 | auto-open Settings 資料落在哪Web UI 和 Wails 控制台共用同一個 config 嗎? | ✅ **已解決**Architect 答:`preferences.json @ <dataDir>/`write-rename 原子寫fallback DefaultPreferencesauto-open toggle 由 Wails 進程讀取Web UI 不碰此欄位) |
| 11-2 | US-1 AC-1.3 10 秒預算是否可達? | ✅ **已解決(由 R5-E 取代)** — 整個問題被重新定義為「60 秒總預算 + 階段化進度 + perceived performance」不再是硬時間指標 |
| 11-3 | idle RAM ≤ 450 MB 是否可達? | ✅ **已解決**Architect 實測Wails + Go server + Python 275405 MB 達標;悲觀估含瀏覽器 tab ~500 MB 超 50 MB但瀏覽器 tab 不在本指標範圍內,已於 §6.1 clarify |
| 11-4 | N-R4 砍 Mock 後 CI / E2E 測試分層 | ⏳ **懸置**,交 Testing Agent 在測試計畫提出「不需硬體的 UI 測試」vs「需硬體測試」分層方案 |
| 11-5 | Web UI 要不要加常駐「本機服務」徽章? | ✅ **已解決:不加**Design Agent 決定,改由 First-Run Step 1 歡迎頁一次性告知;見 Design review §D |
| 11-6 | Settings auto-open toggle 住哪裡? | ✅ **已解決**Design 仲裁採 PRD 原案:住 Wails 控制台 Settings menuDesign Spec v2 `v2/settings-update.md` 將同步修正) |
| 11-7 | R5-E 階段化進度 6 階段文案(中英雙語)定稿 | ⏳ **懸置**R5-E5 使用者授權 Design Agent 決定;最終在 Design Spec v2 wireframe 定版時由使用者審閱 override |
---
## 變更紀錄
| 版本 | 日期 | 作者 | 變更 |
|------|------|------|------|
| v2.0 | 2026-04-14 | PM Agent | 依 R5 五輪決策重寫:砍 Mock / URL 推論 / yt-dlp新增 Wails 控制台 + Server Offline Overlay + 首次自動開瀏覽器 + CORSffmpeg 從 GPL blocker 解除為 LGPL 方案 B 混合Persona P3 降級US-1/US-2/US-5/US-6/US-7 重寫;解除 R6新增 N-R1 ~ N-R4 風險。完整差異見 §10 |
| v2.1 | 2026-04-14 | PM Agent | 吸收 Design 交叉審閱4 Major + 4 Minor+ R5-D1/D2/D3 + R5-E1E6(1) 「首次啟動自動開」→「每次啟動自動開」全文修正R5-D3(2) auto-open 平台預設 Linux OFFR5-D2(3) US-7 新增 AC-7.7 Server 崩潰時發 OS 原生通知R5-D1(4) §4 新增 N7 OS 通知、N8 啟動階段化進度6 階段);(5) **AC-1.3 從 10 秒硬指標改寫為 60 秒 + perceived performance + AC-1.3a/b/c/d 四條新驗收**R5-E1E6(6) US-2 明確日常啟動同樣 auto-openMinor 1(7) Offline Overlay 補齊 `role="alertdialog"` + focus trap + 不可關閉 + 純色卡片Minor 2(8) US-7 AC-7.3 / AC-7.6 補 Wails 控制台 Footer 持久警示 + Error state 三動作按鈕Minor 3(9) US-1 AC-1.4 補 First-Run 從 3 步縮為 2 步 + 「稍後再設定」略過Minor 4(10) §6.1 idle RAM 加不含瀏覽器 tab 註記 + Architect 實測佐證;(11) §11 懸念 5 題結案 + 新增 11-6 / 11-7保留 11-4 CI 測試分層懸置 |

View File

@ -0,0 +1,347 @@
# Design 交叉審閱 PRD v22026-04-14
> 審閱者Design Agent
> 審閱對象:`/Users/jimchen/visionA/local-tool/.autoflow/02-prd/PRD-v2.md`484 行)
> 對照基準Design Spec v2`03-design/design-spec-v2.md` + `03-design/v2/*.md`,共 1452 行子檔)+ R5 決策表 + R5-D 補充決策
> 本審閱使用繁體中文台灣用語
---
## 摘要3 行)
- **總結論****需小改**(整體體驗面完整度 85%4 個 Major + 4 個 Minor
- **發現問題數**Major 4、Minor 4
- **是否阻擋進開發**:⚠️ **部分阻擋**。R5-D1/D2/D3 三題是使用者已做的決策PRD v2 沒完整吸收 → PRD 必須先修正 Major 13 才能進 M 級開發;其餘問題可在補充 FAQ / UX writing 階段處理
---
## A. 使用者體驗完整性檢查
| 檢查項 | 檢查結果 |
|-------|---------|
| **US-1 首次啟動**有無明確寫「每次啟動都會自動開瀏覽器」R5-D3Linux 預設 OFF 的差異處理有沒有寫? | ❌ **Major 1**。PRD v2 §0 摘要、§4 N2、§5 US-1 全部沿用「首次啟動自動開瀏覽器」字面。R5-D3 已明確「每次啟動都自動開」PRD 必須把「首次」改為「啟動時自動開瀏覽器每次啟動都生效」。§6.3 / §4 也完全沒提 **Linux 預設 OFF 的差異**R5-D2|
| **US-2 日常啟動**:同上 | ⚠️ **Minor 1**。US-2 AC-2.1 寫「≤ 5 秒」但沒明確寫「日常啟動時瀏覽器 auto-open 預設仍會發生」。AC-2.3 有提「若使用者關過 auto-open 則只開 Wails」這一條**是正確的**,但和 §0 的「首次啟動自動開」字面有矛盾 |
| **US-5 關閉 app**:有無明確寫「關閉 Wails 視窗會觸發 Offline Overlay」文案是否清楚 | ✅ **通過**。US-7 AC-7.5 + AC-7.6 有寫 |
| **US-6 上傳影片**:副檔名是否正確寫 mp4/avi/mov/mpeg/mpg | ✅ **通過**。AC-6.2 正確 |
| **US-7 server 離線情境(新)**Offline Overlay 是否正確描述?有沒有漏寫 Design Spec v2 的硬阻斷特性? | ⚠️ **Minor 2**。§4 N3 寫「**半透明**覆蓋層」和「**覆蓋層下方的 UI 凍結不可操作**」。「半透明」這個措辭會誤導 Architect/Frontend — Design Spec v2 server-offline-overlay.md §ARIA 明確規定「**卡片背景必須是純色**(不是半透明),才能達到 4.5:1 對比」。PRD 的「半透明」只指 **backdrop**,不指訊息卡片本體。建議改為「全螢幕半透明 backdrop + 置中純色卡片」。**不可關閉** 特性也沒在 PRD 任何一條驗收標準出現(硬阻斷 + focus trap + `role="alertdialog"` 全部缺失)|
| **§4 新功能「每次啟動自動開瀏覽器」R5-D3** | ❌ **Major 1**(同上)|
| **§4 新功能「Linux 預設 OFF 切換」R5-D2** | ❌ **Major 2**。完全缺失。§4 N2、§6.3、§6.4、§8 任何一節都沒提這個跨平台差異。這會導致 Architect 不知道要在 config loader 做平台判斷 |
| **§4 新功能「Server 崩潰時除控制台 banner 外仍發 OS 通知」R5-D1** | ❌ **Major 3**。PRD v2 AC-3.7 只說「插 USB 時不發原生 OS 通知」,但 R5-D1 是**另一個情境**server 崩潰)— 使用者明確決定「**保留** OS 通知」。PRD v2 §4 / §5 US-7 完全沒寫Architect 如果只看 PRD 會以為「crash 時不用發通知」|
| **§6 瀏覽器相容性Safari 攝影機警告、Edge/Chrome 推薦)是否明確?** | ✅ **通過**。§6.3 寫法清楚,且 §US-5 AC-5.6 解釋了為什麼不走 `getUserMedia`(所以 Safari 沒有攝影機權限問題)|
| **§6 i18n 中英雙語是否沒被砍?** | ✅ **通過**。§4.1 明確保留 |
| **§6 Dark Mode 跟隨系統是否保留?** | ✅ **通過**。§4.1 明確保留 |
**A 區結論**PRD v2 吸收 R5 主決策沒問題,但 **R5-D 補充決策三題全部沒吸收**(這是使用者後來加的三個決定)。這三題是 PRD v2 Major 等級的缺漏。
---
## B. Design Spec v2 子檔對應檢查
| Design Spec v2 子檔 | PRD v2 對應章節 | 檢查結果 |
|---------------------|---------------|---------|
| `v2/control-panel.md` | §4 新功能 N1 Wails 控制台 + US-1 AC-1.3 / US-7 AC-7.3 | ✅ **覆蓋**。但 PRD 沒有明確提到 Footer 的「持久警示文字」設計Design Spec v2 §4.6 control-panel.md 行 178。PRD 的 AC-7.6 只提 tooltip + confirm dialog遺漏 Footer 持久警示。**見 Minor 3** |
| `v2/server-offline-overlay.md` | §5 US-7 + §4 N3 | ⚠️ **覆蓋不完整**。見 Minor 2硬阻斷 / 不可關閉 / focus trap 沒寫進 AC|
| `v2/source-selector-update.md` | §5 US-6 + §4 砍除 #1/#3/#4 + §4 保留「批次影像上傳」 | ✅ **覆蓋** |
| `v2/first-run-update.md` | §5 US-1 + §4 砍除 #8 First-Run 模式選擇步驟 | ⚠️ **遺漏**。PRD 沒提「**First-Run wizard 從 3 步縮為 2 步**」Design Spec v2 first-run-update.md 說明「砍模式選擇,只剩歡迎 / 偵測」)。開發階段 Frontend Agent 可能不知道要砍那一步。**見 Minor 4** |
| `v2/settings-update.md` | §4 保留 Settings + §4 N2「自動開瀏覽器」toggle | ⚠️ **位置矛盾**。Design Spec v2 settings-update.md §1.1 表格把 auto-open toggle 放在「**Web UI 裡的 Settings 一般 tab**」sidebar navigation 到 Settings 頁),但 PRD v2 §4 N2 和 §4.4 功能對照表把 Settings「自動開瀏覽器」放在 **Wails 控制台**。**兩個文件對這個 toggle 住哪裡意見分歧**。**見 Major 4** |
**B 區結論**5 個子檔大致都有被 PRD 提到,但 **Settings auto-open toggle 住哪裡** 這個定位問題是 Design Spec v2 和 PRD v2 之間的實質分歧,這是 Major 等級的不一致。
---
## C. 體驗紅線 / 缺漏
### C.1 北極星指標的時間預算
PRD v2 §1.4 新北極星「≤ 5 分鐘從雙擊到瀏覽器第一幀推論」是**正確更新**,符合 Design Spec v2 的無摩擦設計(每次自動開瀏覽器)。✅
### C.2 砍 Mock 後的 P3 fallback 體驗
Design Spec v2 `v2/first-run-update.md` 行 319320 + §Error state 有「**偵測不到硬體 → 可略過進 Dashboard 空白狀態 + 引導文字**」的設計。PRD v2 AC-1.4 確實有寫「沒插硬體顯示空白 + 提示文字」,✅ **通過**。但是 **PRD 沒寫「使用者能不能按 Skip / 繼續」** — 意味 First-Run wizard 的 Step 2 「硬體偵測」是不是強制卡關?建議在 AC-1.4 補一句「First-Run Step 2 在無硬體情況下可按 `稍後再設定` 略過,進 Dashboard 空白狀態。」**見 Minor 3**
### C.3 Server Crash 的錯誤處理體驗
Design Spec v2 `v2/control-panel.md` §5「Server Error 面板」有完整設計Error banner + 三個動作按鈕Restart Server / View log / Report。PRD v2 US-7 AC-7.3 只說「顯示 Error 狀態 + log panel 顯示錯誤」,**完全沒提 Restart Server / View log / Report 三個動作按鈕**。Architect 可能不知道要實作這三個動作。**見 Minor 4**(同時也是上面 Major 3 的配套)
### C.4 關閉 Wails 視窗的 race condition
Design Spec v2 `v2/control-panel.md` 行 178「Footer 右側持久提示:`⚠ Closing this window will stop the server.` 12px muted」是一個重要的**無需彈窗的被動警示**設計。PRD v2 AC-7.6 只寫 tooltip / confirm dialog**沒提 Footer 持久提示**。兩者設計理念不一樣PRD 是「按下 Close 才顯示」Design Spec v2 是「始終可見」。**前者體驗差,因為使用者按 Close 後可能直接盲目點「確定」**Design Spec v2 行 182 已警告過這件事)。**見 Minor 3**(必須把 Footer 持久提示寫進 AC-7.6
### C.5 Offline Overlay 的硬阻斷特性
如上面 Minor 2 所述,**PRD v2 §4 N3 沒告訴 Architect**
- Overlay 是 `role="alertdialog"` + `aria-modal="true"` + focus trap
- 不可被使用者手動關閉(只能靠重試成功 / reload
- 卡片背景必須純色(不是 backdrop 的半透明)
這三條是 WCAG A11y 關鍵設計決策PRD v2 必須在 §4 N3 或 AC-7.1/7.2 補齊。
---
## D. 對 PM 懸念 §11-5 的回答(常駐徽章)
**PM 問題**Web UI 要不要加一個「Server 是從 visionA-local 跑的」常駐徽章?
**Design Agent 決定**:❌ **不加**(支持 PM 的傾向)
**理由**FAANG Senior Product Designer 視角):
1. **視覺雜訊成本 vs 防誤解收益不對等**。常駐徽章會佔用每一個瀏覽器畫面的固定像素(通常 3240 px但它防止的「誤以為是網頁服務」情境只在「使用者截圖給外部」的**極小機率事件**發生。以 FAE Arthur 為主要使用者,他自己知道這是 local app截圖給客戶時「自己加註解」比「產品永遠帶徽章」的 ROI 好太多。
2. **資訊已由其他管道傳遞**。瀏覽器網址列就是 `http://127.0.0.1:3721`,這是**最權威的「這是本機服務」證據**,而且是瀏覽器原生 UI設計成本 0。再加徽章是重複告知。
3. **和設計語言衝突**。Web UI 的設計語言shadcn + Tailwind + 淡色背景)追求**資訊層級乾淨**常駐徽章會像浮水印一樣破壞這個語言。Ollama Web UI / LM Studio / SD WebUI 都沒有這種徽章,這是業界慣例。
4. **替代方案**:如果 PM 仍擔心「使用者不知道這是本機服務」Design Spec v2 `v2/first-run-update.md` 的 Step 1歡迎頁可以加一句「你正在使用 visionA-local 的本機 Web 控制台」一次性文案,既不佔用永久視覺空間,又能在關鍵時刻傳達訊息。
**建議 PM 在 PRD v2 §11-5 寫**「不加常駐徽章Design 決定)。改由 First-Run Step 1 的歡迎文字一次性告知。」
---
## E. 對 Architect Q6 的回答Overlay 關閉 tab 按鈕)
**Architect 問題**`window.close()` 在瀏覽器主動開的 tab 不能用UX 取捨)
**Design Agent 決定**:✅ **Offline Overlay 不設「關閉 tab」按鈕**
**理由**
1. **技術限制已確認**。瀏覽器安全規範:`window.close()` 只能關閉**由 `window.open()` 開的 tab**。visionA-local 的 tab 是使用者手動開的(`xdg-open` / `open` / `start` 透過 OS 層開瀏覽器 → 瀏覽器建立新 tab不是 JS 開的 → `window.close()` **一律失效**Chrome/Edge/Safari/Firefox 全部一致)。
2. **Design Spec v2 `v2/server-offline-overlay.md` 已經沒有這個按鈕**(見行 3841 原型、行 8081 元件表)。目前只有兩個按鈕:
- `重試連線`Primary, lg
- `了解更多 ↓`Ghost, sm展開 help text
3. **使用者的兩條恢復路徑都不需要「關閉 tab」按鈕**
- **路徑 A雙擊重開 app**Design Spec v2 行 142 已設計「Overlay 顯示期間 polling `/api/health` 每 3 秒」,使用者只要重開 app → 自動恢復 → Overlay 自動消失,不需手動關 tab。
- **路徑 B不想用了**:使用者直接按瀏覽器的 `⌘W` / `Ctrl+W` / 點 tab 的 ✕,是瀏覽器原生操作,完全不需要 JS 協助。
4. **「了解更多」展開 help text 已足夠**。如果使用者真的困惑「為什麼按鈕關不了 tab」help text 裡補一句「要結束使用 visionA-local請直接關閉瀏覽器分頁 (⌘W / Ctrl+W)」就好。
**建議 Architect 在 TDD v2 Q6 寫**「Offline Overlay 不提供關閉 tab 按鈕Design 決定)。恢復路徑 A重開 app → auto recovery與路徑 B使用者自行 ⌘W已足夠不需 JS 介入。」
**額外建議**Design Spec v2 `v2/server-offline-overlay.md` 行 154157 的「了解更多」英文 / 中文 copy 應補一句「要離開本頁,請直接關閉瀏覽器分頁」。這是 Design 自己要補的 UX writing 後續項。
---
## F. 臆測 / 誤解 / 超範圍清單
| # | 類別 | 位置 | 內容 | 處理建議 |
|---|------|------|------|---------|
| F1 | **誤解 R5-D3** | §0 摘要點 5、§4 N2、§5 US-1 敘述 / AC-1.3、§10 變更追蹤 | 沿用「首次啟動自動開瀏覽器」字面,沒反映使用者 R5-D3「每次啟動都自動開」的決定 | 全文 replace「首次啟動自動開瀏覽器」→「啟動時自動開瀏覽器每次啟動」 |
| F2 | **遺漏 R5-D2** | §4 N2、§6.3 瀏覽器相容性 | 沒寫 Linux 預設 OFF、macOS/Windows 預設 ON 的差異 | §4 N2 補一句「平台預設macOS / Windows ONLinux OFF`xdg-open` 在極簡 WM 下行為不穩定R5-D2」 |
| F3 | **遺漏 R5-D1** | §5 US-7 AC-7.3、§4 新增功能 | Server crash 時使用者決定「保留 OS 通知」PRD 沒寫 | §5 US-7 補 AC-7.7「Server 崩潰時,除控制台 Error banner 外,同步發一次 OS 原生通知macOS Notification Center / Windows Toast / Linux `libnotify`R5-D1」 |
| F4 | **和 Design Spec v2 分歧** | §4 N2 / §4.4 對照表 | PRD 把 auto-open toggle 放在 Wails 控制台Design Spec v2 放在 Web UI Settings > 一般 tab | 見 Major 4 的仲裁方案 |
| F5 | **沒臆測新增功能** | — | PRD v2 沒有超出 R5 範圍的功能(已確認)| ✅ 通過 |
| F6 | **Offline Overlay 描述失真** | §4 N3 | 寫「半透明覆蓋層」可能誤導為整個 overlay 都半透明 | 補強為「半透明 backdrop`rgba(0,0,0,0.4-0.5)`+ 置中純色卡片(光模式 `color.surface`)」 |
---
## G. 問題清單
### Major阻擋開發必須改
**Major 1PRD v2 沒吸收 R5-D3「每次啟動都自動開瀏覽器」**
- 位置§0 §4 N2 §5 US-1 §10
- 影響若不改Architect 會把「只在 `firstRunCompleted === false` 時 auto-open」寫進 TDD導致第二次啟動不自動開瀏覽器違反使用者決定
- 修正PRD 全文去掉「首次」二字,改為「啟動時自動開瀏覽器(預設每次啟動生效)」
**Major 2PRD v2 沒寫 R5-D2 Linux 平台預設差異**
- 位置§4 N2 / §6.3 / §6.5
- 影響若不改Architect 會寫「三平台統一預設 ON」Linux 使用者第一次啟動會看到瀏覽器沒開、不知道發生什麼
- 修正§4 N2 補平台預設表§6.3 瀏覽器相容性加一行「Linuxauto-open toggle 預設 OFF見 §4 N2」
**Major 3PRD v2 沒寫 R5-D1 Server 崩潰仍發 OS 通知**
- 位置§5 US-7
- 影響Architect 會以為「crash 時只顯示控制台 banner」違反使用者決定
- 修正US-7 新增 AC-7.7 / AC-7.8:「崩潰事件同步發一次 OS 原生通知;只在前景切換回控制台時不重複發(去重策略由 Architect 在 TDD v2 決定)」
**Major 4Settings auto-open toggle 住哪裡 — PRD 和 Design Spec v2 分歧**
- 位置PRD v2 §4 N2 / §4.4 vs Design Spec v2 `v2/settings-update.md` §1.1
- 背景PRD 說住 Wails 控制台Design Spec v2 說住 Web UI Settings > 一般 tab
- **Design Agent 仲裁****應該放 Wails 控制台PRD 是對的)**。理由:
1. auto-open 是 **Wails 啟動時的行為**,讀取 config 的時機點在 Wails `main.go` 啟動階段,那時 Web UI 還沒 loadconfig 必須被 Wails 進程讀到
2. 使用者心智模型:「我要改 Wails 的行為 → 去 Wails 控制台改」比「我要改 Wails 的行為 → 去瀏覽器改」更直覺
3. **Design Spec v2 `v2/settings-update.md` 本節需要修正**。這是 Design Agent 自己的文件錯位,我會在下一輪更新
- 修正PRD v2 §4 N2 不用改原本就對Design Spec v2 `v2/settings-update.md` 下一輪迭代時改為「auto-open toggle 住 Wails 控制台的 Settings menu不住 Web UI」
### Minor可在後續補不阻擋開發
**Minor 1US-2 AC 沒寫清楚日常啟動時 auto-open 預設仍會發生**
- 位置§5 US-2 AC-2.1
- 修正:在 AC-2.1 補一句「日常啟動時,若 Wails 控制台 Settings `autoOpenBrowser === true`,仍會自動開瀏覽器」
**Minor 2Offline Overlay 的 A11y 硬阻斷特性沒寫進 PRD**
- 位置§4 N3 / §5 US-7 AC-7.1
- 修正§4 N3 補「Overlay `role="alertdialog"` + `aria-modal="true"` + focus trap + 不可手動關閉(只能 retry 成功 / 重 loadAC-7.1 補「Overlay 無 ✕ 按鈕」
**Minor 3US-7 沒寫 Wails 控制台 Footer 持久警示 + Error state 三個動作按鈕**
- 位置§5 US-7 AC-7.3 / AC-7.6
- 修正:
- AC-7.3 補「Error state 面板含 `Restart Server` / `View log` / `Report issue` 三個動作按鈕(見 Design Spec v2 `v2/control-panel.md §5`)」
- AC-7.6 把「tooltip / confirm dialog」升級為「Footer 持久警示 + tooltip / confirm dialog 雙保險」
**Minor 4US-1 沒寫 First-Run wizard 從 3 步縮為 2 步**
- 位置§5 US-1 敘述 / AC-1.4
- 修正AC-1.4 補「First-Run wizard 只剩 Step 1 歡迎 + Step 2 硬體偵測,砍 Mock 模式選擇步驟R5-5aStep 2 在無硬體時可按 `稍後再設定` 略過進 Dashboard 空白狀態」
---
## H. 通過 / 不通過 結論
**不通過**(需要先處理 Major 13 才能進下一步)。
**Major 1/2/3 是使用者已做的決定被 PRD 漏寫**,必須由 PM Agent 修訂 PRD v2 一次小版本v2.1),補齊三個 R5-D 決策。**Major 4 Design 自己承諾下一輪修正 Design Spec v2**(把 auto-open toggle 從 Web UI Settings 移到 Wails 控制台 Settings menuPRD v2 本身不用改。
Minor 14 可由 PM 在同一次 v2.1 修訂時順便補齊,或記入「未解決問題」清單在開發前補齊,不阻擋 M 級架構動工,但必須在 Architect 寫 TDD v2 **之前**補上,否則會再次發生文件間矛盾。
**建議流程**
1. PM Agent 收到本 review → 產出 PRD v2.1,修正 Major 13 + Minor 14預估 20 分鐘)
2. Design Agent 同時修正 Design Spec v2 `v2/settings-update.md`Major 4auto-open 位置更正)
3. Architect 拿 PRD v2.1 + 更新後的 Design Spec v2 寫 TDD v2
4. 三方再次互審後進 M 級開發
---
## 附錄:引用對照
- R5-D1progress.md「R5-Design 補充」第 1 題 — 保留 OS 通知
- R5-D2progress.md「R5-Design 補充」第 2 題 — Linux 預設 OFF
- R5-D3progress.md「R5-Design 補充」第 3 題 — 每次啟動都自動開
- Design Spec v2 `v2/control-panel.md` 行 77 / 178 — Footer 持久警示
- Design Spec v2 `v2/control-panel.md` §5 — Error state 面板三個按鈕
- Design Spec v2 `v2/server-offline-overlay.md` 行 3841 / 8081 / 203 / 216217 — Overlay 元件 + 硬阻斷 + focus trap
- Design Spec v2 `v2/first-run-update.md` 行 24 / 5152 — auto-open 預設 ON、兩步 wizard
- Design Spec v2 `v2/settings-update.md` §1.1 — auto-open toggle 位置(本文認定需修正)
- PRD v2 §0 / §4 N2 / §5 US-1 / §5 US-7 — 以上被指出的段落
---
# Design 第二輪審閱 PRD v2.12026-04-14
> 審閱者Design Agent
> 審閱對象:`/Users/jimchen/visionA/local-tool/.autoflow/02-prd/PRD-v2.md`v2.1500 行)
> 對照基準:第一輪審閱(本檔 §A§H+ R5-D / R5-E 決策 + Design Spec v2.1 `03-design/v2/startup-progress.md`417 行6 階段已定版)
> 範圍:**只審 v2.1 補丁差異**,不重審 v2.0 未動章節
> 本審閱使用繁體中文台灣用語
---
## 摘要3 行)
- **總結論**:✅ **通過**(不需第三輪 PRD 修訂;可直接進 M8 開發前 TDD v2 撰寫)
- **第一輪 4 Major / 4 Minor 修復情況**Major 全部修好4/4、Minor 全部修好4/4。R5-E1E6 全數正確落地進 US-1 AC-1.3 系列。
- **是否阻擋 M8**:❌ **不阻擋**。第二輪只發現 1 個 Minor 文字殘留 + 1 個 Minor Linux skipped 狀態未在 PRD 提及(可由 TDD / Design Spec 側吸收,不需 PRD 再修)。
---
## A. 第一輪 Major 修復檢查
| 第一輪 Major | v2.1 修復位置 | 判定 |
|------|-------------|------|
| **Major 1** R5-D3「每次啟動自動開」 | §0 §22 摘要點 5 改寫為「**每次啟動**都自動開」§0.1 補丁表 M1 明列 replace§2.1 P1 描述、§4 N2、§5 US-1 AC-1.6、§5 US-2 AC-2.1、§10 變更追蹤行 456 全部一致 | ✅ **修好** |
| **Major 2** R5-D2 Linux 預設 OFF | §0 摘要點 5 + §4 N2 + §5 US-1 AC-1.6 + §5 US-2 AC-2.3 + §6.3 + §10 變更追蹤行 456 全部明確列出「macOS/Windows ONLinux OFF」+ 引用 R5-D2 + 引用 `xdg-open` 行為不穩理由 | ✅ **修好** |
| **Major 3** R5-D1 Server 崩潰 OS 通知 | §5 US-7 新增 **AC-7.7**(行 275明寫「除控制台 Error banner 外,同步發一次 OS 原生通知」+ 平台對應Notification Center / Toast / `libnotify`+ 去重策略授權 Architect 在 TDD v2 決定§4 N7行 167也新增「Server 崩潰 OS 原生通知」功能條目 | ✅ **修好**(同時於 US 與功能清單兩處落地,比第一輪要求更完整) |
| **Major 4** auto-open toggle 位置 | §4 N2行 162明標「auto-open toggle 住 Wails 控制台的 Settings menu **(已與 Design 取得共識)**」§4.4 對照表行 179 同步§11-6 標 ✅ 已解決 | ✅ **修好** |
**A 區結論**:第一輪 4 個 Major 全部修好,無殘留。
---
## B. 第一輪 Minor 修復檢查
| 第一輪 Minor | v2.1 修復位置 | 判定 |
|------|-------------|------|
| **Minor 1** US-2 沒寫日常 auto-open | §5 US-2 AC-2.1(行 214改寫「日常啟動時若 Wails 控制台 Settings `autoOpenBrowser === true`**仍會自動開瀏覽器**和首次啟動行為一致R5-D3 / Design review Minor 1」 | ✅ **修好** |
| **Minor 2** Offline Overlay 硬阻斷特性 | §4 N3行 163「**A11y 硬阻斷特性**`role="alertdialog"` + `aria-modal="true"` + focus trap + **不可由使用者手動關閉**(無 ✕ 按鈕§5 US-7 AC-7.1(行 269也補「Overlay 無 ✕ 按鈕、不可手動關閉」+ 引用 Design review Minor 2§4 N3 同時補強為「全螢幕半透明 backdrop`rgba(0,0,0,0.4-0.5)`+ 置中純色卡片」(解除 F6 措辭誤導) | ✅ **修好** |
| **Minor 3** Footer 持久警示 + Error state 三按鈕 | §4 N1行 161補完整段「**Footer 持久警示**:右下角固定顯示 `⚠ 關閉此視窗會停止 Local Server`12 px muted」+「**Error state 面板**log panel 上方浮動 banner 含 `Restart Server` / `View log` / `Report issue` 三個動作按鈕」§5 US-7 AC-7.3(行 271+ AC-7.6(行 274同步 | ✅ **修好** |
| **Minor 4** First-Run wizard 3 步→2 步 | §5 US-1 AC-1.4(行 202「First-Run wizard **從 v1.2 的 3 步縮為 2 步**砍模式選擇R5-5aStep 1 歡迎 → Step 2 硬體偵測。Step 2 在無硬體情況下可按 `稍後再設定` 略過」§10 變更追蹤行 459 同步登記 | ✅ **修好** |
**B 區結論**:第一輪 4 個 Minor 全部修好,無殘留。
---
## C. R5-E 落地檢查v2.1 最大新增)
| R5-E | PRD v2.1 落點 | 判定 |
|------|-------------|------|
| **R5-E1** AC-1.3 改 60 秒 | §5 US-1 AC-1.3(行 197改寫「全程 ≤ 60 秒R5-E1原 10 秒硬指標取消)... 採 Nielsen Norman perceived performance 原則」§11-2 標「✅ 已解決(由 R5-E 取代)」 | ✅ **正確落地** |
| **R5-E2** 階段化進度6 階段)| §5 US-1 **AC-1.3a**(行 198「啟動全程顯示階段化進度6 階段,見 §4 N8每階段有編號 + 動作描述 + 視覺回饋spinner 或進度條)+ 中英雙語文案」§4 N8行 168同步新增完整功能條目 | ✅ **正確落地** |
| **R5-E3** 20 秒 retry hint | §5 US-1 **AC-1.3b**(行 199「任一啟動階段卡超過 **20 秒**UI 必須顯示『正在重試』或『這步比預期久』類提示,**不可停留在白畫面或無變化狀態**」 | ✅ **正確落地** |
| **R5-E4** 60 秒 Error state | §5 US-1 **AC-1.3c**(行 200「總啟動時間超過 **60 秒**仍未進入『就緒』狀態Wails 控制台進入 Error state顯示『Restart Server』『View log』『Report issue』三個動作按鈕對應 §4 N1 / N6與 watchServer 3 次失敗後行為一致)」 | ✅ **正確落地**(按鈕命名與 Design Spec v2.1 §3.7 完全一致) |
| **R5-E5** 階段文案 Design 決定 | §4 N8 行 168 末句:「階段文案由 Design Agent 最終決定R5-E5§5 AC-1.3a 同樣引用§11-7 標「⏳ 懸置R5-E5 使用者授權 Design Agent 決定)」— 但 Design Spec v2.1 已經定版(見 §D | ✅ **正確落地**PRD 不列文字本身,授權 Design 主導) |
| **R5-E6** WebSocket 就緒 | §5 US-1 **AC-1.3d**(行 201「『瀏覽器就緒』的定義 = **Web UI 建立 WebSocket 連線到 server**hub 收到第一個 client不是瀏覽器視窗出現時。不做新 endpoint、不做固定延遲R5-E6§4 N8 + §6.3「WebSocket 就緒偵測」一節(行 321同步 | ✅ **正確落地** |
**C 區結論**R5-E1E6 六項全部正確、完整落地進 PRD v2.1,且分布合理(功能條目 §4 N8 / 驗收標準 §5 AC-1.3 系列 / 非功能 §6.3 / 變更紀錄 §10 / 懸念 §11 都有同步),不會發生 Architect 漏看的情況。
---
## D. PRD 與 Design Spec v2.1 一致性
對照 Design Spec v2.1 `03-design/v2/startup-progress.md`
| 對照點 | Design Spec v2.1 | PRD v2.1 | 判定 |
|------|------------------|---------|------|
| 階段數量 | 6 階段§4 表格) | §4 N8 + AC-1.3a 都明寫「6 階段」 | ✅ 一致 |
| 階段順序與名稱 | 1 初始化控制台 / 2 檢查 Python / 3 啟動 server / 4 偵測裝置 / 5 開瀏覽器 / 6 等待 Web UI 連線 | §4 N8 順序:「初始化控制台 → 檢查 Python → 啟動 server → 偵測裝置 → 開瀏覽器 → 瀏覽器就緒」 | ✅ 一致PRD 第 6 階段稱「瀏覽器就緒」Design Spec 稱「等待 Web UI 連線」語意對齊不衝突Design 為 ground truth |
| Error state 三按鈕命名 | `重試 / Retry``檢視 log / View Log``回報問題 / Report Issue` | AC-1.3c「Restart Server / View log / Report issue」AC-7.3:「`Restart Server` / `View log` / `Report issue`」 | ⚠️ **Minor 5**PRD 把第一顆按鈕寫成「**Restart Server**」控制台原本既有的按鈕命名Design Spec v2.1 §3.7 行 219 寫「**重試 / Retry**」(重置進度面板,重新跑階段 1。**兩者語意不同**Restart Server 是重啟 server 子程序、Retry 是重置啟動流程跑階段 1。建議 PRD 在 AC-1.3c 與 AC-7.3 統一為「Retry / 重試」(與 Design Spec v2.1 一致),或請 Architect 在 TDD v2 決定兩個是否合併為同一動作。**不阻擋開發**,但 TDD 必須仲裁。 |
| 60 秒總預算 | §3.7 + §1R5-E4| AC-1.3 / AC-1.3c | ✅ 一致 |
| 20 秒卡住 hint | §3.6 + §2.2 wireframe | AC-1.3b | ✅ 一致 |
| WebSocket = ready 訊號 | §4 階段 6 完成條件 | AC-1.3d + §4 N8 | ✅ 一致 |
| **Linux 階段 5 跳過 / 階段 6 manual hint** | Design Spec v2.1 §4.1「階段 5 Linux/Settings OFF 情境:`pending → skipped`」+「階段 6 Settings OFF 情境description 改為 `請點擊控制台的「在瀏覽器開啟」按鈕`」 | PRD v2.1 §4 N2 / §6.3 / §5 AC-1.6 / AC-2.3 都有寫 Linux 預設 OFF 與「使用者可手動按 Open in Browser」但**沒明寫階段化進度面板在 Linux 下「階段 5 跳過 + 階段 6 改 manual hint」**這個細節 | ⚠️ **Minor 6**:缺 Linux 路徑下的階段面板行為描述。但這純屬實作層細節Design Spec v2.1 已是 ground truthFrontend 看 Design Spec 即可。**不阻擋、不需 PRD 修訂**。 |
| Reduced motion / WCAG 4.5:1 | Design Spec v2.1 §6 | PRD v2.1 §6.4 / §4 N3 已寫卡片純色 4.5:1未提 startup panel 的 reduced-motion但這已超出 PRD 顆粒度 | ✅ 可接受(細節由 Design Spec 與 TDD 承擔) |
**D 區結論**:核心一致,唯一需要警示的是 **Minor 5按鈕命名分歧**TDD v2 必須仲裁。
---
## E. §11 懸念狀態核對
| # | v2.1 標示 | 第二輪判定 |
|---|---------|----------|
| 11-1 preferences 落點 | ✅ 已解決 | ✅ 確認 |
| 11-2 AC-1.3 10 秒可達 | ✅ 已解決(由 R5-E 取代) | ✅ 確認 |
| 11-3 idle RAM 450 MB | ✅ 已解決 | ✅ 確認 |
| 11-4 N-R4 CI/E2E 分層 | ⏳ 懸置 → Testing | ✅ 維持懸置(屬於 Testing 範疇,不阻擋 PRD |
| 11-5 常駐徽章 | ✅ 已解決:不加 | ✅ 確認 |
| 11-6 auto-open toggle 位置 | ✅ 已解決Wails 控制台 | ✅ 確認 |
| 11-7 R5-E 階段文案定稿 | ⏳ 懸置 | 🟡 **可改為 ✅ 已解決** — Design Spec v2.1 §4 已定版 6 階段中英雙語文案zh-TW + en且使用者已授權 Design 決定R5-E5。建議 PRD 下次小修時把 §11-7 改為 ✅ 並指向 `03-design/v2/startup-progress.md §4 / §7`。**不阻擋** |
**E 區結論**懸念狀態合理§11-7 可以順手結案但非必要。
---
## F. 第二輪新發現問題
### Major阻擋
**無**。
### Minor不阻擋建議交 TDD / Design Spec 階段順手處理)
| # | 標題 | 位置 | 修正建議 | 接手者 |
|---|------|------|---------|-------|
| **Minor 5** | Error state 第一顆按鈕命名分歧Restart Server vs Retry | PRD §5 AC-1.3c / AC-7.3 vs Design Spec v2.1 §3.7 | TDD v2 仲裁兩個動作是否合併。建議Startup Error 階段用「Retry重置進度重跑階段 1」、Running 階段 watchServer 失敗用「Restart Serverkill + 重 spawn server 子程序)」,是兩個獨立動作 | ArchitectTDD v2 |
| **Minor 6** | PRD 未描述 Linux/auto-open OFF 情境下啟動進度面板的階段 5 skipped + 階段 6 manual hint 行為 | PRD §4 N8 / AC-1.3a | 不需修 PRDFrontend 依 Design Spec v2.1 §4.1 實作即可 | FrontendM8 開發) |
| **Minor 7極輕微** | §變更紀錄行 499 v2.0 那一列仍寫「首次自動開瀏覽器」 | PRD 行 499 | 純歷史紀錄、非生效規格,不需修 | — |
---
## G. 第二輪通過 / 不通過 結論
**通過**
**理由**
1. 第一輪 Major 4/4 + Minor 4/4 全部修好,無殘留。
2. R5-E1E6 六項全部正確落地分布合理功能、AC、非功能、變更紀錄、懸念全部同步
3. 與 Design Spec v2.1 `startup-progress.md` 主要一致唯一分歧Error state 按鈕命名 Minor 5屬於語意層差異由 TDD v2 仲裁即可。
4. §11 懸念狀態合理§11-7 可順手結案但非必要。
5. PRD v2.1 控制在 500 行,符合文件管理策略;模組化結構未被破壞。
**不需第三輪 PRD 修訂**。建議流程:
1. ✅ PRD v2.1 凍結,進入 M8 開發前 TDD v2 撰寫
2. Architect 撰寫 TDD v2 時:
- 仲裁 **Minor 5** Error state 按鈕命名Retry vs Restart Server
- 接手 Minor 6 細節Frontend 依 Design Spec v2.1 §4.1 實作 Linux 路徑)
- 接手 R5-D1 OS 通知去重策略PRD 已授權)
3. Design Agent 同步:
- 確認 `v2/settings-update.md` 已修正 auto-open toggle 位置Major 4 後續)
- 把 `startup-progress.md §4` 6 階段文案定版告知 PM請 PM 在下次小修時把 §11-7 改 ✅
**對 M8 開發的訊號**:🟢 GO。

View File

@ -0,0 +1,537 @@
# Design 第一輪分析 — visionA-local 方向變更2026-04-14
> Design Agent · 第一輪分析筆記(非正式規格) · 2026-04-14
> 對應事件:使用者提出方向重大變更 — Wails 桌面 app 由「跑 Next.js 主 UI 的殼」**退化**為「Local Server 控制台」,主 UI 改為純瀏覽器網頁。
---
## 摘要3 行)
- 使用者要求把 visionA-local 從「Photo Booth 型桌面 app」轉為「**Docker Desktop / Ollama 型服務控制台 + 瀏覽器網頁 UI**」雙 UI 架構桌面殼只管「server 生命週期 + log」瀏覽器端承擔所有業務操作。
- 推論輸入從目前的 `camera / image / video(file) / video(url)` **砍為三種**`camera / image / video 檔案上傳`**URL/YT-DLP 整條移除**;模型來源只剩「預置 + 使用者上傳」,不再有第三方目錄。
- 這個改動在體驗面有實質 upside多視窗、devtools、書籤、和 localhost 生態整合),但也讓原本已完成的 splash regression 修復方向逆轉、和第三輪 Q-A「砍 tray」決策再度打架因為沒有 tray 又沒有主視窗、server 要不要在桌面 app 關閉後繼續活)。**這幾個衝突必須在進開發前先解。**
---
## A. 變更解讀(體驗面)
### A1. 為什麼使用者想這樣
使用者沒明講動機,但以 UX 研究角度推測有幾個合理動機,**按可能性由高到低**排序:
1. **對齊 localhost 生態的心智模型**
使用者自己是 FAE / 開發者,日常工具鏈是 `docker desktop` / `ollama serve` / `jupyter notebook` / `stable-diffusion-webui`。這些工具都走「桌面端只是 server 控制器,真正操作在瀏覽器 / CLI」的雙重介面模式。把 visionA-local 做成同樣形狀,學習成本 = 0符合「這類工具應該長這樣」的直覺。
2. **瀏覽器能力是 Wails WebView 的超集**
- **多視窗**:同時開兩個 tab 對照 camera 推論 vs 影片推論
- **devtools**F12 即用,可以看 network、console、storage
- **書籤 / 分頁歷史**:跳回上次看的裝置
- **瀏覽器擴充**screenshot、錄影、Postman 等 workflow 工具直接接上
- **瀏覽器字體 / 高 DPI / 縮放**Wails 在 Windows 的 WebView2 某些版本字體渲染有 bug瀏覽器沒這問題
- **分享 URL**`http://localhost:3721/workspace/xxx` 可以貼給同事或記在筆記裡
3. **推論結果可以在不同視窗同時看**
目前 Wails 單視窗模式,使用者只能看一個 workspace。FAE 到客戶現場 demo 時常見需求:一邊跑 camera、一邊準備下一段影片 — 開兩個瀏覽器 tab 就解決了。
4. **切換到不同 OS 或遠端機時維持一致**
雖然 visionA-local 定位單機,但使用者也許會在 Linux 無頭機器上 run server然後用 Mac 筆電瀏覽器連 LAN 過去 demo。**這個動機如果存在,就不是單純的 UX 偏好,而是產品範圍的擴張**LAN 可訪問),要主動問清楚(見 D 節)。
5. **Server 健康度獨立可見**
目前 Wails UI 把 server 當成黑盒子server 崩了 UI 才喊 fatal dialog。獨立 log 面板讓使用者**在事情出錯前**就看到徵兆(例如 Python sidecar 有 warning降低 debug 門檻。
**最有說服力的論證是 1 + 5 的組合**:使用者想要的不是「瀏覽器介面」這個手段,而是「**把 visionA-local 從『桌面軟體』重新定位為『本機服務 + 網頁控制台』**」這個心智模型。這是產品層面的 re-positioning不只是 UI 重排。
### A2. 對使用者旅程的影響
#### 首次開啟流程First-Run
**現況M7 splash + Next.js**
```
雙擊 app → Wails 視窗開 → splash 輪詢 GetServerStatus
→ server 起來 → window.location.replace 跳 Next.js 主 UI
→ First-Run 歡迎頁(歡迎 / 模式 / 偵測)→ Dashboard
```
**新方向**
```
雙擊 app → 桌面控制台視窗開 → 自動 start server → log 跑 → status: running
→ 使用者點「Open in browser」→ 瀏覽器開 localhost:3721 → 進 First-Run → Dashboard
```
**體驗斷裂點**
- 使用者從「雙擊 app → 看到 app」變成「雙擊 app → 看到 server 控制台(不是我要的)→ 還要再點一下」。這是**多一個步驟**。
- 對第一次用的 FAE 來說看到「Local server: running · Open in browser」可能會愣一下 — 「我不是要裝網頁工具啊?」
- **解法**:首次啟動**自動**彈出瀏覽器桌面控制台照樣開著在背景。Docker Desktop 沒這麼做是因為 docker daemon 不需要瀏覽器,但 Ollama / Stable Diffusion WebUI 都會自動開。對齊後者體驗。
#### 日常使用流程
**現況**:雙擊 app → 直接操作
**新方向**:雙擊 app → 瀏覽器自動彈(或從 dock/tray 記憶上次狀態)→ 操作
- 如果使用者前一個 session 還沒關瀏覽器 tab雙擊 app 時不該又彈一個新 tab — 應該偵測既有 tab 並 focus技術上 `window.open` with same `target` name 就行)。
- 如果桌面控制台已經在跑、使用者雙擊 app 第二次,要 raise 既有控制台視窗single-instance第四輪決策有定義 `/ipc/raise` endpoint可重用
#### 錯誤排除流程
**這是新架構最大的 UX 升級點。** 目前 server 崩潰時,使用者看到的是:
- Wails 視窗從 Next.js UI 變回 splash
- 或者彈出 fatal dialog「Server 已停止」
- 使用者要自己去 `~/Library/Application Support/visiona-local/logs/` 翻 log
**新架構**
- 桌面控制台永遠看得到 logserver 崩了會有紅色行 / stack trace 直接 inline
- 一鍵 Restart 按鈕就地重試
- 可以 Copy log 貼 slack 問人
- 「Open logs folder」按鈕開 Finder/Explorer 到 log 目錄
**這個價值是 A1 論證 5 的具體落地。桌面控制台不是退化,是獲得一個「自助除錯介面」。**
#### 離開流程
**第四輪決策 Q7 是「B 傳統式(關閉 = 結束)」。新架構打到這個決策頭上:**
- 如果桌面控制台關了 = server 停了 → 瀏覽器的網頁就 **突然變磚塊**fetch 全 500camera 串流斷線)。使用者會很困惑:「我只是關掉那個 log 視窗啊?為什麼我的推論頁面壞了?」
- 如果桌面控制台關了 ≠ server 停 → 又回到 tray / 背景服務心智模型,和第三輪 Q-A「砍 tray」決策衝突。
- **這是 D 節最重要的待決策問題**
### A3. 現有頁面搬遷工作
現有 Next.js 頁面結構:
```
frontend/src/app/
├── layout.tsx ← 全域 layoutsidebar + header
├── page.tsx ← Dashboard/
├── workspace/[deviceId] ← 推論主畫面
├── devices/ ← 裝置列表
├── devices/[id] ← 裝置細節
├── models/ ← 模型列表
├── models/[id] ← 模型細節
└── settings/ ← 設定4 分頁)
```
**搬遷需要改動的點**
| 面向 | 現況 | 新架構需要做什麼 | 工作量 |
|------|------|-----------------|--------|
| **頁面結構** | Next.js pages | **完全不用動**。現有頁面直接以瀏覽器開 `http://localhost:<port>/` 就能跑 | 0 |
| **workspace 推論源 tabs** | camera / image / videovideo tab 內有 file/url 雙模式 | **砍掉 video tab 內的 url 按鈕**video tab 變成單一「upload file」。`pasteUrl` / `urlPlaceholder` / `urlHelpText` i18n keys 刪除。`source-selector.tsx``videoMode` state 和 `startFromUrl` 呼叫都刪。另外 `batch_image` sub-mode 保留(它是 image tab 的多檔上傳變體)。 | S |
| **第四輪「url 推論首次 ≤ 250ms」指標** | 本來假設 url 來源也算在延遲指標內 | URL 整條砍後,這條指標只剩 camera + 本地 video file。更新 PRD 的 R4-2 註記。 | XS文字調整 |
| **模型來源** | models 頁面有預置模型 + Upload Dialog | 使用者說「除了預設的幾種只能用上傳的」→ 確認這和目前狀態一致(預置 + 上傳),**不需要改**。只是要刪掉任何「從 URL 下載模型」或「線上模型市集」的殘留(目前沒有,但要檢查) | XS |
| **devices 頁面 scan/connect** | 現有已經有 `Refresh Devices` 按鈕(快捷鍵 ⌘Shift+R也有 per-device connect | 不用改使用者說的「scan/connect device 介面」已經存在 | 0 |
| **Wails 原生呼叫** | splash 用 `GetServerStatus()` binding、fatal dialog 用 `runtime.EventsEmit` | **splash 從瀏覽器消失**變成桌面控制台內部邏輯。Next.js 主 UI 必須**徹底不用任何 Wails JS binding**M7-B 設計時已經做到,這點不用改)| 0 |
| **Dark Mode 偵測** | 現況走 CSS `prefers-color-scheme`(第四輪決策) | 瀏覽器原生支援,不用改 | 0 |
| **i18n 切換** | Settings > 一般 > 語言 | 不用改,瀏覽器 localStorage 持久化即可 | 0 |
| **OS 通知** | 第四輪 R4-8裝置連/斷 toast、server 崩潰 native notification | **toast 完全不變**(瀏覽器支援好)。**Server 崩潰的 native notification 換位置**:不再由 Wails 處理,改由**桌面控制台自己處理**(它本來就是服務管理者,由它發通知比瀏覽器更合邏輯) | S |
| **Sidebar 與 Top bar** | Next.js 的 `layout.tsx` 已實作,含 sidebar navigation + user-friendly chrome | 不用改,但要考慮**要不要隱藏任何「return to desktop app」按鈕**之類的東西(目前沒有) | 0 |
| **Window chrome tokens** | 第四輪 `spec/07-design-tokens.md §7.5` 有 desktop 專用 elevation / window chrome token | 這些 token 只在桌面控制台用;網頁端不用(瀏覽器 chrome 由瀏覽器繪製)。不需要刪,但桌面控制台可以沿用 | 0 |
| **快捷鍵集**⌘1-4 切換主區塊、⌘,、⌘U 等) | 現有由 Next.js 內部 keyboard handler 綁定 | **分裂為兩套**:桌面控制台只有 `⌘W關閉結束/ ⌘Q`;其餘 ⌘1-4、⌘, 、⌘Shift+R、⌘U 全部留在瀏覽器端。`⌘W` 在瀏覽器是「關 tab」這是原生行為不衝突。**但 `⌘Q` 在瀏覽器是「結束 browser」會誤殺所有 tab**,要和使用者確認怎麼處理(見 D 節) | M |
**總結****現有 Next.js 頁面 80-90% 可以零改動直接搬**。實際修改集中在三個點:
1. 砍 workspace 的 URL 推論 UI1 個元件 + 相關 i18n
2. 桌面控制台是全新 appWails main.go 大改)
3. First-Run 自動開瀏覽器的新 hook
### A4. 砍掉 URL 推論的影響
使用者這句「推論只需包含這三種 camera/image/上傳影片」等於宣判 URL 推論死刑,影響面:
**UX 面影響**
1. **workspace 的 video tab 簡化**
- 現況:點 video tab → 看到 `[上傳檔案] [貼上連結]` 兩顆 button → 選 file → 再點 Select Video
- 新方向:點 video tab → **直接就是檔案上傳區**(大 drop zone + 檔案選擇)
- 這**改善**了 video tab 的體驗(少一個決策層),符合 Jakob Nielsen 的「減少選擇負擔」原則。
- 支援格式:使用者說 `avi, mpeg, mp4, 瀏覽器能吃的格式`,實際上瀏覽器 `<input type="file" accept="video/*">` 就能列一批。我建議 accept list 顯式寫 `.mp4,.avi,.mpeg,.mpg,.mov,.mkv,.webm`(瀏覽器相容格式的超集),因為後端是 ffmpeg 而非 `<video>` tagffmpeg 吃得下的更多。**這條要和 Architect 確認 ffmpeg 的 decoder whitelist**。
2. **i18n 文案刪除**
`camera.pasteUrl` / `camera.urlPlaceholder` / `camera.urlHelpText` 三個 key 刪掉zh-TW + en避免殘留 dead key。
3. **yt-dlp 打包怎麼辦?**
- 第四輪 R4 已決定 yt-dlp 要內嵌35MB+ ffmpeg 內嵌77MB GPL build。URL 推論砍掉後,**yt-dlp 的 35MB 完全是死重量**。
- **強烈建議**把 yt-dlp 從 vendor 清單移除,安裝檔可以瘦身 35MB220MB → 185MB
- **ffmpeg 不能砍**:本地影片解碼還是需要 ffmpeg`avi / mpeg / mov` 瀏覽器原生 `<video>` 不一定吃,後端 ffmpeg 是 decoder + transcode 關鍵)。
- **這條要寫進 D 節給 PM / Architect**:這是 M6 任務成果的部分退場,要追蹤 vendor 清單、Makefile、PRD 的第三方授權頁、安裝檔預期大小等多處更新。
4. **URL 推論是否有隱藏的未來用途?**
例如「RTSP 串流」IP camera也是走 URL 輸入。如果使用者未來想接監視器場景RTSP 就回不來了。**這條要和 PM 確認是否真的永久砍,還是只是 MVP 先砍**。(我的 PM hat 建議MVP 先砍future backlog 標記 `RTSP 可能回歸`。)
5. **對現場 demo 的影響**
FAE 帶筆電到客戶現場,如果沒有 Wi-Fi / 網路受限YouTube URL 本來就跑不動 — 砍掉它和「完全離線」承諾更一致。**這是 PM/PRD 的 strategy 論證可以直接套用的 UX narrative**。
**總結**:砍 URL 是**淨收益**(簡化 UI + 瘦身 + 對齊離線承諾),只要注意 yt-dlp 要一起砍、PRD 文案要清。
---
## B. 新的 Desktop Control Panel 設計提案
### B1. 視窗規格
| 項目 | 建議值 | 說明 |
|------|--------|------|
| 預設視窗寬度 | **720 px** | 不能太窄log 行要能看;不能太寬,沒必要 |
| 預設視窗高度 | **560 px** | log 區塊要能看 15-20 行 |
| 最小寬度 | **560 px** | 保證按鈕 + status 不疊 |
| 最小高度 | **420 px** | |
| 可調整大小 | 是 | 使用者可能想把它拖大看更多 log |
| 最大化按鈕 | 保留 | |
| 最小化按鈕 | 保留 | |
| 置頂 | 否 | 不干擾瀏覽器操作 |
| 全螢幕 | 否(不需要) | |
| Title bar | 原生 | 不做自訂 title bar省跨平台工 |
| 視窗標題 | `visionA-local · Server Control` | |
| 應用程式 icon | 沿用現有 `visiona-local/frontend/icon.png` | |
### B2. 佈局區塊ASCII wireframe
```
┌─────────────────────────────────────────────────────────────────┐
│ ● visionA-local · Server Control [─][□][✕]│ ← Title bar原生
├─────────────────────────────────────────────────────────────────┤
│ │
│ [visionA-local] Status: ●Running Port: 3721 v0.1.0 │ ← Header品牌 + 狀態列)
│ Uptime: 00:12:43 PID: 45821 │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ [ Open in Browser ] [ Start ] [ Stop ] [ Restart ] │ ← Primary controls
│ │
│ ☑ Follow tail ☑ Show timestamps [ Filter: _______ ] │ ← Log controls
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 2026-04-14 10:23:41 INFO HTTP server listening on :3721 │
│ 2026-04-14 10:23:41 INFO wails ipc port: 49152 │
│ 2026-04-14 10:23:42 INFO device scan: found 1 Kneron KL520 │
│ 2026-04-14 10:23:43 INFO GET /api/devices 200 (4ms) │ ← Log panel
│ 2026-04-14 10:23:45 INFO GET /api/models 200 (2ms) │ (等寬字體, 可捲動,
│ 2026-04-14 10:23:58 WARN python sidecar restart (attempt 1) │ 等級著色)
│ 2026-04-14 10:23:59 INFO python sidecar ready │
│ 2026-04-14 10:24:12 INFO inference session start: classification│
│ ... │
│ │
│ │
│ │
├─────────────────────────────────────────────────────────────────┤
│ [ Clear log ] [ Copy log ] [ Export log ] [ Open log folder ] │ ← Log actions
│ │
│ Lines: 142 / 1000│ ← Footer status
└─────────────────────────────────────────────────────────────────┘
```
### B3. 元件細節
#### Header
- **品牌標記**:左側一個 20px 高的 visionA-local logo + 產品字樣(同現有 splash 視覺)
- **狀態指示燈**:大圓點 + 文字
- `●Running`(綠 / semantic `color.success`
- `●Starting...`(黃 / semantic `color.warning`,附 spinner
- `●Stopped`(灰 / semantic `color.muted-foreground`
- `●Crashed`(紅 / semantic `color.destructive`,附小 ⚠️ icon
- **運行資訊**port / uptime / PID / version — 次要資訊用 `color.muted-foreground`
- **沿用既有 design tokens**shadcn oklch不另外造一套
#### Primary Controls3 顆服務按鈕 + 1 顆瀏覽器按鈕)
| 按鈕 | 狀態邏輯 | 視覺優先級 |
|------|----------|----------|
| **Open in Browser** | 只在 `Running` 時 enabled點擊呼叫 OS open URL | **Primaryfilled**,位於最左側最顯眼處 |
| Start | 只在 `Stopped / Crashed` 時 enabled | Secondaryoutlined |
| Stop | 只在 `Running` 時 enabled點擊後要有確認避免誤按 | Secondary |
| Restart | 只在 `Running` 時 enabled | Secondary |
設計 rationale**`Open in Browser` 是日常最高頻操作**每次啟動都要按一次必須視覺最重Start/Stop/Restart 只在出事時才點。這違反了「Start 應該在第一個」的慣例,但符合使用頻率。
可以考慮**合併 Stop + Restart 為一顆「⋯」overflow menu**,避免誤按:
```
[ Open in Browser ] [ Start ] [ ⋯ Manage ▼ ]
├─ Stop server
├─ Restart server
└─ Clear data (dangerous)
```
#### Log 控制列Log controls
- **Follow tail**:預設開。使用者往上捲動時**自動關閉**(像 Docker Desktop / `tail -f`),捲到最底時自動重新開啟(或顯示 `Jump to latest` 按鈕)
- **Show timestamps**:預設開。關閉後 log 行不含時間戳,節省寬度
- **Filter**:即時過濾(字串 match不做 regex 避免使用者不會用。keyboard`⌘F` 聚焦 filter
#### Log panel
- **字體**等寬SF Mono / Consolas / Monospace
- **字級**12px無 line-height 壓縮
- **等級著色**
- `INFO` → 預設文字色
- `WARN``color.warning`(琥珀)
- `ERROR``color.destructive`(紅)
- `DEBUG``color.muted-foreground`
- **長度上限**:記憶體最多 1000 行ring buffer超過舊的丟。避免長時間跑爆記憶體。
- **完整歷史**:寫檔到 `~/Library/Application Support/visiona-local/logs/server.log`,和當前 `stderr.log` / `stdout.log` 共存Architect 確認)
- **支援選取複製**:使用者可以滑鼠拖選文字。無論 follow tail 是否開啟,選取時都要凍結畫面
- **Auto scroll 平滑**:新行滑入動畫 60ms太快會閃
- **Drag-select + ⌘A select all + ⌘C copy**:原生支援即可
#### Log actions底部按鈕列
| 按鈕 | 功能 |
|------|------|
| Clear log | 清空**畫面上**的 log不動 log 檔)。二次確認 |
| Copy log | 複製全部 log 到剪貼簿(連同 timestamp |
| Export log | 匯出 .log 檔到指定位置(原生 Save Dialog |
| Open log folder | 用 OS 預設 file manager 開 `~/Library/Application Support/visiona-local/logs/` |
#### Footer status
- **行數統計**`Lines: 142 / 1000`(顯示當前行數 / 上限)
- **Memory hint**(可選):`Server RAM: 240 MB` — 從 `ps` 取樣每 3 秒刷新,供使用者觀察是否記憶體漏水
### B4. 首次啟動行為
**建議預設行為**
1. 雙擊 app → 桌面控制台視窗開(預設位置:螢幕中央)
2. **自動** start server不需要使用者點 Start
3. Server 起來後status → Running**自動**呼叫 OS 開預設瀏覽器到 `http://localhost:3721/`
4. 桌面控制台**留在背景**(不最小化、不關閉 — 不然使用者會以為 app 壞了)
5. 使用者下次啟動時,記住**上次**的行為:
- 如果上次使用者**手動把控制台關閉**,下次啟動仍然彈控制台(因為關閉 = 結束程式,第四輪 Q7 決策)
- 如果上次使用者**只是把瀏覽器關了**,下次只要控制台還活著,雙擊 app → 直接 raise 既有控制台 + 重新彈瀏覽器
**Settings在桌面控制台內**
- `☑ 啟動時自動開啟瀏覽器`(預設開)
- `☑ 啟動時自動 start server`(預設開)
- `☑ Log 開啟時自動 follow tail`(預設開)
- `☑ Dark mode: Follow system`(唯讀,和第三輪決策一致)
- Language: `[ 繁體中文 ▼ ] / [ English ]`(桌面控制台自己也要 i18n
**例外情境**
- Server 在 5 秒內沒 ready例如 Python sidecar 起不來)→ log 裡已經有錯訊,不彈瀏覽器;使用者看 log 自助排除
- Server 起來但 port 被佔 → 改用 fallback port 並在 header 顯示,`Open in Browser` 連到新 port
---
## C. Web UI vs Desktop Control Panel 職責分野
| 功能 / 面向 | Desktop Control Panel | Web UI瀏覽器| 備註 |
|------------|----------------------|-----------------|------|
| Server 啟動 / 停止 / 重啟 | ✅ **唯一入口** | ❌ | Web UI 不能自殺 |
| Server log 顯示與操作 | ✅ **唯一入口** | ❌ | |
| 「Open in Browser」一鍵跳轉 | ✅ 唯一入口 | — | |
| First-Run 歡迎 / 模式選擇 / 硬體偵測 | ❌ | ✅ | Next.js 現有 flow |
| Dashboard快速開始 + 狀態卡)| ❌ | ✅ | |
| Devices 頁面scan / connect / detail| ❌ | ✅ | |
| Models 頁面(預置 + 上傳 + detail| ❌ | ✅ | |
| Workspace 推論頁面camera / image / video file| ❌ | ✅ | **砍掉 URL tab** |
| Settings > 一般(語言、深色模式狀態) | **部分**(只控制桌面控制台自己)| ✅ | 語言設定**兩套獨立**!見下方問題 |
| Settings > 硬體 / 模型 / 進階 | ❌ | ✅ | |
| OS 通知:裝置連/斷 | ❌App toast 走瀏覽器)| ✅toast| Web 端用瀏覽器 `Notification API` 或 in-page toast |
| OS 通知Server 崩潰 | ✅ **改由控制台負責** | ❌ | 原本第四輪 R4-8 是 Wails shell out現在改成桌面控制台直接呼 OS notification |
| 資料目錄開啟 | ✅ Open log folder 按鈕 | Settings > 進階 也可以 | 兩邊都有,方便 |
| 快捷鍵 ⌘,Settings| ❌ | ✅ | 瀏覽器端 |
| 快捷鍵 ⌘1-4切換主區塊| ❌ | ✅ | |
| 快捷鍵 ⌘Shift+R重新整理裝置| ❌ | ✅ | |
| 快捷鍵 ⌘U上傳模型| ❌ | ✅ | |
| 快捷鍵 ⌘W / ⌘Q | ✅ | ✅(瀏覽器原生關 tab| **衝突**⌘Q 在瀏覽器會結束瀏覽器,誤殺所有 tab見 D 節 |
| i18n 語系 | **自己一套**(控制台)| **自己一套**web app| 兩邊都要雙語,可共用 i18n 辭典 |
| Dark mode | 跟隨系統 | 跟隨系統 | 兩邊都是 CSS `prefers-color-scheme` |
| 品牌 logo / 字體 / 色盤 | **與 Web 一致** | — | Design token 跨兩套 UI 套用 |
**設計決策重點Settings 的語系切換**
目前設計是「Settings > 一般 > 語言」在瀏覽器 web UI 內。桌面控制台**本身**也有文字,必須有自己的語系切換(通常跟系統 locale
兩個選項:
- **方案 1**:桌面控制台只跟系統 locale不提供手動切換最省工符合「控制台是透明的服務管理者」定位
- **方案 2**:桌面控制台 Settings 有獨立語系切換,但預設「跟隨 Web UI 設定」(會需要 IPC 讓兩邊同步)
**Design 建議方案 1**,因為控制台出現的文字量極少(按鈕 + log level
---
## D. 待使用者決策的問題
### D1. 桌面控制台關閉後 server 是否繼續跑? ⚠️ **最重要**
目前第四輪 Q7 決策是「關閉 = 結束」,第三輪 Q-A 決策是「不做 tray」。新架構下這兩個決策打架
| 選項 | 行為 | 代價 | 收穫 |
|------|------|------|------|
| **A. 關閉 = 結束(維持第四輪 Q7** | 關桌面控制台 → server 也停 → 瀏覽器 tab 變磚塊 | 使用者會困惑「我為什麼不能關 log 視窗?」。必須顯示確認對話框「關閉將停止 server 並中斷瀏覽器操作,確定?」(但這本身就是 UX 摩擦) | 簡單、和現有決策一致 |
| **B. 控制台可最小化但不可關閉** | 控制台只能最小化,關閉按鈕 disabled 或變「最小化」 | **非傳統**,違反 OS 慣例macOS 的 `⌘W` / 紅點會壞掉 | 強制維持 server 活著 |
| **C. 背景 daemon 模式(復活 tray** | 關控制台 → 縮回 menu bar / tray → server 繼續跑 → tray 點擊重開控制台 | **正面推翻第三輪 Q-A 決策**tray 跨平台工要做 | 最符合 Docker Desktop / Ollama 模式 |
| **D. 關閉控制台彈警告,使用者選「停 server」或「只關視窗」** | 提供兩個選項 | 決策疲勞,每次關都彈 | 最靈活但最煩 |
**Design 建議:選 C並正式推翻 Q-A**。理由:
- A 會讓使用者每次關控制台都中斷 server完全違背「當成 daemon 用」的 Docker Desktop 心智模型
- B 不符合 OS 慣例,無障礙地雷
- D 每次都問太煩
- C 雖然要投資 tray 做圖資產與 Wails tray第三輪說踩坑但**新架構下 tray 的價值從「可有可無」變成「核心」**值得投資。Ollama / Docker Desktop 都走這條,使用者預期會符合。
**這題使用者必須親自拍板。**
### D2. 首次啟動是否自動彈瀏覽器?
| 選項 | 我的評價 |
|------|---------|
| **A. 自動彈(建議)** | 符合 Ollama / SD WebUI 體驗,降低「我要怎麼開?」摩擦 |
| B. 手動點「Open in Browser」 | 符合 Docker Desktop 體驗,但 Docker Desktop 本身就不需要瀏覽器visionA-local 不彈等於多一步 |
| C. Settings 可切換,預設自動 | 最靈活 |
**建議 C預設 A**。
### D3. 瀏覽器端是否需要 authLocalhost-only 還是允許 LAN
目前 server 預設 `:3721` 沒 auth。使用者如果把筆電 hotspot 打開、或連到公共 Wi-Fi`http://筆電-ip:3721` 可能被同網段的人看到推論結果。
| 選項 | 說明 |
|------|------|
| **A. Localhost-only`127.0.0.1:3721`+ 不做 auth建議** | 最安全,符合「單機工具」定位,無 LAN 視窗 |
| B. 綁 `0.0.0.0` + Token auth | 支援 LAN demo副作用使用者 A1 論證 4 可能要這個)但要做 token UI + URL 帶 token + 首次啟動產生 token |
| C. 綁 `0.0.0.0` + 不做 auth | 最危險,絕對不要 |
| D. Settings 可切換,預設 A | 靈活 |
**建議 A**。如果使用者真的要 LAN demofuture backlog現在不做。
### D4. Desktop 控制台支援 Dark Mode跟隨系統
**建議:跟隨系統**(和 Web UI 一致,使用 `prefers-color-scheme`)。不做手動切換。
### D5. Log 保留多少 session
- 畫面內 ring buffer1000 行(建議)
- 磁碟 log 檔:保留最近 7 天 / 7 個 session每個 10MB 上限rotate
- 「Clear log」按鈕是否也清檔案**建議否**,只清畫面
**待確認7 天 / 7 個 session 這個 retention 對使用者夠嗎?**
### D6. Desktop 控制台視窗位置記憶
第一次開:螢幕中央。
第二次開:記住上次關閉時的位置 + 大小嗎?**建議是**,寫到 `~/Library/Application Support/visiona-local/control-panel.json`
### D7. 桌面控制台的 ⌘Q / ⌘W 行為
- `⌘W` 關視窗:在新架構下 = 觸發 D1 的決策Stop/Hide/Ask
- `⌘Q` 結束 app一定 = Stop server + quit
**瀏覽器裡的 ⌘Q** 會結束瀏覽器本身,不是 visionA-local 的問題,使用者本來就會知道。但要在 UI 文案明說「⌘Q 會結束 server」避免誤會。
### D8. Web UI 是否保留任何 Wails 專屬功能?
例如原生檔案對話框、native drag-and-drop、`@wailsapp/runtime` 事件。
**建議:完全不用**。Next.js 在瀏覽器跑就是標準 Web API**徹底切斷** Wails binding這樣未來如果要讓使用者直接在 Chrome 開也能跑。現況 M7-B 已經做到這點,維持即可。
---
## E. 風險觀察
### E1. 使用者認知負荷:兩套 UI 會造成困惑嗎?
**可能的情境**
- 使用者在桌面控制台看到 `Language: [繁中]`,又在 web UI Settings 看到 `語言: [繁中]` — 兩個是同一個還是不同?
- 使用者在 web UI 砍了某個自上傳模型,桌面控制台 log 卻還顯示它?
**緩解**
- 文案要明確:控制台叫 **"Server Control Panel"**web UI 是 **"visionA-local Web"**,兩個名字不要混
- 桌面控制台 UI 極簡,只做服務管理,**完全不碰業務邏輯**(不在這裡管 device、model— 避免使用者以為這裡也能操作
### E2. 「一鍵啟動」變「兩步啟動」是體驗退化嗎?
如果自動開瀏覽器生效 → 體驗等於一鍵。如果沒有 → 退化。
**D2 的決策直接決定這個風險是否存在**。
### E3. 既有的 i18n / Design System / Dark Mode 要重新套到桌面控制台嗎?
- **i18n**:要。桌面控制台文字雖少,但仍需中英雙語(符合第二輪 Q13 決策)。**建議**直接讀 web UI 的 `zh-TW.ts / en.ts`,抽一個 `desktop-control` namespace。
- **Design tokens**:要。從 `spec/07-design-tokens.md``color.success / warning / destructive / muted / surface / on-surface`Wails WebView 一樣是 CSS直接用。
- **Dark Mode**:要(和 web UI 一致 `prefers-color-scheme`)。
**這代表桌面控制台不是 Tauri/Electron 原生 widget而是一個 WebView 頁面**(它本來就是 Wails = WebView。這很好因為可以直接複用 Tailwind + shadcn 元件。
### E4. Log 面板的 PII / 敏感資訊風險
Server log 可能會印:
- 檔名(包含使用者的私人檔名)
- 模型名稱
- 裝置 serial
- API 呼叫 body如果 log level = DEBUG
使用者點「Copy log」貼到 Slack 就洩漏了。
**緩解**
- 預設 log level = INFO不印 body
- `Copy log` / `Export log` 前顯示一次性提示「Log 可能包含檔名與裝置資訊,確定要複製?」
- Log 目錄不加密,視 OS 檔案權限保護
### E5. Server 崩潰後桌面控制台需要「自動重啟」嗎?
第四輪 Plan B 有 Python sidecar crash loading + 自動重啟(`spec/08-states.md §8.8`)。
新架構下,控制台自己是 watchdog**可以**直接做 server 層級的 auto-restart例如崩潰 3 秒後自動重啟一次,再崩就紅字等使用者手動)。**這個行為要和 Architect 討論**,因為第四輪原本是 sidecar 層級重啟。
### E6. 「有 log 就有濫用」風險
如果使用者在桌面控制台看到 log 後,**試圖自己寫 script 去 tail log 檔**(例如公司 IT 規範要求把 log 送到 SIEMlog 檔格式就變成一個隱性 API。
**緩解**log 檔格式要穩定,寫進 TDD 的「日誌格式契約」章節(這條以前沒有,可以新增)。
### E7. 新架構和原 PRD 「零依賴、離線可用」承諾一致嗎?
- 瀏覽器?使用者一定有(系統預設)→ ✅
- 127.0.0.1 連線?完全本機 → ✅
- 不需要網路 → ✅
**完全一致,沒問題。**
### E8. 「關閉 Wails 視窗」在新架構下的心智模型衝突
**這個風險是 D1 的延伸**。目前設計決策 Q7 (關閉=結束) 是**假設主視窗是業務 UI** 的前提下做的。主視窗變成控制台後,這個決策的前提崩了。必須重新詢問使用者。
---
## F. 給 PM 和 Architect 的議題
### 給 PM
1. **R4-2 MJPEG 延遲指標的 URL 分母要移除**(原本可能包含 `via url` 情境,砍後只剩 camera + local file
2. **第三方授權頁要更新**yt-dlp 從 vendor 清單移除,安裝檔預期大小從 220MB → 185MB發佈 AC 要重算
3. **RTSP 未來回歸路徑**:使用者說「只做這三種」,要寫到 vision-and-non-goals.md 的非目標清單,避免未來說好的功能被砍後爭議
4. **Persona 更新**新架構下「FAE 帶筆電到客戶現場」的首次啟動腳本要重寫,因為多一個「桌面控制台自動彈 + 瀏覽器自動開」步驟
5. **PRD 產品定位語言**:從「桌面 app」改成「本機服務 + 網頁控制台」— 這是 strategic repositioning不只 UI 改動
6. **Q-A 砍 tray 決策要不要推翻**D1— 這是三方要一起拉回來討論的關鍵點
### 給 Architect
1. **main.go 架構大改**:從「`go:embed all:frontend` + splash redirect」改為「全新 control panel 頁面 + server lifecycle 控制器」。M7-B 的 splash 修復的邏輯大部分可重用(輪詢 `GetServerStatus`),但呈現層完全不同。
2. **`/ipc/raise` endpoint**:從「將 splash 視窗提前」改為「raise 控制台視窗 + open browser」。語意變了。
3. **Single-instance 第二次雙擊 UX**:第四輪 `spec/06-cross-platform.md §6.9` 定義是「靜默 raise」新架構下要不要同時 open browser我的建議順手
4. **OS 通知**:第四輪 R4-8 的 `Server 崩潰 → shell out 原生通知`,新架構下改由桌面控制台直接呼(桌面控制台本來就是 Wails直接 `runtime.MessageDialog` 或用系統 notification API
5. **Log pipeline**server stderr/stdout → 桌面控制台 WebView。需要一個 IPC 或直接讀 log file tail 的機制。建議用 Wails `runtime.EventsEmit` 把新行推到前端
6. **yt-dlp vendor**`make vendor-ytdlp` 任務移除payload-macos / payload-windows / payload-linux 都不再 stage yt-dlp。**Go server 端如果還有任何 `exec.Command("yt-dlp", ...)` 的程式碼路徑,要一起刪**。
7. **ffmpeg 保留**:即使砍 URL 推論ffmpeg 仍要給本地 video file decode 用。R4 的 GPL release blocker 仍然存在。
8. **Port 衝突 fallback**:原架構下 port 佔用是 Next.js 跑不起來 → 整個 app 廢掉。新架構下控制台仍能跑,可以試 fallback port 3722 / 3723並在 header 顯示當前 port
9. **Log 檔案格式契約**E6 風險):需要寫到 TDD 新增章節「日誌格式」,定義欄位與 stability commitment
10. **Desktop control panel 是獨立 routing 還是和 Next.js 共用 WebView**
- 選項 AWails main window load 一個靜態 HTML/JS/CSS不是 Next.js主 UI 跑在瀏覽器 → **推薦**,切得乾淨
- 選項 BWails main window load Next.js 的一個獨立 route `/_desktop-control`,然後瀏覽器跳 `/` → 複用 Next.js 但混在一起Next.js build 會多 chunk
- 建議 A和 M7-B 的 splash 同樣策略:極簡 HTML + vanilla JS或小型 React只做 server 控制和 log 顯示
### 給三方共同討論:
1. **要不要正式推翻「砍 tray」決策**(見 D1這是這次變更的單一最大決策點。
2. **要不要正式把 Q7「關閉=結束」改成「關閉=背景 daemon」**(依賴 1
3. **要不要讓 web UI 也可以綁 LAN**D3這會把產品從「本機單人工具」變成「一人發起多人觀看」的雛形需要 PM 重新評估定位。
4. **新架構下的 First-Run flow 在哪一層跑?**
- 選項 A完全在瀏覽器Next.js控制台只管啟動
- 選項 B桌面控制台也顯示「首次啟動正在準備…」進度條
- 建議 A簡單控制台永遠只做 server 管理。
---
## 結語 / 下一步建議
這次變更的本質是**產品類別變更**:從「桌面軟體」→「本機服務 + 網頁控制台」。如果使用者確定要這樣做,我強烈建議:
1. **先解 D1**(桌面控制台關閉後 server 是否繼續跑 + 是否復活 tray這是最大的架構叉路
2. **再解 D2/D3**啟動行為、LAN 範圍),這些決定預設體驗
3. 三方各自產出正式文件的 deltaPM 更 PRD strategy + feature-inventory + vision-and-non-goalsDesign 更 design-spec §1 IA + §5 replacement for Tray + §6 cross-platform + 新增 §11 Desktop Control PanelArchitect 更 design-doc 架構圖 + main.go 改寫 + api-endpoints + removed-code 加 yt-dlp
4. 然後才進開發
**不要先進開發再補文件。** 這次是第三輪 Q-A 決策的直接推翻 + Q7 決策的間接推翻,如果跳過文件同步,未來不知道為什麼會這樣做。
— Design Agent / 2026-04-14

View File

@ -0,0 +1,127 @@
# visionA-local 設計規格 v2索引
> Design Agent · 第五輪正式規格 · 初版 2026-04-14
> **目前版本v2.12026-04-14 補丁)** — 吸收 Architect 交叉審閱 Major 1+2 / Minor 1-5 修正 + R5-D1/D2/D3 / R5-E 追加決策
> 本版依 **R5 決策**重構visionA-local 從「Wails 跑 Next.js 的單一桌面 app」轉為「**Wails Server Control Panel + 瀏覽器 Web UI**」雙 UI 架構。
> 本文件為 v2 索引檔,各章節詳細內容見 `v2/` 子檔。v1`design-spec.md` + `spec/`)仍保留作為基準參考,未來的實作以 v2 為準。
---
## v2 範圍
本次 v2 只重新設計**因 R5 決策而改變的部分**,其餘 v1 章節IA、Settings 分頁結構、design tokens、i18n 策略、A11y 等)**仍然有效**。
### R5 決策總表(本 v2 的立論基礎)
| # | 題目 | 使用者決定 | 對設計的影響 |
|---|------|----------|------------|
| R5-1 | 重構動機 | A+B+G多視窗便利 + devtools + 需求方要求) | 確立雙 UI 架構 |
| R5-2 | 關閉 Wails 視窗行為 | **關閉 = 結束 server**瀏覽器顯示「Local Server 已離線」覆蓋層 | 新增 Server Offline Overlay 設計 |
| R5-3 | Tray 復議 | **維持砍 tray**T1 | 控制台無 tray 退縮選項 |
| R5-4 | 首次啟動自動開瀏覽器 | **A首次自動開Settings 可關** | 新增 Settings toggle + First-Run 流程變動 |
| R5-5 | Wails 控制台 scope | PM 清單 **拿掉 Mock 模式切換** | 控制台不顯示 Mock UI |
| R5-5a | Mock 模式歸處 | **完全砍掉 Mock 模式** | Settings 刪除硬體分頁的 Mock 相關項 / First-Run 砍模式選擇步驟 |
| R5-6 | ffmpeg 授權 | LGPL 混合Win/Linux BtbN、macOS 自 build | 不影響 UI 設計本身 |
| R5-7 | M7 Windows | 先不管 | 不影響 v2 |
---
## 文件結構
| # | 章節 | 檔案 | 一句話摘要 |
|---|------|------|-----------|
| v2.1 | Wails Server Control Panel | `v2/control-panel.md` | 桌面控制台完整規格wireframe、狀態機、元件、i18n、A11y |
| v2.2 | Server Offline Overlay | `v2/server-offline-overlay.md` | 瀏覽器端偵測到 server 斷線時的全螢幕覆蓋層 |
| v2.3 | source-selector 修改 | `v2/source-selector-update.md` | 砍 URL mode、重寫 video tab、要刪的 i18n key 清單 |
| v2.4 | First-Run 流程重定義 | `v2/first-run-update.md` | 砍 Mock 模式選擇步驟、自動開瀏覽器的起手流程圖 |
| v2.5 | Settings 頁更新 | `v2/settings-update.md` | 新增「啟動時自動開啟瀏覽器」toggle、Linux 預設 OFF、落地 `preferences.json`、砍 Mock 相關設定 |
| **v2.6** | **啟動進度面板**v2.1 新增) | **`v2/startup-progress.md`** | **R5-E 階段化啟動進度面板6 階段 wireframe、中英雙語文案、20 秒卡頓提示、60 秒 timeout Error 流程** |
---
## v2 未動到的章節(仍以 v1 為準)
- `spec/01-information-architecture.md` — IA 不變4 主區塊 + Settings
- `spec/02-pages-diff.md` — 頁面清單變動極小(僅 workspace 推論源砍 URL
- `spec/03-wireframes.md` — Dashboard / Devices / Models / Workspace 主體不變
- `spec/06-cross-platform.md` — 跨平台 UX 差異不變
- `spec/07-design-tokens.md` — tokens 完全沿用
- `spec/08-states.md` — 全域錯誤分級、空狀態文案不變
- `spec/09-accessibility.md` — 盡力而為原則不變
- `spec/10-i18n.md` — i18n 策略不變
---
## Design Token 一致性(總則)
**Wails Server Control Panel 與瀏覽器 Web UI 共用同一套 shadcn oklch design tokens**(見 `spec/07-design-tokens.md`)。
具體做法:控制台前端是 vanilla HTML/JS/CSSR5 三方共識),但仍引入與 Next.js Web UI 一致的 CSS variable 定義檔(從 `frontend/src/app/globals.css` 擷取 `:root``[data-theme='dark']` 的 token block達到
- 控制台與 Web UI 在 Light / Dark 下視覺語言一致
- 錯誤訊息紅、警告琥珀、成功綠完全相同(不會出現控制台紅和 Web 紅不同的 UX 破綻)
- 未來若 tokens 更新,只要同步 CSS variable 檔即可
**不另外定義控制台專屬 tokens**,唯一例外是控制台的 log panel 使用等寬字體 token`font.mono`v1 已存在),以及標題列 `window-chrome.height`v1 `spec/07-design-tokens.md §7.5` 已定義)。
---
## v1 → v2 差異總覽
| 面向 | v1第四輪 | v2第五輪 | 來源決策 |
|------|-------------|-------------|---------|
| 主視窗呈現 | Wails 單視窗 = Next.js 主 UI | Wails 單視窗 = Server Control PanelWeb UI 跑在瀏覽器 | R5-1 |
| 視窗關閉 | 結束程式(第四輪 Q7 | 結束程式 **+** 瀏覽器顯示離線 Overlay | R5-2 |
| Tray | 無 | 無(維持) | R5-3 |
| 首次啟動 | Wails 開 → First-Run wizard 三步(歡迎 / 模式選擇 / 偵測) | Wails 控制台開 → 自動 start server → 自動 open 瀏覽器 → First-Run wizard **兩步**(歡迎 / 偵測) | R5-4、R5-5a |
| 首次啟動 Settings | — | 新增「首次啟動時自動開啟瀏覽器」toggle預設 ON | R5-4 |
| Mock 模式 | 預設真實硬體,可於 Settings > 硬體切換為 Mock | **完全砍除**,沒插硬體就空白 | R5-5a |
| Settings 分頁 | 一般 / 硬體 / 模型 / 進階 | 一般 / 硬體 / 模型 / 進階(結構不變,內容刪減) | v1 保留 |
| Workspace 推論源 | camera / image / videofile + url | camera / image / video僅 file | R5 三方共識 |
| 支援影片副檔名 | `.mp4, .avi, .mov` | **`.mp4, .avi, .mov, .mpeg, .mpg`** | R5 三方共識 |
| 影片載入文案 | 「正在解析影片連結YouTube 影片可能需要 10-30 秒…」 | 純檔案上傳,無解析文案 | R5 三方共識 |
| Wails 控制台 scope | 不存在 | Header 狀態列 + Primary controls + Log panel + Log actions + Footer | R5-5 |
| Server 狀態機 | 黑盒 | **五態視覺化**Running / Starting / Stopping / Stopped / Error | v2 新增 |
| Server 崩潰通知 | Wails shell out native notification | 控制台內的 Error state 面板 + 紅字 log + OS notification | v2 細化 |
| 瀏覽器端 server 斷線 | 未定義 | 全螢幕 Server Offline Overlay不可關閉、提供重試 | R5-2 |
---
## v2 → v2.1 差異總覽2026-04-14 補丁輪)
v2.1 是 v2 三方互審後的修正補丁,不改變 R5 雙 UI 架構立論,只處理 Architect 互審發現 + R5-D / R5-E 追加決策。
| 面向 | v22026-04-14 初版) | v2.12026-04-14 補丁) | 來源 |
|------|--------------------|----------------------|------|
| Settings 落地檔 | `settings.json` + 「走 Wails settings store」 | **`preferences.json` @ `<dataDir>/`** + Go server write-rename atomic | Architect Review Major 1 |
| 「啟動時自動開瀏覽器」預設值 | 三平台一致 ON | **macOS/Windows = ONLinux = OFF**(依 `runtime.GOOS` | R5-D2 |
| Toggle label 字面 | 「首次啟動時自動開啟瀏覽器」(待確認) | **「啟動時自動開啟瀏覽器」**R5-D3 定案:每次啟動都開) | R5-D3 |
| Log panel 上限 | 1000 行 + rotate 7 天 / 10 MB | **2000 行 + 無落地 rotate**in-memory ring buffer使用者透過 Export log 手動匯出) | Architect Review Minor m-1、m-12 |
| Error banner 「Report...」按鈕 | 正常按鈕 | **【hold】** 待 PM 提供 GitHub Issue repo URL | Architect Review Minor m-11 |
| Error state OS 通知 | v2 模糊(控制台可見,通知視為次要重複) | **R5-D1 保留**Error state 發 non-blocking toast notification與控制台 Error banner 並存 | R5-D1 |
| Starting state 視覺 | 只有 header spinner5 秒 timeout | **階段化啟動進度面板**`v2/startup-progress.md`6 階段 + 20 秒卡頓提示 + **60 秒總上限** | R5-E |
| 首次啟動流程 | 「首次」自動開瀏覽器 | **「每次」** 啟動都自動開瀏覽器Wails 每次新啟動) | R5-D3 |
---
## 給 Orchestrator 的問題(懸而未決)
**v2 懸而未決已解決**
- ~~Q2 Server 崩潰 OS 通知~~**R5-D1 保留**(已吸收至 `control-panel.md §6.2` + `startup-progress.md §3.7`
- ~~Q3 Linux 預設值~~**R5-D2 Linux 預設 OFF**(已吸收至 `settings-update.md §2.2`
- ~~R5-4 label 字面歧義「首次 vs 每次」~~**R5-D3 每次**(已吸收至 `settings-update.md §2.3` + `control-panel.md §7.1`
**仍懸而未決**
1. **控制台 header「port 變更 fallback」視覺字樣**v1 第四輪決策「port 佔用則 fallback 到 3722/3723」建議格式 `Port: 3722 (default 3721 in use)`,待使用者最終確認字樣。
2. **v2 是否要回頭作廢 v1 的 `spec/04-first-run.md`**:目前 v2 另開 `v2/first-run-update.md`,但未修改 v1 原檔。建議 v2 穩定後作廢 v1 原檔並改為索引到 v2在此之前保持雙軌。
3. **R5-E startup-progress.md 新增 3 個小懸念**`v2/startup-progress.md §11`
- (a) 20 秒卡頓 hint 文案「正在重試...」vs 中性「正在處理中,請稍候...」
- (b) 階段 6 WebSocket 連線被安全軟體擋的 Error 說明是否要特別提示
- (c) 使用者按 Retry 的語意:重置整個啟動流程 vs 重試當前階段Design 建議前者)
---
**下一步:**
- 三方對 v2.1 最終收斂確認Architect 回看 Design v2.1 是否全部修正到位)
- 使用者最終審 `v2/startup-progress.md` 的 6 階段 wireframe + 文案定版
- 確認後進入開發階段 M8-1 ~ M8-10
- 所有 v2 文件的決策引用格式:`[R5-X]` / `[R5-D-X]` / `[R5-E-X]` 以便追溯

View File

@ -0,0 +1,551 @@
# Architect 交叉審閱 Design Spec v22026-04-14
> 審閱者Architect Agent
> 對象:`03-design/design-spec-v2.md` + `03-design/v2/*.md`
> 基準:`04-architecture/TDD-v2.md` + `04-architecture/v2/*.md`
---
## 摘要
- **結論****有條件通過**(需小改)
- **Major****2**A-4 Settings 儲存 spec + A-5 Linux 預設值)
- **Minor****12**
- **是否阻擋進開發****不阻擋**Major 都是 < 10 spec 改動可在 M8-4/M8-5 前補
---
## A. Design ↔ TDD 實作一致性
| Design v2 | TDD v2 | 一致性 | 備註 |
|---|---|---|---|
| `control-panel.md`5 態 + 元件 + i18n | `control-panel.md` + `server-lifecycle.md §5` | ⚠️ 部分 | A-1A-5 |
| `server-offline-overlay.md``role=alertdialog` + focus trap + 3s active polling | `web-ui-offline-overlay.md` | ⚠️ 間隔差 | A-6 |
| `source-selector-update.md`(砍 URL + `.mpeg/.mpg` + i18n | `deletions.md §1 §5` | ✅ 大致 | A-7 |
| `first-run-update.md`2 步 + 硬體可略過) | 未描述(屬 Next.js onboarding | ✅ 無衝突 | — |
| `settings-update.md`auto-open toggle | `control-panel.md §4.1` + `server-lifecycle.md §2.1` | ⚠️ 命名差 | A-4 |
**A-1**Minor狀態名大小寫 Design 用首大寫 / TDD 用全小寫 — 語意同i18n key 對齊即可。不需改。
**A-2**MinorDesign 3 顆 PrimaryStop/Restart 收 Manage dropdownvs TDD 6 顆扁平。binding 層一致Design 是 UI 組合方式。**M8-5 實作時 Manage dropdown item 呼叫 TDD 定義的 `StopServer()`/`RestartServer()` bindings**。我會在 M8-5 前補到 TDD `control-panel.md §3` 註記。
**A-3**MinorLog 行數上限Design 1000 / TDD 2000。**決案採 2000**Go ring buffer 容量常數,~400KB 記憶體可忽略)。**Design Spec §4.4 需改 1000 → 2000**(含 Footer 的 `Lines: {current} / 1000`)。
**A-4Major**Settings 儲存 spec 不一致
- Design Spec `settings-update.md §2.2``settings.json` + 「走 Wails 既有 settings store」
- TDD v2 `control-panel.md §4.1``preferences.go` + `<dataDir>/preferences.json`
- **問題**Wails v2 **沒有**內建 settings storeDesign 誤解);現況無任何 settings 存檔機制,需新建
- **決案**:採用 TDD 的 `preferences.json` + `<dataDir>/` 路徑 + 新建 `visiona-local/preferences.go`
- **Design 需改**§2.2 檔名改 `preferences.json`,刪掉「走 Wails 既有 settings store」一句
**A-5Major**R5-D2 Linux 預設 OFF 兩份 spec 都未落地
- Design 寫「預設值ON」三平台一致
- TDD `Preferences{OpenBrowserOnStart bool}` 預設填 `true`
- **決案**:新增 `DefaultPreferences()``runtime.GOOS` 回傳:
```go
func DefaultPreferences() Preferences {
return Preferences{OpenBrowserOnStart: runtime.GOOS != "linux"}
}
```
- **Design 需改**§2.2「預設值」那格加註「macOS/Windows=ONLinux=OFFxdg-open 極簡 WM 可能失敗)」
- **TDD 需改**`control-panel.md §4.1``DefaultPreferences()`(我負責 M8-4 前補)
**A-6**MinorOffline Overlay 間隔不同
- Design10s 正常 / 失敗 2 次 / overlay 期間 3s
- TDD5s 正常 / 失敗 3 次(無 active 間隔切換)
- **決案採 Design**10s 省 CPU3s active 更貼合 R5-D3 重啟救援體驗)
- **TDD 需改**`web-ui-offline-overlay.md §3.1` `POLL_INTERVAL_MS=10000` / `FAILURE_THRESHOLD=2` + overlay active 切換至 `3000`(我負責 M8-7 前補)
**A-7**Minori18n 刪除清單微差
- Design 砍 `camera.uploadFile`TDD 漏列)
- TDD 砍 `cannotOpenVideoUrl`Design 漏列)
- **M8-1 執行者取聯集即可**
---
## B. R5-D 補充決策技術落地
**B-1 R5-D1 Server 崩潰保留 OS 原生通知**
- 現況 `visiona-local/app.go:240-272` `showNativeError()` 已有三平台實作osascript display dialog / PowerShell MessageBox / zenity / kdialog但這是**給 `reportFatal()` 用的 modal dialog**
- TDD v2 `control-panel.md §4.7` 把 watchServer 3 次失敗從 `reportFatal` 改為 `setState(Error)`**同時等於砍掉 OS 通知 → 違反 R5-D1**
- **決案**Error state 時並存兩件事:(1) 控制台 bannerDesign §6(2) 新的 **non-blocking toast notification**(不是 modal
- macOS`osascript -e 'display notification "..." with title "..."'`toast非 dialog
- Windows優先 `wailsRuntime.SendNotification`(見 `frontend/wailsjs/runtime/runtime.d.ts:275-310` 已自動生成fallback `msg *` 命令列
- Linux`notify-send`fallback `zenity --notification`
- 新增 `visiona-local/notify.go``sendCrashNotification(title, body)`
- **保留** `showNativeError()` / `reportFatal()` 僅給 startup 致命錯誤用data dir 建不起來等)
- **TDD 需改**`control-panel.md §4.7``sendCrashNotification` 呼叫(我負責 M8-4 前補)
- **Design 建議加**§6.2/§6.3 加一句「Error state 時另發 non-blocking OS notificationR5-D1
**B-2 R5-D2 Linux 預設 OFF** — 見 A-5。
**B-3 R5-D3 每次啟動都自動開**
- TDD `control-panel.md §4.6``if prefs.OpenBrowserOnStart && !autoOpenedThisSession`
- `autoOpenedThisSession` 是 per-App-process flag每次 Wails 新啟動會重建 → 每次啟動都會開 ✅
- Restart Server同一 App process 內)不會重開瀏覽器 ✅
- **技術落地正確**
- **Design 建議改**`control-panel.md §7.1` 第 5 步「[首次 / Settings 為 ON]」→「[每次 / Settings 為 ON]」
---
## C. 體驗 ↔ 技術紅線
**C-1 OnBeforeClose**Design Footer 持久提示取代 modal 確認 ↔ TDD `server-lifecycle.md §7` `OnBeforeClose return false` → ✅ 一致。但 Linux AppImagei3/xmonadflaky 的 edge case TDD §10 已記錄M8-10 驗收時實測。
**C-2 Offline Overlay focus trap**Design 要求 Tab 只能在「重試 / 了解更多」間循環TDD `web-ui-offline-overlay.md §4.4` **沒 focus trap 實作**。**決案**M8-7 Frontend Agent 手動實作(`useEffect` 裡 onKeyDown 擋 Tab + 強制 focus~15 行,不引入 `react-focus-lock`)。**SSR 相容性**`'use client'` + Zustand + effect 只在 client 跑,✅ 無問題。
**C-3 3s active polling 對 log pump 的壓力**0.33 行/秒 vs R-v2-3 的推論 30-100 行/秒差兩個量級,✅ 無壓力。**額外建議**server Gin logger 加入 `SkipPaths: []string{"/api/system/boot-id"}` 避免 boot-id probe 洗版控制台 log panel。我負責 M8-9 前補到 TDD。
**C-4 Wails 控制台 i18n**
- Design Spec §9.1 列 30+ keynamespace `control.*`
- TDD §6.1 範例只 ~15 keynamespace `statusCard.*`/`actions.*`
- **決案**:以 Design 為準(`control.*` 更語意化,對齊 Web UI 風格。M8-5 執行者依 Design Spec §9.1 填 `visiona-local/frontend/i18n/{zh-TW,en-US}.json`
**C-5 Dark Mode**Design 要求跟隨系統 + 與 Web UI 共用 tokenTDD §2 用 CSS vars + `prefers-color-scheme` → ✅ 方向一致。**建議做法**M8-5 執行者**直接從 `frontend/src/app/globals.css` 複製 `:root` + `[data-theme='dark']` token block 到 `visiona-local/frontend/style.css`**,不重造一份。
---
## D. Architect 7 懸念決策
**Q1 NewVideoSourceFromURL 呼叫者**grep 驗證):
- `camera_handler.go:435``StartFromURL` 內(即將砍)
- `camera_handler.go:731` 在 seek handler 的 `if h.videoIsURL` 分支;砍 `StartFromURL``h.videoIsURL` 永遠 false → dead branch
- **決案**`NewVideoSourceFromURL` / `NewVideoSourceFromURLWithSeek` / `h.videoIsURL` field 一起砍。M8-1 執行時套用。我負責補到 TDD `deletions.md §1.2` 末段。
**Q3 uuid vs crypto/rand****採 `crypto/rand`**~10 行 hex encode不引入 `google/uuid` 依賴。TDD `server-lifecycle.md §9.1/§10 Q3` 以此為準。
**Q4 shutdownGracePeriod 5s vs 10s****blocked-on-pm-review**(我的意見:建議 10s 對齊 server `shutdownFn` timeout使用者等 10s 是罕見 edge case
**Q5 navigator.language fallback**
```javascript
const raw = (navigator.language || navigator.userLanguage || 'en').toLowerCase();
if (raw.startsWith('zh')) return 'zh-TW';
if (raw.startsWith('en')) return 'en-US';
return 'zh-TW'; // 預設繁中visionA 團隊內部工具)
```
TDD §6.2/§8 Q4 以此為準。
**Q6 window.close() 限制****blocked-on-design-review**我的意見Chrome/Safari/Edge 禁止非 `window.open()` 開啟的 tab 呼叫 `window.close()`。建議 Design 把「關閉這個頁面」按鈕改為「我知道了」純視覺收斂,或改為提示「請手動關閉此分頁」)
**Q7 Preferences JSON atomic write****write-rename pattern**
```go
os.WriteFile(path+".tmp", data, 0o644); os.Rename(path+".tmp", path)
```
POSIX/Windows 都原子。TDD §8 Q3 以此為準。
---
## E. PM 3 技術懸念回答
**§11-1 Settings 資料落地位置**
- 檔名 `preferences.json`,路徑 `<dataDir>/preferences.json`
- macOS `~/Library/Application Support/visiona-local/preferences.json`
- Windows `%APPDATA%\visiona-local\preferences.json`
- Linux `~/.local/share/visiona-local/preferences.json`
- 以 `migrateOldDataDirs()/ensureDataDir()` 算出的路徑(`visiona-local/app.go:103-155`
- JSON 格式:`{"openBrowserOnStart": true}`
- 原子寫入write-renameD-Q7
- 讀取失敗 fallback`DefaultPreferences()`(平台差異,見 A-5
**§11-2 US-1 AC-1.3 10 秒預算**
| 步驟 | 估時 |
|---|---|
| Wails 載入 + OnStartup | 0.3-0.5s |
| migrate/lock/ipc/seed | 0.1-0.2s |
| ensurePythonRuntime | 0.2-0.5s |
| pickPort + spawn server | 0.05-0.1s |
| Server cold start | 1.5-2.5s |
| waitHealthy 首次成功 | 0.5-1.5s |
| OpenInBrowser + OS open | 0.3-0.8s |
| Next.js SPA 載入 | 0.8-1.5s |
| boot-id + WS ready | 0.3-0.5s |
| **樂觀** | **~4.1s** ✅ |
| **悲觀**Intel Mac + system Python | **~8.1s** ✅ |
| **Linux AppImage 首次解壓** | **+1-2s**,仍達標 |
| **Windows + Defender 掃描** | **+2-3s**,最壞 ~11s**可能超時** |
- **風險**Windows 首次啟動可能超過 10s。**建議 M8-10 驗收實測,超時則 PRD AC-1.3 放寬到 12s**
**§11-3 idle RAM ≤ 450 MB**
| 程序 | v1 | v2 |
|---|---|---|
| Wails shell | 150-200 MB | **120-160 MB**(無 Next.js bundle |
| Go server | 80-120 MB | **70-100 MB**(砍 Mock + yt-dlp |
| Python sidecar | 150-250 MB | **0 MB 若 on-demand150-250 MB 若 always spawn** |
| Browser tab (Next.js Web UI) | — | **180-240 MB**(新項目) |
| **樂觀** | — | **~370 MB** ✅ |
| **悲觀** | — | **~500 MB** ❌ 超 50 MB |
- **建議**PRD v2 §NFR 明示「**450 MB 不含 browser tab**,只含 Wails + Go server + Python sidecarbrowser tab 視為使用者瀏覽器自己的資源」—— 這個 clarify 符合常識且達標
- 或:放寬到 **≤ 550 MB**
- **另需確認**v1 `visiona-local/app.go:441``ensureDriverInstalled` 是否在無硬體時仍 spawn python。若是v2 建議改為 on-demand 節省 idle RAM
---
## F. 技術衝突
**F-1 CORS ↔ 瀏覽器 tab origin**
- Wails「Open in Browser」→ `http://127.0.0.1:<port>` → 白名單內 ✅
- 使用者手改 `localhost` → 白名單也接受 ✅
- Same-origin fetch不送 Origin`CORSMiddleware()` 第一個 if 放行 ✅
- **無衝突**
**F-2 Restart Server port 保留**Major 隱含議題):
- TDD `v2/server-lifecycle.md §3`Restart 後重新 `pickPort(3721)`,可能 fallback 到 3722
- Boot-ID 機制假設 tab URL 的 port 不變 → Restart 後 port 變 → 舊 tab `window.location.reload()` 仍連舊 port → overlay 永久卡死
- **決案**`ServerController.Restart()` **強制保留舊 port**,若 old port 不可用 → 進 Error state 並發 OS notification「port 被占用」,**不 fallback**。cold start 則仍允許 3721→3722 fallback
- **TDD 需改**`server-lifecycle.md §3` 新增 Restart 同 port 規則(我負責 M8-4 前補)
- Design Spec `control-panel.md §7.3` 的 fallback 情境只適用 cold start不影響 Design但 Frontend Agent 需知
**F-3 Boot-ID 機制在「關 Wails = 結束 server」場景**
- server 沒了 → boot-id 不會變 → 由 overlay 接手(連續失敗 → 顯示)
- 使用者重開 app 若走 cold start port fallback 到新 port → 舊 tab 的 URL3721連不上新 port3722→ overlay 永卡;使用者需從新 Wails 控制台 Open in Browser 開新 tab
- **設計正確**TDD §2.3 時序已暗示,不需改
---
## G. 臆測 / 超範圍
**G-1 Export log 按鈕**Design §4.5TDD 沒對應 binding。技術可行`wailsRuntime.SaveFileDialog`**M8-5 補 `ExportLog(path) error` binding**。我負責補到 TDD §4.2。
**G-2 Copy 按鈕**Design §4.5):直接用 `navigator.clipboard.writeText()` 瀏覽器 APIWails WebView 支援。**不需新 binding**。
**G-3 Report 按鈕**Design §6.2 Error banner需要 GitHub Issue URL**目前沒公開 repo**。**降級**M8-5 若 PM 沒提供 URL → 不實作 Report 按鈕Error banner 只留 Restart + View details。**Design 需加**§6.2 hold 註記「Report 按鈕待 PM 提供 repo URL暫不實作」。
**G-4 Log rotate 7 天/10MB**Design §4.4TDD 沒 rotate 實作v1 也無。**降級為 append 模式,不做 rotate**rotate 需要 `lumberjack.v2` 或自刻定時掃描 + size 比較,非 M8 scope。**Design 需改**§4.4 「rotate 7 天/10MB」改為「append 模式;使用者可 Clear Logs 清畫面、Open Log Folder 手動刪檔」。未來 M9+ 再加。
---
## H. 問題清單
**Major阻擋需修正**
| # | 位置 | 問題 | 修正 |
|---|------|------|------|
| M-1 | Design `settings-update.md §2.2` | 檔名 `settings.json` + 「走 Wails settings store」誤解 | 改 `preferences.json`,刪掉 Wails settings store 那句 |
| M-2 | Design `settings-update.md §2.2` + TDD `control-panel.md §4.1` | R5-D2 Linux 預設 OFF 未落地 | Design 加「macOS/Windows=ONLinux=OFF」TDD 加 `DefaultPreferences()` 依 GOOS |
**Minor不阻擋**
| # | 位置 | 處理者 |
|---|------|------|
| m-1 | Design `control-panel.md §4.4` + Footer | 1000 → 2000 |
| m-2 | TDD `web-ui-offline-overlay.md §3.1` | 10s/2 次/3s active我 M8-7 前補) |
| m-3 | M8-1 執行者 | i18n 刪除清單取聯集 |
| m-4 | TDD `control-panel.md §4.7` + Design `control-panel.md §6` | Error state 補 OS notification我 M8-4 前補 TDD |
| m-5 | Design `control-panel.md §7.1 第 5 步` | 「首次」→「每次」對齊 R5-D3 |
| m-6 | M8-7 Frontend Agent | Offline Overlay focus trap 實作 |
| m-7 | TDD `server-lifecycle.md §9` | Gin SkipPaths + crypto/rand我 M8-9 前補) |
| m-8 | M8-5 執行者 | i18n 以 Design §9.1 為準 |
| m-9 | TDD `server-lifecycle.md §3` | Restart 強制同 port我 M8-4 前補) |
| m-10 | TDD `control-panel.md §4.2` | 補 `ExportLog` binding我 M8-5 前補) |
| m-11 | Design `control-panel.md §6.2` | Report 按鈕 hold 註記 |
| m-12 | Design `control-panel.md §4.4` | Log rotate 降級為無 rotate |
---
## I. 結論
**✅ 有條件通過** — Design Spec v2 方向正確,與 TDD v2 無不可修復衝突。
**Design Agent 必改**Major + 關鍵 Minor
- `settings-update.md §2.2`M-1 + M-2
- `control-panel.md §4.4`m-1 + m-12
- `control-panel.md §6.2`m-4 + m-11
- `control-panel.md §7.1`m-5
**TDD 必改我負責M8-* 執行前補)**
- `control-panel.md §4.1``DefaultPreferences()` 平台差異)
- `control-panel.md §4.2``ExportLog` binding
- `control-panel.md §4.7``sendCrashNotification` + 新增 `notify.go`
- `control-panel.md §3`Manage dropdown 註記)
- `server-lifecycle.md §3`Restart 同 port
- `server-lifecycle.md §9`Gin SkipPaths + `crypto/rand`
- `web-ui-offline-overlay.md §3.1`10s/2 次/3s active
- `deletions.md §1.2``videoIsURL` field 直接砍)
**Q 決案狀態**Q1 ✅ Q3 ✅ Q4 ⏳ PM Q5 ✅ Q6 ⏳ Design Q7 ✅
**PM 3 技術懸念**
- §11-1 ✅ `preferences.json` @ `<dataDir>/`write-rename
- §11-2 ✅ 10 秒樂觀 ~4s / 悲觀 ~8s / Windows 邊界 ~11sM8-10 驗收時實測,可能需放寬到 12s
- §11-3 ⚠️ idle RAM 需 clarify「不含 browser tab」或放寬到 550 MB另需確認 Python sidecar 是否 on-demand
**不阻擋進開發**:所有修正都是 < 10 spec 改動可在 M8-4 / M8-5 執行前 1-2 小時內完成
---
**審閱日期**2026-04-14
**下一步**:交 Orchestrator 彙整三方 + R5-D 對齊 + Q4/Q6 收斂 → 使用者最終確認 → M8-1
---
# Architect 第二輪審閱 Design Spec v2.12026-04-14
> 對象:`design-spec-v2.md` v2.1 索引 + `v2/settings-update.md` v2.1 + `v2/control-panel.md` v2.1 + **新檔** `v2/startup-progress.md` v2.1
> 基準TDD v2.1 `v2/startup-pipeline.md`518 行)+ 第一輪審閱結論
## 摘要
- **總結論****通過,需小改**3 個 Minor都在 TDD 側補齊)
- **第一輪 Major × 2 / Minor × 12 修復****Major 2/2 修好****Minor 12/12 修好或已落到 TDD/執行者待辦**
- **R5-E startup-progress.md 技術可行性****可行**event schema 7 成對齊,有 3 處 TDD 側需補小改
- **是否阻擋進 M8 開發****不阻擋**TDD 小改可在 M8-4b 執行前 30 分鐘內補完
---
## A. 第一輪 Major 修復檢查
| # | 第一輪意見 | v2.1 現況 | 結果 |
|---|-----------|---------|-----|
| **M-1** Settings 落地檔案 | 改 `preferences.json` + 刪 Wails settings store | `settings-update.md §2.2`L48-56已明文「`preferences.json` @ `<dataDir>/`」+「Go server 端負責讀寫(非 Wails 內建機制 — Wails v2 沒有 settings store」+「write-rename pattern」全檔 grep 無 `settings.json` 殘留無「Wails settings store」殘留 | ✅ **修好** |
| **M-2** R5-D2 Linux 預設 OFF | 加「macOS/Windows=ONLinux=OFF」依 GOOS | `settings-update.md §2.2`L51「macOS/Windows = ONLinux = OFF」+ L55「`DefaultPreferences()``runtime.GOOS`」+ §2.2 Linux 首次使用說明L59-63+ §7 遷移策略L180-190全部落地 | ✅ **修好** |
---
## B. 第一輪 Minor 修復檢查
| # | 第一輪意見 | v2.1 現況 | 結果 |
|---|-----------|---------|-----|
| m-1 | Log 上限 1000→2000 | `control-panel.md §4.4`L152「最大行數 2000」、§4.6 FooterL186`Lines: {current} / 2000`」| ✅ |
| m-2 | Offline Overlay 10s/2 次/3s active → TDD 側 | 非 Design 修,我負責 M8-7 前補 TDD | ⏳ 我的 TODO未阻擋 |
| m-3 | i18n 刪除清單聯集 → 執行者 | M8-1 執行者責任 | ⏳ 執行者 TODO |
| m-4 | Error state OS notification | `control-panel.md §6.3`L269-275「R5-D1 OS 原生通知並存」完整三平台實作表Design Diff §12-6L459| ✅ |
| m-5 | §7.1 「首次」→「每次」 | `control-panel.md §7.1`L310「5. 【每次 / Settings 為 ONmacOS/Windows 預設Linux 預設 OFF】」L331「每次啟動每次 Wails App process 新啟動)都會跑完整 6 階段流程」| ✅ |
| m-6 | Offline Overlay focus trap | M8-7 Frontend 責任 | ⏳ 執行者 TODO |
| m-7 | Gin SkipPaths + crypto/rand → TDD 側 | 我負責 M8-9 前補 | ⏳ 我的 TODO |
| m-8 | i18n 以 Design §9.1 為準 | Design `control-panel.md §9`L387-408完整 `control.*` 清單 | ✅ |
| m-9 | Restart 強制同 port → TDD 側 | 我負責 M8-4 前補 | ⏳ 我的 TODO |
| m-10 | `ExportLog` binding → TDD 側 | 我負責 M8-5 前補 | ⏳ 我的 TODO |
| m-11 | Report 按鈕 hold | `control-panel.md §6.2`L265「**【hold】** 現階段**先不實作**」;`startup-progress.md §3.7`L221「Report Issue **【hold】**」| ✅ |
| m-12 | Log rotate 降級 | `control-panel.md §4.4`L152-162「無落地寫檔in-memory ring buffer」+「為什麼取消落地寫檔與 rotate」整段說明 | ✅ |
**修復統計**Design 側 8/8 已修m-1、m-4、m-5、m-8、m-11、m-12 + M-1、M-2TDD 側 4 個由我自己負責m-2、m-7、m-9、m-10執行者側 2 個m-3、m-6。**無 Design 側遺漏**。
---
## C. R5-E `startup-progress.md` 技術可行性(重中之重)
### C-1 Wireframe DOM/CSS 難度
`startup-progress.md §2`L25-101三個 wireframe 全部 text-basedStageItem 結構§3.3 L141-154明確示範 vanilla HTML + CSS class data-state可直接對齊 TDD `startup-pipeline.md §6`L407-491的 vanilla JS 實作。**無 framework-specific 要求**。✅
### C-2 6 階段定義對應 TDD §3 階段表
| # | Design label | TDD `§3` Go 實作點 | 對應 |
|---|-----|-----|-----|
| 1 | 初始化控制台 | `app.go:startup` 首行 → `seedUserDataDir()` 返回 | ✅ |
| 2 | 檢查 Python 執行環境 | `startServerV2``ensurePythonRuntime()` 返回 | ✅ |
| 3 | 啟動本機伺服器 | `waitHealthy(port, 30s)` 返回 | ✅ |
| 4 | 偵測 Kneron 裝置 | `GET /api/devices` 第一次 response | ✅ |
| 5 | 開啟瀏覽器 | `OpenInBrowser("")` 命令 return | ✅ |
| 6 | 等待 Web UI 連線 | WebSocket hub `OnClientConnected` 首次 | ✅ |
**完全對齊**。Design §8 的訊號來源L361-367和 TDD §3 表格用詞一致。✅
### C-3 Event schema 一致性(發現 3 個小衝突)
Design `startup-progress.md §9`L386-393預設的 event 名:
```
startup:stage {id, state, errorMessage?}
startup:complete / startup:error
```
TDD `startup-pipeline.md §1`L28-84定義的 event 名:
```
startup:progress (StartupProgressEvent: stage, totalStages, labelKey, status, startedAt)
startup:stage-timeout (StartupStageTimeoutEvent: stage, softTimeoutSeconds)
startup:error (StartupErrorEvent: stage, error, cause)
startup:ready (無 payload)
```
**衝突 1**:事件名 `startup:stage` vs `startup:progress` 不一致。**決案:採 TDD 的 `startup:progress`**(更語義化;`stage` 單字太模糊。Design Agent 不必改 startup-progress.md §9備忘性質由 M8-4b / M8-5 執行者以 TDD 為準。
**衝突 2**Design `StageState` 列舉§8 L335-341`pending | running | running-slow | done | failed | skipped`TDD `StartupProgressEvent.Status`§1.1 L41只定義 `pending | running | completed | failed`
差異分析:
- `running-slow` — Design 明確標註「UI 派生狀態」L338 註解),由前端根據 `now - startedAt > 20s` 計算Go 端不需 emit → **無衝突**
- `done` vs `completed` — 語義同,用詞差。**決案Go 端 emit `completed`**,前端 Design class name 用 `done` 即可CSS 層面對應)
- **`skipped` Go 端完全沒有** — 這是 **TDD 側 Minor 問題**,要補
**衝突 3**`soft timeout` 事件名不同。Design §9 預設「前端自己 timer 檢查 20 秒 → 派生 `running-slow`TDD §1.2 定義「Go 端 watcher goroutine emit `startup:stage-timeout`」。兩種實作都可行,**決案以 TDD 為準**(後端 source of truth避免兩邊各自計時導致時間漂移Design 前端僅負責接 `startup:stage-timeout` event 更新 UI。
**小結**:事件協議 Design 預設是草稿TDD 是 ground truthM8-4b/M8-5 執行者以 TDD 為準即可,**Design Agent 無需修 startup-progress.md §9**。但 **TDD 需補 `skipped` status**(見 E 節)。
### C-4 Timeout UI 觸發時機對齊
- **20 秒 soft timeout**Design §3.6L189-205`stage.state === 'running' && (now - stage.startedAt) > 20_000ms`TDD §4 watcher goroutineL332-346`sinceStage > startupSoftTimeout` + `softTimeoutEmitted` flag 確保只 emit 一次。✅ **觸發時機一致**Go emit event → 前端收到顯示 hint
- **60 秒 hard timeout**Design §3.7L207-224「任一階段 failed 或總計 > 60 秒 → Error mode」TDD §4 watcherL325-330`sinceTotal > startupHardTimeout``Fail(cur, ...)` + `emitError(cur, ..., "total-timeout")`。✅ **一致**
### C-5 無障礙可行性
- `role="progressbar"` + `aria-valuemin="0"` + `aria-valuemax="6"` + `aria-valuenow` — vanilla HTML `<section>` 直接標即可 ✅
- `aria-live="polite"` 搭配 `.sr-only` div — Design §6L281已設計vanilla JS `textContent` 更新即可觸發 SR 讀出 ✅
- `⌘0` / `Ctrl+0` focus + `Esc` — Design §6L283vanilla JS `addEventListener('keydown')` 攔截即可 ✅
- `prefers-reduced-motion` — CSS media queryvanilla CSS 支援 ✅
**可行性****100%**。不需引入任何 third-party library。
### C-6 Linux / Toggle OFF 分支(**重要**
Design 規格§4.1 L244-256
- 階段 5`pending → skipped`(不經過 `running`),顯示 ⏭ icon
- 階段 6`pending → running`description 改 manualHint**不套 20 秒 retry hint**
- 當使用者手動點 Open in Browser 並建立 WebSocket → `done`
TDD §3 表L117階段 5「若 `AutoOpenBrowser=false` 則立即 complete 跳過」— **TDD 是 `completed`Design 是 `skipped`,語義不同**。Design 要顯示「跳過(依偏好設定)」⏭ icon不是綠勾 ✅。
TDD 也**沒有處理階段 6 不套 soft timeout 的情境**§4 watcher 對所有階段一視同仁Toggle OFF 時階段 6 會在 20 秒後觸發 `startup:stage-timeout`,前端會顯示「正在重試...」—— 這是錯的,因為在等人為動作。
**兩個問題都是 TDD 側需補**(見 E 節 E-2 / E-3
### C-7 i18n key 30+ 條
Design §7L291-329列出 27 個 i18n keynamespace 一致用 `startup.*`。TDD §2L87-103只列 11 個 key 作為骨架,註明「文案由 Design Spec v2.1 敲定」。
**key 名稱微差**
- Design`startup.panel.title` / TDD`startup.title` — 統一採 **Design 的 `startup.panel.title`**(更完整的 namespace 樹狀結構)
- Design`startup.timeout.message` / TDD`startup.retrying` — 統一採 **TDD 的 `startup.retrying`**(對應 TDD event `startup:stage-timeout` 的 copy更精準
- Design`startup.error.description.timeout` / TDD`startup.error.totalTimeout` — 統一採 **Design 的樹狀版**
這些微差不影響可行性M8-4b/M8-5 執行者統一用 Design §7 清單 + TDD 補上 labelKey path 即可。載入時機Wails 控制台前端 `app.js``main()` 最前呼叫 `i18n/loader.js` 載入 JSON早於 `initStartupPanel()`,順序正確。✅
**可行性****可行**。
### 7 子項總結:**全部可實作**。發現的 3 個技術衝突都在 TDD 側(不在 Design 側)— skipped status、階段 6 不套 soft timeout、`RestartStartupSequence` binding — 由我在 M8-4b 執行前補 TDD 修復。
---
## D. Design v2.1 新增 3 個懸念的技術回答
### D-Q1「正在重試」vs「正在處理中」文案
**技術面****不影響實作**。Go 端只 emit `startup:stage-timeout` event文案在前端 i18n JSON改字面 0 行程式碼成本。
**Architect 決案**:交由 Design 最終定稿即可,我不干涉文案選擇。若要技術建議:採 Design 原提案「正在重試...」—— event 名本身是 `stage-timeout`,副文字用「重試」與 icon ⚠ 搭配更有「系統知道有狀況且在努力」的語感,對齊 Nielsen Norman perceived-performance 原則。**不阻擋**。
### D-Q2 WebSocket 被安全軟體攔截的偵測
**技術面評估****不可行**。
- Windows Defender SmartScreen 攔截瀏覽器連線時Go server 端 `OnClientConnected` 根本不會被呼叫server 無法區分「使用者還沒打開瀏覽器」、「瀏覽器正在載入 Next.js」、「瀏覽器被擋」這三種情境
- 唯一能辨識的訊號是「階段 5 已 complete 但階段 6 > 30 秒未 complete」但這個時間閾值和正常冷啟動悲觀 ~8 秒 Next.js + WS無法可靠區分
- 若硬要辨識需要辨識多種安全軟體的特徵Defender SmartScreen / McAfee / Kaspersky / 企業 MDM過度設計
- **接受 Design 建議**不做特殊偵測Error 說明用通用文案「階段 6 超時,請檢查瀏覽器是否能開啟 http://127.0.0.1:{port}」+ 引導看 log details 即可
**Architect 決案****同意 Design 建議,不做特殊偵測**。M8-5 前端 i18n 的 `startup.error.description.timeout` 文案建議補上「請確認瀏覽器可存取 127.0.0.1:{port}」作為通用提示。
### D-Q3 Retry 按鈕語意:重置整個啟動 vs 重試當前階段(**關鍵技術決策**
**技術面評估****「重置整個啟動流程」可行且建議採用**。
**實作細節**`RestartStartupSequence()` 新 function位於 `startup_pipeline.go`
```go
// RestartStartupSequence 停掉當前 pipeline清理 server 狀態,重新跑 pipeline 從階段 1 開始。
// 只能在 Error state 時呼叫Starting/Running 時 noop
func (a *App) RestartStartupSequence() error {
if a.ctrl.State() != ServerStateError {
return errors.New("RestartStartupSequence only allowed in Error state")
}
// 1. 停掉舊 watcher若還活著
if a.pipeline != nil {
a.pipeline.stopWatcher()
}
// 2. 強制 kill 舊 server process若還存在
// ctrl.Stop() 會走 graceful → 這裡用 ForceKill 因為 server 很可能已處於壞狀態
a.ctrl.ForceKill()
// 3. 重置 ServerController state machine 回 Stopped
a.ctrl.setState(ServerStateStopped, "")
// 4. 重建 StartupPipeline清掉所有 stage state
a.pipeline = NewStartupPipeline(a)
a.pipeline.Start(a.ctx)
// 5. 重跑 ctrl.Start() — 會依序觸發階段 2-6
return a.ctrl.Start()
}
```
**關鍵考量**
1. **階段 1 不需重跑**Wails `OnStartup` 已經跑過,`seedUserDataDir` / `migrateOldDataDirs` 等只需跑一次。**建議Retry 時 `pipeline.Start(ctx)` 後直接 `Complete(1)`**0.0 秒完成,視覺上瞬過)
2. **階段 2 `ensurePythonRuntime()` 重跑成本**:若 Python venv 已在 `<dataDir>/python/` 解壓完成,`ensurePythonRuntime()` 內部 check 應該 < 100ms return Python extract 到一半失敗需重跑成本較高但仍有邊界Architect §11-2 估悲觀 0.5s)。**可接受**
3. **階段 3 server 全砍重起**:必須全砍。舊 server process 若處於半掛狀態port 卡著、goroutine leak保留只會讓新 pipeline 再卡一次。`ForceKill``pickPort` 重新選 port — 但這和第一輪 F-2Restart 強制同 port有衝突需要微調**Retry 情境下 port 可以 fallback**(反正 Error state 下舊 Web UI tab 已顯示 overlay使用者會從新控制台 Open in Browser 開新 tab
4. **階段 6 WebSocket sentinel file 清理**:若 TDD §3 採 sentinel file `<dataDir>/.first-ws-connected`Retry 時必須 **先 `os.Remove()` sentinel file**,否則新階段 6 會瞬間 `completed` 錯誤
5. **Wails binding**:需新增 `RestartStartupSequence() error``app.go` bindings前端 Design §3.7 Retry 按鈕 `onclick` 呼叫 `window.go.main.App.RestartStartupSequence()`
**Architect 決案****可行,採用「重置整個啟動流程」語意**。我會在 TDD `v2/startup-pipeline.md` 新增 §9「Retry 機制」小節落地上述實作細節M8-4b 執行前補。Design Agent **無需**修 startup-progress.md維持 §3.7 Retry 按鈕「重置進度面板,重新跑階段 1」的 wireframe 文字即可。
---
## E. 新發現的 Design ↔ TDD 衝突TDD 側 Minor
### E-1 WebSocket `OnClientConnected` → Wails event 路徑未敲定
- Design `startup-progress.md §9-4`L391明確要求「Go WebSocket hub `OnConnect` 事件中 emit `startup:stage {id: 6, state: 'done'}`
- TDD `startup-pipeline.md §3`L122-132提出兩個選項long-poll endpoint 或 sentinel file**尚未敲定**
- **Architect 補決案****採 sentinel file 方案**long-poll endpoint 要改 server 路由、rg ctx 維護成本較高。server WebSocket hub 的 `OnClientConnected` callback 首次觸發時 `os.WriteFile(<dataDir>/.first-ws-connected, []byte{}, 0o644)`Wails 階段 5 complete 後 goroutine `for { if exists { pipeline.Complete(6); return }; time.Sleep(200ms) }`30 秒逾時 `pipeline.Fail(6, ...)`
- **TDD 需改**`startup-pipeline.md §3` 表格階段 6 列敲定 sentinel 方案(我負責 M8-4b 前補)
### E-2 `skipped` status Go 端未定義
- Design §3.4L165、§4.1L244-248明確需要 `skipped` status階段 5 Toggle OFF 時)
- TDD §1.1 `StartupProgressEvent.Status` 只有 `pending | running | completed | failed`**缺 `skipped`**
- **Architect 補決案**TDD `startup-pipeline.md §1.1` 新增 `"skipped"` 到 Status 枚舉§4 `StartupPipeline` 新增 `Skip(stage int)` method內部 `p.stages[stage].status = "skipped"` + `emitProgress` + 自動進入下一階段(不 `Fail`
- **TDD 需改**§1.1 補 status、§4 補 `Skip()` method我負責 M8-4b 前補)
### E-3 階段 6 `AutoOpenBrowser=false` 時不觸發 soft timeout
- Design §4.1L255「不套 20 秒 retry hint因為是等待人為動作不是系統卡住
- TDD §4 watcher goroutine 對所有階段一視同仁,**未排除階段 6 Toggle OFF 情境**
- **Architect 補決案**TDD §4 watcher 新增條件 `if cur == 6 && !p.app.prefs.OpenBrowserOnStart { continue }`(跳過 soft timeout 檢查hard timeout 仍照常檢查(使用者 60 秒內不點 Open in Browser 仍應進 Error state合理
- **TDD 需改**§4 watcher goroutine 條件補上(我負責 M8-4b 前補)
---
## F. 第二輪新發現問題
### Major阻擋
**無**。
### Minor不阻擋
| # | 位置 | 問題 | 處理 |
|---|-----|-----|------|
| 2-m1 | TDD `startup-pipeline.md §1.1` | `StartupProgressEvent.Status``"skipped"` | 我 M8-4b 前補 |
| 2-m2 | TDD `startup-pipeline.md §3` | 階段 6 WebSocket sentinel 方案未敲定 | 我 M8-4b 前補(採 sentinel file |
| 2-m3 | TDD `startup-pipeline.md §4` + 新增 §9 | watcher 階段 6 Toggle OFF 跳過 soft timeout新增 `RestartStartupSequence()` Retry 小節 | 我 M8-4b 前補 |
---
## G. 第二輪結論
**✅ 通過**需小改TDD 側 3 個 Minor
**Design Spec v2.1 側**
- **Major 2/2 修好**`settings-update.md §2.2` 檔名 + Linux 預設 OFF 全部落地
- **Design 側 Minor 8/8 修好**m-1/m-4/m-5/m-8/m-11/m-12 + M-1/M-2
- **無新發現 Design 側問題**Design Agent **無需再動 v2.1 任何檔案**
**TDD v2.1 側**我自己負責M8-4b 執行前補):
- 第一輪遺留 4 項m-2/m-7/m-9/m-10
- 第二輪新增 3 項2-m1/2-m2/2-m3
- 合計 7 項小改,估計 1-2 小時完成
**R5-E startup-progress.md 通過**Design 定義完整且技術可行,與 TDD startup-pipeline.md 7 成對齊3 處差異全部是 TDD 側可小改。
**3 個 Design v2.1 懸念已技術回答**
- D-Q1 文案不影響技術Design 自由定稿
- D-Q2 安全軟體偵測:不做(過度設計),採通用文案
- **D-Q3 Retry 按鈕語意:採「重置整個啟動流程」**`RestartStartupSequence()` 可行,實作細節見 D-Q3 區塊
**不阻擋進 M8 開發**。三方 v2.1 已收斂。
**下一步**Orchestrator 可將此第二輪結論 + Design v2.1 現況 + TDD 7 項待補 → 呈報使用者最終確認 → 我補 TDD 小改 → 進 M8-1。
**審閱日期**2026-04-14第二輪

View File

@ -0,0 +1,465 @@
# v2.1 — Wails Server Control Panel 設計規格
> 本章對應 R5-1 / R5-2 / R5-3 / R5-4 / R5-5 / R5-5a / **R5-D1 / R5-D3 / R5-E**v2.1 補丁)
> 上層索引:`../design-spec-v2.md`
> 版本:**v2.1** · 更新日期2026-04-14
> 相關:`v2/startup-progress.md`R5-E 階段化啟動進度面板Starting state 時顯示)
---
## 1. 定位與職責
Wails Server Control Panel以下稱「控制台」是 visionA-local 雙擊開啟後看到的**第一個畫面**,也是使用者**唯一可以關 server** 的地方。它的職責:
- Server lifecycle 管理Start / Stop / Restart
- 即時 log 顯示、過濾、複製、匯出
- 一鍵開啟瀏覽器 Web UI
- 顯示 server 狀態port、PID、uptime、version
- 錯誤狀態的視覺呈現與自助排除入口
**控制台不做的事**(與 v1 清單一致、R5 複核):
- 不管 device、model、inference那是 Web UI 的事)
- 不顯示 Mock 模式切換R5-5拿掉 Mock 切換)
- 不提供語言切換(跟隨系統 locale見 §9
- 不提供 Dark Mode 切換(跟隨系統)
---
## 2. 視窗規格
| 項目 | 值 | 說明 |
|------|----|------|
| 預設寬度 | `720 px` | log 一行約 90-100 字元可顯示 |
| 預設高度 | `560 px` | log 區塊可顯示 18-20 行 |
| 最小寬度 | `560 px` | 防止 primary controls 擠壞 |
| 最小高度 | `420 px` | log 區最少 6 行 |
| 可調整大小 | 是 | 拖角落縮放 |
| 最大化 | 保留 | |
| 最小化 | 保留 | |
| 置頂 | 否 | |
| Title bar | 原生 | 不做自訂 title bar |
| 視窗標題 | `visionA-local · Server Control` | |
| 視窗 icon | 沿用 `frontend/icon.png` | |
| 初始位置 | 螢幕中央(首次)/上次位置(後續) | 記憶寫到 `~/Library/Application Support/visiona-local/control-panel.json` |
---
## 3. 佈局 Wireframe最終版
```
┌───────────────────────────────────────────────────────────────────┐
│ ● visionA-local · Server Control [ ][ □ ][ × ]│ ← Title bar原生
├───────────────────────────────────────────────────────────────────┤
│ │
│ ┌────┐ visionA-local v0.1.0 │
│ │LOGO│ ● Running · Browser opened │ ← Header
│ └────┘ Port: 3721 Uptime: 00:12:43 PID: 45821 │
│ │
├───────────────────────────────────────────────────────────────────┤
│ │
│ [ 🌐 Open in Browser ] [ Start ] [ ⋯ Manage ▾ ] │ ← Primary controls
│ │
│ ☑ Follow tail ☑ Show timestamps 🔍 [ Filter ... ] │ ← Log controls
│ │
├───────────────────────────────────────────────────────────────────┤
│ 10:23:41 INFO HTTP server listening on 127.0.0.1:3721 │
│ 10:23:41 INFO wails ipc ready │
│ 10:23:42 INFO device scan: found 1 Kneron KL520 │
│ 10:23:43 INFO GET /api/devices 200 (4ms) │ ← Log panel
│ 10:23:45 INFO GET /api/models 200 (2ms) │ (等寬字體
│ 10:23:58 WARN python sidecar restart (attempt 1) │ 可捲動
│ 10:23:59 INFO python sidecar ready │ 等級著色)
│ 10:24:12 INFO inference session start: classification │
│ ... │
│ │
│ │
├───────────────────────────────────────────────────────────────────┤
│ [ Clear ] [ Copy ] [ Export log ] [ Open log folder ] │ ← Log actions
│ │
│ Lines: 142 / 2000 ⚠ Closing this window will stop │ ← Footer
│ the server. │
└───────────────────────────────────────────────────────────────────┘
```
**和 v1 分析稿(`design-analysis-round2-refactor.md` §B2的差異**
- 拿掉 log 控制列上方「Mock 模式切換」區v1 分析稿其實沒有這個,只是 `controlPanelSection` 清單有——R5-5a 確認砍)
- Primary controls 從 4 顆精簡為 3 顆(`Start` / `Stop` / `Restart` 三顆合併為 `Start` + overflow menu
- Header 狀態列文字擴充,加入 "Browser opened"(首次 auto-open 後的視覺回饋,見 §5
- Footer 新增「關閉視窗會停止 server」持久提示R5-2 明確決策,用持久文字取代每次彈對話框)
---
## 4. 元件清單
### 4.1 Header高度 80 px
| 元素 | 類型 | 尺寸 / 位置 | 狀態 | 備註 |
|------|------|------------|------|------|
| Brand logo | `<img>` | 40×40 px左側 padding 16 | static | 沿用 `frontend/icon.png` |
| Product name | `<h1>` | 16 px / SemiBold | static | 文字:`visionA-local` |
| Version tag | `<span>` | 12 px / muted-foreground | static | 文字:`v{major}.{minor}.{patch}`,右上角 |
| Status indicator | `<span>` + `<svg>` | 圓點 8 px | 見 §5 狀態機 | 顏色綁 semantic tokens |
| Status text | `<span>` | 14 px / Medium | 見 §5 | 例:`Running · Browser opened` |
| Server meta | `<dl>` | 12 px / muted | 6 個欄位 | Port / Uptime / PIDUptime 每秒刷新) |
### 4.2 Primary controls高度 48 px
| 按鈕 | 變體 | 大小 | 啟用條件 | 備註 |
|------|------|------|---------|------|
| **Open in Browser** | `primary` filled | `md` | 僅 `Running` | 最左、最顯眼,附 🌐 icon |
| **Start** | `outline` | `md` | `Stopped` / `Error` | 附 ▶ icon |
| **Manage ▾** | `outline` + dropdown | `md` | `Running` | 展開後包含 `Stop server``Restart server` |
**Manage overflow menu 內容**
```
┌──────────────────────────┐
│ Stop server │ ← destructive 色彩提示
│ Restart server │ ← 普通
├──────────────────────────┤
│ Open log folder │ ← 重複項(方便直接存取)
└──────────────────────────┘
```
**為什麼 Primary CTA 是 "Open in Browser"**Design Rationale
- R5-4 決定首次啟動會自動開瀏覽器一次
- 使用者後續可能關 browser tab環境整理、記憶體、誤關
- 「關了想重開」是日常第二高頻操作(第一高頻是雙擊 app 本身,已被 auto-open 覆蓋)
- Start/Stop/Restart 只在出事時才點
- 結論:**Open in Browser 保留為 primary**(沿用第一輪 B3 提案)
**Stop 放進 overflow 的原因**
- 避免誤按導致 server 中斷 + Web UI 爆掉
- Stop 放在 dropdown 多一個「點擊 > 選擇」保護,等效輕度確認
- 不做「你確定要 Stop」modal減少 UX 摩擦
### 4.3 Log controls高度 40 px
| 元素 | 類型 | 預設 | 行為 |
|------|------|------|------|
| `Follow tail` | `<checkbox>` | ✅ ON | 使用者往上捲動時**自動關閉**,捲到最底自動重啟。附提示 `Jump to latest` pill |
| `Show timestamps` | `<checkbox>` | ✅ ON | 關閉後 log 行去掉時間戳 |
| `Filter` | `<input type="search">` | 空 | 即時字串過濾,無 regex`⌘F` / `Ctrl+F` 聚焦 |
### 4.4 Log panel高度剩餘 flex-grow
| 屬性 | 值 |
|------|---|
| 字體 | `font.mono`SF Mono / Consolas / Menlo |
| 字級 | `12 px` |
| 行高 | `1.5` |
| 背景 | `color.surface-1`Light`oklch(0.99 0 0)`Dark`oklch(0.18 0 0)` |
| 選取背景 | `color.primary/20` |
| **最大行數** | **2000**ring buffer超過舊的 drop對齊 TDD v2 Go server 常數,~400KB 記憶體可忽略) |
| 寫檔 | **無**TDD v2 採 in-memory ring bufferlog 不落地;使用者若需保存用 `Export log` 手動匯出) |
| 滑入動畫 | 60 ms fade-in`prefers-reduced-motion` 時關閉) |
| 選取冰結 | 使用者拖選文字時自動暫停 auto-scroll |
**為什麼取消落地寫檔與 rotate**
- TDD v2 決定 Go server 採 in-memory ring buffer容量 2000 行)統一管理 log**不落地滾動檔案**
- rotate 7 天 / 10MB 需要 `lumberjack.v2` 或自刻定時掃描 + size 比較,非 M8 scope 且會增加技術債
- 使用者如需保存 log → `Export log` 按鈕§4.5)原生 save dialog 匯出當下 buffer 內容
- 使用者如需檢視/清理 → `Open log folder` 保留,指向 `<dataDir>/logs/`(若未來重新啟用落地再用;目前該資料夾可能為空)
- 未來若有落地需求 → 放 M9+ 迭代,不影響 v2.1 交付
**等級著色**(和 Web UI semantic token 一致):
| Level | Token | Light 範例 | Dark 範例 |
|-------|-------|-----------|----------|
| DEBUG | `color.muted-foreground` | `#6b7280` | `#9ca3af` |
| INFO | `color.foreground` | `#111827` | `#e5e7eb` |
| WARN | `color.warning` | `#b45309` | `#fbbf24` |
| ERROR | `color.destructive` | `#b91c1c` | `#f87171` |
### 4.5 Log actions高度 40 px
| 按鈕 | 類型 | 功能 |
|------|------|------|
| `Clear` | `ghost` small | 清空畫面 log不動檔案二次確認toast「Log cleared」5 秒內可 undo |
| `Copy` | `ghost` small | 複製全部可見 log 到剪貼簿首次點擊時提示「Log 可能包含檔名與裝置資訊」一次 |
| `Export log` | `ghost` small | 原生 save dialog預設檔名 `visiona-local-{yyyyMMdd-HHmmss}.log` |
| `Open log folder` | `ghost` small | 呼叫 OS 開 `~/Library/Application Support/visiona-local/logs/` |
### 4.6 Footer高度 32 px
| 元素 | 位置 | 文字 / 樣式 |
|------|------|-----------|
| 行數統計 | 左 | `Lines: {current} / 2000`12px muted |
| 關閉提示 | 右 | `⚠ Closing this window will stop the server.`12px muted |
**持久提示為什麼不彈 modal**R5-2 解釋):
- 使用者已明確決策「關閉 = 結束 server」
- 每次關都彈 modal 只會煩,且使用者按過幾次就會盲目點「確定」失去意義
- 持久 footer 文字是「被動告知」而非「主動打斷」,符合 Jakob Nielsen 錯誤預防原則
- 使用者如果真的不想關,看到 footer 提示就會改按最小化
- 若使用者仍誤關,下次開啟 (R5-4 自動起 server + 自動開瀏覽器) 只需 3-5 秒就回到原狀,損失可控
---
## 5. Server 狀態機(五態視覺化)
### 5.1 狀態定義v2.1 修訂)
| State | 觸發條件 | 持續時間 |
|-------|---------|---------|
| `Starting` | 控制台剛開啟 / 使用者按 Start / Restart 過程中 | **通常 4-15 秒,上限 60 秒**R5-E1 |
| `Running` | 6 階段全部完成(含 WebSocket 連上R5-E6 | 主要 state |
| `Stopping` | 使用者按 Stop / 視窗關閉中 | 通常 <2 |
| `Stopped` | Stop 完成、尚未 Restart | 空 state |
| `Error` | **啟動階段**:任一階段超時 20 秒進 Retry 提示;**總計超過 60 秒**R5-E4仍未就緒 → Error<br>**運行階段**`/api/health` 連續失敗達閾值 / sidecar crash 超過 auto-restart 上限 | 停留直到使用者介入 |
### 5.2 視覺對照表v2.1 修訂)
| State | 圓點顏色 | Icon | Status text 範例 | 附加元素 |
|-------|---------|------|----------------|---------|
| **Starting** | `color.warning` 琥珀 | 旋轉 spinner | `Starting · Stage {n}/6` | **log panel 上方浮出「啟動進度面板」**(見 `v2/startup-progress.md`<br>Primary controls 全部 disabled |
| Running | `color.success` 綠 | — | `Running``Running · Browser opened`Toggle ON 首次顯示 10 秒) | 啟動進度面板 fade-outOpen in Browser enabled |
| Running自動開瀏覽器瞬間| `color.success` 綠 | → 淡入 ✓ icon 2 秒 → fade out | `Running · Browser opened` 持續 10 秒後自動變回 `Running` | — |
| Stopping | `color.warning` 琥珀 | 旋轉 spinner | `Stopping...` | 所有 primary controls disabled |
| Stopped | `color.muted-foreground` 灰 | — | `Stopped` | 只有 `Start` 按鈕可按 |
| Error | `color.destructive` 紅 | ⚠ | `Error: {簡短原因}` | **見 §6 錯誤面板**;若從啟動階段進入 Error啟動進度面板切換為 Error 狀態(見 `startup-progress.md §5` |
### 5.3 狀態轉場動畫
- 圓點顏色過渡300 ms ease-out
- Spinner 旋轉1 s linear infinite
- `Running · Browser opened` 出現fade + slide-in-left 200 ms停留 10 秒fade-out 200 ms
- `prefers-reduced-motion: reduce` → 全部動畫降為 0 ms 跳變
---
## 6. Error State 面板R5 共識)
當 Server 進入 `Error` 時,控制台 log panel 上方(介於 log controls 和 log panel 中間)**浮出一個 error banner**log 面板不消失。
### 6.1 Wireframe
```
├───────────────────────────────────────────────────────────────────┤
│ ☑ Follow tail ☑ Show timestamps 🔍 [ Filter ... ] │
├───────────────────────────────────────────────────────────────────┤
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ ⚠ Server failed to start │ │
│ │ │ │
│ │ Python sidecar exited with code 1 after 3 retries. │ │
│ │ Last error: ModuleNotFoundError: kneron_plus │ │
│ │ │ │
│ │ [ Restart Server ] [ View log details ↓ ] [ Report... ] │ │
│ └───────────────────────────────────────────────────────────────┘ │
├───────────────────────────────────────────────────────────────────┤
│ 10:24:12 ERROR python sidecar exited code=1 │ ← Log panel 照常顯示
│ 10:24:13 ERROR ... │
│ ... │
├───────────────────────────────────────────────────────────────────┤
```
### 6.2 元件
| 元素 | 類型 | 細節 |
|------|------|------|
| Banner 容器 | `<div role="alert">` | 背景 `color.destructive/10`,邊框 1px `color.destructive/30`,圓角 `radius.md`padding 16 |
| 警告 icon | `<svg>` | 20×20`color.destructive` |
| 標題 | `<strong>` | 14 px SemiBold`color.destructive` |
| 說明 | `<p>` | 13 px`color.foreground`,最多 2 行,溢出 `…` |
| `Restart Server` | Button `primary` `sm` | 點擊 → 呼叫內部 restartbanner 轉為 Starting state |
| `View log details ↓` | Button `ghost` `sm` | 點擊 → 自動捲動 log panel 到最後一條 ERROR 行並 flash 2 次 |
| `Report...` | Button `ghost` `sm` | **【hold】** 現階段**先不實作**。待 PM 提供 GitHub Issue repo URL 後再恢復。Design 原意:開預設瀏覽器到 GitHub Issue 新增頁面,預填錯誤摘要 + 環境資訊version、OS、最後 20 行 log**不含檔名 / 裝置 serial** |
**R5-D1 落地OS 原生通知並存)**
Error state 進入時,除了控制台 log panel 上方的 Error banner`role="alert"` 由 Wails WebView 內部顯示)**另外發送一次 OS 原生 non-blocking 通知**。這是 R5-D1 使用者決策:控制台可能被最小化、或在另一個桌面 / 虛擬桌面,使用者不一定會看到 bannerOS 通知作為次要冗餘提醒仍有價值。
| 平台 | 通知機制 | Fallback |
|------|---------|---------|
| macOS | `osascript -e 'display notification "..." with title "visionA-local"'`toast 非 dialog | — |
| Windows | `wailsRuntime.SendNotification`(優先) | `msg *` 命令列 |
| Linux | `notify-send` | `zenity --notification` |
**行為細節**
- 通知內容:`標題 = visionA-local Server Error` / `內文 = {error.title}: {error.description 前 60 字}`
- **non-blocking**:不阻塞 UI不彈 modal與先前 v1 的 `showNativeError()`(給 startup 致命錯誤用的 modal dialog區分
- **不重複發**:同一次 Error state 只發一次通知,使用者按 Restart Server 或 State 變回 Starting 後才重置「本次已發」flag
- **技術實作**:新增 Go 檔案 `visiona-local/notify.go`,函式 `sendCrashNotification(title, body string) error`;對應 TDD v2 `control-panel.md §4.7`
### 6.3 Dismiss 條件
Banner **不可手動關閉**(避免使用者忽略問題)。只有下列條件自動消失:
- 使用者按 `Restart Server` 且 server 成功進入 `Running`
- 使用者手動修復環境後按 Start 成功
---
## 7. 啟動行為(對應 R5-4 / R5-D3 / R5-E
### 7.1 預設流程v2.1 修訂)
**v2.1 重要變更**Starting 狀態下控制台顯示**階段化啟動進度面板**(見 `v2/startup-progress.md`),不是只有一顆 spinner。下述「step」對應進度面板的 6 個階段。
```
1. 使用者雙擊 visionA-local.app
2. 控制台視窗開(螢幕中央 / 上次位置)
3. 控制台進入 Starting 狀態log panel 上方顯示「啟動進度面板」
→ 階段 1初始化控制台
→ 階段 2檢查 Python runtime 與驅動
→ 階段 3啟動本機伺服器等 /api/health 200
→ 階段 4偵測 Kneron 裝置
4. Server ready階段 3 完成訊號 = /api/health 200
5. 【每次 / Settings 為 ONmacOS/Windows 預設Linux 預設 OFF
階段 5「開啟瀏覽器」觸發 OS open browser
- macOS: `open http://127.0.0.1:3721/`
- Windows: `start http://127.0.0.1:3721/`
- Linux: `xdg-open http://127.0.0.1:3721/`
【Toggle OFF 時】階段 5 標記為「跳過(依偏好設定)」,不執行 OS open但仍推進
6. 階段 6等待 Web UI 連線
(等 WebSocket hub 收到第一個 client 連線R5-E6 決策)
【Toggle OFF 時】此階段改為「等待使用者手動點擊『在瀏覽器開啟』」
7. 所有 6 階段完成
→ 啟動進度面板淡出fade-out 200 ms
→ Status: Running
→ Status text 顯示 `Running · Browser opened` 10 秒Toggle ON 時)
或純 `Running`Toggle OFF 時,使用者尚未手動 Open
控制台留在背景(不最小化、不關閉)
8. 瀏覽器 tab 進入 Next.js First-Run wizard見 v2.4
```
**R5-D3 重點****每次**啟動(每次 Wails App process 新啟動)都會跑完整 6 階段流程並觸發 OS open不是只有首次。**Restart Server**(同一個 Wails process 內重啟 server不會重開瀏覽器 tab — 由 Offline Overlay 的自動重連處理(見 `v2/server-offline-overlay.md`)。
### 7.2 視覺回饋
第 6 步的 `Running · Browser opened` 是使用者看到控制台第一個確認 server 就緒的訊號。具體視覺:
- Status dot 綠色
- Status text 後方 fade-in 一個 ✓ icon`color.success`
- Text 改為 `Running · Browser opened`
- 10 秒後 ✓ icon 淡出text 縮為 `Running`
### 7.3 例外情境
| 情境 | 控制台行為 |
|------|----------|
| Server `Starting` 超過 5 秒 | 進入 Error state見 §6**不開瀏覽器** |
| Port 3721 被佔 | Server fallback 到 3722 / 3723Header 顯示 `Port: 3722 (default 3721 in use)`,瀏覽器開的 URL 同步換 |
| Settings「自動開瀏覽器」= OFF | Server `Running` 後不做 auto-open使用者需手動點 `Open in Browser` |
---
## 8. 深色模式處理
控制台深色模式**與 Web UI 同步**,機制:
- 讀取 OS 偏好:控制台是 Wails WebView直接用 CSS `prefers-color-scheme`
- CSS 變數切換:和 `frontend/src/app/globals.css` 用一樣的 `:root` / `[data-theme='dark']` block
- 不提供手動切換v1 決策延續)
**Dark 下額外考量**
- Log panel 背景 `oklch(0.18 0 0)`(比 surface 再暗 5%,模仿 terminal
- ERROR 紅色在 dark 下用 `oklch(0.72 0.19 25)`(避免過亮刺眼)
- 圓點狀態色全部用 dark variant確保 4.5:1 對比R4-3 降為盡力而為,但狀態色這種 critical 信號仍維持嚴格)
---
## 9. i18n key 清單(新元件)
控制台文字走 `desktop-control` namespace**獨立於** Next.js Web UI 的 i18n 檔(但抽自同一份辭典,避免兩處維護)。
### 9.1 新增 keyzh-TW / en
| Key | zh-TW | en |
|-----|-------|----|
| `control.title` | visionA-local · 伺服器控制台 | visionA-local · Server Control |
| `control.status.starting` | 啟動中... | Starting... |
| `control.status.running` | 執行中 | Running |
| `control.status.runningBrowserOpened` | 執行中 · 已開啟瀏覽器 | Running · Browser opened |
| `control.status.stopping` | 停止中... | Stopping... |
| `control.status.stopped` | 已停止 | Stopped |
| `control.status.error` | 錯誤:{reason} | Error: {reason} |
| `control.meta.port` | 連接埠 | Port |
| `control.meta.portFallback` | 連接埠:{port}(預設 {default} 被佔用) | Port: {port} (default {default} in use) |
| `control.meta.uptime` | 執行時間 | Uptime |
| `control.meta.pid` | 程序 ID | PID |
| `control.meta.version` | 版本 | Version |
| `control.action.openBrowser` | 在瀏覽器開啟 | Open in Browser |
| `control.action.start` | 啟動 | Start |
| `control.action.manage` | 管理 | Manage |
| `control.action.stopServer` | 停止伺服器 | Stop server |
| `control.action.restartServer` | 重新啟動伺服器 | Restart server |
| `control.log.followTail` | 自動跟隨最新 | Follow tail |
| `control.log.showTimestamps` | 顯示時間戳 | Show timestamps |
| `control.log.filterPlaceholder` | 過濾 log... | Filter... |
| `control.log.jumpToLatest` | 跳到最新 | Jump to latest |
| `control.log.clear` | 清空 | Clear |
| `control.log.clearToast` | 已清空 log可復原 | Log cleared (undo) |
| `control.log.copy` | 複製 | Copy |
| `control.log.copyPrivacyHint` | Log 可能包含檔名與裝置資訊,請注意分享對象 | Log may contain filenames and device info. Share with care. |
| `control.log.export` | 匯出 log | Export log |
| `control.log.openFolder` | 開啟 log 資料夾 | Open log folder |
| `control.log.lines` | 行數:{current} / {max} | Lines: {current} / {max} |
| `control.footer.closeWarning` | ⚠ 關閉此視窗會停止伺服器 | ⚠ Closing this window will stop the server |
| `control.error.title` | 伺服器無法啟動 | Server failed to start |
| `control.error.description` | {具體原因} | {reason} |
| `control.error.restartButton` | 重新啟動伺服器 | Restart Server |
| `control.error.viewLogDetails` | 檢視 log 詳情 | View log details |
| `control.error.reportButton` | 回報問題... | Report... |
### 9.2 刪除 key從現有 Next.js i18n 砍)
`v2/source-selector-update.md §3.2`
---
## 10. 無障礙考量
| 項目 | 設計 |
|------|------|
| Keyboard navigation | 所有 interactive 元素 `tabindex` 合理序列Open in Browser → Start/Manage → Follow tail → Show timestamps → Filter → (log panel 可選取) → Clear → Copy → Export → Open folder |
| Focus ring | 沿用 Web UI token `ring.2 · color.primary`2 px outline-offset |
| Keyboard shortcut | `⌘F` / `Ctrl+F` 聚焦 filter`⌘C` / `Ctrl+C` 複製選取 log`⌘W` / `Ctrl+W` 關視窗R5-2結束 server |
| `⌘Q` | macOS 原生結束 app停 server + quit |
| Screen reader | Status indicator `<span role="status" aria-live="polite">`Error banner `<div role="alert">`log panel `<output aria-live="polite" aria-atomic="false">`(新行 append |
| ARIA label | 所有 icon button 有 `aria-label`(例如 Follow tail checkbox 的 trailing spinner 有 `aria-label="Auto-scrolling enabled"` |
| 色彩對比 | Status dot / ERROR level log / Error banner 強制 ≥ 4.5:1即使 A11y 整體降級為「盡力而為」critical 信號不妥協) |
| Reduced motion | `prefers-reduced-motion: reduce` → 關閉 spinner 旋轉(改為靜態點)、關閉 log 滑入動畫、關閉 Browser opened fade |
| 字級可縮放 | 使用 `rem` 而非 `px` 定義字級,支援 OS 字級偏好 |
---
## 11. 與 v1`design-analysis-round2-refactor.md`)的差異
| 面向 | v1 分析稿 | v2 正式規格 |
|------|----------|------------|
| 視窗職責 | 三方尚在討論 | 確定為雙 UIR5-1 |
| 關閉行為 | 待 D1 決策 | **關閉 = 結束 server**footer 持久提示R5-2 |
| Tray | 建議復活 tray | **不做**R5-3 |
| 首次啟動 | 建議自動開瀏覽器 | 採納自動開瀏覽器R5-4 |
| Primary controls | 4 顆 | 3 顆Stop/Restart 併入 Manage 下拉) |
| Header 狀態列 | 固定 `Running` | 首次啟動後動態 `Running · Browser opened` 10 秒 |
| Error 狀態視覺 | 未設計 | 新增 Error banner§6 |
| Mock 切換 | 未納入 scope | **明確砍除**R5-5a |
| Log 上限 | 1000 行 | 1000 行(維持) |
| Log 寫檔 | 7 天 / 10MB rotate | 維持 |
| 語系 | 跟隨系統 | 跟隨系統(維持) |
---
## 12. v2 → v2.1 Diff2026-04-14
| # | 位置 | v2 | v2.1 | 來源 |
|---|------|----|----|------|
| 1 | §4.4 Log panel 最大行數 | 1000 行 | **2000 行**(對齊 TDD v2 ring buffer | Architect Review Minor m-1 |
| 2 | §4.4 Log 寫檔 | rotate 7 天 / 10 MB | **無落地寫檔**in-memory ring buffer使用者透過 Export log 手動匯出) | Architect Review Minor m-12 |
| 3 | §4.6 Footer 行數顯示 | `Lines: {current} / 1000` | `Lines: {current} / 2000` | Architect Review Minor m-1 |
| 4 | §5 狀態機 | Starting 只有 spinner1-5 秒 | Starting 顯示**階段化啟動進度面板**4-15 秒(上限 60 秒R5-E1 | R5-E |
| 5 | §6.2 Error banner | `Report...` 正常按鈕 | **【hold】** 待 PM 提供 GitHub Issue repo URL 後再恢復 | Architect Review Minor m-11 / G-3 |
| 6 | §6.2 新增 | — | **R5-D1 OS 原生通知並存**Error state 發 non-blocking toast notification不是 modal | R5-D1 / Architect Review Minor m-4 |
| 7 | §7.1 第 5 步 | 「首次 / Settings 為 ON」 | **「每次 / Settings 為 ON」**,新增 Linux 預設 OFF 說明,流程改為 6 階段化 | R5-D3 + R5-E |
| 8 | §7.1 新增 | — | 引用新檔 `v2/startup-progress.md`R5-E 階段化啟動進度面板) | R5-E |
---
**下一步**:交 M8-5 Frontend Agent 實作Wails 控制台 + 啟動進度面板),交 Reviewer 審查 control-panel.md + startup-progress.md 整體一致性。

View File

@ -0,0 +1,340 @@
# v2.4 — First-Run 流程重定義
> 本章對應 R5-4首次啟動自動開瀏覽器+ R5-5aMock 模式完全砍除)。
> 取代 v1 `spec/04-first-run.md` 的「歡迎 → 模式選擇 → 硬體偵測」三步流程。
> 上層索引:`../design-spec-v2.md`
---
## 1. 背景與變更點
v1 First-Run 流程:
```
雙擊 app → Wails splash → Next.js 主 UI 打開 First-Run wizard
Step 1: 歡迎 / 產品介紹
Step 2: 模式選擇(真實硬體 / Mock
Step 3: 硬體偵測
→ Dashboard
```
R5 兩個決策直接改寫這個流程:
1. **R5-5a 完全砍除 Mock 模式** → Step 2 整步消失
2. **R5-4 首次啟動自動開瀏覽器** → 雙擊 app 看到的是 Wails 控制台First-Run wizard 跑在瀏覽器裡(不是 Wails 裡)
結果First-Run wizard 從**三步**變成**兩步**且入口從「Wails splash」變成「瀏覽器」。
---
## 2. 新的 First-Run 流程圖
```
┌──────────────────────────────────────────────────────────────────┐
│ 新 First-Run 完整流程 │
└──────────────────────────────────────────────────────────────────┘
使用者雙擊 visionA-local.app
┌────────────────────────────┐
│ Wails Server Control │
│ Panel 開啟 │
│ 狀態: Starting... │
└────────────┬───────────────┘
│ 內部自動 startServer()
┌────────────────────────────┐
│ Server ready │
│ 狀態: Running │
└────────────┬───────────────┘
│ [Settings.autoOpenBrowser === true]
│ (預設 ON首次啟動一定 ON)
┌────────────────────────────┐
│ 控制台呼叫 OS open │
│ http://127.0.0.1:3721/ │
└────────────┬───────────────┘
┌────────────────────────────────────────────────────────────┐
│ 瀏覽器打開 → Next.js 啟動 │
│ 檢查 localStorage.firstRunCompleted │
│ │
│ ├── false → 導向 /onboarding │
│ └── true → 導向 / (Dashboard) │
└────────────────────────────────────────────────────────────┘
│ firstRunCompleted === false
┌────────────────────────────────────────────────────────────┐
│ First-Run Wizard │
│ │
│ Step 1: 歡迎 ─────────────────────────┐ │
│ ┌──────────────────────────────────┐ │ │
│ │ 歡迎使用 visionA-local │ │ │
│ │ │ │ │
│ │ 這是一個本機 Edge AI 推論工具 │ │ │
│ │ 你可以用它在自己的電腦上跑模型 │ │ │
│ │ │ │ │
│ │ [Gatekeeper 警告說明] │ │ │
│ │ │ │ │
│ │ [ 下一步 ] │ │ │
│ │ [ 略過 ] │ │ │
│ └──────────────────────────────────┘ │ │
│ ▼ │
│ Step 2: 硬體偵測 ────────────────── │
│ ┌──────────────────────────────────┐ │
│ │ 正在掃描 Kneron 裝置... │ │
│ │ │ │
│ │ ┌─ 偵測結果 ────────────────┐ │ │
│ │ │ 情境 A: 找到 1+ 裝置 │ │ │
│ │ │ ✓ Kneron KL520 x1 │ │ │
│ │ │ [ 進入 Dashboard ] │ │ │
│ │ │ │ │ │
│ │ │ 情境 B: 未找到裝置 │ │ │
│ │ │ ⚠ 未偵測到 Kneron 裝置 │ │ │
│ │ │ 請插入 Kneron 裝置後 │ │ │
│ │ │ 重新整理,或略過直接進入 │ │ │
│ │ │ Dashboard可隨時重掃 │ │ │
│ │ │ [ 重新掃描 ] [ 略過 ] │ │ │
│ │ └─────────────────────────────┘ │ │
│ └──────────────────────────────────┘ │
│ │ │
│ ▼ │
│ firstRunCompleted = true │
│ │ │
│ ▼ │
│ Dashboard │
└────────────────────────────────────────────────────────────┘
```
---
## 3. Step-by-Step 規格
### 3.1 Step 1 — 歡迎
**目的**:介紹產品、安撫 Gatekeeper 警告焦慮、讓使用者按一個按鈕就進入下一步。
**結構**
```
┌──────────────────────────────────────────────────┐
│ │
│ [LOGO] │
│ │
│ 歡迎使用 visionA-local │
│ │
│ 這是一個本機 Edge AI 推論工具, │
│ 你可以在自己的電腦上跑模型,完全離線。 │
│ │
│ ──────────────────────────────────── │
│ │
│ 🔒 你可能剛才看到系統安全性警告? │
│ │
│ 因為這是內部工具,未購買 Apple/Microsoft │
│ 憑證簽章。你看到的警告是正常的, │
│ 我們已經驗證此版本的完整性。 │
│ │
│ [ 下一步 ] [ 略過 Onboarding ] │
│ │
└──────────────────────────────────────────────────┘
```
**元件**
- Logo80×80
- 標題 `<h1>` 28 px Bold
- 簡介 `<p>` 15 px
- Gatekeeper 安撫段 `<section>`(灰底卡片 `color.muted/40`
- `[ 下一步 ]` Button primary lg
- `[ 略過 Onboarding ]` Button ghost sm右下角
**「略過」行為**:直接設 `firstRunCompleted = true` 並跳 Dashboard不經過 Step 2。
### 3.2 Step 2 — 硬體偵測
**目的**:掃描 Kneron 裝置,讓使用者知道硬體 ready 與否,但 **不強迫** 插裝置。
**結構**(含三種子狀態):
#### 3.2.1 Scanning 子狀態(進入 Step 2 的首個瞬間)
```
┌──────────────────────────────────────────────────┐
│ │
│ [LOGO] │
│ │
│ 硬體偵測 │
│ │
│ ⟳ 正在掃描 Kneron 裝置... │
│ │
│ (平均 2-5 秒) │
│ │
└──────────────────────────────────────────────────┘
```
#### 3.2.2 Found 子狀態(找到 ≥ 1 個裝置)
```
┌──────────────────────────────────────────────────┐
│ │
│ ✓ 偵測到 Kneron 裝置 │
│ │
│ ┌────────────────────────────┐ │
│ │ Kneron KL520 · USB 001 │ │
│ │ 韌體 v2.1.0 │ │
│ └────────────────────────────┘ │
│ │
│ 偵測到 1 個裝置,已就緒 │
│ │
│ [ 進入 Dashboard ] [ 重新掃描 ] │
│ │
└──────────────────────────────────────────────────┘
```
#### 3.2.3 Not Found 子狀態R5-5a 新行為:沒找到就空白)
```
┌──────────────────────────────────────────────────┐
│ │
│ ⚠ 未偵測到 Kneron 裝置 │
│ │
│ ┌────────────────────────────────────┐ │
│ │ 可能的原因: │ │
│ │ • 裝置尚未插入 USB │ │
│ │ • 驅動程式未安裝 │ │
│ │ • 裝置被其他程式占用 │ │
│ └────────────────────────────────────┘ │
│ │
│ 你可以現在插入裝置後「重新掃描」, │
│ 或略過直接進入 Dashboard稍後 │
│ 從 Devices 頁面再掃描。 │
│ │
│ [ 重新掃描 ] [ 略過並進入 Dashboard ] │
│ │
└──────────────────────────────────────────────────┘
```
**R5-5a 的關鍵決策**:沒插硬體**不會**停在這個畫面逼使用者插,使用者可以按「略過並進入 Dashboard」Dashboard / Devices 頁面自己會顯示「無裝置」空狀態。
### 3.3 Step 2 的「進入 Dashboard」規則
| 情境 | 按鈕 | 行為 |
|------|------|------|
| Found | `[ 進入 Dashboard ]` primary | `firstRunCompleted = true` → Dashboard |
| Not Found | `[ 略過並進入 Dashboard ]` secondary | 同上 |
| Found但使用者按 `[ 重新掃描 ]` | — | 重跑掃描,回到 Scanning 子狀態 |
| Not Found使用者插入裝置後按 `[ 重新掃描 ]` | — | 重跑掃描,成功的話進入 Found 子狀態 |
---
## 4. 「沒有 Mock 的替代心智模型」
R5-5a 砍掉 Mock 後,使用者如果沒插硬體就**什麼都看不到推論結果**。這對「想先玩玩看」的新手是個空狀態問題。
**設計回應**
- Dashboard 空狀態:顯示「插入 Kneron 裝置開始推論」的**友善引導**,而不是冷冰冰的「無資料」
- Devices 頁面空狀態:顯示「如何取得 Kneron 裝置」的連結 + 「重新掃描」按鈕
- Workspace 入口如果沒有裝置sidebar 的 Workspace 項目 **disabled** 並 tooltip `請先在 Devices 頁面連接裝置`
這些空狀態細節沿用 v1 `spec/08-states.md`,但**把原本寫「或切換到 Mock 模式試用」的句子全部刪除**。
---
## 5. i18n key 變動
### 5.1 新增 keyzh-TW / en
| Key | zh-TW | en |
|-----|-------|----|
| `firstRun.step1.title` | 歡迎使用 visionA-local | Welcome to visionA-local |
| `firstRun.step1.description` | 這是一個本機 Edge AI 推論工具,你可以在自己的電腦上跑模型,完全離線。 | A local edge AI inference tool. Run models on your own computer, fully offline. |
| `firstRun.step1.gatekeeperTitle` | 你可能剛才看到系統安全性警告? | Did you see a system security warning? |
| `firstRun.step1.gatekeeperDescription` | 因為這是內部工具,未購買 Apple/Microsoft 憑證簽章。你看到的警告是正常的,我們已經驗證此版本的完整性。 | This is an internal tool without Apple/Microsoft code signing certificates. The warning is expected, and this version has been verified for integrity. |
| `firstRun.step1.next` | 下一步 | Next |
| `firstRun.step1.skip` | 略過 Onboarding | Skip onboarding |
| `firstRun.step2.title` | 硬體偵測 | Hardware detection |
| `firstRun.step2.scanning` | 正在掃描 Kneron 裝置... | Scanning for Kneron devices... |
| `firstRun.step2.found` | 偵測到 Kneron 裝置 | Kneron device detected |
| `firstRun.step2.foundSummary` | 偵測到 {count} 個裝置,已就緒 | {count} device(s) detected and ready |
| `firstRun.step2.notFound` | 未偵測到 Kneron 裝置 | No Kneron device detected |
| `firstRun.step2.notFoundReasons` | 可能的原因:• 裝置尚未插入 USB • 驅動程式未安裝 • 裝置被其他程式占用 | Possible reasons: • Device not plugged in • Driver not installed • Device occupied by another app |
| `firstRun.step2.notFoundHelp` | 你可以現在插入裝置後「重新掃描」,或略過直接進入 Dashboard稍後從 Devices 頁面再掃描。 | Plug in a device and rescan, or skip to the Dashboard and scan later from Devices. |
| `firstRun.step2.rescan` | 重新掃描 | Rescan |
| `firstRun.step2.enterDashboard` | 進入 Dashboard | Enter Dashboard |
| `firstRun.step2.skipToDashboard` | 略過並進入 Dashboard | Skip to Dashboard |
### 5.2 刪除 key原 Step 2「模式選擇」的 key 全砍)
**所有** `firstRun.mode.*` / `firstRun.step2.*`(若原 step2 = 模式選擇)相關 key 全部刪除。這包含但不限於:
| Key | 理由 |
|-----|------|
| `firstRun.mode.title` | 模式選擇整步消失 |
| `firstRun.mode.real.title` | 同上 |
| `firstRun.mode.real.description` | 同上 |
| `firstRun.mode.mock.title` | 同上 |
| `firstRun.mode.mock.description` | 同上 |
| `firstRun.mode.recommend` | 同上 |
| `firstRun.mode.switchLater` | 同上 |
(實際 key 名以現有 i18n 檔為準grep `firstRun.mode` 全刪)
### 5.3 重編號注意
v1 的 `firstRun.step2.*`模式選擇被砍v1 的 `firstRun.step3.*`(硬體偵測)在 v2 變成 `firstRun.step2.*`。**需要 rename**`firstRun.step3.*``firstRun.step2.*`,或者乾脆用意義化命名:`firstRun.welcome.*``firstRun.hardware.*`。建議後者,後續維護更清楚。
---
## 6. 無障礙考量
| 項目 | 設計 |
|------|------|
| 步驟指示 | Header 顯示 `Step 1 of 2` / `Step 2 of 2`,對 screen reader friendly |
| Keyboard | `Tab` 循序、`Enter` 觸發 primary button、`Esc` 不關閉(防止誤按) |
| Focus 初始 | 每步的 primary button 預設聚焦 |
| ARIA | `<section role="region" aria-labelledby="step-title">`;掃描 spinner `<span role="status" aria-live="polite">` |
| Reduced motion | 關閉步驟間 slide transition |
---
## 7. 與 v1 差異對照
| 面向 | v1三步 | v2兩步 |
|------|----------|----------|
| 入口 | Wails splash → 跳 Next.js | Wails 控制台 → 自動開瀏覽器 → Next.js |
| 步驟數 | 3歡迎 / 模式選擇 / 硬體偵測) | **2**(歡迎 / 硬體偵測) |
| 模式選擇 | 有(真實硬體 / Mock | **無**R5-5a 砍 Mock |
| 硬體未偵測到的引導 | 「或切換到 Mock 模式試用」 | 「略過並進入 Dashboard稍後重掃」 |
| 略過按鈕位置 | 每步右上 | Step 1 右下、Step 2 主按鈕之一 |
| 完成標記 | `localStorage.firstRunCompleted` | 同(不變) |
---
## 8. 邊界情境
| 情境 | 處理 |
|------|------|
| 使用者在 Step 1 關閉瀏覽器 tab | `firstRunCompleted` 不變,下次打開還是從 Step 1 開始 |
| 使用者在 Step 2 Scanning 中關閉 | 同上 |
| 使用者在 Step 2 按「略過」 | `firstRunCompleted = true`,下次打開直達 Dashboard |
| 使用者清 localStorage 後重開 | 重新跑 First-Run這是 feature 不是 bug |
| 首次自動開瀏覽器失敗xdg-open 在極簡 Linux | 瀏覽器沒開 → 使用者會看到控制台「Running · Browser opened」但瀏覽器沒動 → 使用者手動點 `[Open in Browser]` → 瀏覽器打開 → 進 First-Run |
| 使用者在 Wails 控制台 Settings 關了 auto-open下次雙擊 | 進 Step 1 的前提需要使用者**手動**點 Open in Browser。控制台開著等使用者動作 |
---
## 9. 給 PM 的提醒
- PRD 原本定義 First-Run 是 3 步,`02-prd/` 子檔需要更新
- Persona 流程描述 v1 寫「使用者被引導到模式選擇」,要改
- 「Mock 模式是 MVP 功能」若曾寫進 PRD須移到「非目標 / removed features」
- `feature-inventory.md` 若列出 Mock 模式為 feature需標記為 removed
---
**下一步**
- 給 PM Agent 更新 PRD 對應段落Orchestrator 自行更新或轉交 PM
- 給 Frontend Agent 按本 spec 實作 onboarding 頁面
- 給 Testing Agent 設計 E2E雙擊 app → 看控制台 → 自動開瀏覽器 → 跑 First-Run → 進 Dashboard 的完整 flow

View File

@ -0,0 +1,275 @@
# v2.2 — Server Offline Overlay 設計規格
> 本章對應 R5-2關閉 Wails 視窗 = 結束 server瀏覽器端顯示離線 Overlay
> 上層索引:`../design-spec-v2.md`
---
## 1. 背景與目的
R5-2 決定**關閉 Wails 控制台 = 結束 server**,但瀏覽器端不知道這件事發生。如果使用者一手把控制台關掉、另一手還停留在瀏覽器 tab
- 下一次發 fetch → 連線被拒(`ECONNREFUSED`
- 正在 stream 的 MJPEG / WebSocket → 突然斷線
- 使用者體感:「我只是關掉那個 log 視窗,為什麼網頁壞了?」
**Server Offline Overlay** 是當瀏覽器端偵測到 server 不可達時,**全螢幕蓋住整個 Web UI**的硬阻斷畫面明確告訴使用者「server 真的沒了」,並提供重試 / 重開 app 的自助路徑。
---
## 2. 視覺 Wireframe
```
╔═══════════════════════════════════════════════════════════════════╗
║ ║
║ ║
║ ║
║ ┌─────────┐ ║
║ │ ⚠ │ ║
║ │ │ ║
║ └─────────┘ ║
║ ║
║ Local Server 已離線 ║
║ ║
║ visionA-local 已結束或崩潰,請重新開啟應用程式 ║
║ ║
║ ║
║ ┌────────────────────────┐ ║
║ │ 重試連線 │ ║
║ └────────────────────────┘ ║
║ ║
║ 了解更多 ↓ ║
║ ║
║ ║
║ ┌──────────────────────────────────────────────────┐ ║
║ │ 如何重新啟動 visionA-local │ ║
║ │ │ ║
║ │ 1. 前往應用程式資料夾或 Dock │ ║
║ │ 2. 雙擊 visionA-local 圖示 │ ║
║ │ 3. 控制台會自動啟動伺服器並重新開啟瀏覽器 │ ║
║ │ │ ║
║ │ 如果問題持續發生,請檢查 Log 或回報問題 │ ║
║ └──────────────────────────────────────────────────┘ ║
║ ║
║ ║
╚═══════════════════════════════════════════════════════════════════╝
↑ 半透明黑色背景覆蓋整個 viewport底下原 Web UI 仍隱約可見但不可互動
```
### 2.1 尺寸與位置
| 元素 | 規格 |
|------|------|
| Overlay 背景 | `position: fixed; inset: 0; z-index: 9999` |
| 背景色Light | `rgba(0, 0, 0, 0.55)` + `backdrop-filter: blur(8px)` |
| 背景色Dark | `rgba(0, 0, 0, 0.72)` + `backdrop-filter: blur(8px)` |
| 內容卡片寬度 | `clamp(320px, 42vw, 480px)` |
| 內容卡片背景 | `color.surface`Light近白Dark近黑 |
| 內容卡片圓角 | `radius.xl`16 px |
| 內容卡片 padding | 40 px |
| 內容卡片 shadow | `shadow.xl`v1 token |
### 2.2 元件規格
| 元素 | 類型 | 規格 | 文字 |
|------|------|------|------|
| Icon 容器 | `<div>` | 80×80圓形 `color.destructive/10` 背景 | — |
| Icon | `<svg>` 警告三角 | 40×40`color.destructive` | — |
| 標題 | `<h1>` | 24 px Bold `color.foreground` 置中 | `Local Server 已離線` |
| 副標 | `<p>` | 15 px Regular `color.muted-foreground` 置中 | `visionA-local 已結束或崩潰,請重新開啟應用程式` |
| 重試按鈕 | Button `primary` `lg` | 寬度 240 px 置中 | `重試連線` |
| 了解更多 | Button `ghost` `sm` | toggle 展開下方 help text | `了解更多 ↓` / `收起 ↑` |
| Help text 區 | `<div>` 展開 | padding 16 inline-block背景 `color.muted/30`,圓角 `radius.md` | 見 §5 |
### 2.3 Dark Mode
- 背景半透明更深(`rgba(0,0,0,0.72)`
- 卡片背景改為 `color.surface-2`Dark 模式下比 surface 淺一階,視覺上「浮起」)
- Icon 圓圈背景改為 `color.destructive/15`
- 所有文字對比保持 ≥ 4.5:1
---
## 3. 觸發條件
### 3.1 首次載入
使用者首次在瀏覽器打開 `http://127.0.0.1:{port}/` 時:
```
頁面載入
前端 boot sequence: fetch GET /api/health
├── 2xx → 正常顯示 Web UI不顯示 Overlay
└── 網路錯誤 / 非 2xx → 立即顯示 Overlay
```
### 3.2 使用中偵測
當瀏覽器 Web UI 已經在跑時,透過以下兩個管道偵測 server 斷線:
#### 3.2.1 定期 Health Check主動
- Polling `/api/health`,間隔 `10 秒`
- 連續 **2 次** 失敗20 秒)→ 顯示 Overlay
- **為什麼是 2 次而非 3 次**Local 127.0.0.1 幾乎不可能有網路抖動2 次已足夠避免誤報3 次會拖到 30 秒,使用者早就察覺異常
#### 3.2.2 WebSocket / SSE 斷線(被動)
- 現有的 `camera-store` 使用 SSE / WebSocket 接收 inference 結果
- 任何 `onclose` / `onerror` event → **立即** 觸發一次 `/api/health` 驗證
- 如果 health check 也失敗 → 直接顯示 Overlay不等 10 秒 polling
- 如果 health check 成功(代表只是單一 stream 斷線)→ 由 camera-store 自己處理 reconnect不顯示 Overlay
### 3.3 首次載入特殊情境
如果使用者把瀏覽器書籤設成 `http://127.0.0.1:3721/workspace/xxx`,但 visionA-local 還沒啟動 → 首次 fetch 就會失敗 → Overlay 立即顯示。這個情境比「中途斷線」更常見,必須處理。
---
## 4. Dismiss 條件
| 條件 | 行為 |
|------|------|
| 按「重試連線」按鈕 | 立即呼叫一次 `/api/health`,成功 → 自動 dismiss失敗 → 按鈕顯示 loading 500 ms然後 shake 動畫 + toast「仍無法連線請檢查 visionA-local 是否執行中」 |
| 背景 polling 成功 | **自動 dismiss**(覆蓋層淡出 200 ms |
| 使用者手動 reload 頁面 | 頁面重載 → 首次載入流程 → 若 server 仍掛Overlay 再次出現 |
| 使用者按 ESC / 點背景 | **不 dismiss**(這是硬阻斷,不允許偷偷略過) |
### 4.1 自動重連的細節
Overlay 顯示期間,仍維持 polling `/api/health`(間隔 `3 秒`,比正常時更積極)。這是為了**使用者雙擊 app 重開 → 等 3-6 秒 → 不需手動點重試就自動恢復**,接近 Ollama 「server 回來就立刻恢復」的體驗。
---
## 5. 文案(雙語完整版)
### 5.1 主文案
| 位置 | zh-TW | en |
|------|-------|----|
| 標題 | Local Server 已離線 | Local Server is offline |
| 副標 | visionA-local 已結束或崩潰,請重新開啟應用程式 | visionA-local has stopped or crashed. Please restart the app. |
| Primary CTA | 重試連線 | Retry connection |
| Secondary CTA展開前 | 了解更多 ↓ | Learn more ↓ |
| Secondary CTA展開後 | 收起 ↑ | Hide ↑ |
| Retry 失敗 toast | 仍無法連線,請檢查 visionA-local 是否執行中 | Still cannot connect. Please check if visionA-local is running. |
### 5.2 展開的 Help text繁中
```
如何重新啟動 visionA-local
1. 前往應用程式資料夾或 Dock
2. 雙擊 visionA-local 圖示
3. 控制台會自動啟動伺服器並重新開啟瀏覽器
如果問題持續發生,請檢查 Log 或回報問題。
```
### 5.3 展開的 Help text英文
```
How to restart visionA-local:
1. Go to your Applications folder or Dock
2. Double-click the visionA-local icon
3. The control panel will automatically start the server and reopen the browser
If the issue persists, please check the logs or report the problem.
```
---
## 6. 動畫與 Transition
| 事件 | 動畫 | 時長 |
|------|------|------|
| Overlay 顯示 | 背景 `opacity 0 → 1` + backdrop-filter `blur(0) → blur(8)`;卡片 `opacity 0 → 1 + translateY(20px → 0)` | 250 ms ease-out |
| Overlay 消失 | 反向 | 200 ms ease-in |
| 重試按鈕 click | Button scale `0.98``1` | 150 ms |
| 重試失敗 shake | 卡片 `translateX ±4px` 3 次 | 400 ms |
| Help text 展開 / 收起 | height auto transition + opacity | 200 ms ease-out |
| `prefers-reduced-motion` | 全部動畫降為 0 ms 跳變 | — |
---
## 7. 與 Toast 系統的差異
| 面向 | Toast | Server Offline Overlay |
|------|-------|----------------------|
| 呈現 | 螢幕上方 / 下方滑入的小卡片 | 全螢幕半透明覆蓋層 |
| 可關閉 | 可(點 ✕ 或自動 3-5 秒消失) | **不可關閉**(只能靠重試成功 / reload |
| 可互動底層 UI | 可Toast 不阻擋) | **不可**(底層全部 pointer-events: none |
| 用途 | 成功 / 失敗的短暫通知 | 結構性錯誤,使用者必須知道且必須行動 |
| 觸發頻率 | 高 | 極低(只有 server 斷線) |
**設計原則**Server 斷線代表「整個應用程式沒了」這不是「提示」等級是「必須正視」等級。Toast 無法傳達嚴重程度。Overlay 是唯一合理的呈現方式。
---
## 8. 無障礙考量
| 項目 | 設計 |
|------|------|
| ARIA role | `<div role="alertdialog" aria-modal="true" aria-labelledby="offline-title" aria-describedby="offline-desc">` |
| Focus trap | Overlay 顯示時焦點強制落在「重試連線」按鈕上Tab 只能在重試 / 了解更多 兩顆按鈕之間循環 |
| ESC 鍵 | **不 dismiss**(硬阻斷),但不阻止 ESC避免破壞 browser 原生 ESC 行為,例如退出全螢幕) |
| Screen reader | Overlay 顯示瞬間 announce 標題 + 副標(`aria-live="assertive"` |
| 色彩對比 | 標題、副標、按鈕文字全部 ≥ 4.5:1在半透明背景上也要達標因此卡片背景必須是純色不是半透明 |
| 鍵盤可達 | 只有兩個 interactive 元素(重試、了解更多),都可 Tab 聚焦 |
---
## 9. 實作注意事項(給 Frontend
### 9.1 放置位置
建議在 `frontend/src/app/layout.tsx` 掛一個全域 Provider
```tsx
<ServerHealthProvider>
<ServerOfflineOverlay />
{children}
</ServerHealthProvider>
```
`ServerHealthProvider` 負責:
- 啟動 10 秒 interval polling `/api/health`
- 暴露 `useServerHealth()` hook 給任何需要知道 server 狀態的元件
- 監聽 SSE / WebSocket 錯誤事件
`ServerOfflineOverlay` 訂閱 hook狀態變 `offline` 時 render`online` 時 unmount。
### 9.2 不要在 First-Run 前就觸發 Overlay 的誤報
使用者首次載入網頁時First-Run wizard 還沒跑。如果 health check 剛好在 fetch 其他 `/api/...` 之前觸發,而那些 API 需要 DB 初始化可能出現health check 成功、但其他 API 還沒 ready。
**解法**Overlay 只依賴 `/api/health` 的結果,不依賴其他 API。`/api/health` 在 server 啟動後立即 200不等任何 DB init。
### 9.3 Restart Server 場景的特殊處理
雖然 R5-2 選了「關閉視窗 = 結束 server」但 R5 三方共識仍保留「Restart Server」按鈕控制台 Manage menu。當使用者按 Restart 時:
1. Server 短暫 `Stopping``Starting``Running`,中間可能有 2-5 秒斷線
2. 瀏覽器偵測到 health check 失敗 → Overlay 顯示
3. 3 秒後 polling 發現 server 回來 → Overlay 自動消失
4. **使用者體感**:短暫看到 Overlay → 自動恢復 → 繼續用
這個行為**符合預期**不需要特別處理。Overlay 對「暫時斷線」和「永久斷線」的區別透過 `polling 自動恢復 vs 使用者手動重開 app`自然分化。
---
## 10. 與 v1 的差異
| 面向 | v1 | v2 |
|------|----|----|
| Server 斷線 UX | **未定義** | 全螢幕 Overlay新增 |
| 使用者教育 | 無 | 展開式 Help text教重開 |
| 自動恢復 | 無 | 3 秒 pollingserver 回來自動 dismiss |
| 文案 | 無 | 中英雙語完整版 |
---
**下一步**:交 Frontend Agent 實作 `ServerHealthProvider` + `ServerOfflineOverlay` 元件。

View File

@ -0,0 +1,239 @@
# v2.5 — Settings 頁更新
> 本章對應 R5-4Settings 新增自動開瀏覽器 toggle+ R5-5a砍 Mock 模式相關設定)+ R5-D2Linux 預設 OFF+ R5-D3每次啟動都自動開+ R5-E階段化啟動進度
> 上層索引:`../design-spec-v2.md`
> 版本:**v2.1** · 更新日期2026-04-14
---
## 1. 變更摘要
Settings 維持 v1 的 **4 個分頁** 結構R4 第四輪決策確認):
| Tab | v1 內容 | v2 變更 |
|-----|---------|---------|
| 一般 | 語言、深色模式狀態(唯讀) | **新增**「首次啟動時自動開啟瀏覽器」toggle |
| 硬體 | 真實 / Mock 切換、裝置掃描策略 | **刪除** Mock 相關,只保留裝置掃描策略 |
| 模型 | 預置模型清單、上傳、自訂 | 不變 |
| 進階 | 資料目錄、Python 雙策略、清 log 等 | 不變 |
---
## 2. 「一般」分頁新增項目
### 2.1 結構
```
┌─ Settings > 一般 ───────────────────────────────────────────┐
│ │
│ 語言 │
│ ┌───────────────────────────┐ │
│ │ 繁體中文 ▾ │ │
│ └───────────────────────────┘ │
│ │
│ 深色模式 │
│ 跟隨系統(目前:深色) │
│ │
│ ── 啟動行為 ──────────────────────────────────────── │
│ │
│ 首次啟動時自動開啟瀏覽器 [■□] ON │
│ 啟動 visionA-local 時自動在預設瀏覽器開啟 Web UI。 │
│ 關閉此選項後,你需要手動點控制台的「在瀏覽器開啟」按鈕。 │
│ │
└──────────────────────────────────────────────────────────────┘
```
### 2.2 Toggle 規格
| 屬性 | 值 |
|------|----|
| 元件類型 | `<Switch>`shadcn |
| **預設值(依平台)** | **macOS / Windows = `ON`****Linux = `OFF`**R5-D2 |
| i18n key | `settings.general.autoOpenBrowser.label` / `settings.general.autoOpenBrowser.description` |
| 落地檔案 | **`preferences.json` 位於 `<dataDir>/`**(對齊 TDD v2<br>路徑:<br>• macOS `~/Library/Application Support/visiona-local/preferences.json`<br>• Windows `%APPDATA%\visiona-local\preferences.json`<br>• Linux `~/.local/share/visiona-local/preferences.json` |
| 儲存機制 | **Go server 端負責讀寫**`visiona-local/preferences.go`,非 Wails 內建機制 — Wails v2 沒有 settings store<br>JSON 格式:`{"openBrowserOnStart": true}`<br>原子寫入:**write-rename pattern**`os.WriteFile(path+".tmp", ...)``os.Rename(path+".tmp", path)`POSIX / Windows 均原子 |
| 讀取失敗 fallback | 呼叫 `DefaultPreferences()`,依 `runtime.GOOS` 回傳平台預設:<br>`Preferences{OpenBrowserOnStart: runtime.GOOS != "linux"}` |
| 生效時機 | 立即生效,不需 restart app |
| 影響對象 | 控制台的「每次啟動自動開瀏覽器」邏輯(見 `v2/control-panel.md §7` |
**Linux 首次使用說明R5-D2 落地)**
Linux 使用者第一次開 Wails 控制台時Settings > 一般 的「啟動時自動開啟瀏覽器」toggle **預設是關的**。原因是 Linux 桌面環境差異大,`xdg-open` 在無頭環境 / i3 / xmonad / 極簡 WM 下行為可能異常(例如無預設瀏覽器、或開到錯的 DE session。Linux 使用者若確認自己的環境可以正常 `xdg-open`,可手動打開此 toggle下次啟動就會自動開瀏覽器。
關閉此 toggle 時,**啟動進度面板仍然會顯示**(見 `v2/startup-progress.md`),只是第 5 階段「開啟瀏覽器」會被標記為 `跳過(依偏好設定)/ Skipped (per preference)`,第 6 階段「等待 Web UI 連線」改為「等待使用者手動點擊『在瀏覽器開啟』」— 視覺上仍會收尾至 Running state只是由使用者主動觸發第 5 階段(對齊 R5-E 階段化體驗)。
### 2.3 重要細節R5-D3 已定案)
**這個 toggle 同時影響「首次」和「每次」啟動嗎?**
**R5-D3 決策****每次**啟動(含每次 `StartServer` 成功後都會自動開瀏覽器不是只有首次。R5-4 原字面「首次啟動」已於 R5-D3 修正。精確行為:
- **Toggle ONmacOS / Windows 預設)**:每次雙擊 app 或每次 Start Server 成功後,控制台自動呼叫 OS open browser
- **Toggle OFFLinux 預設)**:啟動時只開控制台,不開瀏覽器;使用者自行點控制台的「在瀏覽器開啟」按鈕
- **Restart Server 情境**:因為 Restart 是同一個 Wails App process 內重啟R5 雙 UI 架構下 Wails 不關),且啟動進度面板只在 Wails 剛啟動時顯示一次Restart 時瀏覽器 tab 應由 Offline Overlay 自動重連,不會再開新 tab避免開一堆重複 tab
**Label 定版**:「啟動時自動開啟瀏覽器」/ "Auto-open browser on startup"(去掉「首次」二字)
| 版本 | label | description |
|------|-------|-------------|
| 原 R5-4 字面 | 首次啟動時自動開啟瀏覽器 | — |
| **最終採用R5-D3 定案)** | 啟動時自動開啟瀏覽器 | 啟動 visionA-local 時自動在預設瀏覽器開啟 Web UI。關閉此選項後你需要手動點控制台的「在瀏覽器開啟」按鈕。 |
---
## 3. 「硬體」分頁變更
### 3.1 刪除項目R5-5a
**所有 Mock 模式相關設定全砍**
| v1 項目 | 說明 | v2 處理 |
|---------|------|---------|
| `模式:真實硬體 / Mock` radio | 切換整個 app 推論來源 | **刪除** |
| `Mock 模式描述` 說明文字 | 介紹 Mock 用途 | **刪除** |
| `Mock 模式假資料速率` slider | 調 Mock 輸出速率 | **刪除** |
| `Mock 模式裝置名稱` input | 自訂假裝置名 | **刪除** |
| `切換模式會重啟推論 session` 警告 | 切換時的提醒 | **刪除** |
### 3.2 保留項目
| 項目 | 說明 |
|------|------|
| 裝置自動掃描頻率 | 每次啟動時 / 手動 / 關閉 |
| USB 熱插拔偵測 toggle | 自動偵測新裝置插入 |
| Kneron KL520 / KL720 韌體版本顯示 | 唯讀 |
### 3.3 新增項目
無。R5-5a 砍 Mock 後這個 tab 變得更簡潔。
### 3.4 Tab 是否保留?
因為「硬體」tab 被清空後仍有內容(掃描頻率、熱插拔、韌體版本),**tab 結構保留**,不合併也不刪除。
---
## 4. 「模型」與「進階」分頁
**完全不動**。R5 沒有觸及這兩個 tab 的內容。
---
## 5. i18n key 異動
### 5.1 新增 key
| Key | zh-TW | en |
|-----|-------|----|
| `settings.general.sectionStartup` | 啟動行為 | Startup behavior |
| `settings.general.autoOpenBrowser.label` | 啟動時自動開啟瀏覽器 | Auto-open browser on startup |
| `settings.general.autoOpenBrowser.description` | 啟動 visionA-local 時自動在預設瀏覽器開啟 Web UI。關閉此選項後你需要手動點控制台的「在瀏覽器開啟」按鈕。 | Automatically open the Web UI in your default browser when visionA-local starts. When off, you need to manually click "Open in Browser" in the Control Panel. |
### 5.2 刪除 key
執行 grep 找出所有 mock 相關 key
```
grep -rE "mock|Mock" frontend/src/lib/i18n/
```
預期刪除(實際清單以 grep 為準):
| Key | 原值zh-TW |
|-----|-------------|
| `settings.hardware.mode.label` | 模式 |
| `settings.hardware.mode.real` | 真實硬體 |
| `settings.hardware.mode.mock` | Mock |
| `settings.hardware.mode.description` | 選擇 visionA-local 的執行模式 |
| `settings.hardware.mock.rate.label` | Mock 速率 |
| `settings.hardware.mock.deviceName.label` | Mock 裝置名稱 |
| `settings.hardware.modeSwitchWarning` | 切換模式會重啟推論 session |
| `firstRun.mode.*` | 全部(見 `v2/first-run-update.md §5.2` |
| `devices.mockBadge` | Mock | (若存在於 Devices 頁面的 Mock 標籤)
| `dashboard.tryMockHint` | 或切換到 Mock 模式試用 | (若 Dashboard 空狀態有這句)
### 5.3 type 檔同步
`frontend/src/lib/i18n/types.ts` 對應 interface 欄位全砍,確保 TypeScript 編譯不留 dangling reference。
---
## 6. 清理殘留的 Mock 程式碼
Settings 改完後,前後端其他地方可能仍有 Mock 殘留:
### 6.1 前端
- `frontend/src/stores/` 任何 `mock` state / action
- `frontend/src/components/` 任何 `Mock` badge / pill
- `frontend/src/app/devices/` 頁面右上的「真實 / Mock」切換v1 `design-spec.md §第三輪修訂後仍待確認 4.` 提到)
### 6.2 後端
- Go server 任何 mock handler、假資料 generator
- Python sidecar 的 mock inference path
**這部分非 Design 的 scope**,但 Design 提醒 Orchestrator**R5-5a 的「完全砍除」不只是 UI 層面,後端也要同步砍**。交 Architect Agent 評估實作影響。
---
## 7. 遷移策略Migration
**使用者情境**v1 版本沒有 `preferences.json`Mock 模式相關設定(若存在)是記在前端 localStorage 或 v1 舊式 config不在 Go 端的 preferences 檔裡。v2 改用 Go 端 `preferences.json` 管理「是否啟動時自動開瀏覽器」,是**全新的檔案**。
**v2 首次讀取 preferences.json 流程**
1. 檢查 `<dataDir>/preferences.json` 是否存在
2. **不存在** → 呼叫 `DefaultPreferences()`(依 `runtime.GOOS` 回傳平台預設macOS/Windows `OpenBrowserOnStart=true`Linux `OpenBrowserOnStart=false`
3. **存在但解析失敗**JSON 毀損等)→ 同樣 fallback 到 `DefaultPreferences()`,並發 log WARN不擋啟動
4. **存在且解析成功** → 使用使用者設定
5. 使用者在 Settings 點擊 toggle → write-rename atomic pattern 寫回 `preferences.json`
**v1 → v2 無 migration 成本**:因為 v1 沒有對應檔案v2 啟動時若 `preferences.json` 不存在就建立新檔(或延遲到使用者第一次改 toggle 才寫),行為是**靜默**的,使用者不需要知道。
**Mock 模式殘留**v1 localStorage 可能有 mock 相關 keyFrontend 清理見 §6.1;與 `preferences.json` 無關。
---
## 8. 無障礙考量
| 項目 | 設計 |
|------|------|
| Toggle 元件 | 沿用 shadcn `<Switch>`,已內建 `role="switch"` / `aria-checked` |
| Tab 切換 | 沿用現有 Tabs 元件,`role="tablist"` / `role="tab"` / `role="tabpanel"` |
| Keyboard | `←` / `→` 切換 tab、`Tab` 進入 tab panel 內部 |
| 分區標題 | 「啟動行為」區用 `<h3>` 讓 screen reader 感知結構 |
---
## 9. 與 v1 差異對照
| 面向 | v1 | v2 |
|------|----|----|
| 一般 tab 項目數 | 2語言、深色模式 | **3**(新增 auto-open browser |
| 硬體 tab 項目數 | 5+(含 Mock 相關) | 3只剩真實硬體相關 |
| Mock 模式 UI | Settings + Devices pill + First-Run Step | **全砍** |
| 模型 / 進階 tab | — | 不變 |
---
## 10. v2 → v2.1 Diff2026-04-14
| # | 位置 | v2 | v2.1 | 來源 |
|---|------|----|----|------|
| 1 | §2.2 落地檔案 | `settings.json` + 「走 Wails 既有 settings store」 | **`preferences.json` @ `<dataDir>/`** + Go server 讀寫 + write-rename atomic | Architect Review Major 1 |
| 2 | §2.2 預設值 | 三平台一致 `ON` | **macOS/Windows = `ON`Linux = `OFF`**(依 `DefaultPreferences()``runtime.GOOS` | R5-D2 / Architect Review Major 2 |
| 3 | §2.2 新增 | — | Linux 首次使用說明toggle 預設關、關閉時啟動進度面板仍顯示、第 5/6 階段跳過)| R5-D2 + R5-E |
| 4 | §2.3 標題 | 「重要細節」待 confirm | **「R5-D3 已定案」每次啟動都開瀏覽器**label 定版 | R5-D3 |
| 5 | §7 遷移策略 | 假設 v1 有 `settings.json` + mock key 遷移 | 澄清 `preferences.json` 是**全新檔**,無 v1 migration 成本 | Architect Review Major 1 衍生 |
---
## 11. 給 Orchestrator 的提醒
1. ~~**R5-4 的 label 字面歧義**~~**已於 R5-D3 定案**每次啟動都開label 去掉「首次」)
2. **後端 Mock 清理**不在 Design scope需轉給 Architect / Backend Agent
3. **Devices 頁面的 Mock pill**v1 `design-spec.md` 第三輪修訂 §4要一併砍此變更未在本文件細寫但屬 R5-5a 範圍
4. **Linux 預設 OFF 對 E2E 測試的影響**testing Agent 在 Linux CI 上跑自動化時要注意,啟動後不會 auto-open需手動模擬點擊「在瀏覽器開啟」或在測試前將 `preferences.json` 改為 `{"openBrowserOnStart": true}`
---
**下一步**:交 Frontend Agent 更新 Settings 頁面。

View File

@ -0,0 +1,204 @@
# v2.3 — source-selector 修改規格
> 本章對應 R5砍 URL 推論)+ R5-6ffmpeg decoder 支援 mp4/avi/mov/mpeg/mpg
> 影響檔案:`frontend/src/components/camera/source-selector.tsx``frontend/src/stores/camera-store.ts``frontend/src/lib/i18n/zh-TW.ts``frontend/src/lib/i18n/en.ts``frontend/src/lib/i18n/types.ts`
> 上層索引:`../design-spec-v2.md`
---
## 1. 修改範圍摘要
**砍**
- video tab 下的 `URL mode``videoMode === 'url'` 分支整塊)
- `videoMode` state 本身(因為 video tab 只剩單一 `file` 模式)
- `videoUrl` state、`handleUrlSubmit` handler
- `startFromUrl` store action 呼叫端與 store 內實作
- 「正在解析影片連結YouTube 影片可能需要 10-30 秒…」的 hard-coded 紅字提示
- YouTube / Vimeo / RTSP 相關文案
- i18n keys`camera.uploadFile``camera.pasteUrl``camera.urlPlaceholder``camera.urlHelpText`
**改**
- `<input accept>``.mp4,.avi,.mov``.mp4,.avi,.mov,.mpeg,.mpg`
- dropzone filter若有影片 drop 支援)同步
- i18n key `camera.mp4AviMov` 值從 `MP4, AVI, MOV``MP4, AVI, MOV, MPEG`
- Video tab 內只剩一個「選擇影片」按鈕 + 格式說明文字,無 sub-mode 切換
**保留**
- `isUploading` state上傳大檔仍需 loading
- `uploadVideo` action
- `sourceFilename` 顯示
- Camera / Image tabs 的所有邏輯完全不動
---
## 2. 新的 video tab UI 狀態表
### 2.1 結構ASCII
```
┌─ Video tab ────────────────────────────────────────────────┐
│ │
│ [ 選擇影片 ] MP4, AVI, MOV, MPEG │
│ │
可選dropzone拖放影片檔到此 │
│ │
└──────────────────────────────────────────────────────────────┘
```
### 2.2 狀態對照表
| 狀態 | 條件 | 可見元素 | 按鈕狀態 |
|------|------|--------|---------|
| Idle預設 | 非 streaming、非 uploading | `[選擇影片]` button + 格式說明 `MP4, AVI, MOV, MPEG` | enabled |
| Uploading | `isUploading === true` | `[上傳中...]` buttondisabled+ 格式說明 | disabled |
| Streaming | `isStreaming === true`(由父元件處理,此 tab 的內容被 `[停止影片]` 按鈕取代) | `[停止影片]` + 檔名 | — |
**注意**Video tab 不再有 `Upload file` / `Paste URL` 兩顆 sub-mode 切換按鈕;使用者點 tab 後**直接就是上傳區**。
---
## 3. i18n key 異動清單
### 3.1 新增 key
| Key | zh-TW | en | 說明 |
|-----|-------|----|------|
| `camera.mp4AviMovMpeg` | MP4, AVI, MOV, MPEG | MP4, AVI, MOV, MPEG | 取代舊的 `mp4AviMov` |
(也可以選擇直接改舊 key 的值,見 §3.3 的建議)
### 3.2 刪除 keyzh-TW / en / types 三檔都要刪)
| Key | 原值zh-TW | 刪除理由 |
|-----|-------------|---------|
| `camera.uploadFile` | 上傳檔案 | Video tab 不再有 sub-mode 切換,`camera.selectVideo` 已足夠 |
| `camera.pasteUrl` | 貼上連結 | URL mode 砍除 |
| `camera.urlPlaceholder` | `https://example.com/video.mp4` | 同上 |
| `camera.urlHelpText` | 支援 YouTube、直接影片 URL.mp4 等)及 RTSP 串流。 | 同上 |
### 3.3 修改 key保留但更新值
| Key | 原值zh-TW / en | 新值zh-TW / en |
|-----|-----------------|-----------------|
| `camera.mp4AviMov` | `MP4, AVI, MOV` / `MP4, AVI, MOV` | `MP4, AVI, MOV, MPEG` / `MP4, AVI, MOV, MPEG` |
**注意**`key 名`雖然寫 `mp4AviMov`,但值改為包含 `MPEG`。保留 key 名避免 rename 造成其他地方引用失效。或者也可以 rename 為 `camera.supportedVideoFormats`,若 renametype 檔也要同步。**建議 rename**,更語義化。
### 3.4 `camera.selectVideo` 保留
文字「選擇影片 / Select video」不變仍用於 video tab 的主 CTA。
---
## 4. 程式碼變更預期(示意,實作以 Frontend Agent 為準)
### 4.1 `source-selector.tsx` diff 摘要
**刪除**(大約 50 行):
- `const [videoMode, setVideoMode] = useState<'file' | 'url'>('file');`
- `const [videoUrl, setVideoUrl] = useState('');`
- `const handleUrlSubmit = async () => { ... }`
- 從 store 解構 `startFromUrl`
- `{activeTab === 'video' && (...)}` 內的 sub-mode 切換按鈕 `[上傳檔案] / [貼上連結]`
- URL mode 的整個 JSX 分支(輸入框 + 「正在解析」提示 + `camera.urlHelpText`
- `import { Input }` 如果其他地方沒用,連 import 一起砍
**修改**
- Video tab 的結構從 `<div className="flex flex-col gap-2 w-full">` 內含兩個 mode 分支,改為直接 render file upload 區
- `<input accept=".mp4,.avi,.mov">``<input accept=".mp4,.avi,.mov,.mpeg,.mpg">`
- 格式說明文字改為 `t('camera.supportedVideoFormats')`(或維持 `mp4AviMov` key
**最終 video tab JSX 預期樣貌**
```tsx
{activeTab === 'video' && (
<div className="flex items-center gap-3">
<Button
onClick={() => videoFileRef.current?.click()}
disabled={isUploading}
>
{isUploading ? t('common.uploading') : t('camera.selectVideo')}
</Button>
<span className="text-sm text-muted-foreground">
{t('camera.supportedVideoFormats')}
</span>
<input
ref={videoFileRef}
type="file"
accept=".mp4,.avi,.mov,.mpeg,.mpg"
className="hidden"
onChange={handleVideoSelect}
/>
</div>
)}
```
### 4.2 `camera-store.ts` diff 摘要
**刪除**
- interface 中的 `startFromUrl: (url: string, deviceId: string) => Promise<void>;`
- 實作中的 `startFromUrl: async (url, deviceId) => { ... }` 整塊
**保留**
- `uploadVideo` 不動
- `isUploading` state 不動
### 4.3 `i18n/zh-TW.ts``i18n/en.ts``i18n/types.ts`
三檔同步:
- 刪 `uploadFile``pasteUrl``urlPlaceholder``urlHelpText`
- 新增 `supportedVideoFormats`(或修改 `mp4AviMov` 值)
- types 檔移除對應 type 定義
### 4.4 其他可能殘留
執行 grep 檢查:
- `grep -r "startFromUrl" frontend/src/`
- `grep -r "pasteUrl" frontend/src/`
- `grep -r "youtube\|vimeo\|RTSP" frontend/src/`
若有殘留,一併清理。
---
## 5. 無障礙考量
Video tab 變為單一按鈕後:
| 項目 | 設計 |
|------|------|
| Tab order | camera → image → video → (內部) 選擇影片 button |
| Button aria-label | `aria-label="選擇影片檔案,支援 MP4 AVI MOV MPEG"` |
| Focus ring | 沿用 Button 元件預設 focus ring |
| Screen reader | Button 點擊開啟 native file pickermacOS VoiceOver / Windows Narrator 會自動 announce file dialog |
---
## 6. 回歸測試重點(給 Testing Agent
- [ ] Video tab 點擊後**直接**看到 `[選擇影片]` 按鈕,無 sub-mode 切換
- [ ] 支援上傳 `.mp4``.avi``.mov``.mpeg``.mpg` 五種副檔名
- [ ] 不支援上傳 `.mkv``.webm``.flv`ffmpeg LGPL minimal build 不含這些 decoder
- [ ] 上傳中顯示 `上傳中...`,按鈕 disabled
- [ ] 上傳完成後開始 inference`sourceFilename` 顯示正確檔名
- [ ] 全站 grep 不到任何 `startFromUrl` / `pasteUrl` / `youtube` / `vimeo` 字串
- [ ] 兩個語系zh-TW / en都沒有 dead keys
- [ ] 舊版 i18n key 被刪後TypeScript 編譯無 dangling reference
---
## 7. 與 v1 差異對照
| 面向 | v1 | v2 |
|------|----|----|
| Video 來源類型 | file + URLYouTube / Vimeo / RTSP / 直接 .mp4 | 僅 file |
| 支援副檔名 | `.mp4, .avi, .mov` | `.mp4, .avi, .mov, .mpeg, .mpg` |
| Sub-mode 切換 | 有2 顆切換按鈕) | 無 |
| URL 輸入框 | 有 | **無** |
| 解析 URL 提示 | 有「YouTube 影片可能需要 10-30 秒…」) | **無** |
| i18n key 數 | 含 4 個 URL 相關 key | 砍 4 個、新增 / 修改 1 個 |
| `startFromUrl` store action | 有 | **無** |
---
**下一步**:交 Frontend Agent 按上述 spec 實作,完成後交 Reviewer 審查、Testing Agent 做回歸測試。

View File

@ -0,0 +1,417 @@
# v2.6 — 啟動進度面板Startup Progress Panel
> 本章對應 **R5-E1 ~ R5-E6**perceived performance 階段化啟動)。
> 上層索引:`../design-spec-v2.md`
> 相關:`v2/control-panel.md §5`(狀態機 Starting state`v2/control-panel.md §7`(啟動流程)
> 版本:**v2.1**(新增章節)· 建立日期2026-04-14
---
## 1. 定位與動機
**問題**v2 第一版把 AC-1.3 定為「10 秒上限」硬指標,但 Architect §11-2 分析估計樂觀 ~4 秒 / 悲觀 ~8 秒 / Windows + Defender 首次掃描最壞 ~11 秒。強求 10 秒會卡 Windows 使用者。
**使用者決策R5-E**:把問題從「要多快」翻轉成「**讓使用者感覺進度有在推動**」,採 Nielsen Norman *perceived performance* 原則 —
> 使用者能忍受 60 秒,只要每一秒都有視覺反饋。使用者不能忍受 10 秒,如果其中 8 秒是白畫面。
**本章職責**:設計 Starting state 時浮在 log panel 上方的**階段化啟動進度面板**,讓使用者知道:
- 現在在做什麼(階段編號 + 名稱 + 描述)
- 進度到哪裡6 階段的哪一階,視覺進度條)
- 快 OK 了 vs 卡住了(超時提示)
- 出錯了怎麼辦Error state 三個救援按鈕)
---
## 2. Wireframe
### 2.1 正常啟動中(階段 3「啟動本機伺服器」進行中
```
├───────────────────────────────────────────────────────────────────┤
│ ☑ Follow tail ☑ Show timestamps 🔍 [ Filter ... ] │
├───────────────────────────────────────────────────────────────────┤
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 正在啟動 visionA-local · Starting visionA-local │ │
│ │ │ │
│ │ ✅ 1 · 初始化控制台 完成 │ │
│ │ Initializing control panel │ │
│ │ │ │
│ │ ✅ 2 · 檢查 Python 執行環境 完成 │ │
│ │ Checking Python runtime │ │
│ │ │ │
│ │ 🔄 3 · 啟動本機伺服器 (spinner) │ │
│ │ Starting local server... │ │
│ │ │ │
│ │ ⏳ 4 · 偵測 Kneron 裝置 等待中 │ │
│ │ Detecting Kneron devices │ │
│ │ │ │
│ │ ⏳ 5 · 開啟瀏覽器 等待中 │ │
│ │ Opening browser │ │
│ │ │ │
│ │ ⏳ 6 · 等待 Web UI 連線 等待中 │ │
│ │ Waiting for Web UI to connect │ │
│ │ │ │
│ │ ▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 進度 3 / 6 │ │
│ └───────────────────────────────────────────────────────────────┘ │
├───────────────────────────────────────────────────────────────────┤
│ 10:23:40 INFO HTTP server binding on 127.0.0.1:3721 │ ← log panel
│ 10:23:41 INFO wails ipc ready │ 照常顯示
│ ... │
├───────────────────────────────────────────────────────────────────┤
```
### 2.2 階段卡超過 20 秒Retry HintR5-E3
```
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ 正在啟動 visionA-local · Starting visionA-local │ │
│ │ │ │
│ │ ✅ 1 · 初始化控制台 完成 │ │
│ │ ✅ 2 · 檢查 Python 執行環境 完成 │ │
│ │ │ │
│ │ 🔄 3 · 啟動本機伺服器 (spinner) │ │
│ │ Starting local server... │ │
│ │ ⚠ 這個步驟花的時間比預期久,正在重試... │ │
│ │ This step is taking longer than expected, retrying... │ │
│ │ │ │
│ │ ⏳ 4 · 偵測 Kneron 裝置 等待中 │ │
│ │ ... │ │
│ │ │ │
│ │ ▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 進度 3 / 6 · 已等待 22 秒 │ │
│ └───────────────────────────────────────────────────────────────┘ │
```
### 2.3 Error 狀態R5-E460 秒總上限或任一階段失敗)
```
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ ❌ 啟動失敗 · Startup failed │ │
│ │ │ │
│ │ 啟動時間超過 60 秒,可能是系統環境異常或網路中斷。 │ │
│ │ Startup exceeded 60 seconds. Your environment may have │ │
│ │ issues or the network is interrupted. │ │
│ │ │ │
│ │ 失敗階段3 · 啟動本機伺服器 │ │
│ │ Failed stage: 3 · Starting local server │ │
│ │ │ │
│ │ [ 🔄 重試 Retry ] [ 📋 檢視 log View Log ] │ │
│ │ [ 🐞 回報問題 Report Issue (hold) ] │ │
│ └───────────────────────────────────────────────────────────────┘ │
```
---
## 3. 元件規格
### 3.1 Panel 容器
| 屬性 | 值 |
|------|----|
| Root element | `<section role="progressbar" aria-valuemin="0" aria-valuemax="6" aria-valuenow="{currentStage}" aria-label="{i18n:startup.panel.ariaLabel}">` |
| 位置 | log controls 下方、log panel 上方(和 Error banner 同一個 slot |
| 背景 | `color.surface-1` |
| 邊框 | 1 px `color.border` |
| 圓角 | `radius.md`(沿用 tokens |
| Padding | 16 px |
| Max width | 控制台 content 寬度(約 688 px |
| 進入動畫 | fade-in 200 ms`prefers-reduced-motion` 時跳變) |
| 收尾動畫 | 所有階段完成後 fade-out 200 ms之後 unmount |
### 3.2 Panel header
| 元素 | 類型 | 樣式 |
|------|------|------|
| 標題 | `<h2>` | 14 px SemiBold`color.foreground` |
| 文字zh-TW | `正在啟動 visionA-local` | |
| 文字en | `Starting visionA-local` | |
**i18n key**`startup.panel.title.zh` / `startup.panel.title.en`
### 3.3 階段列 `<StageItem>`
每個階段是一個獨立列,包含:
| 子元素 | 類型 | 內容 | 狀態變化 |
|------|------|------|---------|
| Icon圓圈 | 20×20 px `<span>` | ✅(完成)/ 🔄(進行中,旋轉 spinner/ ⏳(等待)/ ❌(失敗) | 見 §3.4 狀態對照 |
| 階段編號 | `<span>` | `{n}` `·` | 14 px Mediummuted-foreground |
| Label雙語併呈 | `<div>` | 第一行zh-TW 14 px第二行en 12 px muted-foreground | 見 §4 文案 |
| 狀態標籤 | `<span>` | 完成 / 進行中 / 等待中 / 失敗 | 右對齊 |
**範例結構**
```html
<div class="stage-item" data-state="running">
<span class="stage-icon" aria-hidden="true">
<Spinner />
</span>
<span class="stage-number">3 ·</span>
<div class="stage-label">
<div class="stage-label-primary">啟動本機伺服器</div>
<div class="stage-label-secondary">Starting local server</div>
</div>
<span class="stage-status">進行中</span>
</div>
```
### 3.4 階段狀態對照
| 資料狀態 | Icon | Icon 顏色 | Label 顏色 | 狀態文字zh / en |
|---------|------|----------|-----------|------------------|
| `pending` | ⏳ (outline circle) | `color.muted-foreground` | `color.muted-foreground` | `等待中` / `Waiting` |
| `running` | 🔄 旋轉 spinner 16 px | `color.primary` | `color.foreground` Bold | `進行中` / `Running` |
| `running-slow` | 🔄 旋轉 spinner + ⚠ 小圖示 | `color.warning` | `color.foreground` Bold | `正在重試...` / `Retrying...` |
| `done` | ✅ filled check | `color.success` | `color.muted-foreground`(略淡) | `完成` / `Done` |
| `failed` | ❌ filled cross | `color.destructive` | `color.destructive` Bold | `失敗` / `Failed` |
| `skipped` | ⏭ filled skip | `color.muted-foreground` | `color.muted-foreground` 斜體 | `跳過(依偏好設定)` / `Skipped (per preference)` |
**動畫**
- pending → runningspinner fade-in 150 ms
- running → donespinner → check icon 交叉淡入 200 ms整行 label 漸變淡
- running → running-slow⚠ icon slide-in-left 200 ms
- running → failedspinner → ❌ icon整行背景 `color.destructive/5` 淡入
- `prefers-reduced-motion: reduce` → 全部 fade 降為 0 ms 跳變spinner 改為靜態點
### 3.5 進度條
| 屬性 | 值 |
|------|----|
| 類型 | 6 格離散進度條(不是連續 bar每格對應一階段 |
| 已完成格 | `color.success` 填滿 |
| 進行中格 | `color.primary` 填滿 + 脈衝動畫opacity 0.6 ↔ 1.0, 1.5 s |
| 等待中格 | `color.border` 空格 |
| 失敗格 | `color.destructive` 填滿 |
| 高度 | 6 px |
| 格間距 | 2 px |
| 附加文字 | 右側 `進度 {current} / 6` + 卡超 20 秒時附 `· 已等待 {elapsed} 秒` |
ARIA`<div role="progressbar" aria-valuenow="{current}" aria-valuemax="6" aria-label="啟動進度">`
### 3.6 Retry HintR5-E3
當任一階段 `running` 狀態超過 **20 秒**未變 `done`,該 StageItem 下方浮出 hint line
| 屬性 | 值 |
|------|----|
| 觸發 | `stage.state === 'running' && (now - stage.startedAt) > 20_000ms` |
| 隱藏 | 階段狀態變 `done``failed` 後隨 StageItem 一起消失 |
| 顏色 | `color.warning` text |
| Icon | ⚠ 14×14 |
| 雙語 | 第一行中文、第二行英文12 px muted |
**文案**
- zh`這個步驟花的時間比預期久,正在重試...`
- en`This step is taking longer than expected, retrying...`
**i18n key**`startup.timeout.message.zh` / `startup.timeout.message.en`
### 3.7 Error StateR5-E4
任一階段 `failed` 或總計超過 **60 秒** → Panel 整體換為 Error mode
- StageItem 列表隱藏(只保留失敗的那一階段顯示為 ❌)
- 進度條換成 Error 樣式(整條 `color.destructive/20` 背景)
- 大標題 `啟動失敗 / Startup failed`
- 說明文字(雙語)
- 三顆按鈕:
| 按鈕 | 類型 | 行為 |
|------|------|------|
| 🔄 重試 / Retry | Button `primary` `md` | 重置進度面板,重新跑階段 1 |
| 📋 檢視 log / View Log | Button `ghost` `md` | 收起 panelfocus 到 log panel 最後一條 ERROR 行flash 2 次 |
| 🐞 回報問題 / Report Issue **【hold】** | Button `ghost` `md` | **現階段不實作**(待 PM 提供 GitHub Issue repo URL |
**R5-D1 OS 原生通知並存**:進入 Error state 時同時呼叫 `sendCrashNotification()` 發 OS non-blocking toast`control-panel.md §6.2` 的 Error banner 一致)。
---
## 4. 6 階段文字定版R5-E5 定稿)
| # | 階段 | Label (zh-TW) | Label (en) | Description (zh-TW) | Description (en) | 完成條件(技術訊號) |
|---|------|-------------|------------|--------------------|------------------|------------------|
| 1 | 初始化控制台 | 初始化控制台 | Initializing control panel | 準備 visionA-local 桌面環境 | Preparing visionA-local desktop | Wails `OnStartup` 完成、i18n 載入、面板 mount |
| 2 | 檢查 Python 執行環境 | 檢查 Python 執行環境 | Checking Python runtime | 首次啟動可能需要較長時間 | First launch may take longer | `ensurePythonRuntime()` 回傳 + 驅動檢查通過 |
| 3 | 啟動本機伺服器 | 啟動本機伺服器 | Starting local server | 在 127.0.0.1:3721 啟動服務 | Starting service on 127.0.0.1:3721 | `/api/health` 回 200首次成功 |
| 4 | 偵測 Kneron 裝置 | 偵測 Kneron 裝置 | Detecting Kneron devices | 掃描已連接的硬體 | Scanning connected hardware | Go server 回傳 devices scan 結果(不論有無裝置都算成功) |
| 5 | 開啟瀏覽器 | 開啟瀏覽器 | Opening browser | 在預設瀏覽器開啟 Web UI | Opening the Web UI in your default browser | `OpenInBrowser()` 呼叫完成(不等瀏覽器實際開好) |
| 6 | 等待 Web UI 連線 | 等待 Web UI 連線 | Waiting for Web UI to connect | 正在與瀏覽器建立即時連線 | Establishing realtime connection with the browser | **WebSocket hub 收到第一個 client 連線**R5-E6 |
### 4.1 特殊文案
**階段 2 首次啟動提示(常態)**
- Description 固定顯示「首次啟動可能需要較長時間 / First launch may take longer」
- 這不是超時 hint是預設的 description讓使用者看到就不焦慮即使只花 0.5 秒也顯示)
**階段 5 Linux / Settings OFF 情境**
- 狀態直接從 `pending` 跳到 `skipped`
- 狀態文字顯示「跳過(依偏好設定)/ Skipped (per preference)」
- Icon 用 ⏭
- 進度條該格仍算推進(不當失敗,不擋住階段 6
**階段 6 Settings OFF 情境**
- 狀態從 `pending` 跳到 `running`label description 改為
- zh`請點擊控制台的「在瀏覽器開啟」按鈕`
- en`Please click "Open in Browser" in the Control Panel`
- 當使用者手動點 Open in Browser 並成功建立 WebSocket 連線後 → `done`
- **不套 20 秒 retry hint**(因為是等待人為動作,不是系統卡住)
---
## 5. 成功狀態收尾Running state 轉場)
當階段 6 狀態變 `done`
1. 進度條最後一格填滿 `color.success`,脈衝動畫停止
2. 停留 500 ms 讓使用者看到「全綠」
3. 整個 Panel fade-out 200 ms
4. Panel unmount
5. 控制台 Status 區域變 `Running · Browser opened`Toggle ON 首次)或純 `Running`
6. Primary controls 啟用Open in Browser 等)
**總轉場時間**:約 700 ms500 ms 停留 + 200 ms fade
**`prefers-reduced-motion: reduce`**:省略 500 ms 停留 + 200 ms fade直接 unmount。
---
## 6. 無障礙考量
| 項目 | 設計 |
|------|------|
| **Role** | Panel root `<section role="progressbar" aria-valuemin="0" aria-valuemax="6" aria-valuenow="{current}">` |
| **Live region** | Panel 下加 `<div class="sr-only" aria-live="polite" aria-atomic="true">` 宣告階段變化:`階段 {n}{label}{status}`zh`Stage {n}: {label}, {status}`en |
| **Focus trap** | Panel 顯示期間**不 trap focus**Starting state 使用者不應該需要操作 panel 以外的元素,但允許使用者切換視窗或捲 log panel |
| **Keyboard** | `⌘0` / `Ctrl+0` focus 進度面板第一個可聚焦元素Retry 按鈕或 panel root<br>`Esc`:若 panel 已進入 Error state → 不動作(避免誤關);若進度已跑完正在 fade-out → 立即 unmount |
| **色彩對比** | Stage label / Description / 狀態文字 ≥ 4.5:1failed / running-slow hint ≥ 4.5:1critical 信號不妥協,對齊 control-panel §10 |
| **Reduced motion** | 所有 fade / spinner / 脈衝動畫降為 0 ms 跳變spinner 改為靜態點Retry hint 直接顯示不滑入 |
| **字級可縮放** | 使用 `rem` 定義字級 |
| **Icon 替代文字** | 每個 icon 有 `aria-hidden="true"`(狀態透過 live region 宣告),或 `role="img" aria-label="..."` |
---
## 7. i18n key 清單
全部加到 `desktop-control` namespace 的 `startup.*` 子樹:
| Key | zh-TW | en |
|-----|-------|----|
| `startup.panel.title` | 正在啟動 visionA-local | Starting visionA-local |
| `startup.panel.ariaLabel` | 啟動進度:階段 {current} / {max} | Startup progress: stage {current} / {max} |
| `startup.progressLabel` | 進度 {current} / {max} | Progress {current} / {max} |
| `startup.progressWithElapsed` | 進度 {current} / {max} · 已等待 {elapsed} 秒 | Progress {current} / {max} · {elapsed}s elapsed |
| `startup.stage.1.label` | 初始化控制台 | Initializing control panel |
| `startup.stage.1.description` | 準備 visionA-local 桌面環境 | Preparing visionA-local desktop |
| `startup.stage.2.label` | 檢查 Python 執行環境 | Checking Python runtime |
| `startup.stage.2.description` | 首次啟動可能需要較長時間 | First launch may take longer |
| `startup.stage.3.label` | 啟動本機伺服器 | Starting local server |
| `startup.stage.3.description` | 在 127.0.0.1:{port} 啟動服務 | Starting service on 127.0.0.1:{port} |
| `startup.stage.4.label` | 偵測 Kneron 裝置 | Detecting Kneron devices |
| `startup.stage.4.description` | 掃描已連接的硬體 | Scanning connected hardware |
| `startup.stage.5.label` | 開啟瀏覽器 | Opening browser |
| `startup.stage.5.description` | 在預設瀏覽器開啟 Web UI | Opening the Web UI in your default browser |
| `startup.stage.5.skipped.label` | 跳過(依偏好設定) | Skipped (per preference) |
| `startup.stage.6.label` | 等待 Web UI 連線 | Waiting for Web UI to connect |
| `startup.stage.6.description` | 正在與瀏覽器建立即時連線 | Establishing realtime connection with the browser |
| `startup.stage.6.manualHint` | 請點擊控制台的「在瀏覽器開啟」按鈕 | Please click "Open in Browser" in the Control Panel |
| `startup.status.pending` | 等待中 | Waiting |
| `startup.status.running` | 進行中 | Running |
| `startup.status.done` | 完成 | Done |
| `startup.status.failed` | 失敗 | Failed |
| `startup.status.skipped` | 跳過(依偏好設定) | Skipped (per preference) |
| `startup.timeout.message` | 這個步驟花的時間比預期久,正在重試... | This step is taking longer than expected, retrying... |
| `startup.error.title` | 啟動失敗 | Startup failed |
| `startup.error.description.timeout` | 啟動時間超過 60 秒,可能是系統環境異常或網路中斷。 | Startup exceeded 60 seconds. Your environment may have issues or the network is interrupted. |
| `startup.error.description.stageFailed` | 階段「{stageLabel}」執行失敗。 | Stage "{stageLabel}" failed. |
| `startup.error.failedStage` | 失敗階段:{n} · {label} | Failed stage: {n} · {label} |
| `startup.error.retry` | 重試 | Retry |
| `startup.error.viewLog` | 檢視 log | View Log |
| `startup.error.report` | 回報問題 | Report Issue |
| `startup.liveRegion.stageUpdate` | 階段 {n}{label}{status} | Stage {n}: {label}, {status} |
---
## 8. 資料模型(交給 Architect / Frontend 參考)
```typescript
type StageState =
| 'pending'
| 'running'
| 'running-slow' // UI 派生狀態running 且 elapsed > 20s
| 'done'
| 'failed'
| 'skipped';
interface Stage {
id: 1 | 2 | 3 | 4 | 5 | 6;
state: StageState;
startedAt: number | null; // epoch ms
finishedAt: number | null;
errorMessage?: string;
}
interface StartupProgressState {
currentStage: 1 | 2 | 3 | 4 | 5 | 6 | 'done' | 'error';
stages: Record<1 | 2 | 3 | 4 | 5 | 6, Stage>;
startedAt: number; // 整個啟動流程開始時間
totalElapsedMs: number; // 從 startedAt 算起
errorReason?: 'timeout' | 'stageFailed';
failedStageId?: number;
}
```
**訊號來源(給 Architect 看)**
- 階段 1 完成Wails `OnStartup` 回呼結束 + 面板 mount 事件
- 階段 2 完成Go `ensurePythonRuntime()` 回傳 + driver check OK
- 階段 3 完成:`/api/health` 首次回 200
- 階段 4 完成Go server 裝置掃描回傳(有 / 無 / 錯誤都算)
- 階段 5 完成:`OpenInBrowser()` 呼叫 return`skipped` 若 toggle 關)
- 階段 6 完成WebSocket hub 收到第一個 client 連線事件(**R5-E6**
每個階段的 `startedAt` 由前端 React 在「前一階段變 done 時」設定當下時間。`totalElapsedMs` 每秒更新用於超時判斷。
**60 秒總計時 timer**
- Panel mount 時 `setTimeout(fireError, 60_000)`
- 每個階段 `done` 時不清 timer
- 只要最後階段6`done` 前 timer 觸發 → 進 Error modereason: `timeout`
- 階段 6 `done``clearTimeout`
**20 秒階段卡頓 timer**
- 每個階段變 `running``setTimeout(markSlow, 20_000)`
- 階段變 `done``failed``clearTimeout`
- 觸發時 `stage.state` 不真的改成 `running-slow`(仍是 `running`),而是 UI 層根據 `now - startedAt > 20_000` 派生出 `running-slow` 樣式和 hint line
---
## 9. 開發備忘(交給 Frontend / Architect
1. **技術實作位置**:本面板跑在 Wails 控制台前端vanilla HTML/JS/CSS非 Next.js路徑約 `visiona-local/frontend/`
2. **狀態同步**Go 端每個階段完成時透過 `wailsRuntime.EventsEmit('startup:stage', {id, state})`;前端 listen 後 setState 並更新 panel
3. **Go 端需新增事件**
- `startup:stage` payload `{id: 1..6, state: 'running'|'done'|'failed'|'skipped', errorMessage?: string}`
- 最終 `startup:complete``startup:error` 作為 panel 淡出訊號
4. **階段 6 WebSocket 訊號**:需要在 Go WebSocket hub 的 `OnConnect` 事件中,若是**這個 process 生命週期的第一次 client 連線**emit `startup:stage {id: 6, state: 'done'}`;後續連線不再 emit
5. **階段 5 Linux 跳過時**Go 端若讀到 `Preferences.OpenBrowserOnStart == false`emit `startup:stage {id: 5, state: 'skipped'}`
6. **Architect 待補 TDD**:本面板需 TDD 對應章節(建議位於 `04-architecture/v2/startup-progress.md` 或併入 `control-panel.md`)落地事件協議 + Go 端 stage emitter 位置
---
## 10. 與 v2 Starting state 的差異對照
| 面向 | v2第一版 | v2.1 |
|------|-------------|------|
| Starting 視覺 | 只有 header 一顆 spinner + `Starting...` 文字 | 浮出 6 階段進度面板,每階段有 label + description + 狀態 icon |
| 時間上限 | 5 秒超時進 Errorv1 寫死) | **60 秒總上限**R5-E1任一階段 20 秒進 Retry hintR5-E3 |
| 使用者可見性 | 零(只有「啟動中」三字) | 每階段明確的中英雙語 label + description |
| Error state 入口 | 一律從 watchServer 失敗 | 新增「60 秒 timeout」與「階段失敗」兩條路徑都走 Error mode |
| 無障礙 | 基本 `aria-live` | 新增 `role="progressbar"` + `aria-valuenow` + live region 逐階段宣告 |
---
## 11. 懸而未決問題(交 Orchestrator
1. **20 秒 retry hint 的「正在重試」字面**:目前文案寫「正在重試...」retrying但實際上 Go 端不一定真的在 retry可能只是單純慢。是否改為較中性的「正在處理中請稍候... / Still working, please wait...」Design 建議維持「正在重試」—— 使用者對「retry」比「still working」更有信心感即使技術上沒在 retry心理預期是「系統知道有問題、在努力中」。待使用者最終確認。
2. **階段 6 WebSocket 連線訊號失敗的情境**:若 OS open browser 成功但使用者的預設瀏覽器被某些安全軟體攔截Windows Defender SmartScreen 或家長控制),階段 6 永遠等不到 WebSocket → 60 秒後進 Error mode。此時 Error 說明是否應該特別提示「請檢查瀏覽器是否被安全軟體攔截」Design 建議:不做特殊偵測,通用說明 + 看 log details 即可,否則要辨識各種安全軟體失敗 pattern過度設計。
3. **階段卡頓超過 20 秒仍未完成,使用者按 Retry 的語意**:是「重置整個啟動流程」(從階段 1 開始還是「重試當前階段」從當前階段重跑Design 建議:**重置整個啟動流程**簡單明確、使用者心智模型一致Go 端只需要暴露一個 `RestartStartupSequence()` API。待 Architect 確認可行。
---
**下一步**:交 Architect 審閱技術落地事件協議、WebSocket 訊號、Go 端 stage emitter 位置)、交 M8-5 Frontend Agent 實作 Wails 控制台啟動進度面板。

View File

@ -0,0 +1,162 @@
# TDD v2 — visionA-localRound-2 refactor 後)
> 作者Architect Agent
> 版本:**v2.1**
> 日期2026-04-14v2.0 → v2.1 吸收 PM 審閱 + R5-D + R5-E
> 狀態Draftv2.1 由 Architect 根據 PM 互審 + R5-D/E 新決策產出,待三方再次 cross-review + 使用者確認)
> 取代:`TDD.md` v1.02026-04-11四輪修訂 + Plan B 補件)
> 決策來源:`progress.md` §「R5 第五輪使用者決策」+ 「R5-D 補充決策」+「R5-E 階段化啟動新指標」2026-04-14
---
## 0. 版本資訊與變更摘要
### 0.0 v2.0 → v2.1 差異速覽2026-04-14 本次更新)
| # | 變更面向 | v2.0 | v2.1 | 觸發 |
|---|---------|------|------|------|
| 1 | **AC-1.3 啟動時間** | 10 秒硬指標 | **60 秒 + 6 階段進度顯示**20 s soft timeout / 60 s hard timeout| R5-E1~E6 |
| 2 | **自動開瀏覽器行為** | 首次啟動開一次(`autoOpenedThisSession` flag 控制 per-session-once| **每次 Start/Restart 成功都開**(砍 flagR5-D3| R5-D3 |
| 3 | **AutoOpenBrowser 預設值** | 三平台統一 true | **macOS/Windows true、Linux false**(分平台 default| R5-D2 |
| 4 | **Server 崩潰通知** | 僅控制台 Error banner | **Error banner + OS 原生通知並存**`notify.go` 三平台實作)| R5-D1 |
| 5 | **Shutdown grace period** | 5 s → 10 sArchitect Q4 提議)| **7 s + 1 s 顯示「停止中…」modal**PM Q4 定案)| PM Q4 |
| 6 | **Shutdown race condition** | 靠 polling 3 次失敗0.5-15 s 後顯示 overlay | **WebSocket `server:shutdown-imminent` 廣播**(秒內觸發 overlay| PM Minor 4 |
| 7 | **Restart port 處理** | 允許 fallback3721 → 3722 …)| **強制保留舊 port**,被佔用進 Error state | Architect F-2 |
| 8 | **Preferences 持久化** | 提及 atomic write 但未定位置 | **`<dataDir>/preferences.json` + write-rename**(完整 spec| PM §11-1 |
| 9 | **`videoIsURL` field 處置** | 「視情況保留或刪除」 | **明確刪除**grep 證實是 dead code| PM Minor 5 |
| 10 | **idle RAM 450 MB 目標** | 未澄清範圍 | **澄清不含瀏覽器 tab**(只計 Wails + Go server + Python| PM §11-3 |
| 11 | **日常啟動時間估算** | 無 | **新增估算 ~3.8 s**,符合 AC-2.1 ≤ 5 s | PM Minor 2 |
| 12 | **boot-id 生成方式** | 建議 google/uuid | **用 `crypto/rand` 16 bytes → hex**(不引依賴)| Architect Q3 |
| 13 | **navigator.language fallback** | 基本 startsWith('zh') | **強化:處理 C/POSIX/空字串 + hardcoded 英文 fallback** | Architect Q5 |
| 14 | **milestone 數量** | M8-1 ~ M8-1010 個)| **+M8-4b 階段化啟動**11 個) | R5-E |
| 15 | **總工時估算** | ~10 人天 | **~12 人天**R5-E +1 天、R5-D1 +0.3 天、PM Q4 +0.2 天、Minor 4 +0.3 天 + 其他 0.2 天)| 以上 |
| 16 | **新增檔案** | — | **`v2/startup-pipeline.md`**R5-E 實作細節) | R5-E |
**未變更**v2.0 其餘設計保持不動Wails 視窗 = 控制台 UI、瀏覽器 tab 載業務、yt-dlp/Mock 砍除、ffmpeg LGPL 方案 B、CORS 白名單、boot-id 機制、LogBuffer 2000 行 ring buffer、state machine 5 個狀態。
### 0.1 v1 → v2 差異速覽
| 面向 | v12026-04-11 | v22026-04-14 | 觸發決策 |
|------|-----------------|-----------------|---------|
| **Wails 視窗載入內容** | Splash → `window.location.replace` 跳到 Next.js 主 UIM7-B| Splash 路徑完全移除Wails 永遠停在「控制台 UI」server 狀態 / log panel / 啟停控制 / Open in Browser | R5-1 A+B+G |
| **使用者業務 UI 承載者** | Wails WebViewM7-B 是純 HTTP但仍在 WebView 內)| **瀏覽器 tab**Chrome / Safari / Edge`http://127.0.0.1:<port>/` | R5-1 |
| **yt-dlp 全套** | 保留M6 vendor 35 MB + handler + 前端 URL tab| **完全砍除**vendor + handler + frontend UI + i18n + bootstrap + installer payload | R5-7 前置M8-1 |
| **Mock 模式** | 保留(`--mock` flag + `VISIONA_MOCK` env + UI hint + 兩份 i18n | **完全砍除**Go mock driver / mock camera / env var / flag / UI 元件 / i18n keys | R5-5a |
| **ffmpeg 授權** | GPLevermeet / BtbN / johnvansickle`VISIONA_ALLOW_GPL_FFMPEG=1` release blocker | **LGPL 方案 B混合**Win/Linux 換 BtbN LGPLmacOS 自 build decoder-only ~20 MBbinary commit 到 `vendor/ffmpeg/macos/` | R5-6 / R5-6a / R5-6b |
| **ffprobe** | 未打包 | **三平台都一起包**ffmpeg + ffprobe | R5-6c |
| **Tray** | 第三輪 Q-A 砍掉 | 維持砍,不復議 | R5-3 |
| **關閉視窗行為** | Q7=B 傳統式(關 = 結束 app| 維持 Q7=B但新增關閉前先 `StopServer()` 優雅結束;瀏覽器 tab 偵測 server 離線後顯示全域 Offline Overlay | R5-2 |
| **Server 控制 bindings** | 只有 `GetServerStatus` / `GetServerURL` / `OpenBrowser`(隱式 start| 補齊 `StartServer` / `StopServer` / `RestartServer` / `GetRecentLogs` / `ClearLogs` / `GetSystemInfo` 及完整 state machine | R5-1 |
| **watchServer 失敗行為** | 3 次失敗 → `reportFatal` + `os.Exit(1)`app 一起死) | 3 次失敗 → 切 `ServerStateError`Wails app 保留讓使用者手動 Restart 或查 log | 三方共識 #10 |
| **自動開瀏覽器** | — | 首次 server 就緒自動開一次Settings 的 `openBrowserOnStart` 可關 | R5-4 |
| **CORS 政策** | 寬鬆(`Access-Control-Allow-Origin: <任意 Origin>`| 嚴格 whitelist `http://127.0.0.1:*` + `http://localhost:*`;其他 Origin → 不回 ACAO header / OPTIONS 405 | 三方共識 #5 |
| **綁定 interface** | `--host 127.0.0.1` | 維持不動(不做 LAN | R5-1 |
| **上傳影片副檔名** | `.mp4 / .avi / .mov` | `.mp4 / .avi / .mov / .mpeg / .mpg`(瀏覽器能吃 + Kneron pipeline 吃得到的交集) | 三方共識 #11 |
| **Boot-ID 機制** | 無 | 新增 `GET /api/system/boot-id`server 啟動時產生 UUID瀏覽器每 5 s pollboot-id 變更 → force reload | 三方共識 #14 |
| **控制台 UI 技術選型** | — | vanilla HTML/JS/CSS從現有 `visiona-local/frontend/` splash 改寫 | 三方共識 #7 |
| **沿用率** | — | **85-95%**(詳見 `v2/code-reuse-v2.md` | 三方共識 #1 |
| **總工時** | — | **~10 人天**,拆成 10 個 milestone詳見 `v2/milestone-plan.md` | 三方共識 #1 |
### 0.2 v1 未變的決策v2 繼續沿用)
- 三層程序模型Wails 殼 + Go server 子行程 + Python sidecar— D1
- Python runtime 雙策略bundled / system / auto— D2
- 完全放棄程式碼簽章macOS ad-hoc、Windows 無 Authenticode、Linux 無簽章)— D3
- x86_64 only三平台都不做 ARM — D4
- 預置模型全部打包(~73 MB不做 auto-update、不收 telemetry — D5
- 資料目錄位置、single-instance lock、舊資料目錄遷移、IPC raise 機制全部保留
- 首次安裝 ≤ 5 分鐘、首次推論 ≤ 30 s / 回訪 15 s 等 NFR 目標不變
- Ubuntu 與 Windows 打包流程AppImage / Inno Setup不變
- 中英雙語(前端)機制不變,控制台 UI 使用同一份 `en-US` / `zh-TW` JSON詳見 `v2/control-panel.md`
---
## 1. 新架構總覽
### 1.1 三層進程模型
```
Wails 殼(桌面控制台 UI
└─spawn─▶ Go server 子行程Gin HTTP + WebSocket on 127.0.0.1:random_port
├─spawn─▶ python3 kneron_bridge.pyKneronPLUS SDKsidecar
└─spawn─▶ ffmpeg / ffprobeon-demand 解碼)
Wails 殼 ←── IPC / Wails bindings ── Wails 視窗內的控制台 UIvanilla HTML/JS/CSS
Go server ←── HTTP / WebSocket over loopback ── 瀏覽器 tabNext.js Web UI業務操作全在這裡
```
**關鍵差別於 v1**Wails 視窗**不**載入業務 UI它是獨立的控制台status / log / start-stop / open-in-browser / preferences。業務 UI 在瀏覽器 tab 跑 `http://127.0.0.1:<port>/` 的 Next.js SPA。
完整 ASCII 架構圖詳見:`v2/control-panel.md` §3控制台 UI wireframe`architect-analysis-round2-refactor.md` §A1v1→v2 資料流對照)。
### 1.2 ServerController State Machine
五個狀態:`Stopped / Starting / Running / Stopping / Error`。轉換由 `ServerController.txMu + mu` 雙 mutex 保護,不可跳過中間狀態。
- **Start**`Stopped|Error → Starting → Running`(失敗走 Error
- **Stop**`Running → Stopping → Stopped`
- **Restart**`Running → Stopping → Stopped → Starting → Running`
- **watchServer 3 次失敗**`Running → Error`v1 是 `os.Exit`v2 改為 Error state 讓使用者手動復原)
完整細節見 `v2/server-lifecycle.md` §5。
### 1.3 資料流摘要
| 情境 | 簡述 |
|------|------|
| **冷啟動 + R5-4 自動開瀏覽器** | Wails `OnStartup` → 常規 seed / lock / IPC → `ServerController.Start()` → spawn server + logPump × 2 → 健康檢查 → `state = Running` → 若 `openBrowserOnStart` 且本 session 首次 → `OpenInBrowser("")` |
| **Log 推送到控制台** | server stdout/stderr → `cmd.StdoutPipe/StderrPipe``logPump` goroutinebufio scanner + 10 ms micro-batch→ 同時 (1) 寫 `logs/server.{stdout,stderr}.log` + (2) append 到 `LogBuffer`ring 2000 行)+ (3) `EventsEmit("log:append", []LogLine)` → Wails JS 訂閱 `EventsOn('log:append', ...)` → log panel 增量 render |
| **Restart 期間瀏覽器 tab 自動重連** | 使用者按 Restart → Stop → Start → server 新 boot-id → 瀏覽器 polling `/api/system/boot-id`5 s intervalPage Visibility API偵測到 id 變 → `window.location.reload()` |
| **關 Wails 視窗 (R5-2)** | `OnBeforeClose` return false → `OnShutdown` → watchCancel → `ServerController.Stop()`SIGTERM → 10 s → SIGKILL→ releaseLock → Wails quit。瀏覽器 tab 的 polling 連續 3 次失敗15 s`<ServerOfflineOverlay>` 顯示「Server 已離線」 |
詳細時序與 Go 實作見 `v2/server-lifecycle.md` §2-9瀏覽器端實作見 `v2/web-ui-offline-overlay.md`
---
## 2. 子檔案地圖
| # | 子檔 | 目的 | 對應 R5 決策 / M8 milestone |
|---|------|------|-----------------------------|
| 2.1 | [`v2/control-panel.md`](./v2/control-panel.md) | Wails 控制台 UI + Go App bindings + LogBuffer + log pump + 狀態機 + PreferencesR5-D2/D3+ OS 通知觸發點 | R5-1, R5-5, R5-D1/D2/D3, R5-EM8-4, M8-4b, M8-5 |
| 2.2 | [`v2/ffmpeg-lgpl.md`](./v2/ffmpeg-lgpl.md) | 三平台 LGPL ffmpeg vendor 策略Makefile patch + macOS build script + 授權檔管理) | R5-6, R5-6a, R5-6b, R5-6cM8-3 |
| 2.3 | [`v2/server-lifecycle.md`](./v2/server-lifecycle.md) | Server 生命週期細節state machine、port 分配F-2 強制保留、pipe 捕捉、7+1 秒 graceful shutdown、boot-id、OS 通知§10、Preferences 持久化§11 | R5-2, R5-4, R5-D1, PM Q4, F-2, PM §11-1/11-3M8-4, M8-9 |
| 2.4 | [`v2/cors-security.md`](./v2/cors-security.md) | CORS whitelist middleware、WS origin check、資料驗證邊界 | 三方共識 #5M8-8 |
| 2.5 | [`v2/deletions.md`](./v2/deletions.md) | yt-dlp / Mock 模式全清單(檔案 / 函式 / 行號 / i18n key / vendor / installerv2.1 修正 `videoIsURL` / `NewVideoSourceFromURL` 明確刪除 | R5-5a, R5-7, PM Minor 5M8-1, M8-2 |
| 2.6 | [`v2/web-ui-offline-overlay.md`](./v2/web-ui-offline-overlay.md) | 瀏覽器 tab 的 `<ServerOfflineOverlay>` 實作polling + **WebSocket shutdown-imminent** 雙管道、重試、SSR 相容 | R5-2 三方共識 #14, PM Minor 4M8-7 |
| 2.7 | [`v2/milestone-plan.md`](./v2/milestone-plan.md) | M8-1 ~ M8-10 + **M8-4b 階段化啟動**;總工時 ~12 人天 | 整體M8-* |
| 2.8 | [`v2/code-reuse-v2.md`](./v2/code-reuse-v2.md) | 逐模組沿用 / 改寫 / 新寫比例表 | 整體 |
| 2.9 | [`v2/startup-pipeline.md`](./v2/startup-pipeline.md) | **v2.1 新增**R5-E 6 階段啟動管線實作event schema、StartupPipeline struct、watcher goroutine、60 s hard timeout / 20 s soft timeout、前端 startup-panel.js| R5-E1~E6M8-4b |
---
## 3. 風險清單v2 更新)
繼承 v1 `risks-and-mitigations.md` 全部風險,以下為 v2 新增或升級的條目:
| # | 風險 | 等級 | 新增/升級 | 緩解 |
|---|------|------|----------|------|
| R-v2-1 | **M7-B M1 驗收漏看 Wails 視窗** 的教訓 — v2 控制台是全新 UI重複踩同樣坑的風險 | 🟠 中 | 新增 | M8-10 驗收 checklist 強制三個檢查:(1) 雙擊 .app / .exe / .AppImage 打開後 Wails 視窗顯示的是控制台 UI不是 splash / wizard / 白畫面);(2) 點 Open in Browser 後瀏覽器確實載入 Next.js(3) 點 Stop 後瀏覽器 tab 能看到 Offline Overlay。每個平台都要做 |
| R-v2-2 | **macOS 自 build ffmpeg 的可重現性** — LGPL 合規稽核時必須能證明 `vendor/ffmpeg/macos/ffmpeg` 是我們在特定 configure flags 下從特定 ffmpeg commit build 出來的 | 🔴 高 | 新增 | `vendor/ffmpeg/macos/BUILD.md` 必須記錄ffmpeg release tag`n7.1`、source tarball sha256、完整 `./configure` line、build hostmacOS version + Xcode CLT version、build date、binary sha256。未來升級 ffmpeg 時要重跑並更新 BUILD.md不能「手改一下再傳」。同步把 configure flags 寫進 `Makefile``vendor-ffmpeg-macos-build` target而非埋在 BUILD.md 內)讓其可 reproduce |
| R-v2-3 | **Wails EventsEmit 在高頻 stdout 下丟事件或延遲** — server boot 時 Gin / logger 一次可能噴 200+ 行;推論 frame log 若誤進 stdout 會產生秒級 30-100 行 | 🟠 中 | 新增v1 F-4 升級)| (1) logPump 加 micro-batch緩存 10 ms window 內的行,一次 emit 一個 `log:append` eventpayload 為陣列);(2) server 推論 frame 狀態禁止用 logger.Info改用 debug levelline-rate 測試會檢查此條);(3) 若 LogBuffer 滿 > 80% 時 logPump 降為只寫檔不 emit event控制台看到「…skipped N events…」提示使用者可 Clear Logs。實作細節見 `v2/control-panel.md` §4 |
| R-v2-4 | **Wails 關閉視窗 → StopServer 過程中瀏覽器 tab 的 race condition** — 使用者按 × → Wails OnBeforeClose → ServerController.Stop() → SIGTERM → wait → SIGKILL → Wails 退出,**整段 ~0.5-5s 內**瀏覽器 tab 的 polling 可能看到 ECONNREFUSED 但 Overlay 還沒觸發(需連續 3 次失敗15 s 才顯示) | 🟡 低 | 新增 | 實務上:使用者關了 Wails 視窗通常也會關瀏覽器 tabrace 不構成實際問題。若使用者真的沒關瀏覽器15 s 後 Overlay 會出現使用者看到「Server 已離線」訊息即可理解。不做額外優化Wails 關閉前主動告知瀏覽器 — 需要 Wails → Browser 的 push channel成本太高 |
| R-v2-5 | **macOS 自 build 的 ffmpeg 需要 codesign** — Gatekeeper 會擋未簽章的執行檔 | 🟠 中 | 新增 | (1) 在 macOS build script 最後做 `codesign --force --sign - ...`ad-hoc sign(2) `wails-macos` target 的 `codesign --force --deep --sign - visiona-local.app` 已覆蓋 Resources/bin 下的執行檔,沿用即可;(3) 驗收時用 `spctl --assess --verbose vendor/ffmpeg/macos/ffmpeg` 確認不會被 Gatekeeper 拒絕 |
| R-v2-6 | **boot-id polling 對瀏覽器 tab 休眠的影響** — Chrome 會把背景 tab 的 setInterval 降頻到 1 次/分鐘,可能讓使用者切回 tab 時 60 s 才偵測到 server 重啟 | 🟡 低 | 新增 | 用 Page Visibility APItab visible → 5 s intervaltab hidden → 停 pollingtab 再次 visible → 立即 probe 一次再恢復 5 s interval。實作細節見 `v2/web-ui-offline-overlay.md` §3 |
| R-v2-7 | **砍 Mock 後空白 UI 體驗** — 使用者第一次打開沒插硬體時Devices 頁會是空的 | 🟡 低 | 新增 | (1) Devices 頁顯示友善 empty state「未偵測到 Kneron 裝置。請連接 KL520/KL720 後按『掃描』。」附安裝 driver 按鈕Windows(2) 這是 R5-5a 明示接受的結果PRD v2 也會記錄為預期行為 |
v1 已列、v2 解除的風險:
- **ffmpeg GPL release blockerF-5, R9** — R5-6 LGPL 方案 B 解除
- **F-1 Wails tray 在 Linux GNOME 可能壞掉** — R5-3 維持砍 tray風險消失
- **F-3 使用者找不回 app** — R5-2 維持關閉 = 結束,風險消失
---
## 4. 審閱紀錄
| 日期 | 審閱者 | 結論 |
|------|-------|------|
| 2026-04-14 | Architect Agent | v2.0 Draft 產出 |
| 2026-04-14 | PM Agent | v2.0 互審完成(見 `reviews/pm-review-of-tdd-v2.md`)— Major × 4 / Minor × 5 |
| 2026-04-14 | Architect Agent | **v2.1** 產出:吸收 PM Major/Minor 修正 + R5-D 三條補充決策 + R5-E 階段化啟動 + Architect 自補清單F-2 port 保留 / B-1 OS 通知 / Q1/Q3/Q5/Q7 自決)。新增 `v2/startup-pipeline.md`;總工時 ~10 → ~12 人天 |
| 2026-04-14 | PM Agent | v2.1 待再次審閱(確認 Major × 4 都已落地 + R5-D/E 理解一致)|
| 2026-04-14 | Design Agent | v2.1 待審重點R5-E5 啟動階段文案、7+1 秒 stopping modal 文案、startup-panel.js 視覺對齊 Design Spec v2.1|
| 2026-04-14 | 使用者 | 待確認 |

View File

@ -0,0 +1,798 @@
# Architect 第一輪分析 — visionA-local 方向變更2026-04-14
> 作者Architect Agent
> 狀態Round-2 refactor 的第一輪筆記(非正式 Design Doc / TDD
> 目的:針對使用者提出的方向重大變更做技術可行性評估 + 方案選項 + 風險標記
> 前置閱讀:`design-doc.md` 索引 + `architecture-overview.md` + `tray-and-lifecycle.md` + `dependency-bundling.md` + `risks-and-mitigations.md` + `progress.md` + `visiona-local/app.go` + `server/main.go`
---
## 摘要3 行)
1. **好消息:技術上非常可行,而且大部分搬遷成本已經被 M7-B 提前付掉**。前端現在已經是純 HTTP從 Wails WebView 搬到 Browser tab 只差「不把同一個網址 redirect 進 Wails」這一步。
2. **砍量顯著**yt-dlp 整包可丟vendor 87MB + resolver 程式碼 + frontend URL tab安裝檔從 220MB 降到 ~135MBWails 內嵌的控制台 UI 改寫成 3 頁獨立 shelllog / server 控制 / 開瀏覽器)。
3. **但 lifecycle 決策必須復議**Q-A砍 tray與 Q7關閉視窗=結束 app都站不住腳了——新方向的核心是「Wails 視窗只是控制台server 是主角」,控制台關掉不能殺 server否則瀏覽器 tab 瞬間斷線。這點若不先跟使用者談清楚,後面做出來會有嚴重體驗落差。
---
## A. 技術影響範圍
### A1. 新架構圖ASCII
```
┌─────────────────────────────────────────────────────────────────────┐
│ visionA-local.app (Wails Control Console — 桌面殼) │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 控制台 UIHTML/CSS/JSgo:embed 進 Wails binary │ │
│ │ ┌──────────────┐ ┌───────────────┐ ┌─────────────────┐ │ │
│ │ │ Server 狀態 │ │ Log panel │ │ 動作列 │ │ │
│ │ │ ● Running │ │ [ring buffer] │ │ [Start] [Stop] │ │ │
│ │ │ Port: 3721 │ │ scrollable │ │ [Restart] │ │ │
│ │ │ PID: 42131 │ │ filter/search │ │ [Open Browser] │ │ │
│ │ │ Python: sys │ │ clear / save │ │ [Reveal Logs] │ │ │
│ │ └──────────────┘ └───────────────┘ └─────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ↑ Wails runtime (IPC) │
│ │ - Bindings: StartServer/StopServer/... │
│ │ - Events: server:log, server:status, │
│ │ server:dead, server:recovered │
│ ┌─────────────────┴───────────────────────────────────────────┐ │
│ │ App (Go, app.go) │ │
│ │ - Lifecycle / single-instance / data-dir migration │ │
│ │ - Server process manager (spawn/kill/watch) │ │
│ │ - Log pump: stdout/stderr → ring buffer → EventsEmit │ │
│ │ - Python runtime 雙策略、driver installer │ │
│ └──────────────────────┬──────────────────────────────────────┘ │
└───────────────────────────│──────────────────────────────────────────┘
│ exec.Command(...) pipe stdout/stderr
┌─────────────────────────────────────────────────────────────────────┐
│ visiona-local-server (Gin on 127.0.0.1:random_port) │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ REST API (/api/*) + WebSocket (/ws/*) │ │
│ │ - /api/devices/* /api/models/* /api/inference/* │ │
│ │ - /ws/devices/events /ws/inference/frames /ws/server-logs │ │
│ │ Next.js UI (go:embed) —— 使用者業務介面 │ │
│ │ - Dashboard / Devices / Models / Workspace / Settings │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │ ▲ │
│ │ spawn │ HTTP/WS │
│ ▼ │ │
│ ┌──────────────────────────────┐ │ │
│ │ python3 kneron_bridge.py │ │ │
│ │ (KneronPLUS SDK) │ │ │
│ └──────────────────────────────┘ │ │
│ │ │
│ ┌──────────────────────────────┐ │ │
│ │ ffmpeg (on demand) │ │ │
│ │ (webcam / 上傳影片解碼) │ │ │
│ └──────────────────────────────┘ │ │
└──────────────────────────────────────┼──────────────────────────────┘
│ HTTP/WS over loopback
┌────────────────────────────────────────┐
│ Browser tab (Chrome / Safari / Edge) │
│ http://127.0.0.1:<random_port>/ │
│ │
│ Next.js SPA (fetch + WebSocket) │
│ - 所有業務操作在這裡 │
└────────────────────────────────────────┘
```
**資料流關鍵差異vs. 現況)**
| 項目 | 現況M7-B| 新方向 |
|------|-------------|--------|
| Wails WebView 載入 | splash → `window.location.replace(http://127.0.0.1:port/)` 跳 Next.js 主 UI | splash 邏輯移除Wails 永遠停在「控制台 UI」不跳轉 |
| 使用者業務操作 | Wails WebView 內(但已經是純 HTTP| 另開 Browser tabWails 不碰業務 UI |
| Server stdout/stderr | 寫進 `logs/server.stdout.log` + `server.stderr.log`file only| **同時**管到 ring buffer → Wails events 給控制台 log panel |
| 關閉 Wails 視窗 | Q7=B關 = 殺 server + 殺 app | **必須改**:關 = 隱藏到 tray 或 minimize真正結束需透過「Quit」按鈕 |
| yt-dlp pipeline | vendor 87MB + `ResolveWithYTDLP` + `/api/media/url` + 前端 URL tab | **整條砍** |
| 瀏覽器端情境 | 使用者若自己連 `http://127.0.0.1:port/` 也可以用(未文件化)| **一級公民**Wails 的「Open in Browser」會主動導引 |
**Lifecycle 狀態機**
```
┌──────────────┐
│ app launched │
└──────┬───────┘
┌────────────────────────┐
│ acquire single-instance│
└──────┬─────────────────┘
┌──────────────────┐
│ seed user-data │
└──────┬───────────┘
┌─────────────────────┐
│ ensure python │
└────┬────────────────┘
┌──────────────────────┐ ┌───────────────┐
│ STATE: Starting │──OK──▶│ STATE: Running │◀──┐
│ spawn server │ └──┬─────────────┘ │
│ wait /health │ │ │
└──┬───────────────────┘ │ user: Stop │
│ error ▼ │
▼ ┌───────────────┐ │
┌──────────────────┐ │ STATE: Stopping│ │
│ STATE: Error │ │ SIGTERM+wait │ │
│ show error in │ └──┬─────────────┘ │
│ console, wait │ ▼ │
│ for user action │ ┌───────────────┐ │
└──┬───────────────┘ │ STATE: Stopped │ │
│ └──┬─────────────┘ │
│ user: Retry │ user: Start │
▼ └───────────────────────▶│
(back to Starting) │
user: Restart ─────────▶ Stop then Start ──────────────┘
watchServer() 失敗 3 次 ──▶ STATE: Error非 reportFatal + os.Exit
```
跟現在最大的不同:**watchServer 觸發 3 次失敗時,不能再直接 `reportFatal + os.Exit(1)`**——那只是 server 死掉,使用者的控制台還有用,應該切到 Error state 等使用者手動 Restart。
---
### A2. Wails 視窗載入什麼(方案比較)
| 方案 | 描述 | 成本 | 風險 | 推薦? |
|------|------|------|------|-------|
| **A1繼續 go:embed 極簡 HTML/JS/CSS**(沿用現有 splash 路徑)| 直接改寫 `visiona-local/frontend/` 的 splash 成真正的控制台 UI保留 ES module + Wails bindings + events | ⭐ 最低(~200 行 JS、一張 index.html、一份 style.css| 控制台要長成什麼樣要自己刻,少 shadcn 之類 lib。但這是 3 個 widget 的頁面,手刻 1-2 天完成 | **✅ 推薦** |
| **A2Go server 內的 `/control` 路由**(和主 UI 同 server瀏覽器看不到| 把控制台 UI 也塞進 `server/web/out/`,用 Gin 中介層鎖「只有 `X-Wails-Control: 1` header 才能看」 | ⭐⭐⭐ 高(要兩個 SPA 共用 Next.js project + middleware 阻擋瀏覽器存取)| **根本性問題**server 還沒起來時Wails 視窗要載什麼Chicken-and-egg。而且 server 死掉時控制台也跟著死,等於沒控制台可用 | ❌ 否決 |
| **A3獨立的 Vite/Next.js mini project**(專為 Wails 控制台)| 新開一個 `visiona-local/console-ui/` 有自己的 build pipeline | ⭐⭐ 中(新增 node build 依賴、Tailwind/shadcn 可復用、更好看)| 對一個只有 3 個 widget 的頁面工具鏈太重。build 時間變長(~30s| ⚠️ 不推薦(除非要做很多控制台 UI |
| **A4純原生 Wails UI不 embed 任何網頁)**| 用 Wails 的 native 模式(但 Wails v2 不支援真原生)| — | Wails v2 一律要有 embedded WebView這方案根本不存在 | ❌ 技術上不可行 |
**推薦 A1**。理由:
- 控制台複雜度低status text + log scrollview + 4 個按鈕),手刻 vanilla 最直接
- 已經有現成 splash 範本app.js + style.css + index.html改寫比從零快
- 不引入 Next.js / Vite build 依賴Wails binary 體積不受影響
- Log panel 不需要 shadcn用原生 `<pre>` + CSS 就夠
- 未來若真的要加很多功能再重構成 A3
**必要改動**
```
visiona-local/frontend/
├── index.html ← 改寫成控制台 layout
├── app.js ← 改寫:訂閱 server:log/status events呼叫 Start/Stop/Restart bindings
├── style.css ← 新增 log panel 樣式
├── components/ ← 視需要拆出來log-panel.js / action-bar.js
└── wailsjs/ ← 自動生成,不動
```
---
### A3. Log 顯示機制
**核心技術決策**:伺服器 stdout/stderr 同時寫檔 + 寫 ring buffer + 推送 Wails event。
#### 現況app.go 481-516 行)
目前 `startServer()` 是把 server 的 stdout/stderr 直接 assign 給 `os.File`
```go
cmd.Stdout = stdoutLog // os.File, 寫到 logs/server.stdout.log
cmd.Stderr = stderrLog
```
**限制**
- 沒有任何地方能「讀」這些 log 回來
- Wails 前端拿不到 server log
- 使用者看 log 必須自己去 `~/Library/Application Support/visiona-local/logs/`
#### 新方案:多重 writer + 環形緩衝
```go
type LogBuffer struct {
mu sync.Mutex
lines []LogLine // ring buffercap=2000
head int
size int
subs map[uint64]chan LogLine
nextSub uint64
}
type LogLine struct {
Ts time.Time `json:"ts"`
Stream string `json:"stream"` // "stdout" / "stderr"
Line string `json:"line"`
Level string `json:"level,omitempty"` // 解析過的 levelinfo/warn/error
}
// 每條新 log
// 1. 寫入 logs/server.{stdout,stderr}.log持久化
// 2. append 進 ring buffer供初次載入 + "過去 2000 行"
// 3. broadcast 給訂閱者Wails events
func (b *LogBuffer) Write(stream, line string) { ... }
```
`startServer()` 改成:
```go
stdoutPipe, _ := cmd.StdoutPipe()
stderrPipe, _ := cmd.StderrPipe()
go a.logPump(stdoutPipe, "stdout", stdoutLog)
go a.logPump(stderrPipe, "stderr", stderrLog)
```
`logPump` 同時寫檔 + 寫 ring buffer + EventsEmit
```go
func (a *App) logPump(pipe io.ReadCloser, stream string, fileWriter io.Writer) {
scanner := bufio.NewScanner(pipe)
scanner.Buffer(make([]byte, 64*1024), 1024*1024) // 避免長 log line 爆
for scanner.Scan() {
line := scanner.Text()
fmt.Fprintln(fileWriter, line)
a.logBuf.Append(stream, line)
wailsRuntime.EventsEmit(a.ctx, "server:log", map[string]any{
"stream": stream,
"line": line,
"ts": time.Now().UnixMilli(),
})
}
}
```
#### 前端訂閱app.js in Wails frontend
```javascript
import { EventsOn } from '../wailsjs/runtime/runtime.js';
import { GetRecentLogs } from './wailsjs/go/main/App.js';
// 1. 初次載入:抓 ring buffer 裡已累積的行
const initial = await GetRecentLogs(2000);
logPanel.append(initial);
// 2. 持續訂閱
EventsOn('server:log', (line) => logPanel.append([line]));
```
#### 關鍵參數與風險
| 項目 | 值 | 理由 |
|------|----|------|
| Ring buffer 大小 | 2000 行 | ~200KB 記憶體,足以涵蓋「開啟控制台時看到最近 10 分鐘」 |
| 單行最大長度 | 1MB | 防止超長 log堆疊追蹤爆 scanner |
| Events 批次 | 每行 emit 一次 | 初版先簡單,若壓力大再改 micro-batch10ms window |
| 持久化 | 仍寫 `logs/server.{stdout,stderr}.log` | 不改現況;控制台 log panel 只是即時視圖 |
| Log 檔 rotation | 暫不做M1 就沒做)| 已在 `tray-and-lifecycle.md` 風險清單。建議跟這次重構一起做:按大小 rotate10MB × 5 份)|
**高頻 log 壓力評估**
- 推論一次 frame 若列一行 log → 30 fps × 即時 stream = 30 events/sec
- Wails IPC 在 macOS 上實測可輕鬆吞 >1000 events/sec
- 不是瓶頸。但**推論的 frame 狀態不應該用 log 傳**——server 應該用 WebSocket `/ws/inference/frames`
- 控制台只看 app 級別 log不看 per-frame log
**⚠️ 待確認**:目前 `server/pkg/logger` 是否有把 `per-frame` 塞進 stdout需要翻一下。若有必須先降級成 debug-only。
---
### A4. Server 生命週期控制
#### 目前狀態(`app.go`
- `startServer()`spawn → health check → 寫 ipc-port → 啟動 watchServer
- `stopServer()`cancel watch goroutine → `proc.stop()`SIGTERM → 5s → SIGKILL
- **沒有 Restart**。要重啟只能重開整個 Wails app
- `watchServer()` 3 次失敗 → `reportFatal``os.Exit(1)` → 連 Wails app 一起死
#### 新方案:補齊 Start/Stop/Restart彼此互斥
新增 bindings
```go
// Wails bindings
func (a *App) StartServer() error // 若 Runningno-op
func (a *App) StopServer() error // 若 Stoppedno-op
func (a *App) RestartServer() error // stop then start
func (a *App) GetServerState() ServerState // Running | Stopping | Starting | Stopped | Error
```
實作約束:
1. **互斥**:用 `sync.Mutex` 保護 state 欄位,任何時候只能有一個操作進行中
2. **Restart 期間 port 可重用**`pickPort(preferredPort=<old_port>)`,但若被別的程序搶了就用新 portipc-port 檔必須更新
3. **Python runtime 不重跑**`a.pythonBin` / `a.pythonModeR` 已 resolvedStop 後留著Start 時直接重用(省 5-10s
4. **Log buffer 不清空**Stop 時只 reset `server:status` eventlog panel 保留既往 log加個分隔線
#### Watch 行為變更(**重大修改**
現在:
```go
if failures >= 3 {
a.reportFatal("server died", ...) // os.Exit(1) — 連 Wails app 一起死
return
}
```
新版:
```go
if failures >= 3 {
a.setServerState(ServerStateError, "health check failed 3 times")
wailsRuntime.EventsEmit(a.ctx, "server:dead", ...)
// 不 os.Exit讓控制台 UI 顯示錯誤狀態,使用者可手動 Restart / 查看 log
return
}
```
這個是 **Q7 決策復議** 的關鍵理由之一(見 C2
- 現況的邏輯假設「沒有 server 就沒有 app」但新方向的 Wails app 就是要提供「server 死掉後給人看 log + 決定下一步」的地方
- 保留 reportFatal 只留給「完全無法啟動」的致命錯誤(例如 data-dir 建不起來、lock 取不到)
#### Restart 期間的瀏覽器 tab
這是體驗層的核心問題。Restart 過程(最快 ~3s含 SIGTERM grace + 新 server spawn + health check
```
t=0.0s 使用者按 Restart
t=0.0s SIGTERM → 舊 server
t=0.5s 舊 server 優雅結束
t=0.5s spawn 新 server可能是同 port
t=2.0s health check 通過
t=2.0s 寫新 ipc-port若 port 變更)
```
瀏覽器 tab 在 t=0~2s 期間的請求會 connection refused / ECONNREFUSED。方案
| 方案 | 描述 | 推薦? |
|------|------|-------|
| **R1前端 retry + toast** | Next.js 全域 fetch interceptor 偵測 ECONNREFUSED重試 5 次 × 500ms同時顯示 toast「伺服器重啟中」 | ✅ 推薦,且這不是大工 |
| **R2Wails 代理 proxy** | 讓 Wails app 自己起一個固定 port 做 reverse proxybackend 切換時透明處理 | ❌ 成本太高,且 Wails 關了瀏覽器更慘 |
| **R3不處理**| 使用者看到 ERR_CONNECTION_REFUSED 錯誤頁,自己按 reload | ❌ 體驗爛 |
R1 可搭配「port 若變更時 Wails 控制台 emit event瀏覽器透過 WS 接收後 location.reload()」,但要注意瀏覽器 tab **不可能**透過 Wails bindings 收事件——它就是個 HTTP client。解法server 啟動後暴露 `/api/system/boot-id` endpoint前端 poll 比對,不一致就 reload。
---
### A5. Open in Browser 機制
#### 現況(`platform_*.go`
`openBrowser(url string)` 已經實作,`app.go:329``OpenBrowser` binding。實際定義在 `platform_darwin.go` / `platform_linux.go` / `platform_windows.go`
- macOS`exec.Command("open", url).Start()`
- Linux`exec.Command("xdg-open", url).Start()`
- Windows`rundll32 url.dll,FileProtocolHandler url``cmd /c start url`
**可直接沿用,不用重寫**。
#### 要加的東西
1. **URL 組合**`http://127.0.0.1:<actual_port>/`,從 `a.server.port`
2. **控制台按鈕**:呼叫 `OpenBrowser(url)` binding
3. **自動開啟(選配)**:使用者決定要不要在 server 就緒後自動開瀏覽器
- 預設「就緒後自動開一次」(符合 Ollama / Docker Desktop 行為)
- Settings 可關閉
- 避免每次 Restart 都多開一個 tab要記錄「這個 session 已自動開過」)
4. **多次點擊** :瀏覽器會自動 reuse 同一個 tab若 URL 相同),我們不用處理
#### Wails runtime 有沒有 BrowserOpenURL
Wails v2 runtime 有 `BrowserOpenURL(ctx, url)` 可用,但它底層也是 shell out。我們現有的 `openBrowser` 已經封裝得跟 Wails 一樣,不必換。
---
### A6. 依賴瘦身
#### 可以砍的
| 項目 | 大小 | 來源 | 原因 |
|------|------|------|------|
| `vendor/yt-dlp/` | **87MB** | M6-2 | 新方向完全不用 URL 推論 |
| `server/internal/camera/video_source.go``ResolveWithYTDLP` | <1KB | | yt-dlp 呼叫點 |
| `server/internal/api/handlers/camera_handler.go``ytdlpHosts` / `classifyVideoURL` / `StartFromURL` | ~100 行 | — | URL classify + resolve |
| `server/internal/api/router.go``api.POST("/media/url", ...)` | 1 行 | — | endpoint |
| `server/internal/deps/checker.go``check("yt-dlp", ...)` | ~3 行 | — | deps 檢查 |
| `server/main.go` 的 yt-dlp PATH 注入註解 | — | — | 註解 |
| `frontend/src/components/camera/source-selector.tsx``videoMode === 'url'` 分支 | ~50 行 | — | UI |
| `frontend/src/stores/camera-store.ts``startFromUrl` | ~25 行 | — | store action |
| `frontend/src/lib/i18n/{zh-TW,en,types}.ts``camera.pasteUrl` / `urlPlaceholder` / `urlHelpText` 等 key | ~10 keys × 2 lang | — | i18n |
| payload-*.sh 中的 `vendor-ytdlp` 相關 stage 步驟 | — | M6-3 | 打包 |
| `Makefile``vendor-ytdlp` target | — | M6-2 | build |
| `server/internal/deps``VISIONA_BUNDLE_BIN_DIR` 對 yt-dlp 的偵測 | ~10 行 | M6-4 | env 注入 |
**總砍量**
- Binary size**~87MB**dmg 從 220MB 降到 ~135MB
- Source~200 行 Go + ~100 行 TS/TSX + 10 幾個 i18n keys
#### 必須保留的
**ffmpeg 必須留**。新需求:「上傳影片 avi/mpeg/mp4」。這些格式的解碼靠 ffmpegKneron pipeline 吃的是 ffmpeg 產出的 MJPEG frame stream。現狀 `UploadVideo` handler 已支援 `.mp4 / .avi / .mov`,再加個 `.mpeg / .mpg` 即可。
**但 ffmpeg GPL release blocker 還在**。狀況比之前更尷尬:
- 之前 URL 推論是賣點之一GPL 對使用情境比較 justify
- 現在 ffmpeg 的唯一用途是「解碼一個使用者自己選的本地檔」,使用情境非常窄
- **機會**使用情境變窄後LGPL build 的 feature set 更容易覆蓋(不需要 x264 encoder只需要 decoder
- **建議**:這次 refactor 是重新談 ffmpeg GPL 的好時機。可以考慮切到 LGPL build 或甚至改用 Go 純粹解碼(例如 `github.com/zergon321/reisen` 包 libav-via-Go但還是需要 ffmpeg 的 lib...
**Camerawebcampipeline**:也需要 ffmpegmacOS 用 AVFoundation device、Windows 用 dshow、Linux 用 v4l2。這條保留。
#### 決策點
- **yt-dlp** — 砍,無爭議
- **ffmpeg** — 保留,但 LGPL 切換評估
---
### A7. 程式碼變更清單(砍哪些 / 改哪些)
#### 砍(刪整段 / 整檔)
| 檔案 | 改動 | 行數 |
|------|------|------|
| `vendor/yt-dlp/` 整個目錄 | 刪 | 87MB |
| `Makefile` `vendor-ytdlp` target + `payload-*` 的 yt-dlp stage 段 | 刪 | ~20 行 |
| `server/internal/camera/video_source.go` | 砍 `ResolveWithYTDLP` + `friendlyYTDLPError` | L91-L160 ~70 行 |
| `server/internal/api/handlers/camera_handler.go` | 砍 `ytdlpHosts` map + `urlKind` enum + `classifyVideoURL` + `StartFromURL` + `stopActivePipeline` 裡的 URL cleanup若有| L341-L500 ~160 行 |
| `server/internal/api/router.go` | 刪 `api.POST("/media/url", ...)` | L83 1 行 |
| `server/internal/deps/checker.go` | 刪 yt-dlp check entry | L30-L32 3 行 |
| `server/main.go` | 刪 yt-dlp PATH 相關註解(若 binDir 已放其他東西則 binDir 注入保留)| ~5 行 |
| `frontend/src/components/camera/source-selector.tsx` | 砍 `videoMode``'url'` 分支、`pasteUrl` 按鈕、URL input、helper text | L51, L190-L260 ~70 行 |
| `frontend/src/stores/camera-store.ts` | 砍 `startFromUrl` action + 相關 state | L32, L167-L195 ~30 行 |
| `frontend/src/lib/i18n/zh-TW.ts` / `en.ts` / `types.ts` | 砍 `camera.pasteUrl` / `urlPlaceholder` / `urlHelpText` / 相關 errors | ~12 行 × 3 |
#### 砍(新方向要求)
| 檔案 | 改動 |
|------|------|
| `frontend/src/stores/model-store.ts`(視 UX 決策)| 確認「上傳模型」對應的只是使用者自訂 .nef 路徑。使用者說「模型除了預設的幾種只能用上傳的」= 保留現況(預置 + 上傳),不砍 |
#### 改
| 檔案 | 改動 | 預估行數 |
|------|------|---------|
| `visiona-local/frontend/index.html` | 改寫成控制台 layoutstatus / log panel / actions| ~80 行 |
| `visiona-local/frontend/app.js` | 改寫:訂閱 server:log events、呼叫 Start/Stop/Restart、OpenBrowser**移除** 自動跳轉邏輯 | ~150 行 |
| `visiona-local/frontend/style.css` | log panel + buttons + status badge 樣式 | ~150 行 |
| `visiona-local/app.go` | 新增 LogBuffer + logPump + StartServer/StopServer/RestartServer bindings + GetRecentLogs + ServerStatewatchServer 不再 os.Exit | ~300 行新增 / 20 行刪改 |
| `visiona-local/main.go` | 加 EventsEmit 支援(已有);若需要 tray 則補 options.App SystemTray | ~10 行 |
| `server/internal/api/handlers/camera_handler.go` `UploadVideo` | 擴充支援的副檔名:`.mp4, .avi, .mov, .mpeg, .mpg`(「瀏覽器能吃的」集合與 Kneron pipeline 吃得到的集合的交集)| L251 1 行改 |
| `frontend/src/components/camera/source-selector.tsx` | `accept=".mp4,.avi,.mov,.mpeg,.mpg"` | 1 行 |
#### 新寫
| 檔案 | 用途 |
|------|------|
| `visiona-local/log_buffer.go` | Ring buffer + pubsub |
| `visiona-local/server_control.go` | StartServer / StopServer / RestartServer + state machine |
| `visiona-local/frontend/components/log-panel.js` | Log 顯示元件(可選,可與 app.js 合併)|
---
### A8. 控制台 UI 技術選型(詳細)
承 A2 推薦 A1vanilla JS/HTML/CSS這裡補細節。
**UI 結構建議**
```
┌─────────────────────────────────────────────────────────────────┐
│ visionA-local │
├─────────────────────────────────────────────────────────────────┤
│ Server Status │
│ ● Running • http://127.0.0.1:3721 • PID 42131 │
│ Python: system (/usr/bin/python3.12) │
├─────────────────────────────────────────────────────────────────┤
│ [▶ Start] [■ Stop] [⟲ Restart] [🌐 Open in Browser] │
│ [📁 Reveal Logs] │
├─────────────────────────────────────────────────────────────────┤
│ Server Log [Pause] [Clear] [Save] │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ 14:23:01 [INFO] visiona-local-server starting on :3721 ││
│ │ 14:23:01 [INFO] python bridge path: /usr/bin/python3.12 ││
│ │ 14:23:02 [INFO] loaded 8 preset models ││
│ │ 14:23:02 [INFO] HTTP server listening on 127.0.0.1:3721 ││
│ │ 14:23:15 [INFO] GET /api/system/info 200 2.1ms ││
│ │ ... ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
```
**元件清單vanilla**
- Status card一個 `<div>` + CSSdata 從 `GetServerStatus()` 輪詢(或更好:`EventsOn('server:status', ...)`
- Action bar5 顆 button對應 5 個 bindings
- Log panel`<pre class="log">` with auto-scroll + virtual scrolling不需要第三方~100 行 JS 手刻)
- Pause button停止 auto-scroll方便使用者檢查過去的 log
- Filtertext inputclient-side substring filterM2 再加)
- Save把 ring buffer dump 成 txt
**Dark mode**跟隨系統M1 已有 `prefers-color-scheme: dark`)。
**i18n**:中英雙語,走 `navigator.language` 判斷 + 硬 code 兩份文字(不用引 i18next。或直接用現有 `server/web/out` 的 i18n 資料檔(不推薦,兩邊耦合)。
**預估工作量**1.5-2 人天。
---
### A9. 資料安全 / 綁定 interface
#### 現況
`server/main.go` 的預設 `--host` 是什麼?從 `config.go` 看應是可 flag 控制。`visiona-local/app.go:468` 明寫:
```go
args := []string{
"--host", "127.0.0.1",
...
}
```
**目前是強制 127.0.0.1**,瀏覽器只能從同一台機器連。
#### 新方向的安全情境
新方向把「可在瀏覽器使用」變成 first-class會不會有人希望能從區網另一台電腦連例如PM 在 Mac 上跑 visionA-local工程師想從筆電瀏覽器連進來看推論結果
**方案比較**
| 方案 | 描述 | 風險 | 推薦 |
|------|------|------|------|
| **N1維持 127.0.0.1,不提供選項** | 只能本機連 | 無新風險 | ✅ 預設 |
| **N2Settings 新增「允許區網存取」toggle** | 切 `0.0.0.0` + firewall 提示 | 必須加 auth token否則區網任何人都能控制裝置 / 上傳模型macOS/Windows 會跳 firewall 警告 | ⚠️ 有需求再做 |
| **N3LAN mode with bearer token** | N2 + 自動產生 token + Open in Browser 時帶 token query param + Cookie | 成本:全套 auth middleware + session破壞「純 fetch 沒 auth」的簡潔現況 | ❌ M1-M7 沒這需求 |
**建議 N1**。若使用者有需求M8+ 再加 N2/N3。
#### 127.0.0.1 的隱性安全假設
- 本機其他 process 可以連上 visiona-local-server例如惡意的 Electron app。這是現況就有的風險。
- 沒有 CORS 限制(預設 gin 允許 localhost 跨來源)。在瀏覽器情境下 **要確認**:使用者如果在 Chrome 開了其他網站,那些網站能不能 `fetch('http://127.0.0.1:3721/api/models/upload')`
- 答案:預設 **可以送請求**,但 CORS 預檢會擋(若 server 沒回 `Access-Control-Allow-Origin`),讀不到回應
- **但 POST 是會被送出去的**CSRF risk
- **⚠️ 建議**:這次 refactor 時把 CORS 政策明確定義:只允許 `Origin: http://127.0.0.1:*``Origin: http://localhost:*`。不允許其他 origin。
- 這個議題在「Wails WebView 模式」下沒什麼威脅(因為 WebView 的 origin 是 `wails://`,沒人能偽造),但移到瀏覽器後要當真
---
## B. 方案選項彙總表
| 議題 | 選項 | 推薦 | 工時 | 備註 |
|------|------|------|------|------|
| Wails 視窗載入 | A1 vanilla / A2 server route / A3 mini Vite / A4 native | **A1** | 1.5-2 天 | |
| Log 顯示 | stdout→file only / + ring buffer + events / + WebSocket 分支 | **ring buffer + events** | 0.5 天 | |
| Server 控制 | 現有only start/ 加 Stop/Restart | **加 Stop/Restart + state machine** | 1 天 | |
| Open in Browser | 沿用 `openBrowser` / Wails `BrowserOpenURL` | **沿用** | <1h | |
| yt-dlp | 保留 / 砍 | **砍** | 0.5 天 | |
| ffmpeg | 保留 GPL / 切 LGPL / 純 Go 解碼 | **保留,但安排 LGPL 評估** | — | 跟使用者討論 |
| 影片格式 | `.mp4,.avi,.mov` / + `.mpeg,.mpg` / 所有 ffmpeg 能吃 | **`.mp4,.avi,.mov,.mpeg,.mpg`** | <1h | 對齊瀏覽器能吃 |
| 綁定 interface | 127.0.0.1 / 0.0.0.0 toggle / LAN + token | **127.0.0.1(維持)** | — | |
| CORS 政策 | 不設(寬鬆)/ 只允許 localhost | **只允許 127.0.0.1/localhost** | 0.5 天 | 瀏覽器模式下必做 |
| Lifecycle Q7 復議 | 維持關閉=結束 / 改為關閉=隱藏 / 加 tray | **復議,見 C2** | — | 需要使用者決策 |
| watchServer 失敗行為 | os.Exit / 進 Error state 等使用者 | **Error state** | 0.5 天 | |
---
## C. 和既有決策的衝突
### C1. 砍 tray 決策Q-A=A3是否復議
#### 原本的理由(第三輪決策)
> Q-A Tray 角色衝突Q7 選關閉=結束後 tray 價值變低)→ A3 砍掉 tray省跨平台圖資產與 Wails tray 踩坑。
原本邏輯是「Q7 選關閉=結束程式,所以 tray 的核心價值(關視窗後還能找回 app消失乾脆砍」。
#### 新方向下的衝突
新方向的本質是:**server 是主角Wails app 只是控制台**。這產生幾個矛盾:
1. **使用者主要時間在瀏覽器**。若 Wails 控制台關了,使用者還想停 server 或看 log就沒有入口
2. **使用者不關 Wails 控制台**(因為不想斷 server桌面就一直有個不太用的視窗。體驗像強迫你開著 Docker Desktop 的主視窗
3. **瀏覽器 tab 關了之後**,使用者怎麼再開?如果 Wails 控制台也關了,就得重開 app
**Tray 在新方向下的價值反而提高了**。
#### 方案比較
| 方案 | 描述 | 優點 | 缺點 | 推薦 |
|------|------|------|------|------|
| **T1維持砍 trayWails 視窗必須開著** | 現況 | 不用跨平台 tray 資產、不用處理 Wails systray 踩坑 | 使用者被迫開著一個沒人看的視窗;體驗倒退 | ❌ |
| **T2復活 traymacOS menu bar / Win systray / Linux indicator** | tray icon 有 Show / Hide Console / Open in Browser / Start / Stop / Quit 選單;關 Wails 視窗 = minimize to tray | 主場:使用者可關視窗但保留 server菜單直接提供核心動作 | Wails v2 的 tray 支援有限macOS OK、Windows OK、Linux 看 DE要準備 icon 資產(~10 KB可能會有個別 Linux DE 不支援KDE/GNOME 沒 StatusNotifierItem 的) | ✅ |
| **T3不做 tray改「關視窗 = 隱藏視窗」**app 仍在前景)| Wails 不支援 hide to dock onlymacOS 本來就有 `runtime.WindowHide` 但 Dock 圖示仍在 | 不用 tray 資產 | Linux 下完全找不到那個視窗(沒有 DockWindows 下工作列會殘留空白按鈕 | ⚠️ 只做 macOS 可接受,跨平台不佳 |
| **T4Wails app 當 daemonOpen in Browser 是唯一入口** | 無視窗,只有 tray | 最極致的 server-as-main | Wails v2 不支援「完全無主視窗」模式;實作困難 | ❌ |
**推薦 T2復活 tray**,但要給使用者決策:
- 使用者是否接受跨平台 tray 資產icon × 3 平台 + light/dark 版)
- 是否接受 Linux 下 tray 可能在某些 DE 不 work 的風險fallback視窗仍可關/最小化)
**tray 菜單建議**
```
visionA-local
━━━━━━━━━━━━━━━━━
● Server: Running (3721)
─────────
Show Console ⌘0
Open in Browser ⌘B
─────────
▶ Start Server
■ Stop Server
⟲ Restart Server
─────────
Reveal Logs
About
Quit ⌘Q
```
#### 估工時
- Wails tray 綁定(參考 [wails tray examples](https://wails.io/docs/reference/runtime/systray)~0.5 天
- Icon 資產(原 edge-ai-platform 的 tray-*.png 可復用或新做):~0.5 天
- 菜單行為 + 跨平台測試:~0.5 天
- **合計 1.5 天**
---
### C2. Q7 關閉視窗=結束 app 決策是否復議
#### 原本的理由(第二輪決策)
> Q7 視窗關閉行為 → **B** 傳統式(關閉 = 結束程式)
原本是因為 tray 砍了Q-A又要簡化體驗所以「關視窗 = 結束 app」最直觀。
#### 新方向下的衝突
**非常嚴重**。若維持 Q7=B
```
使用者打開 Wails 控制台 → 按 Open in Browser → 瀏覽器 tab 跑得好好
使用者覺得控制台占地方 → 關掉 Wails 視窗 → Wails shutdown → server 被 SIGTERM → 瀏覽器 tab 所有請求 ECONNREFUSED → 使用者傻眼
```
這不只是體驗問題,是功能失效。
#### 方案
必須改。三個選項:
| 方案 | 關閉視窗行為 | 真正結束 app 的方式 | 推薦 |
|------|------------|---------------------|------|
| **Q7-B1關視窗 = minimize to tray**(配合 T2| hide windowtray 還在 | tray > Quit / 視窗內 Quit 按鈕 / ⌘Q | **✅ 配套 T2 最順** |
| **Q7-B2關視窗 = hide無 tray**(配合 T3| hide windowDock/taskbar 圖示留著 | macOS右鍵 Dock > Quit其他重開後 Quit | ⚠️ Linux 困難 |
| **Q7-B3關視窗 = confirm dialog 問「也要結束 server 嗎?」** | 使用者選擇 | — | ❌ 太囉嗦 |
**推薦 Q7-B1**(綁 T2 復活 tray
#### 額外細節
- **macOS 慣例本來就是**「紅色關閉鈕 = hide」+「⌘Q = quit」。Q7 原本選 B 是反慣例。現在回歸 macOS 慣例反而更好
- **Windows 慣例**× = quit。如果這邊改成 hide要明確顯示 tray tooltip「visionA-local 仍在執行」,避免使用者以為沒關
- **Linux**:看 DE通常 × = quit但實務上 GNOME 有些 app 有 tray fallback
---
## D. 遷移策略與沿用率
### 沿用率評估
| 模組 | 現況 | 新方向需要 | 沿用率 | 備註 |
|------|------|-----------|-------|------|
| `server/` 整個 Go 後端 | M1-M7 完成 | 幾乎全部保留 | **~95%** | 砍 yt-dlp 相關 + 加 UploadVideo 格式CORS 政策調整;預置模型系統不動 |
| `server/web/out/` Next.js embedded SPA | 完整業務 UI | 保留,幾乎不變 | **~90%** | 砍 URL tabupload 支援 extension 擴充;可能加 server restart 時的 reconnect 邏輯 |
| `visiona-local/frontend/` splash | 78 行 splash | 重寫成控制台80+150+150=~400 行)| **結構可沿用,程式砍掉寫新的** | 保留 ES module 載入、wailsjs 串接模式 |
| `visiona-local/app.go` startup/shutdown/lifecycle/IPC | 1584 行 | 新增 300 行 server control + log buffer改 ~30 行 watchServer 行為,其餘不動 | **~85%** | 核心 startup 順序、Python 雙策略、single-instance、driver install、data migration 全保留 |
| `visiona-local/payload/` | 完整 | 砍 yt-dlp stage其餘不變 | **~90%** | |
| 安裝器Inno Setup / dmgbuild / AppImage| M4/M5/M7-A | 不變 | **100%** | |
| Build pipeline / Makefile | 完整 | 砍 vendor-ytdlp target其餘不變 | **~95%** | |
**總體**:這次 refactor **不是丟掉重做**,是**擴充 + 砍功能**。M1-M7 的打包功夫 **100% 保留價值**,唯一被動搖的是:
1. Wails 視窗長什麼樣splash → 控制台)
2. server 生死邏輯connected to app lifetime → independent lifecycle
3. 關視窗行為quit → hide
### 工時預估
| 任務 | 工時 | 前置條件 |
|------|------|---------|
| A. 砍 yt-dlp 全套vendor + server + frontend + i18n + Makefile + payload| 0.5-1 天 | 使用者確認砍 |
| B. log buffer + log pump + GetRecentLogs binding | 0.5 天 | — |
| C. StartServer/StopServer/RestartServer bindings + state machine | 1 天 | B 完成 |
| D. Wails 控制台 UI 改寫vanilla| 1.5-2 天 | B, C 完成 |
| E. watchServer 改為 Error state不 os.Exit| 0.5 天 | C 完成 |
| F. Tray復活 Q-A| 1.5 天 | 使用者確認復活 tray |
| G. Q7 復議為 hide-to-tray | 0.5 天 | 使用者確認 + F 完成 |
| H. CORS 限制為 localhost | 0.5 天 | — |
| I. 瀏覽器 tab restart 重連邏輯(前端 retry + boot-id 比對)| 1 天 | C 完成 |
| J. UploadVideo 擴充副檔名 | 0.5h | — |
| K. ffmpeg LGPL 切換評估與可能的重 build pipeline | ? | 需另估 |
| L. 端到端重 build + smoke testmacOS + Windows | 1 天 | 全部完成 |
| M. PRD / Design spec / TDD 補文件M 級 refactor| 1 天 | 使用者確認範圍 |
**總計****~10 人天**K 不估)。比「從零做 M1」便宜得多。
### 風險的遷移成本
| 風險 | 影響 |
|------|------|
| **Wails v2 tray 在 Linux 某些 DE 壞掉** | T2 可能在 Ubuntu+GNOME 沒 icon需要 fallback 邏輯(指向「請使用主視窗」)|
| **Wails events 在 Windows 下壓力測試未知** | 若 server 產生大量 logWails IPC 可能掉 event。需要 log pump 加 rate limiting |
| **瀏覽器 tab 的 security context 問題** | 現有前端程式碼假設 origin 固定,移到瀏覽器後要確認 localStorage / sessionStorage 行為 |
| **ffmpeg LGPL 重新評估需時間** | 若使用者要趁這次切build pipeline 要重構,會延長 2-3 天 |
---
## E. 待使用者決策的問題(技術選型類)
| # | 問題 | 選項 | Architect 推薦 |
|---|------|------|---------------|
| **E-1** | Wails 控制台技術選型 | A1 vanilla / A2 同 server 路由 / A3 Vite mini / A4 native | **A1 vanilla** |
| **E-2** | 是否復活 trayQ-A 復議)| 維持砍T1/ 復活T2/ 只做 hideT3| **T2 復活** |
| **E-3** | 視窗關閉行為Q7 復議)| 維持關=quitB/ 改為 hide-to-trayB1/ 確認對話框B3| **B1 hide-to-tray綁 E-2** |
| **E-4** | yt-dlp 處理 | 砍 / 保留 | **砍**(無爭議)|
| **E-5** | ffmpeg 授權處理 | 維持 GPL 繼續開發、發佈前決定 / 現在就切 LGPL / 改純 Go 解碼 | **維持 GPL但把 LGPL 評估列為 M8 必做**(因為使用情境變窄,切起來成本變低)|
| **E-6** | 支援的上傳影片副檔名 | 現有 mp4/avi/mov / + mpeg/mpg / 所有 ffmpeg 能吃的 | **mp4/avi/mov/mpeg/mpg**(使用者明示「瀏覽器能吃的」)|
| **E-7** | 綁定 interface | 只 127.0.0.1 / 加區網 toggle | **只 127.0.0.1**M1-M7 延續)|
| **E-8** | CORS 政策 | 現況(可能寬鬆)/ 明確限制 127.0.0.1 + localhost | **限制**(瀏覽器模式下必做)|
| **E-9** | Restart 時瀏覽器 tab 重連 | 不處理 / 前端 retry + boot-id 比對 / Wails reverse proxy | **前端 retry + boot-id** |
| **E-10** | watchServer 失敗後行為 | 維持 os.Exit / 改為 Error state | **改為 Error state**(無爭議)|
| **E-11** | 自動開瀏覽器 | server 就緒後自動開一次 / 永不自動 / 使用者設定 | **預設自動開Settings 可關**(對齊 Ollama/Docker Desktop|
| **E-12** | Log panel 大小 | 500/1000/2000/5000 行 | **2000 行**~200KB平衡記憶體與可用性|
---
## F. 風險觀察
| # | 風險 | 等級 | 緩解 |
|---|------|------|------|
| F-1 | Wails tray 在 Linux GNOME 下可能完全看不到 iconStatusNotifierItem 協議在某些版本沒支援)| 🔴 高 | 加 fallback視窗仍可 minimize 到 dock提供「一律顯示視窗」的 config |
| F-2 | 瀏覽器 tab 與 Wails 控制台同時存在,兩邊 state 會分歧(例如 Wails 控制台顯示「Running」瀏覽器 fetch 失敗)| 🟠 中 | 控制台的 status 來自 Wails 本地狀態(準確),瀏覽器的失敗要優雅處理(全域 error boundary|
| F-3 | 使用者習慣以「× 關閉」結束程式,改成 hide 後回頭找 app 困難 | 🟠 中 | 第一次 hide 時彈 toast 告訴使用者「visionA-local 正在背景執行,可從 tray/menu bar 找到」|
| F-4 | Wails EventsEmit 在極高頻率下(例如每秒 >100 events可能丟事件或延遲 | 🟡 低 | log pump 加 rate limiting + batch 合併;推論 frame 不走 log |
| F-5 | ffmpeg GPL 發佈前 review 仍未解決 | 🔴 高 | 新方向下使用情境變窄,反而是切 LGPL 的好時機;建議 M8 專門處理 |
| F-6 | 瀏覽器的 CSRF / CORS 攻擊面(任何網站都能嘗試 fetch 127.0.0.1:3721| 🟠 中 | CORS 限制 + 必要時加 state-changing 操作的 CSRF tokenPOST /api/models/upload 等)|
| F-7 | 使用者同時開多個 visionA-local 視窗single-instance 被喚起目前「raise existing window」會失效因為 Wails 視窗可能被 hide 到 tray| 🟡 低 | `/ipc/raise` 除了 WindowShow 還要呼叫 WindowUnminimise + 從 tray 彈出 |
| F-8 | Restart 期間若使用者 close 視窗state machine 亂掉 | 🟡 低 | state 轉換要用 mutex 保護;關視窗也透過 command queue |
| F-9 | Wails app 關閉時若 server 還活著,下次啟動 port 可能佔用 | 🟡 低 | 已有 isOurStaleServer 偵測;新方向若 server 要獨立生命週期要重新評估「Wails 關閉是否真的能殺 server」或「要不要保留 daemon」 |
| F-10 | 瀏覽器快取問題:使用者更新 visionA-local 後,瀏覽器 tab 仍用舊版前端 | 🟠 中 | server 啟動時在每個 response 加 `ETag``Cache-Control: no-store`;或升級時改用新 boot-id前端偵測到強制 reload |
| F-11 | macOS Gatekeeper原本 Wails app 開瀏覽器 = shell out `open`,正常。但若加 trayicon 檔可能觸發 code signing 問題 | 🟡 低 | icon 打在 binary 內,不走 shell out |
| F-12 | 使用者可能期待 Wails app 關掉 = server 也停(避免 USB 裝置被占用)| 🟠 中 | Quit 動作tray > Quit 或 ⌘Q要明確停 serverhide 不停;需要清楚的 UX 差異 |
---
## G. 給 PM 和 Design 的議題
Architect 不作決定,只標記需要 PM / Design 處理的事)
### 給 PM
- **G-P1任務等級** — 這次 refactor 涉及 UX 模式變更Wails 窗 = 控制台、server 獨立生命週期)+ 新 user story「從瀏覽器使用 visionA-local」+ 決策復議Q-A、Q7。**我的判斷是 L 級**(新 user story + 原決策復議),需要 PM 更新 PRD 的「功能願景」「使用情境」「非功能需求」段落,並重新跑 RICE
- **G-P2定位重寫** — PRD 開頭的定位(「單機桌面應用,像 Docker Desktop」需要改為「本機 AI inference 服務,提供 server + 網頁介面,像 Ollamaollama serve + 網頁 / CLI client」。這不只是包裝是產品定位的實質變更
- **G-P3使用者旅程重寫** — 從「打開 app → 在視窗內操作」變成「打開 app → 控制台確認 server 在跑 → 在瀏覽器操作」
- **G-P4成功指標** — 現有 AC「首次啟動 < 5 」「關視窗 = 結束」)有一半要改
- **G-P5yt-dlp 砍功能的 user-facing 影響** — 有沒有 PM 認為「URL 推論」是需要保留的功能若無YouTube / Vimeo / RTSP 這些 URL 都不支援了,需不需要在 release note 明說
- **G-P6ffmpeg LGPL 評估**要不要排 M8 milestone
- **G-P7「預設模型只能用預設的幾種其他只能上傳」** — 現況已如此(預置 + 使用者上傳),是確認還是有新要求?要不要 PM 確認
- **G-P8法律** — 新方向下使用情境更窄GPL 評估可能更好談TOS/Privacy 要看是否有新的資料流動(實際上沒有)
### 給 Design
- **G-D1Wails 控制台 UI 設計** — 雖然我推薦 vanilla JS 實作,但 layout、視覺語言、用字、深色淺色規範還是需要 Design 出稿
- **G-D2tray 菜單 i18n + icon 設計** — 若 E-2 確認復活tray icon 需要新做(至少 light/dark × 3 平台 × 2 狀態 = 12 張 icon
- **G-D3Wails 視窗關閉時的第一次提示 toast** — 「visionA-local 正在背景執行,可從 menu bar 找到」要怎麼寫、什麼時機
- **G-D4Settings 新增「自動開瀏覽器」選項**的文案與位置
- **G-D5Restart 期間瀏覽器 tab 的 skeleton state / loading overlay 設計**
- **G-D6Workspace 的 URL tab 砍掉後的 UI 調整** — source-selector.tsx 目前「上傳檔案 / 貼 URL」雙 mode砍掉 URL 後要不要簡化成單 tab還是加入 camera 的 device 選擇?
---
## H. 下一步建議
1. **給使用者看 E 段的 12 個決策點** → 等使用者裁決
2. **決策後更新 progress.md 的「未解決問題」** → 清掉已經解決的ffmpeg GPL 在新方向下的 posture、Wails 主視窗用途新增新出現的tray 復活、Q7 復議)
3. **若使用者確認走這條路**
- PM更新 PRDL 級文件更新)
- Design出控制台 + tray 設計稿
- Architect把這份筆記升級為正式的 Design Doc 更新 + TDD 補丁
- 一路走完三方交叉審閱後再進 M8 開發
---
**簽名**Architect Agent2026-04-14

View File

@ -0,0 +1,312 @@
# LGPL ffmpeg 三平台可取得性調查2026-04-14
> 作者Architect Agent
> 任務:調查 macOS x86_64 / Windows x86_64 / Ubuntu x86_64 三平台是否都能取得可直接使用的 LGPL ffmpeg 靜態 binary作為 visionA-local M6 release blocker 的解套方案評估
> 範圍:純 research不動 Makefile / payload / vendor 腳本
> 前置閱讀:`/Users/jimchen/visionA/local-tool/Makefile`vendor-ffmpeg*)、`/Users/jimchen/visionA/local-tool/.autoflow/04-architecture/architect-analysis-round2-refactor.md`
---
## 摘要3 行)
1. **Windows x86_64 與 Ubuntu x86_64 可以直接用現成 LGPL binary**——`BtbN/FFmpeg-Builds` 每天自動產出 `ffmpeg-master-latest-win64-lgpl.zip``ffmpeg-master-latest-linux64-lgpl.tar.xz`,經原始碼驗證 `--enable-gpl` 未設、`COPYING.LGPLv3`、明確排除 libx264/libx265且內建 ffmpeg + ffprobe 雙 binary完全符合需求。
2. **macOS x86_64 沒有現成的 LGPL 靜態 binary**——evermeet.cx目前 Makefile 的來源、gyan.dev只做 Windows、Martin-Riedl、osxexperts、ColorsWind 都不是 LGPL+static+維護中+x86_64 的組合。BtbN 官方 README 明確說「Static Windows (x86_64) and Linux (x86_64) Builds」macOS 不在支援清單。
3. **建議走「混合方案 B」Windows/Linux 換 BtbN LGPLmacOS 改自 buildGitHub Actions matrix 跑 ffmpeg configure --disable-everything-gpl + 必要 decoder**。預估工時 1-1.5 人天(只需 macOS 這一支 pipeline遠低於三平台全自 build 的 2-3 人天,且兩週內 LGPL 政策就能通過、解除 M6 release blocker。
---
## 1. 現況 vendor-ffmpeg 狀態
`Makefile` 對應 target目前三平台抓法如下
| 平台 | 來源 URL | License 狀態 | 腳本檢查 |
|------|---------|-------------|---------|
| **macOS x86_64** | `https://evermeet.cx/ffmpeg/getrelease/ffmpeg/zip` | **GPL含 `--enable-gpl --enable-libx264 --enable-libx265`** | Makefile 會 `grep --enable-gpl`,預設擋下,只在 `VISIONA_ALLOW_GPL_FFMPEG=1` 時放行,檔案大小約 77MB |
| **Windows x86_64** | `https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip` | **GPL**(檔名含 `-gpl-` | Makefile 有 `WARNING: BtbN 為 GPL buildlicense 由 PM 最終確認` 註解 |
| **Linux x86_64** | `https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz` | **GPL**johnvansickle 只出 GPL static release | Makefile 有 `WARNING: johnvansickle build 為 GPL build正式發佈前需改用 LGPL 來源` 註解 |
三個 target 都把 `ffmpeg`(單一 binary複製到 `payload/{darwin,windows,linux}/bin/`。目前流程只拉 ffmpeg不拉 ffprobeM6 使用者目前的 video 解碼路徑只用 `ffmpeg`,但若未來要用 ffprobe 驗證 metadataLGPL 候選源也都同時提供)。
關鍵觀察:**Windows 已經在用 BtbN只要檔名從 `-win64-gpl.zip` 換成 `-win64-lgpl.zip` 就能解決 Windows**。成本最小的一個換法。
---
## 2. 三平台候選來源調查結果
### 2.1 Windows x86_64
| # | 候選來源 | URL | License | 靜態 | ffprobe | 檔案大小 | 更新頻率 | 推薦度 |
|---|---------|-----|---------|-----|---------|---------|---------|--------|
| 1 | **BtbN/FFmpeg-Buildslgpl** | `https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-lgpl.zip` | **純 LGPLv3**`variants/defaults-lgpl.sh` 驗證:`FF_CONFIGURE="--enable-version3 --disable-debug"`**沒有 `--enable-gpl`**`LICENSE_FILE="COPYING.LGPLv3"` | ✅ static | ✅ 有 | 196 MB壓縮後 | **每日 12:00 UTC 自動 build**,保留 14 天 + 每月月尾 build 保留 2 年 | ⭐⭐⭐⭐⭐ **首選** |
| 2 | BtbN/FFmpeg-Buildslgpl-shared | `…-win64-lgpl-shared.zip` | LGPLv3 | ⚠️ shareddll 分開) | ✅ | 87 MB | 同上 | ⭐⭐ 靜態才省事,不推薦 |
| 3 | gyan.dev | `https://www.gyan.dev/ffmpeg/builds/` | **全部 GPLv3**(官方 README 原文All builds are 64-bit, static and licensed as GPLv3 | ✅ | ✅ | — | 每月 | ❌ **不符合** |
| 4 | ffbinaries | `https://github.com/ffbinaries/ffbinaries-prebuilt` | 未標明 | ✅ | ✅ | 54 MB | **停更中**(最後 release v6.1, 2023-12-28 | ❌ 2+ 年未更新license 不清 |
**驗證過程:**
1. 用 `gh release view latest --repo BtbN/FFmpeg-Builds` 確認 `ffmpeg-master-latest-win64-lgpl.zip` 存在、大小 196877693 bytes ≈ 196 MB。
2. 用 `gh api repos/BtbN/FFmpeg-Builds/contents/variants/defaults-lgpl.sh` 驗證 LGPL variant 的 configure flags 就是 `--enable-version3 --disable-debug`**沒有 `--enable-gpl`****License file 指向 `COPYING.LGPLv3`**。
3. 用 `gh api repos/BtbN/FFmpeg-Builds/contents/scripts.d/50-x264.sh` 驗證 x264 library script 有 `[[ $VARIANT == lgpl* ]] && return -1` 的 gating**LGPL variant 不會 link libx264**x265 同樣 gated。
4. 用 `gh api .../util/repack_latest.sh` 確認 BtbN 打包時是整包 `bin/ffmpeg.exe` + `bin/ffprobe.exe` + `doc/` + `LICENSE.txt`
**推薦:方案 1BtbN LGPL static**。Windows 幾乎零遷移成本——現有 Makefile 已經在用 BtbN只要改檔名 `-gpl``-lgpl` 即可,連 curl / unzip 邏輯都不用改。
---
### 2.2 Ubuntu x86_64Linux x86_64 static
| # | 候選來源 | URL | License | 靜態 | ffprobe | 檔案大小 | 更新頻率 | 推薦度 |
|---|---------|-----|---------|-----|---------|---------|---------|--------|
| 1 | **BtbN/FFmpeg-Buildslgpl** | `https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-lgpl.tar.xz` | **純 LGPLv3**(同 Windows 驗證) | ✅ static | ✅ 有 | **128 MB**(壓縮 tar.xz | 每日 12:00 UTC | ⭐⭐⭐⭐⭐ **首選** |
| 2 | BtbNn7.1-lgpl 穩定分支) | `…-linux64-lgpl-7.1.tar.xz` | LGPLv3 | ✅ | ✅ | 106 MB | 每日 build 但對應 ffmpeg 7.1 release 分支 | ⭐⭐⭐⭐ **次選**(更穩定,不追 master |
| 3 | BtbNn8.1-lgpl 穩定分支) | `…-linux64-lgpl-8.1.tar.xz` | LGPLv3 | ✅ | ✅ | 127 MB | 每日 | ⭐⭐⭐⭐ 對應 ffmpeg 8.1 穩定分支 |
| 4 | johnvansickle.com現況 | `https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz` | **GPLv3**(官方頁面明示含 libx264/libx265 | ✅ | ✅ | ~40 MB | 不定期 | ❌ **GPL 不符合** |
**驗證過程:**
1. BtbN 同時產出 master-latest、n7.1、n8.1 三條線,都有 LGPL 變體。release assets 透過 `gh release view` 完整列出。
2. ffmpeg-master-latest-linux64-lgpl 未壓縮後會大幅膨脹(~400 MB 含所有 lib 與 doc但我們只需要 `bin/ffmpeg` + `bin/ffprobe` 兩支,實際複製到 payload 的大小仍在 80-100 MB 級別(和現況 GPL 版差不多)。
3. johnvansickle 無 LGPL variant已驗證其 download page不是可換來源。
**推薦:方案 1 或 2**。若要更穩定,選 `n7.1-latest-linux64-lgpl-7.1.tar.xz`(綁定 7.1 release 分支、仍每日更新),相較 master 減少隨機 regression 風險。遷移成本同樣很低,把 Makefile 的 `FFMPEG_URL_LINUX` 從 johnvansickle 換成 BtbN 這個 URL並把 tar 解壓路徑的 strip-components 調整即可。
---
### 2.3 macOS x86_64
| # | 候選來源 | URL | License | 靜態 | ffprobe | 更新頻率 | 推薦度 | 備註 |
|---|---------|-----|---------|-----|---------|---------|--------|------|
| 1 | **evermeet.cx現況** | `https://evermeet.cx/ffmpeg/getrelease/ffmpeg/zip` | **GPL**configure 含 `--enable-gpl --enable-libx264 --enable-libx265` | ✅ static | ✅(另一個下載點) | 每日 snapshot + 每月 release | ❌ **不符合** | 目前 Makefile 來源 |
| 2 | gyan.dev | — | — | — | — | — | ❌ | **只做 Windows** |
| 3 | BtbN/FFmpeg-Builds | — | — | — | — | — | ❌ | **官方 README 明示「Static Windows (x86_64) and Linux (x86_64) Builds」macOS 不在清單內** |
| 4 | **Martin-Riedlffmpeg.martin-riedl.de** | `https://ffmpeg.martin-riedl.de/` | **GPL**官方描述uses commonly used codecs like **x264 and x265** | ✅ static | ✅ | 每日 snapshot | ❌ **GPL** 且 Apple 已宣布 macOS Intel 停更2026-01 停止 snapshot、2027-01 停止 release | 即使能用也要半年內找 sunset 替代 |
| 5 | ColorsWind/FFmpeg-macOS | `https://github.com/ColorsWind/FFmpeg-macOS` | LGPLv2 | ❌ **shared**`--enable-shared --disable-static` | 未明 | **停更**(最後 release 2022-05 n5.0.1-patch3 | ❌ 4 年未更新、非 static | — |
| 6 | osxexperts.net | `http://www.osxexperts.net/` | GPL含 libx264/libx265且頁面聲明「for educational purposes only」 | ✅ | ✅ | 不定期 | ❌ **GPL + 聲明禁商用** | 分發會有問題 |
| 7 | ffbinaries/ffbinaries-prebuilt | — | 不明(未標示) | ✅ | ✅ | **停更**v6.1, 2023-12-28 | ❌ 2+ 年沒更新 | — |
| 8 | eugeneware/ffmpeg-static | — | **抓取 evermeet.cx + osxexperts.net** | — | — | — | ❌ **實際來源仍是 GPL** | 只是 wrapper |
| 9 | **MacPorts ffmpeg -gpl2 -gpl3 -nonfree** | `sudo port install ffmpeg -gpl2 -gpl3 -nonfree` | **LGPLv2.1+**port variant 可關掉 GPL | ❌ MacPorts 預設 shared | 要自己 build | — | ❌ **不是 prebuilt binary 來源**,須本機編譯 | 可能作為自 build 參考 |
| 10 | **Homebrew ffmpeg** | `brew install ffmpeg` | 預設含 libx264GPL若要 LGPL 需自己 build formula | ❌ shared且有 dylib 相依 `/opt/homebrew` | 可 | — | ❌ **不是 static不是 LGPL** | — |
**結論:三個平台只有 macOS x86_64 找不到符合全部條件(純 LGPL + static + x86_64 + ffmpeg & ffprobe 同時有 + 仍在維護)的現成下載源。**
即使用最寬鬆的標準去找,所有 macOS 來源都至少踩到一個雷:
- evermeet.cx / gyan.dev / Martin-Riedl / osxexperts → **GPL**
- ColorsWind / Homebrew → **shared 不是 static**
- ColorsWind / ffbinaries → **停更 2+ 年**
- MacPorts → **不是 prebuilt要本機編譯**
- osxexperts → **聲明 educational only**
- BtbN / gyan.dev → **根本不出 macOS**
更要命的是Martin-Riedl 已經宣布 2026-01 停止 macOS Intel snapshot build、2027-01 停止 release buildApple 自己停產 Intel Mac 的連鎖效應)。即使我們退而求其次找到勉強能用的 macOS GPL 源,未來 6-18 個月內 macOS x86_64 的 prebuilt ffmpeg 都會嚴重萎縮。
---
## 3. 解碼能力驗證(對 BtbN LGPL build
這裡要回答一個關鍵疑問:**LGPL build 拿掉 libx264/libx265 之後,還能不能解碼 .mp4 / .avi / .mov / .mpeg / .mpg**
**答案:可以,而且完全沒問題。**
### 3.1 ffmpeg 的 decoder vs encoder 授權分離
- **libx264 / libx265 是獨立的外部 library**,是 **GPL** 授權的 **H.264 / H.265 encoder**。LGPL build 只是不 link 這兩個外部 library**不影響 ffmpeg 內部的 decoder**。
- **libavcodecffmpeg 的 codec layer內建的 decoder 都是 LGPL 授權的**,包括:
- `h264`native H.264 decoder自 ffmpeg 0.5 起內建,**與 libx264 無關**
- `hevc`native H.265 decoder
- `aac`native AAC decoder
- `mpeg2video`MPEG-2 decoder
- `mpeg4`MPEG-4 Part 2 decoder涵蓋 DivX/Xvid 檔案)
- `mpeg1video`MPEG-1 decoder
- `mjpeg`Motion JPEG
- `prores`ProRes decoderfor .mov
- `flv1`Sorenson Spark
- 以及所有 AAC / MP3 / Vorbis / Opus / FLAC 等 audio decoder
這些都不受 `--enable-gpl` 開關影響,在 **configure 時 `--disable-everything` 之後還要主動 `--disable-decoder=h264` 才會拿掉**,預設一定會編進去。
### 3.2 容器格式demuxer也都在 LGPL 範圍
- `mov` / `mp4` / `m4a`(同一個 demuxer→ LGPL預設啟用
- `avi` → LGPL預設啟用
- `mpegts` / `mpeg` → LGPL預設啟用
- `matroska`.mkv→ LGPL預設啟用
### 3.3 使用者需求格式 × BtbN LGPL build 對照表
| 輸入檔 | 容器 (demuxer) | video codec | audio codec | BtbN LGPL 支援 |
|-------|---------------|-------------|-------------|----------------|
| `.mp4` | mp4/mov | H.264 (native `h264`) | AAC (native `aac`) | ✅ |
| `.mp4` (H.265) | mp4/mov | HEVC (native `hevc`) | AAC | ✅ |
| `.avi` | avi | MPEG-4 Part 2 (`mpeg4`) / DivX / Xvid / H.264 | MP3 (`mp3` native) / AC-3 | ✅ |
| `.mov` | mov | H.264 / ProRes (`prores`) / H.265 | AAC / PCM | ✅ |
| `.mpeg` / `.mpg` | mpeg(ps) | MPEG-1 (`mpeg1video`) / MPEG-2 (`mpeg2video`) | MP2 (`mp2` native) / AC-3 | ✅ |
| `.mkv` | matroska | H.264 / H.265 / AV1 (`dav1d` or native `av1`) / VP9 (`libvpx-vp9`) | Opus / Vorbis / AAC | ✅ |
**5/5 格式全數 LGPL decoder 可解。**
### 3.4 為什麼 BtbN LGPL build 的檔案還是很大196 MB Windows / 128 MB Linux
因為它仍然 link 了很多 **LGPL-safe 的 extra library**(從 `scripts.d/` 可見):
- `libopenh264`Cisco 的 LGPL H.264 **encoder**——如果產品未來要編碼 H.264,這個是唯一合規選項)
- `libvpx`VP8/VP9 encoder + decoder
- `libaom`AV1 encoder
- `libdav1d`AV1 decoder比 aom 解碼快)
- `libopus` / `libvorbis` / `libmp3lame`audio encoder/decoder
- `libass` / `libfreetype` / `fribidi`(字幕渲染)
- `libbluray` / `libtheora` / `libwebp` / `libvpl` / `vulkan` / `openssl`...
這些對 visionA-local 其實都用不到(我們只做解碼),但 BtbN 統一打包也不是壞事——payload 的 77 MB → 128 MBLinux或 77 MB → 196 MBWindows多出 50-120 MB 是 trade-off若要壓縮可以用 `ffmpeg configure --disable-everything --enable-decoder=h264,hevc,aac,mp3,mpeg2video,mpeg4,...` 自 build 降到 20-30 MB但這是**優化題不是 release blocker**,可留到 M8 處理。
---
## 4. 結論
### 4.1 三方案比較表
| 方案 | 三平台涵蓋 | 工時 | 風險 | M6 release blocker 何時解除 | 推薦 |
|------|----------|------|------|--------------------------|------|
| **A. 全現成 LGPL binary** | ❌ **做不到**macOS 沒來源) | 0.5 人天 | macOS 無法通過 LGPL 政策檢查 | ⚠️ 永遠無法解除 | ❌ 不可行 |
| **B. 混合Win/Linux 用 BtbN LGPLmacOS 自 build** | ✅ 三平台 | **1-1.5 人天**(主要是 macOS 自 build CI pipelineWin/Linux 改 URL 約 1 小時) | macOS 自 build 需在 GitHub Actions 跑、偶爾要維護 | 1.5 人天後 | ⭐⭐⭐⭐⭐ **推薦** |
| **C. 全部自 build LGPL**(三平台 CI matrix | ✅ 三平台 | **2-3 人天** | 三平台 CI 都要寫 + 除錯 + 維護 | 2.5-3 人天後 | ⭐⭐ 工時最高,換到最乾淨但投資報酬率差 |
| **D. 放棄 LGPL維持 GPL + 法律聲明** | ✅ 三平台(現況) | 0 | 商業模式被 GPL copyleft 鎖住:如果產品任何非 ffmpeg 部分要走閉源或商用訂閱,法務可能擋下;需在 About 頁面露出完整 GPL 聲明 + 提供對應 ffmpeg 原始碼下載連結 | 取決於法務 review時程不可控 | ⚠️ Fallback不建議 |
### 4.2 推薦方案B混合
**具體做法(給未來 refactor 的人看):**
1. **Windows**(約 5 分鐘)
- 改 `Makefile``FFMPEG_URL_WINDOWS` 變數:
```
FFMPEG_URL_WINDOWS := https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-lgpl.zip
```
- zip 內路徑仍然是 `ffmpeg-master-latest-win64-lgpl/bin/ffmpeg.exe`,現有 Python 解壓邏輯只需把 `ffmpeg.exe` 的 zip member 前綴對應過去,邏輯一致。
- 同步拉 `ffprobe.exe`(未來 M8 可能用到;現在可選)。
2. **Linux**(約 10 分鐘)
- 改 `FFMPEG_URL_LINUX`
```
FFMPEG_URL_LINUX := https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz
```
(選 n7.1 分支而非 master追求穩定
- tar.xz 內路徑是 `ffmpeg-n7.1-latest-linux64-lgpl-7.1/bin/ffmpeg`,現有 `--strip-components=1` 需改成 `--strip-components=2` 或抓特定 path小幅調整。
3. **macOS**(約 1-1.5 人天,主要工作)
- 新建 `.github/workflows/build-ffmpeg-macos-lgpl.yml`,在 `macos-13` runner 上跑:
```
- 抓 ffmpeg release tarball7.1
- ./configure --prefix=/tmp/ff --enable-version3 --disable-debug
--disable-doc --disable-ffplay --pkg-config-flags=--static
--enable-openssl --extra-ldflags="-Wl,-search_paths_first"
(不加 --enable-gpl / --enable-libx264 / --enable-libx265
(選擇性:--enable-libopenh264 --enable-libvpx --enable-libopus
--enable-libmp3lame 以維持 feature parity但 decoder 層用不到,可全省)
- make -j && make install
- codesign + notarize可 ad-hoc sign 先跑過;正式發佈時接 Apple Developer cert
- 打包 ffmpeg + ffprobe → tar.xz upload 到 visionA-local 的自有 GitHub Release例如 `ffmpeg-lgpl-macos/v7.1/`
- nightly 或 weekly schedule 自動跑
```
- Makefile 的 `FFMPEG_URL_DARWIN` 指向我們自己的 release URL`github.com/<org>/visiona-local/releases/download/ffmpeg-lgpl-macos-v7.1/ffmpeg-macos-x86_64.tar.xz`),與 Windows/Linux 流程保持一致。
- 驗收重點:用 `ffmpeg -version` 檢查 `configuration:` 那行不含 `--enable-gpl` 也不含 `libx264 / libx265`;用 `ffmpeg -decoders` 檢查 `h264`, `hevc`, `aac`, `mpeg2video`, `mpeg4` 五個 decoder 存在。
### 4.3 給使用者的直接答案
> **Windows 和 Ubuntu 都找到能直接用的 LGPL 現成 binary**BtbN 每天自動 build已經在用 BtbN 了,把 Windows 的檔名從 `-gpl` 換成 `-lgpl` 就行Linux 從 johnvansickle 換到 BtbN LGPL variant 也只要改一行 URL。**macOS x86_64 則沒有任何現成 LGPL 來源可用**——evermeet / gyan / Martin-Riedl / osxexperts / ColorsWind 等全部都有雷GPL、shared、停更、或商用限制BtbN 官方根本不出 macOS build。
>
> 所以**不可能用「全現成 binary」把三平台都搞定**。**務實解法是「Windows + Linux 用 BtbN LGPL 現成 binary兩個改 URL 工作macOS 自 build 一支 LGPL 靜態 binary 放到我們自己的 GitHub Release」**。總工時約 1-1.5 人天(主要是 macOS 的 GitHub Actions pipeline只要這個一次做好未來 macOS LGPL binary 完全自主可控,不會被上游來源消失影響。
>
> 這個方案也正好回應 Martin-Riedl 2026-01 停止 macOS Intel snapshot 的公告——即使我們今天妥協用 GPL6-18 個月內 macOS x86_64 的 prebuilt ffmpeg 來源都會萎縮,**自 build 是遲早要做的事,提前做反而省未來一次遷移**。
>
> **技術上需要確認的H.264 / H.265 / AAC / MPEG-2 / MPEG-4 的解碼不受 `--enable-gpl` 影響**libavcodec 內建 native decoder 都是 LGPL`libx264` 只是編碼器),所以 `.mp4 / .avi / .mov / .mpeg / .mpg` 五種格式的解碼在 LGPL build 上 100% 可用,已經用 BtbN 的 build script 原始碼逐一驗證過。
---
## 5. 待使用者決策的問題
1. **要走方案 B 還是方案 C**
- 方案 B工時少 50%但維護上要同時盯兩條路macOS 自 build pipeline + Windows/Linux 的 BtbN 外部來源)。
- 方案 C工時多 50%,三平台一致都是自 build未來完全自主但前期投資大且未來 ffmpeg 有新 release 時要自己追。
2. **macOS 自 build 的 feature set 要到哪裡?**
- **最小版**:只開 decoder`--disable-everything --enable-decoder=h264,hevc,aac,mp2,mp3,mpeg1video,mpeg2video,mpeg4,mjpeg,prores --enable-demuxer=mov,avi,mpegts,mpeg --enable-protocol=file --enable-filter=scale,format`binary 大小 ~15-25 MB。
- **中等版**:跟 BtbN LGPL 對齊,包含 libopenh264/libvpx/libaom/libopus/libmp3lame 等 LGPL-safe extra librarybinary ~80-120 MB。
- 我建議先做**最小版**(因為 visionA-local 只解碼 + 只處理已知幾個格式),但未來若有新需求(例如內嵌推流或錄影),再切到中等版。
3. **BtbN 的「master 分支每日 build」vs「n7.1/n8.1 穩定分支每日 build」要選哪個**
- 建議選 **n7.1-latest**(固定在 ffmpeg 7.1 release 分支、但每日把 bugfix backport 進來。master 版每天可能踩到還沒 release 的 API 改動或 regression對 production 太冒險。
4. **macOS 自 build 的 GitHub Release 要放在哪裡?**
- 選項 1visiona-local repo 自己的 releasetag 類似 `vendor-ffmpeg-lgpl-macos-v7.1-20260414`)。
- 選項 2專門開一個 `visionA-vendor-artifacts` repo。
- 建議選項 1簡單。
5. **要不要同步拉 ffprobe**
- 目前 Makefile 只拉 `ffmpeg`,但 BtbN 的 tar 內含 `ffmpeg` + `ffprobe` 兩支,多拉一支是 0 成本(幾 MB。未來若要在 server/scripts 用 ffprobe 檢查影片 metadata例如驗證長度、bitrate、解析度已經在 payload 裡。建議**都拉**。
---
## 6. 附錄:原始碼驗證紀錄(供未來審閱)
### 6.1 BtbN `variants/defaults-lgpl.sh` 完整內容
```
FF_CONFIGURE="--enable-version3 --disable-debug"
FF_CFLAGS=""
FF_CXXFLAGS=""
FF_LDFLAGS=""
GIT_BRANCH="master"
LICENSE_FILE="COPYING.LGPLv3"
```
### 6.2 BtbN `variants/defaults-gpl.sh` 完整內容
```
FF_CONFIGURE="--enable-gpl --enable-version3 --disable-debug"
FF_CFLAGS=""
FF_CXXFLAGS=""
FF_LDFLAGS=""
GIT_BRANCH="master"
LICENSE_FILE="COPYING.GPLv3"
```
**差別只有兩處**LGPL 少一個 `--enable-gpl` flag、LICENSE_FILE 從 GPLv3 換成 LGPLv3。其餘一致。
### 6.3 BtbN `scripts.d/50-x264.sh` 的 LGPL gating
```bash
ffbuild_enabled() {
[[ $VARIANT == lgpl* ]] && return -1
return 0
}
```
對應的 `50-x265.sh` 有相同 pattern。這證明 LGPL variant build 時這兩個 library 會被 skip最終的 ffmpeg binary **不會** link libx264 / libx265。
### 6.4 BtbN 官方 README 摘錄platform 支援)
> Static Windows (x86_64) and Linux (x86_64) Builds
> Supported targets: win64, win32, winarm64, linux64, linuxarm64
**macOS 不在清單內**,證實 BtbN 不出 macOS。
### 6.5 evermeet.cx 的 GPL configure 證據
```
--enable-gpl --enable-libx264 --enable-libx265 --enable-libxavs
--enable-libxvid --enable-libvidstab --enable-libopenh264 ...
```
`--enable-gpl` 直接寫在 configure line 裡,明確 GPL build。
### 6.6 gyan.dev 官方 README 原文
> All builds are 64-bit, static and licensed as GPLv3
明確全線 GPL沒有 LGPL 變體。
### 6.7 Martin-Riedl 官方描述
> uses commonly used codecs (like x264 and x265)
`x264` 是 GPL encoder library只要 build 有 link 進來整個 ffmpeg 就會變 GPL。
### 6.8 BtbN release 驗證指令(可重現)
```bash
gh release view latest --repo BtbN/FFmpeg-Builds --json assets \
--jq '.assets[] | select(.name | test("lgpl")) | {name, size, url}'
```
輸出確認了 `ffmpeg-master-latest-win64-lgpl.zip`196 MB`ffmpeg-master-latest-linux64-lgpl.tar.xz`128 MB、以及 n7.1 / n8.1 穩定分支版本都存在、每日更新。
---
**以上。待使用者決策後Architect 可進一步寫出 M6-refactor 的 TDD正式決策紀錄 + Makefile 變更 patch + macOS CI workflow。**

View File

@ -0,0 +1,515 @@
# PM 交叉審閱 TDD v22026-04-14
> 審閱者PM Agent
> 被審物:`/Users/jimchen/visionA/local-tool/.autoflow/04-architecture/TDD-v2.md` + `v2/` 全 8 份子檔
> 對照基準:`PRD-v2.md`484 行)+ R5 五輪決策 + R5-D 補充決策
> 審閱範圍需求面完整度、R5 / R5-D 落地、PM 5 懸念的技術回答、體驗紅線
---
## 摘要
- **總結論:需小改後通過**(不是大改、不阻擋整體方向)
- **問題數**Major 4 個 / Minor 5 個
- **是否阻擋進開發****部分阻擋**——Major #1 / #2R5-D1 / R5-D2 / R5-D3 未落地)必須在進 M8-4/M8-5 開發前補上;其餘 Major 可在 M8-10 前修正
- **整體品質**TDD v2 對 R5 主決策的落地完整度 **90%**;對 R5-D 補充決策的落地完整度 **0%**(這是 PM 的主要不滿);對 R5 主決策以外的技術設計state machine、LogBuffer、CORS、ffmpeg LGPL品質很高
---
## A. 需求完整性檢查(對 PRD v2
| PRD v2 章節 | 要求 | TDD v2 落地位置 | 檢查結果 |
|------------|------|---------------|---------|
| §4.1 保留功能 (8 項) | device scan / 模型管理 / 批次影像上傳 / camera / 上傳影片 / MJPEG / WebSocket / 系統 API / i18n / Dark Mode / Log 持久化 | `code-reuse-v2.md`(沿用 85-95%+ `deletions.md` §1-6 明確區分保留 vs 刪除 | ✅ 無誤刪。`deletions.md` §2.6/2.7 小心處理 manager_test / api_e2e_test |
| §4.2 砍除 9 項 | URL endpoint / url_handler / yt-dlp / 前端 URL tab / Mock 全部 / webview 載 Next.js / tray / first-run 模式選擇 / US-2 Mock | `deletions.md` §1-6 + `milestone-plan.md` M8-1/M8-2 逐檔逐行 | ✅ 全部對應;`deletions.md` §8 的驗收 grep 是漂亮的防呆 |
| §4.3 新增 6 項 (N1-N6) | Wails 控制台 / 自動開瀏覽器 / Offline Overlay / boot-id / CORS / watchServer Error state | `control-panel.md`N1 / N2+ `web-ui-offline-overlay.md`N3+ `server-lifecycle.md` §9N4+ `cors-security.md`N5+ `control-panel.md` §4.7N6 | ✅ 六項皆有專屬子檔 |
| §5 US-1 ~ US-7 AC | 每個 AC 應有技術對應 | 見 §C「US AC 追蹤表」 | ⚠️ 有 2 個 AC 沒對應(見 C|
| §6 非功能 | 安裝檔 ≤ 185/165/165 MB、idle RAM ≤ 450 MB、首次推論、瀏覽器相容 | `milestone-plan.md` M8-10 有驗收 installer sizeidle RAM 和首次推論時間**完全沒被估算** | ❌ **見 Major #3** |
| §7 第三方授權 | ffmpeg LGPL 方案 B三平台+ ffprobe + macOS commit + 授權檔 | `ffmpeg-lgpl.md` 完整落地 | ✅ 很完整,甚至連 `spctl` / `codesign` / BUILD.md reproducibility 都想到 |
| §8 新風險 N-R1/2/3/4 | 瀏覽器相容性 / macOS ffmpeg 維護 / 關窗誤解 / CI 測試分層 | TDD v2 §3 新增 R-v2-1 ~ R-v2-7 對應N-R1 吸收進 R-v2-6N-R2 吸收進 R-v2-2N-R3 部分吸收進 AC-7.6 要求N-R4 無明確對應 | ⚠️ **見 Minor #1**N-R4 要求在 TDD v2 討論階段提出分層方案TDD 只在 PM 懸念欄說「交 Testing」而沒給方案|
---
## B. R5 / R5-D 決策落地檢查
### B.1 R5 主決策12 條)
| 決策 | TDD v2 落地位置 | 落地狀況 |
|------|---------------|---------|
| R5-1動機 A+B+G純 127.0.0.1| `control-panel.md` §1-2、`server-lifecycle.md` §2.1、`cors-security.md` §3 | ✅ 完整 |
| R5-2視窗關閉 = 結束 server| `server-lifecycle.md` §2.3、§7 `OnBeforeClose` return false、§8 shutdown 順序 | ✅ 完整,且 main.go 明確寫出 OnBeforeClose hook預留未來改動空間 |
| R5-3維持砍 tray| `server-lifecycle.md` §2.3 註解「不 hide-to-tray」、`TDD-v2.md` §0.1 | ✅ 不存在任何 tray code |
| R5-4啟動自動開瀏覽器 + Settings 可關)| `control-panel.md` §4.6 ServerController.Start 末段、`server-lifecycle.md` §2.1 t=2.500 | ⚠️ **語意違反 R5-D3**(見 Major #1|
| R5-5控制台 scope| `control-panel.md` §1 明列「不做業務 UI」 | ✅ 完整 |
| R5-5aMock 完全砍)| `deletions.md` §2 + §3 + §6、`milestone-plan.md` M8-2 | ✅ 涵蓋 Go / Wails app / 前端 / i18n / CLI flag 所有層 |
| R5-6LGPL 方案 B| `ffmpeg-lgpl.md` §1 表格 | ✅ |
| R5-6amacOS decoder-only ~20 MB| `ffmpeg-lgpl.md` §2.3 configure flags + §2.2 feature set | ✅ 驗收條件 `< 25 MB` 合理 |
| R5-6bmacOS binary commit| `ffmpeg-lgpl.md` §5 layout + §6 `.gitignore` | ✅ 連 `!/vendor/ffmpeg/macos/**` 的 gitignore 順序陷阱都提到 |
| R5-6c一起包 ffprobe| `ffmpeg-lgpl.md` §2.2 末段 + §2.3 cp 指令 + §7 payload + §8 installer | ✅ 三平台全覆蓋 |
| R5-7M7 Windows 先不管)| `milestone-plan.md` M8-10 註解「R5-7 同意先不管,這次順帶驗證」 | ✅ 沒偷跑 M7 Windows但又不漏驗 |
| 共識 #14boot-id| `server-lifecycle.md` §9 + `web-ui-offline-overlay.md` | ✅ 端到端 |
### B.2 R5-D 補充決策3 條)— **這是最大問題**
| 決策 | 應落地位置 | 落地狀況 |
|------|----------|---------|
| R5-D1Server 崩潰時保留 OS 原生通知 | `control-panel.md` §4.7 watchServer Error state / `server-lifecycle.md` §6 watchServer 或 `control-panel.md` §4 ServerController.setState | ❌ **完全缺失**`grep -ri 'osascript|notify-send|powershell.*notification' v2/` 零匹配。TDD 只 emit `server:error` Wails event 給控制台,沒有發系統原生通知 |
| R5-D2Linux 預設 `openBrowserOnStart=OFF`mac/Win = ON | `preferences.go`(預設值載入邏輯)或 `control-panel.md` §4.3 Preferences struct 預設 | ❌ **完全缺失**。TDD 只定義 `Preferences.OpenBrowserOnStart bool`,沒有 `NewDefaultPreferences()``runtime.GOOS` 分平台初值的邏輯。若 `preferences.json` 不存在,使用者第一次啟動在 Linux 上會用 Go zero valuefalse剛好誤中但這是 **碰巧對**,不是設計對 |
| R5-D3每次 Start 成功都自動開瀏覽器(不只首次)| `control-panel.md` §4.6 ServerController.Start 末段 | ❌ **實作與決策相反**。現況是 `if c.app.prefs.OpenBrowserOnStart && !c.app.autoOpenedThisSession``autoOpenedThisSession` per-session-once 只會開一次Restart 後不再開。**這和 R5-D3 「每次啟動都自動開」語意相反**。milestone-plan.md M8-9 step 3 甚至把「防呆autoOpenedThisSession 確保 Restart 不會二次開瀏覽器」列為驗收條件,直接落地了**錯的語意** |
---
## C. US AC 追蹤表PRD §5 × TDD v2
| AC | 需求 | TDD 對應 | 狀況 |
|----|------|---------|------|
| AC-1.1/1.2 | 安裝 ≤ 3 分 / 上限 5 分 | v1 沿用TDD 未碰 | ✅ |
| **AC-1.3** | 首次啟動 Wails + server + 瀏覽器 ≤ 10 秒 | **未估算** | ❌ Major #3 |
| AC-1.4 | 無硬體時顯示 empty state | `deletions.md` §6.5 noDevices 文案、R-v2-7 | ✅ |
| AC-1.5 | Gatekeeper 引導 | 同 v1 沿用 | ✅ |
| AC-1.6 | 可關閉自動開瀏覽器 | Preferences.OpenBrowserOnStart | ✅(但受 R5-D2 / D3 牽連)|
| AC-2.1 | 日常啟動 ≤ 5 秒全就緒 | 未估算 | ⚠️ Minor #2 |
| AC-2.2 | 保留 i18n / dark mode 偏好 | 前端 localStorage 沿用 | ✅(但沒寫進 TDD |
| AC-2.3 | 關過自動開瀏覽器後只開控制台 | Preferences + autoOpenedThisSession | ✅ |
| AC-3.7 | 使用者關瀏覽器後不發 toast / 不發 OS 通知 | TDD 未明確處理,`control-panel.md` 沒有 toast 入口,默認符合 | ⚠️ 和 R5-D1 衝突。AC-3.7 說「不發 OS 通知」R5-D1 說「server 崩潰時保留 OS 通知」。**兩者 scope 不同**AC-3.7 是裝置事件R5-D1 是 server 崩潰TDD 都沒處理,但兩者需要分開實作 |
| AC-4.5 | 拖放 .nef 只在瀏覽器有效 | `control-panel.md` §1 明列「不做業務」隱含 | ✅ |
| AC-4.6 | 8 個預設 .nef + 無 Model Zoo | v1 沿用 | ✅ |
| AC-5.1-5.6 | 瀏覽器 Workspace / 後端 camera pipeline / 不走 getUserMedia | v1 沿用 | ✅ |
| AC-6.1-6.4 | 5 種副檔名上傳影片 / 不支援 URL | `milestone-plan.md` M8-6 + `deletions.md` §5.1 前端 accept + backend handler patch | ✅ diff 清楚 |
| **AC-7.1** | 偵測斷線顯示 Offline Overlay | `web-ui-offline-overlay.md` § 全部 | ✅ |
| **AC-7.2** | Overlay 文案 + Retry | `web-ui-offline-overlay.md` §4.6 i18n | ✅ |
| **AC-7.3** | 控制台狀態變 Error + log panel 顯示錯誤 | `control-panel.md` §4.7 + §3 狀態 badge 紅 | ✅ |
| **AC-7.4** | Restart 後 boot-id 變 → reload | `server-lifecycle.md` §2.2 + `web-ui-offline-overlay.md` §4.3 | ✅ |
| AC-7.5 | 關 Wails → server 結束 → 相同 Overlay | `server-lifecycle.md` §2.3 | ✅ |
| AC-7.6 | Close 按鈕 tooltip / confirm 警語 | **未落地**Design Agent 任務TDD 未強制 Wails main.go 加 confirm | ⚠️ Minor #3 |
---
## D. PM 5 懸念的技術回答檢查
| # | PRD §11 問題 | 歸屬 | TDD 有無回答 |
|---|-------------|------|------------|
| 1 | 自動開瀏覽器 Settings 與 Web UI Settings 資料是否同一份 | Architect | ✅ `control-panel.md` §4.1 明確說「`visiona-local/preferences.go``<dataDir>/preferences.json`」— 這是**控制台側**的偏好Web UI 側的 i18n / dark mode 仍用 browser localStoragev1 沿用)。兩邊**分離**PM 接受這個答案 |
| 2 | AC-1.3 10 秒預算是否可達 | Architect 估算 | ❌ **完全沒估算**。TDD 沒有任何段落討論「Wails 冷啟動 + Go server waitHealthy + 瀏覽器冷啟動」的時間預算分解。PRD 明確要求 Architect 驗證此預算 |
| 3 | idle RAM ≤ 450 MB 可達性 | Architect 實測 | ❌ **完全沒提**。TDD 沒有任何段落討論 idle RAM 估算v2 砍了 Mock state 和 yt-dlp 子程序後的 baseline |
| 4 | N-R4 CI / E2E 測試分層方案 | Testing Agent現階段不審 | — 略過 |
| 5 | 「由 visionA-local 跑」常駐徽章 | Design Agent現階段不審 | — 略過 |
→ **PM 5 懸念中的 2/3AC-1.3、idle RAM完全沒被回答。這兩題是 PRD §11 明列「交 Architect」的題目Architect 沒回等於把球踢回給 PM。**
---
## E. 臆測 / 誤刪 / 誤解清單
### E.1 臆測超出範圍
- **無**。TDD v2 沒有偷偷新增 LAN mode、tray fallback、daemon、auto-update、telemetry 等 R5 明確否決的功能。這點乾淨
### E.2 誤刪
- **無誤刪**。批次影像上傳R5 共識 10 / PRD §4.1)在 `deletions.md` 中未被列入刪除清單,`code-reuse-v2.md` 也把它放在沿用區。`camera_handler.go` 只砍 `StartFromURL`,沒碰 `UploadBatch` 相關 handler
- **但有一個 risky 邊界**`deletions.md` §1.1 說「若 `NewVideoSourceFromURL` **唯一呼叫者**是 StartFromURL → 一起砍」。實際 grep 顯示 `camera_handler.go:731` 還有 `NewVideoSourceFromURLWithSeek` 被本地上傳影片 seek 呼叫。**M8-1 執行者必須先把這個 grep 結果列出**,否則可能會誤砍本地影片 seek 功能。TDD `ffmpeg-lgpl.md` §11 已警示這點,但 `deletions.md` §1.1 的敘述「可從 RTSP stream 呼叫」不精準,實際是「從本地影片 seek 呼叫」
### E.3 誤解 R5 / R5-D
- **R5-4 理解錯誤****Major #1**TDD 把「首次啟動自動開瀏覽器」實作成 per-session-once`autoOpenedThisSession` flag但 R5-D3 明確修正語意為「每次 Start Server 成功都開」。這個誤解在 `milestone-plan.md` M8-9 和 `control-panel.md` §4.6 各出現一次,是**系統性誤解**
- **R5-D1 / R5-D2 整組遺漏****Major #1** 包含)
- **R5-2 理解正確**TDD 明確寫 `OnBeforeClose` return false、不 hide-to-tray、有 `shutdownGracePeriod` 5s → 10s 對齊 server 的討論。這點很到位
---
## F. 體驗紅線檢查
### F.1 北極星指標「5 分鐘內瀏覽器跑第一幀推論」可達嗎
PRD §1.4 把「瀏覽器啟動時間」也納入 5 分鐘預算。TDD 沒有做端到端預算分解,但從元件看:
- Wails 冷啟動:~0.5-1 sv1 經驗)
- Go server spawn + waitHealthy1-3 s依 Python sidecar 啟動)
- `OpenInBrowser("")`:系統 call ~0.1 s
- 瀏覽器冷啟動(第一次點開 Chrome/Safari/Edge**3-8 s**(最大不確定性)
- Next.js static export 載入:~0.5-1 s
→ 在已開瀏覽器的情境下 5-6 s 可達 AC-2.1 的「日常啟動 ≤ 5 秒」;在冷啟動情境下 6-13 s **很可能超過 AC-1.3 的 10 秒上限**
**PM 結論**AC-1.3 的 10 秒上限在「使用者尚未開過瀏覽器」的情境下可能不可達。建議 Architect 在 TDD v2 增補一段預算分解若確實超過PRD 要放寬到 15 秒。這是進開發前必須釐清的事項(見 Major #3)。
### F.2 race conditionWails 關閉視窗 → StopServer 過程中瀏覽器 tab
TDD v2 `R-v2-4` 已明確提到這個 race condition0.5-5s 內瀏覽器可能看到 ECONNREFUSED 但 Overlay 還沒觸發),並分析為「實務上使用者關 Wails 通常也會關瀏覽器 tabrace 不構成實際問題若真的沒關15 s 後 Overlay 會出現」。**PM 認可這個取捨**,但有補充意見:
- **建議**:在 Wails OnBeforeClose 觸發前,先透過 WebSocket broadcaster 發一個 `server:shutdown-imminent` 訊息給所有連著的瀏覽器 tab利用現有 `/ws/server-logs``/ws/devices/*` 通道),前端收到後立即顯示 Overlay不需要等 15 s polling。這個成本低server 端只 3-5 行、前端 store 新增一個 listener 即 10 行)
- 若 Architect 評估成本過高 → 接受現狀。這是 **Minor #4**,不阻擋進開發
### F.3 「關閉視窗 ≠ 結束 server」的心智負擔N-R3
PRD §8.2 N-R3 要求三處文案到位:
1. Wails Close 按鈕 tooltip / confirm dialogAC-7.6)— **TDD 未落地**main.go 只寫了 `OnBeforeClose: return false`,沒加 confirm 對話框邏輯;是否加是 Design Agent 的 scope但 TDD 應該**預留 hook**
2. Offline Overlay 文案引導 — `web-ui-offline-overlay.md` §4.6 ✅
3. First-Run 歡迎頁一句說明 — TDD 未提(但 v1 有v2 沿用,推測 OK
**Minor #3**TDD 應在 `server-lifecycle.md` §7 的 main.go 範例裡保留 `OnBeforeClose` 可讀 `Preferences.ConfirmOnClose` 的 hook而不是寫死 `return false`。這樣 Design Agent 後續加 confirm dialog 時不用再重寫 TDD
---
## G. PM 對 Architect Q4 的回答
Architect 在 `server-lifecycle.md` §10 問:「`shutdownGracePeriod` 5s → 10s 的副作用?使用者頻繁開關 app 可能感覺變慢。」
**PM 回答**(基於使用者體驗基準):
| 問題 | 答案 |
|------|-----|
| 總 grace period 建議值 | **7 秒**(而非 10 秒理由Nielsen Norman 的研究指出 1 秒內 = 即時反饋、10 秒 = 使用者注意力臨界點7 秒保留足夠 server 優雅結束餘量,又不會逼近「我是不是當機」的感受閾值 |
| 超過多久要顯示「停止中…」進度提示 | **1 秒**。Wails 收到關閉訊號後應立即(< 100 ms顯示一個不可關閉的 modal正在停止本機伺服器…」+ spinner避免使用者看到空白卡住」。這個 modal 7 秒後若 server 仍未退出換文字為伺服器未正常結束正在強制關閉…」,之後 SIGKILL |
| 如果 7 秒真的不夠server 端某些清理需要 > 7 秒)| 兩個選擇:(a) 降低 server 端清理時間理想b) 把 server 端的 `shutdownFn` 10 s timeout 改為 6 s與 Wails 7 s 留 1 s 緩衝 |
**建議**:採用 PM 的 7 秒 + modal 方案,這是比 Architect 提的「5-10 秒區間 emit event」更簡單不需要 event 機制)且更符合 UX 基準的做法。Architect 若有技術面反對意見,請在使用者確認前提出。
---
## H. 問題清單
### Major阻擋進開發必須在 M8-4 / M8-5 開發前修)
#### Major #1R5-D1 / R5-D2 / R5-D3 三條補充決策完全沒落地
**問題**R5-D 三條是使用者在 2026-04-14 Design v2 產出後的補充決策,時間上**晚於 TDD v2 draft**,但 TDD v2 必須被修正以落地這三條。現況:
- **R5-D1崩潰時保留 OS 原生通知)**TDD 只在 Wails 控制台發 `server:error` event沒叫 `osascript display notification` / `powershell New-BurntToastNotification` / `notify-send`
- **R5-D2Linux 預設自動開瀏覽器 OFF**`preferences.go` 沒有依 `runtime.GOOS` 分平台 default 的邏輯
- **R5-D3每次 Start 都開,不只首次)**`autoOpenedThisSession` flag 和 milestone-plan M8-9 驗收條件明確是 per-session-once**和決策相反**
**應加在**
| 決策 | 子檔 | 修正位置 |
|------|------|---------|
| R5-D1 | `control-panel.md` §4.7watchServer Error state+ 新增 §4.8「OS 原生通知 helper」 | 新增 `visiona-local/notify_darwin.go` / `notify_windows.go` / `notify_linux.go` 三檔,在 `ctrl.setState(ServerStateError, ...)` 時呼叫 |
| R5-D2 | `control-panel.md` §4.3 Preferences 型別 + `v2/server-lifecycle.md` §2.1 載入流程 | 新增 `NewDefaultPreferences() Preferences``runtime.GOOS == "linux"``OpenBrowserOnStart: false`,否則 `true``preferences.go` 的 load 若檔不存在,用這個預設 |
| R5-D3 | `control-panel.md` §4.6 `ServerController.Start` 末段 + `milestone-plan.md` M8-9 step 3/5 驗收條件 | **砍 `autoOpenedThisSession` flag**,改為:只要 `prefs.OpenBrowserOnStart == true` 就每次 Start 成功後 `OpenInBrowser("")`。M8-9 驗收改為「多次按 Restart每次都開新 tab或 focus 既有 tab瀏覽器行為決定」 |
**阻擋等級**Major必須在 M8-4/M8-5 開發前修 TDD 子檔。因為這三條會影響 `preferences.go` / `server_control.go` / 新增的 notify_*.go 的介面簽名,若先開發再改會浪費時間
---
#### Major #2R5-D3 誤解已被寫進 milestone-plan M8-9 驗收條件
**問題**`milestone-plan.md` 第 335 / 347 / 350 行把「Restart 不會二次開瀏覽器」列為**驗收成功**條件。這會在 Reviewer 審查 M8-9 時被當成「通過」,但其實這**違反 R5-D3**。
**修正**M8-9 step 3 改寫,驗收條件改為:
- 首次啟動 Server瀏覽器開 1 個 tab
- 手動按 Restart瀏覽器開第 2 個 tab或在瀏覽器自動重用既有 tab視瀏覽器行為
- 使用者在 Preferences 關掉 `openBrowserOnStart`:之後 Start/Restart 都不開瀏覽器
**阻擋等級**:與 Major #1 綁在一起修
---
#### Major #3PM 5 懸念中 AC-1.310 秒)與 idle RAM≤ 450 MB沒被 Architect 回答
**問題**PRD §11 明確把這兩題交給 Architect「TDD v2 驗證」,但 TDD 所有 8 個子檔都找不到任何段落在討論這兩個預算。
**應加在**
- **AC-1.3 10 秒可達性**:建議在 `TDD-v2.md` §1 或 `server-lifecycle.md` §2.1 後新增一節「冷啟動預算分解」:
```
Wails 冷啟動 ~0.5-1 s
migrateOldDataDirs ~0.01 s
startIPCServer ~0.05 s
seedUserDataDir ~0.02-2 s首次安裝時 copy 8 個 .nef約 73 MB
ensurePythonRuntime ~0.5-3 s首次啟動 bundled Python extract
exec.Command spawn ~0.01 s
waitHealthy (Python sidecar 起) ~1-3 s
OpenInBrowser system call ~0.1 s
瀏覽器冷啟動 ~3-8 s使用者尚未開瀏覽器
Next.js SSG 首次載入 ~0.5-1 s
────────────────────────
首次安裝冷啟動總計 ~5.5-18 s
回訪情境(瀏覽器已開)~2.5-10 s
```
結論:**AC-1.3 的 10 秒上限在首次安裝 + 冷啟動瀏覽器情境下不可達**,建議 PRD 放寬到 15 秒,或在 PRD AC-1.3 明確註記「若瀏覽器尚未啟動,可能延長到 15 s」
- **idle RAM ≤ 450 MB**:建議在 `TDD-v2.md` §3 風險清單新增一條,或在 `control-panel.md` 新增一節「資源估算」:
```
Wails 殼 ~80-120 MBWebView2 / WKWebView 基礎)
Go server 子程序 ~40-60 MBGin + 8 個預載入 .nef metadata
Python sidecar ~150-220 MBPython runtime + numpy + opencv + pyusb
logs/ LogBuffer 等 ~5 MB
────────────────────
總計 ~275-405 MB
```
結論:**idle RAM ≤ 450 MB 應可達**,上限 550 MB 安全。PM 可以接受此估算作為 TDD 回答
**阻擋等級**Major必須在使用者確認 TDD v2 前補上。若 10 秒不可達,要決定是放寬 AC-1.3 還是改架構(例如 Wails 內建 WebView 搭配預先隱藏視窗的 fallback
---
#### Major #4shutdownGracePeriod 5s → 10s 的使用者體感取捨未解決
**問題**TDD `server-lifecycle.md` §10-2 把這題列為「待確認」,但這是**體驗面**的問題不是技術面Architect 不該把球踢回使用者。
**PM 回答**:見 §G。採用 **7 秒 + 1 秒內顯示「停止中…」modal** 方案。
**應加在**`server-lifecycle.md` §8 把 `shutdownGracePeriod` 改為 7 s§5 ServerController.Stop 新增 `emit "server:stopping"` event前端新增「stopping modal」元件`control-panel.md` §5 action-bar.js 補M8-5 驗收條件加「關閉視窗後 1 秒內看到停止中 modal」
**阻擋等級**Major但範圍小。可併入 M8-4 / M8-5 開發
---
### Minor可先進開發後續修
#### Minor #1N-R4CI / E2E 測試分層)沒有方案
PRD §8.2 明確要求 Architect 在 TDD v2 討論階段提出分層方案。TDD 沒提。但這題現階段交 Testing Agent暫不阻擋。
**建議**:在 `milestone-plan.md` M8-10 新增一段「測試分層建議」,或在 TDD v2 §3 風險區新增 R-v2-8 占位,標註「待 Testing Agent 審 TDD 時補方案」
---
#### Minor #2AC-2.1(日常啟動 ≤ 5 秒)沒估算
和 Major #3 同源。若 Major #3 補上冷啟動預算分解,順便也覆蓋 AC-2.1
---
#### Minor #3AC-7.6Wails Close 按鈕 tooltip / confirm 警語)沒落地
TDD `server-lifecycle.md` §7 的 main.go 範例寫死 `OnBeforeClose: return false`。這等同「直接關,無警告」,和 PRD AC-7.6 要求的「明確寫『關閉此視窗會結束 Local Server』」衝突。
**建議**TDD 應預留 hook例如
```go
OnBeforeClose: func(ctx context.Context) (prevent bool) {
if app.prefs.ConfirmOnClose {
// TODO: Design Agent 決定 confirm dialog 文案
// 用 wailsRuntime.MessageDialog 跳對話框
}
return false
},
```
這樣 Design Agent 後續加 dialog 時不用再修 main.go。是否顯示 confirm 由 Design Agent 決定,但 hook 要先留
---
#### Minor #4Wails 關閉時可主動通知瀏覽器 tab 避免 15 s race condition
見 §F.2。建議在 Wails OnBeforeClose 之前透過 WebSocket 廣播 `server:shutdown-imminent` 訊息,前端立即顯示 Overlay。成本低server 端 ~3 行、前端 ~10 行),體驗提升明顯。
**阻擋等級**Minor可在 M8-10 smoke test 發現體驗不佳時補
---
#### Minor #5`deletions.md` §1.1 「RTSP stream 可能呼叫 NewVideoSourceFromURL」敘述不精準
實際 grep 顯示 `camera_handler.go:731``NewVideoSourceFromURLWithSeek` 處理**本地上傳影片的 seek**,不是 RTSP。若 M8-1 執行者照字面理解,會把「不是 URL 但共用函式」的本地影片 seek 功能誤砍。
**修正**`deletions.md` §1.1 文字改為「若唯一呼叫者是 `StartFromURL` → 整個砍;若仍有本地影片 seek 呼叫者camera_handler.go:731 `NewVideoSourceFromURLWithSeek`)→ **函式保留**,但 rename 去掉 `FromURL` 字眼避免誤解,例如改為 `NewVideoSourceWithSeek`,並把 `--disable-network` 這項 configure flag 保留(因為本地檔案讀取走 `protocol=file` 不需 network
---
## I. 通過 / 不通過 結論
### 結論:**條件通過**
| 條件 | 必須完成 |
|------|---------|
| Major #1 + Major #2 修完R5-D1/D2/D3 落地 + milestone-plan 驗收條件修正)| **進 M8-4 / M8-5 前必須** |
| Major #3 補上冷啟動預算分解 + idle RAM 估算 | **使用者確認 TDD v2 前必須** |
| Major #4 採用 7 秒 + modal 方案 | **進 M8-4 / M8-5 前必須**(介面簽名受影響)|
| Minor #1-#5 | 可先進開發M8-10 smoke test 前補齊即可 |
### 整體評價
**TDD v2 品質8.5/10**(扣分在 R5-D 整組遺漏 + PM 5 懸念 2 題沒回答)。
- ✅ **值得稱讚**
- `ffmpeg-lgpl.md` 的完整度configure flags、BUILD.md reproducibility、spctl 驗證、gitignore 陷阱)
- `deletions.md` 的「按表操作 + 驗收 grep」設計防呆到位
- R5-2 / R5-3 的處理乾淨俐落(不存在任何 tray 殘跡)
- state machine 五態的思考完整(`txMu` vs `mu` 雙 mutex 對抗 race
- 新風險 R-v2-1 ~ R-v2-7 對應具體且有緩解
- milestone-plan.md 的依賴圖清晰,支援平行開發
- ❌ **需要補強**
- R5-D 三條補充決策**整組遺漏**,這是 PM 的主要不滿。Architect 需要在使用者回答 R5-D 後,立即在 TDD v2 出一個 patch
- PM 5 懸念的 AC-1.3 與 idle RAM **完全沒回答**PRD 明確要求 Architect 在 TDD v2 驗證
- UX 取捨shutdownGracePeriod被當成技術問題丟回使用者
### 下一步建議
1. Orchestrator 啟動 Architect Agent**TDD v2 patch**,聚焦修 Major #1 ~ #4
2. 修完後 Orchestrator 再啟動 PM 做第二輪審閱(只審 patch 部分,~30 分鐘)
3. 同時 Orchestrator 啟動 Design Agent 做 Design 交叉審閱(本輪 PM 審閱發現 AC-7.6 是 Design 的未決項)
4. Design + PM 兩輪都通過後交使用者確認 → 進 M8-1 ~ M8-3互不依賴的三個砍除/ffmpeg milestone 可立即啟動,不受 Major #1/#2 影響,加速開發)
---
## 附錄:對照資料
- **PRD v2**`/Users/jimchen/visionA/local-tool/.autoflow/02-prd/PRD-v2.md`484 行)
- **TDD v2 索引**`/Users/jimchen/visionA/local-tool/.autoflow/04-architecture/TDD-v2.md`136 行)
- **TDD v2 子檔 8 份**`/Users/jimchen/visionA/local-tool/.autoflow/04-architecture/v2/`3738 行)
- **R5 / R5-D 決策**`/Users/jimchen/visionA/local-tool/.autoflow/progress.md` §「R5 第五輪」+ §「R5-Design 補充」
---
# PM 第二輪審閱 TDD v2.12026-04-14
> 審閱者PM Agent
> 被審物:`TDD-v2.md` v2.1 + 9 份 `v2/*.md` 子檔(新增 `startup-pipeline.md`
> 審閱範圍:只審 v2.0 → v2.1 補丁Major × 4 + Minor × 5 + R5-E 落地 + Architect 自補),不重審 v2.0 既有內容
## 摘要
- **總結論:通過(附 1 個 Minor 餘項)**
- 第一輪 **Major × 4 全部修好**Minor × 5 修好 4 個 + 1 個以另一等效方案處理
- **R5-E1~E6 六條全部落地**`v2/startup-pipeline.md` 新檔 518 行,程式碼級完整)
- **不阻擋進 M8 開發**。M8-1/M8-2/M8-3/M8-8 可立即啟動M8-4/M8-4b/M8-5/M8-9 在 Design v2.1 文案完成後即可啟動
---
## A. 第一輪 Major 修復檢查
| # | 問題 | 檢查位置 | 結果 |
|---|-----|---------|------|
| **Major 1 R5-D1 OS 通知** | `server-lifecycle.md` 新增 §10L690-791`notify.go` + macOS `osascript` / Linux `notify-send -u critical` / Windows PowerShell BurntToast + `msg *` fallback`control-panel.md` L482-486 在 Start 失敗時 `go sendCrashNotification(...)`§6 diffL405-408在 watchServer 3 次失敗時 fire-and-forget`startup-pipeline.md` L295-299 pipeline 失敗時一併發通知 | ✅ 完整。觸發點三處Start 失敗 / watchServer 3 次失敗 / startup pipeline 失敗)都有 non-blocking `go` 呼叫 |
| **Major 1 R5-D2 Linux 預設 OFF** | `server-lifecycle.md` §11.3L824-850`DefaultPreferences()``runtime.GOOS != "linux"` 分平台;`control-panel.md` L169 + L498-500 註解對應;`milestone-plan.md` M8-9 驗收 L404-405 分 macOS/Win vs Linux 兩條 | ✅ 完整 |
| **Major 1 R5-D3 每次都開** | `control-panel.md` `ServerController.Start` L491-503 直接檢查 `if c.app.prefs.AutoOpenBrowser` + 註解明確「取消 v2.0 的 autoOpenedThisSession flag」`server-lifecycle.md` L923 明示「已移除」;`milestone-plan.md` L393 / L411 / L439 反覆強調砍除 | ✅ 完整。但 `code-reuse-v2.md:92` 有殘留(見 §G Minor|
| **Major 2 M8-9 驗收條件** | `milestone-plan.md` M8-9 L407「多次按 Restart`AutoOpenBrowser=true`,每次 Restart 都會呼叫 `OpenInBrowser`」L411 Reviewer 檢查重點「不存在 `autoOpenedThisSession` 欄位」 | ✅ 完整。原「Restart 不會二次開」條件已全數砍除 |
| **Major 3 §11-1 Preferences 持久化** | `server-lifecycle.md` §11 全段L795-924§11.1 `<dataDir>/preferences.json` + §11.2 struct 定義 + §11.3 DefaultPreferences + §11.4 讀寫時機 + L879 atomic write-rename | ✅ 完整 spec |
| **Major 3 §11-2 冷啟動預算** | 被 R5-E 取代(索引 L35 明示「原 10 秒可達性已被 R5-E 60 s 上限 + 階段化進度取代」);`server-lifecycle.md` §2.1aL83-90補上日常啟動 ~3.8 s 估算 | ✅ 以 R5-E 取代是合理方案 |
| **Major 3 §11-3 450 MB 範圍** | `server-lifecycle.md` §11.6L925-943明確「450 MB 目標不含瀏覽器 tab 的記憶體消耗」+ 量測條件定義 + 悲觀情境 50 MB 超標時的 fallback | ✅ 完整 |
| **Major 4 shutdown 7+1** | `server-lifecycle.md` L125 PM Q4 定案「7 秒 + 1 秒 modal」L527 `shutdownGracePeriod = 7 秒`L550 `graceTimer := time.NewTimer(7 * time.Second)`§8L499-521含廣播 + 1 秒 modal + SIGKILL 完整順序§2.3 時序 t=1.000 `shutdown:modal-show` | ✅ 完整,採 PM 方案 |
**結論**4 個 Major 全部修好。
---
## B. 第一輪 Minor 修復檢查
| # | 問題 | 檢查 | 結果 |
|---|-----|------|------|
| Minor 1 N-R4 測試分層 | `control-panel.md` L826 標 `blocked-on-testing-agent` | ✅ 按約定處理 |
| Minor 2 AC-2.1 ≤5 s | `server-lifecycle.md` §2.1aL83-90日常啟動 ~3.8 s 估算表 + 結論「遠低於 60 s 上限」| ✅ |
| Minor 3 OnBeforeClose confirm hook | `server-lifecycle.md` §7L475-479寫死 `return false` + 註解「不跳確認對話框」,**未預留 `ConfirmOnClose` hook**;但 Architect 在 L490-493 解釋「Wails v2 default 就是直接關,與 R5-2 語意一致,不加 hook」 | ⚠️ 以等效方案處理。PM 第一輪建議是「預留 hook」方便未來改動Architect 選擇「保持乾淨不加 dead code」。兩者語意均不彈對話框符合 R5-2PM 接受此取捨,**非阻擋** |
| Minor 4 WebSocket shutdown-imminent | `server-lifecycle.md` §2.3 t=0.005 / §8 L511 / `web-ui-offline-overlay.md` §3.2aL78-95+ L158 新增 `use-shutdown-watcher.ts` + L361 訂閱處理server 新增 `/ws/system` endpointWails `ctrl.Stop()` SIGTERM 前先廣播 | ✅ 完整server + 前端 + Wails 三側齊全)|
| Minor 5 deletions.md §1.1 精準化 | `deletions.md` L38-94 新增 v2.1 Minor 5 段落:明列 `grep -rn 'NewVideoSourceFromURL'` 命令 + `camera_handler.go:731``handleVideoSeek``videoIsURL` dead code + 整個 function 砍 + `videoIsURL` field 砍 + 驗收 grep | ✅ 完整,甚至列出驗收 grep 指令 |
**結論**5 個 Minor 全部處理1 個以等效方案)。
---
## C. R5-E 落地檢查(重點)
| # | 預期 | 位置 | 結果 |
|---|-----|------|------|
| **R5-E1** 60 s hard timeout | `startup-pipeline.md` L156 `startupHardTimeout = 60 * time.Second` + L325-330 watcher 檢查 `sinceTotal > startupHardTimeout``Fail()` | ✅ |
| **R5-E2** 6 階段化 | L154 `startupTotalStages = 6` + L167-177 `StartupPipeline` struct + §3 6 階段表L118+ L422 前端 render 6 rows | ✅ |
| **R5-E3** 20 s soft timeout 顯示重試提示 | L155 `startupSoftTimeout = 20 * time.Second` + L332-346 watcher 檢查 `sinceStage > startupSoftTimeout` → emit `startup:stage-timeout` + `softTimeoutEmitted` flag 防重複 | ✅ |
| **R5-E4** 60 s Error state + 三按鈕 | L325-330 hard timeout 呼叫 `Fail()` + `emitError()` + L292-294 同步叫 `ctrl.setState(ServerStateError, ...)`;三按鈕(重試/檢視 log/回報)在 `startup-panel.js` `showStartupError()`L469+)和 Design Spec 對齊 | ✅ |
| **R5-E5** 階段文字交 Design | `startup-pipeline.md` §2L87-105只定義 i18n key`startup.stage.{1-6}.label`),實際文字留給 `i18n/zh-TW.json` / `en-US.json`Design 填L517 明示「R5-E5 Design Spec v2.1 尚未敲定i18n key 已預留」 | ✅ 零硬編碼文字,完全交 Design |
| **R5-E6** WebSocket 連上 = 就緒 | `startup-pipeline.md` L22 R5-E6 明文§3 L118 階段 6 定義「`OnClientConnected` 第一次觸發」L120-130 兩種實作方式long-poll endpoint / sentinel file留給 M8-4b 執行者選擇L515 `startup-pipeline.md` 末段留 A-Q1 待 M8-4b 決定 | ✅ 設計完整,實作方式待 M8-4b 決定PM 接受)|
**結論**R5-E 六條全部落地且程式碼級細節event schema、1-indexed 陣列、非阻塞 EventsEmit、softTimeoutEmitted 防重複、watcher 生命週期管理)皆嚴謹。
---
## D. TDD ↔ PRD v2.1 對齊
| PRD v2.1 AC | TDD v2.1 對應 | 結果 |
|------------|--------------|------|
| AC-1.3 60 s 硬上限 | `startup-pipeline.md` L156 + `TDD-v2.md` 索引 L18 | ✅ |
| AC-1.3a 6 階段進度 | `startup-pipeline.md` §3 + §6 前端 render | ✅ |
| AC-1.3b 20 s 重試提示 | `startup-pipeline.md` L332-346 | ✅ |
| AC-1.3c 60 s Error state + 三按鈕 | `startup-pipeline.md` L325-330 + `startup-panel.js` showStartupError | ✅ |
| AC-1.3d 階段文字 Design | `startup-pipeline.md` L517 | ✅ |
| AC-7.7 OS 通知並存 | `server-lifecycle.md` §10 三平台 + L791「兩者互補不取代」 | ✅ |
| AC-2.1 日常啟動 ≤5 s | `server-lifecycle.md` §2.1a 日常啟動 ~3.8 s | ✅ |
| AC-2.1a 每次 Start 都自動開 | `control-panel.md` L491-503 | ✅ |
---
## E. Architect 自補項檢查
- **F-2 Restart port 強制保留**`server-lifecycle.md` §3.2L179-224完整`pickPort(preferredPort, forceMatch=true)` + 使用者情境解釋 + port 被佔則進 Error state + 廣播 shutdown-imminent ✅
- **階段 6 WebSocket 實作細節**`startup-pipeline.md` L120-130 列出 long-poll endpoint / sentinel file 兩方案L515 明示「M8-4b 執行者決定並回報」。PM 接受此處延後決策,因為兩方案介面簽名相容,不影響上游 M8-4 開發 ✅
- **boot-id crypto/rand 16 bytes**Q3索引 L29 v2.1 變更條目記錄,不引額外依賴 ✅
- **navigator.language fallback 強化**Q5索引 L30 記錄 ✅
---
## F. 工時合理性
v2.0 10 人天 → v2.1 12 人天(+2 天),增量分解:
- R5-E 階段化啟動 +1 天M8-4b 新 milestone
- R5-D1 OS 通知 +0.3 天
- PM Q4 7+1 shutdown modal +0.2 天
- Minor 4 WebSocket 廣播 +0.3 天
- 其餘M8-10 驗收項目擴充)+0.2 天
合計 2.0 天,與 milestone-plan.md L491 總計相符。
**PM 接受 12 人天**;另 L493 建議「加 M8-3 / M8-4 buffer 1 天 → 對使用者回報 ~13 人天」也合理。
**輕微瑕疵**`milestone-plan.md` L6 文字摘要寫「~11.5 人天」L491 合計表寫「12.0」。摘要行未同步更新,屬文字不一致。非阻擋。
---
## G. 第二輪新發現問題
### Major
無。
### Minor
#### Minor 6`code-reuse-v2.md:92` 殘留 v2.0 字串
**問題**`v2/code-reuse-v2.md` L92 仍然寫
```
第 83 行:砍 `mockMode` 欄位;新增 `ctrl` / `logBuf` / `prefs` / `autoOpenedThisSession` 欄位
```
**但** `milestone-plan.md:147` / `server-lifecycle.md:923` / `control-panel.md:811` 全部明示「**不存在** `autoOpenedThisSession`」。這是 v2.0 → v2.1 轉版時 `code-reuse-v2.md` 沒同步更新。
**影響**M8-4 / M8-5 的工程師 Agent 若以此為唯一指引,可能加回 `autoOpenedThisSession` 欄位 → 違反 R5-D3。
**修正**`code-reuse-v2.md:92` 移除 `autoOpenedThisSession` 字樣。一行 diff。
**阻擋等級**Minor。不阻擋進開發因其他三處指引都正確但進 M8-5 前最好修掉避免混淆。
#### Minor 7`milestone-plan.md:6` 摘要 11.5 vs 合計 12.0
見 §F。非阻擋下次順手改。
---
## H. 第二輪通過 / 不通過 結論
### 結論:**通過**(附 Minor 6 / Minor 7 可隨開發過程修)
| 項目 | 第一輪 | 第二輪 |
|------|-------|-------|
| Major | 4 | **0** |
| Minor | 5 | **2**(新 Minor 6 / 7非阻擋|
| R5-E 落地 | — | **100%** |
| 是否阻擋進 M8 開發 | 部分阻擋 | **不阻擋** |
| 整體品質 | 8.5/10 | **9.3/10** |
### 稱讚
- R5-D1 OS 通知的三個觸發點Start 失敗 / watchServer 3 次失敗 / startup pipeline 失敗)全覆蓋 + 非阻塞 goroutine 設計完美
- `startup-pipeline.md` 是一份**程式碼級**的子檔518 行 Go + JS 全部寫出Reviewer 可以直接比對
- R5-E5 階段文字零硬編碼,完全以 i18n key 交給 Design等 Design Spec v2.1 敲定就能直接填 JSON
- §11.6 的 450 MB 範圍澄清 + 悲觀情境 fallback 策略很到位
- `deletions.md` Minor 5 修正後的驗收 grep 命令可直接複製執行,防呆
### 下一步
1. 使用者確認 TDD v2.1 + Design Spec v2.1 → 即可進 M8 開發
2. **M8-1 / M8-2 / M8-3 / M8-8 可立即並行啟動**互不依賴Major 修復未觸及此四者)
3. **M8-4 / M8-4b / M8-5 / M8-9** 依賴 `DefaultPreferences()` / `sendCrashNotification()` / `StartupPipeline` / Preferences 持久化,這四者 TDD v2.1 都已 spec 完整可開工
4. Minor 6 / Minor 7 建議 M8-5 啟動前或開發過程中順手修
5. 本輪 PM 審閱完成,不需第三輪
- **審閱耗時**~1 小時(含所有子檔閱讀 + 交叉對照)

View File

@ -0,0 +1,219 @@
# v2/code-reuse-v2.md — 沿用 vs 改寫 vs 新寫
> 所屬TDD v2 §2.8
> 目的:以 v1 M1-M7 已完成)為基準,量化 v2 refactor 的沿用率
> 承接:`architect-analysis-round2-refactor.md` §D
> 結論:**整體沿用率 85-95%**(三方共識 #1**不是丟掉重做**,是**擴充 + 砍功能**
---
## 1. 模組沿用率總表
| 模組 | 現況大小 | 改動量 | 沿用率 | 關鍵影響 |
|------|---------|-------|-------|---------|
| `server/` Go 後端 | ~15k LoC | ~400 行修改 + 新增 boot-id endpoint | **~96%** | 砍 yt-dlp + Mock + 新增 CORS whitelist + boot-id預置模型 / inference / camera pipeline / Python bridge / WebSocket / static serving 全部不動 |
| `server/web/out/` go:embed 的 Next.js static | ~20k LoC | ~100 行修改 + ~250 行新增offline overlay + boot-id watcher + i18n | **~88%** | 砍 URL tab + Mock UI新增 Offline Overlay + boot-id watcher業務頁面全部不動 |
| `visiona-local/app.go` Wails 殼 | 1584 行 | ~350 行新增 / ~30 行刪改 | **~85%** | 新增 ServerController + LogBuffer + PreferenceswatchServer 改為 Error state`mockMode` 欄位與 VISIONA_MOCK 砍除;核心 startup / shutdown / single-instance / driver installer 全部不動 |
| `visiona-local/frontend/` Wails 內嵌 UI | 211 行html + js + css | 三檔全部改寫 + 新增 12 檔 | **程式面砍掉重寫,概念承襲** | 沿用 ES module + wailsjs 串接模式,但內容是全新控制台 UI |
| `installer/` 打包腳本 | macOS dmgbuild / Windows iss / Linux AppImage | 各 ~3 行修改 | **~98%** | 只改 ffmpeg/ffprobe/LGPL licnese 的 copy其餘不變 |
| `Makefile` | 570 行 | ~60 行修改 + ~80 行新增macOS build | **~85%** | 砍 yt-dlp targets改 Windows/Linux ffmpeg URL新增 macOS 自 build |
| `scripts/bootstrap-*.sh/.ps1` | 中等 | 各 ~5 行修改 | **~99%** | 只砍 yt-dlp 參照 |
| `vendor/` | 現有依賴 | 砍 `yt-dlp/`;改 `ffmpeg/`;新增 `ffmpeg/macos/` 進 git | N/A | 內容差異顯著 |
**總計**:整個 repo 程式碼層 ~85-95% 沿用(依粗估方式不同,但結論是「絕大多數 M1-M7 投入仍有效」)。
---
## 2. 逐目錄細節
### 2.1 `server/` — 96% 沿用
**完全不動**> 99%
- `server/internal/api/ws/` — WebSocket hub / handlers除 CheckOrigin 那個 helper 是 M8-8 新增)
- `server/internal/camera/` — 除 `video_source.go` 的 yt-dlp 函式§2.2+ `mock_camera.go`(整檔刪)
- `server/internal/device/` — 除 `manager.go` 的 mockMode 條件 + `driver/mock/` 目錄
- `server/internal/inference/` — 推論 service 邏輯
- `server/internal/model/` — 模型 repository / store / upload
- `server/internal/flash/` — flash serviceKneron 韌體燒錄v1 已保留)
- `server/pkg/logger/` — logger + broadcaster
- `server/scripts/kneron_bridge.py` — Python KneronPLUS bridge
- `server/data/` — 預置 .nef 模型
**修改**< 5% 程式碼
- `server/main.go` 第 86-87 行log message、第 89-95 行(註解)、第 142 行NewManager 簽名)、第 147 行NewManager 簽名)
- `server/internal/api/router.go` 第 83 行(刪 `/media/url`+ 新增 `/api/system/boot-id`
- `server/internal/api/handlers/camera_handler.go` 第 251 行(擴充副檔名)+ 第 341-497 行(刪 yt-dlp 相關)
- `server/internal/api/handlers/system_handler.go` 新增 `BootID` method + constructor 新增 bootID 參數
- `server/internal/api/middleware.go` 整檔覆寫CORS 白名單)
- `server/internal/config/config.go` 刪 MockMode / MockCamera / MockDeviceCount
- `server/internal/deps/checker.go` 刪 yt-dlp entry + 註解更新
- `server/internal/camera/video_source.go``ResolveWithYTDLP` / `friendlyYTDLPError`;可能砍 `NewVideoSourceFromURL`(視 grep 結果)
- `server/internal/camera/manager.go` 砍 mockMode / mockCamera
- `server/internal/device/manager.go` 砍 mockMode / mockCount
**新增**
- `server/internal/api/ws/origin.go` — CheckOrigin helper~30 行)
- `server/internal/api/middleware_test.go` — TestIsAllowedOrigin~50 行)
### 2.2 `server/web/out/`go:embed 的 Next.js SPA實際源碼在 `frontend/`)— 88% 沿用
**完全不動**
- `frontend/src/app/dashboard/` / `devices/` / `models/` / `workspace/` — 業務頁面
- `frontend/src/components/device/` / `model/` / `inference/` — 業務元件
- `frontend/src/lib/api/` — API client
- `frontend/src/stores/``camera-store.ts``startFromUrl`
**修改**
- `frontend/src/components/camera/source-selector.tsx` — 砍 URL tab擴充副檔名
- `frontend/src/stores/camera-store.ts` — 砍 `startFromUrl`
- `frontend/src/app/settings/page.tsx` — 砍 Mock 模式切換
- `frontend/src/app/layout.tsx` — 掛 `<ServerOfflineOverlay />` + `<BootIdWatcherMount />`
- `frontend/src/lib/i18n/{types,zh-TW,en}.ts` — 砍 4 個 yt-dlp keys + 4 個 Mock keys + `noDevices` 文案 + 新增 `serverOffline` 區塊
**新增**
- `frontend/src/stores/system-store.ts`~40 行)
- `frontend/src/hooks/use-boot-id-watcher.ts`~70 行)
- `frontend/src/components/server-offline-overlay.tsx`~90 行)
- `frontend/src/components/boot-id-watcher-mount.tsx`~10 行)
### 2.3 `visiona-local/app.go` — 85% 沿用
**完全不動**的 v1 邏輯:
- `startup()` 的 data dir migration / single-instance lock / IPC server / seed user data dir
- `shutdown()` 的 watchCancel / ipcListener.Close / releaseLock
- `reportFatal()` + `showNativeError()` 三平台原生對話框(保留給 startup 致命錯誤)
- `ensurePythonRuntime()` / `findSystemPython()` / `ensureBundledPython()` 完整 Python 雙策略
- `ensureDriverInstalled()` / `markDriverInstalled()` / `InstallKneronDriver()` — Kneron WinUSB driver 安裝
- `locateServerBinary()` / `pickPort()` / `waitHealthy()` / `writeIPCPort()` — 這些 helper 不動
- `seedUserDataDir()` — 首次啟動的資料 seed
- `configureSysProcAttr()` / `openBrowser()` 跨平台封裝
**修改**
- 第 83 行:砍 `mockMode` 欄位;新增 `ctrl` / `logBuf` / `prefs` / `startupPipeline` / `pipelineCancelFn` 欄位R5-D3 規則:每次 Start 都開瀏覽器,不需 per-session 的 flag詳見 `server-lifecycle.md` §11.5
- 第 119-120 行:砍 VISIONA_MOCK env 讀取
- 第 313-326 行:砍 `GetBootstrapStatus` / `setBootstrapStatus` / `bootstrapStatus`
- 第 425-564 行:`startServer()` 改名為 `startServerV2()`,內容改為
- 用 `StdoutPipe / StderrPipe` 而非 file
- 啟動兩個 `logPump` goroutine
- mockMode 相關條件全砍
- `--mock` arg 拿掉
- 第 571-610 行:`watchServer()``reportFatal + os.Exit` 改為 `ctrl.setState(Error)` + event emit
**新增 bindings**13 個 method`v2/control-panel.md` §4.2
- `StartServer` / `StopServer` / `RestartServer`
- `GetServerStatus`(改版)/ `GetRecentLogs` / `ClearLogs`
- `GetSystemInfo`
- `OpenInBrowser` / `RevealLogsFolder` / `ExportLog`
- `GetPreferences` / `SetPreferences`
- `RestartStartupSequence`v2.1Startup Error state 的 Retry 按鈕;見 `startup-pipeline.md` §9
### 2.4 `visiona-local/` 新增檔案
- `visiona-local/server_control.go`~250 行)— ServerController + startServerV2 + logPump
- `visiona-local/log_buffer.go`~120 行)— LogBuffer ring buffer
- `visiona-local/preferences.go`~60 行)— JSON load/save
### 2.5 `visiona-local/main.go` — 修改 < 5
新增 `OnBeforeClose` handler其餘不動。
### 2.6 `visiona-local/frontend/` — 改寫
原本M7-B 後):`index.html` 25 行 + `app.js` 74 行 + `style.css` 112 行 = 211 行 splash。
v2
- `index.html` ~80 行(控制台 layout
- `app.js` ~130 行init + binding 呼叫 + event 訂閱)
- `style.css` ~300 行(控制台樣式 + dark/light tokens
- 新增 components/×4 + i18n/×3 + icons/×6 + wailsjs/(自動生成)
**概念沿用**ES module + `import` wailsjs + `EventsOn` 訂閱模式——v1 M7-B splash 就是這樣寫的v2 延續這個模式,只是元件更多。
**程式碼沿用**0。三個檔案的內容全部重寫。但這三個檔案加起來才 211 行,重寫 2 天是合理的。
### 2.7 `installer/` — 98% 沿用
- `installer/windows/visiona-local.iss` — 改 1 行(砍 yt-dlp+ 加 2 行ffprobe + license
- `installer/linux/build-appimage.sh` — 改 1 行(砍 yt-dlp 從 for loop
- macOS `dmgbuild` 路徑完全不動dmg 是從 `visiona-local/build/bin/visiona-local.app` 直接建ffmpeg/ffprobe 已透過 payload-macos 置入)
### 2.8 `Makefile` — 85% 沿用
**新增 target**`vendor-ffmpeg-macos-build`~50 行)
**修改**
- `vendor-ffmpeg` 改為驗證(~10 行)
- `vendor-ffmpeg-windows` URL + Python zipfile 提取清單擴充為 ffmpeg + ffprobe + LICENSE
- `vendor-ffmpeg-linux` URL + 解壓邏輯 + 加抓 ffprobe
- `payload-macos / payload-windows / payload-linux` 全部改為 `cp ffmpeg + ffprobe`,砍 `cp yt-dlp`
**刪除**
- `vendor-ytdlp` / `vendor-ytdlp-windows` / `vendor-ytdlp-linux` 三個 target
- 三個 `YTDLP_URL_*` 變數
- `vendor-sync` 中的 `vendor-ytdlp` 依賴
### 2.9 `scripts/bootstrap-*.sh/.ps1` — 99% 沿用
只改刪 yt-dlp 參照,其他完全不動。
### 2.10 `vendor/` 目錄
| 子目錄 | v1 | v2 | 備註 |
|-------|----|----|------|
| `vendor/python/` | 進 vendor不進 git| 不變 | - |
| `vendor/wheels/` | 進 vendor | 不變 | - |
| `vendor/ffmpeg/darwin/` | 進 vendor不進 gitGPL build| **目錄改名** `vendor/ffmpeg/macos/` | 進 gitLGPL build |
| `vendor/ffmpeg/windows/` | 進 vendor | 同路徑LGPL build | - |
| `vendor/ffmpeg/linux/` | 進 vendor | 同路徑LGPL build | - |
| `vendor/yt-dlp/` | 進 vendor | **整個刪除** | R5-7 |
---
## 3. 文字統計估算
| 類別 | 行數 |
|------|------|
| v2 新增 Go 程式碼 | ~600 行server_control.go + log_buffer.go + preferences.go + boot-id handler + CORS middleware + origin.go + middleware_test.go |
| v2 新增 JS/TS 程式碼 | ~700 行Wails 控制台 5 JS 檔 + 6 SVG + 2 JSON + frontend Offline Overlay 4 檔) |
| v2 修改 Go 程式碼 | ~400 行 diff |
| v2 修改 TS 程式碼 | ~200 行 diff |
| v2 修改 Makefile / scripts / installer | ~100 行 diff |
| v2 刪除 Go 程式碼 | ~500 行mock_driver.go 183 + mock_camera.go 95 + camera_handler.go yt-dlp 段 160 + video_source.go yt-dlp 段 50 |
| v2 刪除 TS 程式碼 | ~80 行source-selector URL tab 70 + camera-store startFromUrl 30 - 扣除同 section 重複計算) |
| v2 刪除 Makefile / scripts | ~60 行 |
**總 diff 量**~2600 行(新增 + 修改 + 刪除)
**總 repo 規模**~40k LoC
**diff 比例**~6.5%
→ 換算成**淨沿用率**~93%落在三方共識的「85-95%」區間上半部。
---
## 4. 為什麼沿用率這麼高
M1-M7 的投資集中在「打包」這件事上vendor 機制、dmg/iss/AppImage、Python runtime 雙策略、single-instance、data dir migration、Kneron driver installer這些**完全不動**。
v2 的 refactor 集中在:
1. 把業務 UI 從 Wails WebView 搬到瀏覽器純移動Next.js 源碼不動)
2. Wails 視窗內容從 splash 換成控制台(全新 UI但只有 ~700 行程式碼)
3. 砍兩個功能yt-dlp, Mock— 都是局部刪除,不觸及核心架構
4. 切 ffmpeg 授權(純 vendor 層,不動執行邏輯)
**等於說 v2 是在 v1 基礎上做「加工」,不是推倒重來**。原本這個 refactor 有可能變成「丟掉重做」的惡夢,但 M7-B 已經把 Next.js UI 從 Wails binding 強耦合解開變成純 HTTP少了這個前置條件 v2 真的會很痛。
---
## 5. 被保留但可能失去商業意義的部分
這些 v2 不砍,但隨 R5 決策的不同方向它們的重要性變低了:
- **Python runtime 雙策略 + PBS 內嵌M3 work** — 仍然需要Kneron inference 必須有 Python。保留。
- **驗證 driver installerM4 Windows WinUSB** — 仍然需要。保留。
- **預置 .nef 模型**8 個73 MB — 仍然需要。保留。
- **Kneron KL520/KL720 韌體檔案** — 仍然需要(最近才補上)。保留。
- **splash 進度訊息 `setBootstrapStatus`** — 砍掉R5 下 Wails 視窗是控制台沒有「loading」階段Start 是 async 不阻塞 UI
---
**結論**v2 refactor 的沿用率接近 93%,剩下的 7% diff 主要是新增控制台 UI 與整合 boot-id 機制,總工時 ~10 人天是合理估算。

View File

@ -0,0 +1,849 @@
# v2/control-panel.md — Wails 控制台實作規格
> 所屬TDD v2 §2.1
> 版本v2.12026-04-14 吸收 PM 審閱 + R5-D + R5-E
> 決策依據R5-1Wails 視窗 = 控制台、R5-5Mock 切換不放控制台、R5-D1OS 崩潰通知並存、R5-D2Linux 預設 auto-open OFF、R5-D3每次 Start 成功都開瀏覽器、R5-E階段化啟動進度、三方共識 #7vanilla HTML/JS/CSS
> 對應 milestoneM8-4lifecycle + bindings + LogBuffer、M8-4b階段化啟動管線、M8-5vanilla UI 改寫)
> 關聯子檔:`v2/startup-pipeline.md`R5-E 6 階段啟動管線細節)
---
## 1. 目的與範圍
把現有的 `visiona-local/frontend/` splashM7-B 寫的 78 行 splash + redirect**整組改寫**成一個靜態的控制台 UI長駐在 Wails 視窗內,不再跳轉。控制台提供:
1. **Server 狀態卡片**:即時 state、port、PID、Python runtime 資訊
2. **Log panel**ring buffer 2000 行、auto-scroll、pause、clear
3. **動作列**Start / Stop / Restart / Open in Browser / Reveal Logs Folder / Clear Logs
4. **Preferences 區塊**`AutoOpenBrowser` toggleR5-4 / R5-D2 / R5-D3
5. **系統資訊**data dir / app version / build time對齊 `GET /api/system/info`,但取自 Wails local state 更穩)
控制台**不做**業務資料裝置清單、模型清單、推論畫面、Settings > 語言、Mock 切換)— 這些全在瀏覽器 tab 的 Next.js Web UI。
---
## 2. 技術選型R5-1 + 三方共識 #7
| 面向 | 決定 | 原因 |
|------|------|------|
| UI stack | **vanilla HTML + CSS + ES module JS** | 控制台元件 < 10 引入 React/Vue/Svelte 反而重現有 `visiona-local/frontend/` 已經是 ES module不需重建 build chain |
| Build | **無**`go:embed all:frontend` 直接塞入 Wails binary | 維持現有 `visiona-local/main.go:11-12` 的 embed 機制,完全不動 |
| CSS 方法 | BEM 命名 + CSS variableslight/dark mode token | 不需 Tailwinddark mode 靠 `@media (prefers-color-scheme: dark)` 換 CSS var |
| 圖示 | Inline SVGaction bar 的播放 / 停止 / 刷新 / 瀏覽器 / 資料夾 / 垃圾桶) | 不引外部 icon library避免多一份字型檔 |
| i18n | 沿用 `frontend/src/lib/i18n/{zh-TW,en}.ts` 的字串鍵,但以**獨立的 JSON 子集**提供給控制台(避免控制台與 Next.js 前端耦合 build pipeline | 詳見 §6 |
| 自動偵測語系 | Q5 強化:`navigator.languages[0] \|\| navigator.language`,以 `zh` 開頭 → `zh-TW``C` / `POSIX` / 空字串 → `en-US`;其他 → `en-US`。fetch 失敗 → hardcoded 英文 fallback | Wails v2 在 Windows/Linux 下偶爾回 `en``C`,需 fallback細節見 §6.2 |
### 2.1 檔案結構
```
visiona-local/frontend/
├── index.html ← 改寫:控制台 layout
├── app.js ← 改寫:初始化 + binding 呼叫 + event 訂閱
├── style.css ← 改寫控制台樣式status / log / action bar / prefs
├── components/
│ ├── status-card.js ← 新增:狀態卡片 render + update
│ ├── log-panel.js ← 新增ring buffer 顯示 + auto-scroll + pause
│ ├── action-bar.js ← 新增6 顆按鈕 + disable 邏輯(依 state machine
│ ├── preferences.js ← 新增AutoOpenBrowser toggleR5-D2/D3
│ └── startup-panel.js ← 新增R5-E 6 階段啟動進度面板 render/update
├── i18n/
│ ├── en-US.json ← 新增:控制台 i18n 字串子集
│ └── zh-TW.json ← 新增
├── icons/
│ ├── play.svg ← 新增
│ ├── stop.svg
│ ├── restart.svg
│ ├── browser.svg
│ ├── folder.svg
│ └── trash.svg
└── wailsjs/ ← 自動生成Wails build不動
```
---
## 3. UI 元件清單 + 視覺結構
```
┌────────────────────────────────────────────────────────────────┐
│ visionA Local [■ Dark/Light]│ ← titlebarWails frameless 或 systemv2 保持 system
├────────────────────────────────────────────────────────────────┤
│ ┌─────────────── Server Status ─────────────────────────────┐ │
│ │ ● Running │ │ ← state badge顏色Running 綠 / Starting 黃 / Error 紅 / Stopped 灰)
│ │ http://127.0.0.1:3721 • PID 42131 │ │
│ │ Python: system (/usr/bin/python3.12) │ │
│ │ Data dir: ~/Library/Application Support/visiona-local │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────── Server Log ────────────────────────────────┐ │
│ │ 14:23:01 [INFO] visiona-local-server starting on :3721 │ │
│ │ 14:23:01 [INFO] Python bridge: /usr/bin/python3.12 │ │
│ │ 14:23:02 [INFO] Loaded 8 built-in models │ │
│ │ 14:23:02 [INFO] HTTP server listening on 127.0.0.1:3721 │ │
│ │ 14:23:15 [GIN] 200 | 2.1ms | GET /api/system/info │ │ ← scrollable <pre class="log__body">
│ │ ... │ │
│ └────────────────────────────────────────────────────────────┘ │
│ [Pause auto-scroll] [Clear] │ ← log panel 底下的子動作
│ │
│ ┌─────────────── Actions ───────────────────────────────────┐ │
│ │ [▶ Start] [■ Stop] [⟲ Restart] [🌐 Open in Browser] │ │
│ │ [📁 Reveal Logs] │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────── Preferences ───────────────────────────────┐ │
│ │ ☑ Open browser automatically when server starts │ │
│ │ Linux 預設為 OFF — R5-D2macOS/Windows 預設為 ON │ │
│ └────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
```
Button disable 矩陣(依 ServerState
| 按鈕 | Stopped | Starting | Running | Stopping | Error |
|------|---------|----------|---------|----------|-------|
| Start | ✅ | ❌ | ❌ | ❌ | ✅ |
| Stop | ❌ | ❌ | ✅ | ❌ | ❌ |
| Restart | ❌ | ❌ | ✅ | ❌ | ❌ |
| Open in Browser | ❌ | ❌ | ✅ | ❌ | ❌ |
| Reveal Logs | ✅ | ✅ | ✅ | ✅ | ✅ |
| Clear Logs | ✅ | ✅ | ✅ | ✅ | ✅ |
`Starting``Stopping` 期間顯示進度 spinner 取代 state icon。
### 3.1 Starting state 下的「啟動進度面板」(R5-E)
當 ServerState 處於 `Starting` 時,主控台覆蓋一層啟動進度面板,**取代**而非疊加於Status/Log/Actions 區塊:
```
┌────────────────────────────────────────────────────────────────┐
│ visionA Local │
├────────────────────────────────────────────────────────────────┤
│ │
│ 正在啟動 visionA Local │
│ │
│ [■■■■□□] 4 / 6 │
│ │
│ ✅ 1. 初始化控制台 (0.1 s) │
│ ✅ 2. 檢查 Python runtime (1.8 s) │
│ ✅ 3. 啟動本機伺服器 (2.4 s) │
│ 🔄 4. 偵測 Kneron 裝置 (進行中…) │
│ ⏳ 5. 開啟瀏覽器 │
│ ⏳ 6. 等待 Web UI 連線 │
│ │
│ 已耗時 4.3 s · 上限 60 s │
│ │
│ (第 N 階段正在重試 …) │
│ │
└────────────────────────────────────────────────────────────────┘
```
- 6 階段逐步 render每個階段 status`pending` / `running` / `completed` / `failed`,對應 ⏳ / 🔄 / ✅ / ❌
- 底部總耗時 counter每秒更新一次
- 當某階段收到 `startup:stage-timeout` eventsoft timeout 20 s在該階段行下方顯示 subtle 灰字「第 N 階段正在重試 …」
- 當收到 `startup:error` event任一階段失敗 或 總時 60 s→ 切 Error state淡出進度面板顯示主控台 Error banner
- 當收到 `startup:ready` event → 淡出 300 ms → 主控台 UI 顯現
- i18n key`startup.stage.1.label` ~ `startup.stage.6.label` / `startup.retrying` / `startup.elapsed` / `startup.error`。文案由 Design Spec v2.1 敲定R5-E5
完整 event schema、超時機制、watcher goroutine 見 `v2/startup-pipeline.md`
### 3.2 Error state 的 UI 呈現 + R5-D1 OS 通知並存
當 ServerState 切到 `Error`watchServer 連續 3 次失敗、StartServer 失敗、或階段化啟動總超時 60 s
1. **控制台內**Status card 的 badge 變紅、顯示 `lastError` 訊息、log panel 頂端插入一條 error banner可關閉action bar 的 [Start] 重新 enable 讓使用者手動 Restart
2. **同時**:透過 `visiona-local/notify.go` 發一則 OS 原生通知macOS `osascript display notification` / Linux `notify-send` / Windows `powershell BurntToast``msg *`標題「visionA Local — Server 崩潰」,副文「點回應用程式查看錯誤詳情」
3. **非此即彼**OS 通知與控制台 banner **並存**並非二選一。OS 通知的用途是「使用者把 Wails 視窗縮小/最小化時也能被知會」;控制台 banner 是「使用者打開 Wails 時一眼可見」。
詳細 `notify.go` 三平台實作見 `server-lifecycle.md` §10。
---
## 4. Go 側Wails bindings + state machine + LogBuffer
### 4.1 新增/修改的 Go 檔案
| 檔案 | 狀態 | 內容 |
|------|------|------|
| `visiona-local/app.go` | 修改(~1584 行 → +250 / -30 | 新增 bindings`watchServer` 改為 Error state`shutdown` 不動;`reportFatal` 保留給「完全無法啟動」的致命錯誤 |
| `visiona-local/server_control.go` | **新增** | `ServerController` struct + 狀態機 + Start/Stop/Restart 方法 |
| `visiona-local/log_buffer.go` | **新增** | `LogBuffer` struct + ring buffer + subscribe / unsubscribe / append + logPump |
| `visiona-local/preferences.go` | **新增** | `Preferences` struct + `DefaultPreferences()`(依 `runtime.GOOS` 分平台 defaultR5-D2+ load/save JSONatomic write-rename+ 路徑 `<dataDir>/preferences.json` |
| `visiona-local/notify.go` | **新增** | `sendCrashNotification(title, body string)` 三平台實作macOS `osascript` / Linux `notify-send` / Windows `powershell BurntToast``msg *`)。非阻塞、最佳努力送達。詳見 `server-lifecycle.md` §10 |
| `visiona-local/startup_pipeline.go` | **新增** | R5-E 6 階段啟動管線:`StartupPipeline` struct + watcher goroutine20 s soft / 60 s hard timeout+ event emit。詳見 `v2/startup-pipeline.md` |
### 4.2 新增 BindingsWails 自動暴露為前端 JS 函式)
```go
// server_control.go 中新增的 App methodWails binding 會暴露成前端可呼叫)
// StartServer 啟動 server 子程序。若目前已是 Running / Starting / Stopping → 回錯誤(前端 UI 用 button disable 防呆)。
func (a *App) StartServer() error
// StopServer 優雅停止 server 子程序SIGTERM → 等 5 s → SIGKILL。若目前 Stopped / Error → no-op。
func (a *App) StopServer() error
// RestartServer = Stop 同步完成後 Start。中間經過 Stopped 狀態。
func (a *App) RestartServer() error
// GetServerStatus 取代 v1 版本,回傳更完整的結構(見 §4.3)。
func (a *App) GetServerStatus() ServerStatusV2
// GetRecentLogs 回傳 LogBuffer 最後 n 行n <= 0 或 > 2000 則回全部。
// 前端初次載入時呼叫一次,之後靠 log:append event 增量更新。
func (a *App) GetRecentLogs(n int) []LogLine
// ClearLogs 只清畫面(不動 server.stdout.log / server.stderr.log 磁碟檔)。
// 實作LogBuffer.Reset() + emit event 'log:clear'。
func (a *App) ClearLogs()
// GetSystemInfo 回傳靜態系統資訊(給 Status card 顯示)。
// 不是 HTTP API 調用,直接從 Wails local state / config 讀,避免 server 未 Running 時撈不到。
func (a *App) GetSystemInfo() SystemInfo
// OpenInBrowser 用系統預設瀏覽器開啟 url空字串則用當前 server URL。
// 實作沿用現有 openBrowser()platform_darwin.go / _linux.go / _windows.go完全不動。
func (a *App) OpenInBrowser(url string) error
// RevealLogsFolder 在檔案管理器中開啟 <dataDir>/logs/ 目錄。
// macOS: `open <path>` / Windows: `explorer <path>` / Linux: `xdg-open <path>`
func (a *App) RevealLogsFolder() error
// ExportLog 將 LogBuffer ring buffer 當前內容(最多 2000 行)寫入單一檔案並回傳路徑。
// 路徑:<dataDir>/exports/log-<timestamp>.txt timestamp = time.Now().Format("20060102-150405")
// 內容格式:逐行 dump每行格式 "[level] timestamp message"level 為空時省略 bracket
// 實作要點:
// 1. 確保 <dataDir>/exports/ 目錄存在os.MkdirAll 0755
// 2. LogBuffer.Snapshot() 取得當前 ring buffer 副本(不干擾後續 append
// 3. os.WriteFileatomic-ish檔案小、不需 atomic rename
// 4. 回傳絕對路徑(前端可顯示 toast「已匯出至 <path>」並提供「在檔案管理器中顯示」按鈕)
// 用途Wails 控制台「Export log」按鈕Design Spec v2.1 §3.4);使用者回報問題時一鍵拿到 snapshot
func (a *App) ExportLog() (string, error)
// GetPreferences / SetPreferences 控制台 Preferences 區塊。
// 讀取失敗或檔案不存在時回傳 DefaultPreferences()(依平台預設)。
// SetPreferences 使用 atomic write-rename 避免 crash 時檔案損毀。
func (a *App) GetPreferences() Preferences
func (a *App) SetPreferences(p Preferences) error
```
**保留不動v1 已有)**`GetServerURL()`(前端控制台不會用,但為了相容性不刪)
**刪掉v1 splash 殘留)**`GetBootstrapStatus()`splash 專用,控制台不需要)
### 4.3 型別定義
```go
type ServerState string
const (
ServerStateStopped ServerState = "stopped"
ServerStateStarting ServerState = "starting"
ServerStateRunning ServerState = "running"
ServerStateStopping ServerState = "stopping"
ServerStateError ServerState = "error"
)
type ServerStatusV2 struct {
State ServerState `json:"state"`
Port int `json:"port,omitempty"`
URL string `json:"url,omitempty"`
PID int `json:"pid,omitempty"`
PythonBin string `json:"pythonBin,omitempty"`
PythonMode string `json:"pythonMode,omitempty"`
StartedAt int64 `json:"startedAt,omitempty"` // Unix ms
LastError string `json:"lastError,omitempty"`
}
type SystemInfo struct {
AppVersion string `json:"appVersion"` // Wails build 時塞的 version
BuildTime string `json:"buildTime"`
DataDir string `json:"dataDir"`
LogsDir string `json:"logsDir"`
Platform string `json:"platform"` // runtime.GOOS + "/" + runtime.GOARCH
}
// Preferences 控制台偏好設定。對應檔案:<dataDir>/preferences.json。
// 詳細持久化策略見 server-lifecycle.md §11「Preferences 持久化」。
type Preferences struct {
// AutoOpenBrowser — StartServer 成功後是否自動開瀏覽器。
// 預設值由 DefaultPreferences() 依 runtime.GOOS 決定:
// macOS / Windows → true
// Linux → false R5-D2Linux 桌面環境差異大,預設關)
// 使用者可在 Preferences 區塊自行切換。
AutoOpenBrowser bool `json:"autoOpenBrowser"`
// Locale — 控制台 UI 的語系覆寫;空字串 → 自動偵測navigator.language
Locale string `json:"locale,omitempty"`
// LogRingSize — log panel ring buffer 行數上限。0 → 使用預設 2000。
LogRingSize int `json:"logRingSize,omitempty"`
}
type LogLine struct {
Ts int64 `json:"ts"` // Unix ms
Stream string `json:"stream"` // "stdout" / "stderr" / "wails"(控制台自己的 log
Line string `json:"line"`
Level string `json:"level,omitempty"` // 解析出 INFO/WARN/ERROR 時才填
}
```
### 4.4 LogBuffer 實作(`visiona-local/log_buffer.go`
```go
package main
import (
"bufio"
"io"
"sync"
"time"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
const (
logBufferCap = 2000
logScannerMaxBytes = 1 * 1024 * 1024 // 1 MB 單行上限
logBatchWindowMs = 10 // micro-batch window見 R-v2-3
)
type LogBuffer struct {
mu sync.Mutex
lines [logBufferCap]LogLine
head int
size int
// stats給未來觀察用
dropped uint64
}
func NewLogBuffer() *LogBuffer { return &LogBuffer{} }
func (b *LogBuffer) Append(l LogLine) {
b.mu.Lock()
defer b.mu.Unlock()
b.lines[b.head] = l
b.head = (b.head + 1) % logBufferCap
if b.size < logBufferCap {
b.size++
} else {
b.dropped++
}
}
// Snapshot 依插入順序回傳 (最舊 → 最新) 的行,最多 n 行。
func (b *LogBuffer) Snapshot(n int) []LogLine {
b.mu.Lock()
defer b.mu.Unlock()
if n <= 0 || n > b.size {
n = b.size
}
out := make([]LogLine, 0, n)
start := (b.head - b.size + logBufferCap) % logBufferCap
// 跳過 b.size - n 行
skip := b.size - n
for i := 0; i < b.size; i++ {
idx := (start + i) % logBufferCap
if i < skip {
continue
}
out = append(out, b.lines[idx])
}
return out
}
func (b *LogBuffer) Reset() {
b.mu.Lock()
defer b.mu.Unlock()
b.head = 0
b.size = 0
}
```
### 4.5 logPump goroutine`visiona-local/server_control.go`
```go
// logPump 讀取 server 子程序的 stdout 或 stderr pipe同時寫檔 + append 到 LogBuffer +
// 以 micro-batch 方式 emit log:append event 給前端。
//
// 參數:
// pipe — server cmd.StdoutPipe() 或 cmd.StderrPipe() 回傳的 ReadCloser
// stream — "stdout" / "stderr"
// fileWriter — logs/server.<stream>.log 的 os.Fileappend 模式)
// done — 當 pump 結束時關閉(例如 process exit / pipe EOF
func (a *App) logPump(pipe io.ReadCloser, stream string, fileWriter io.Writer, done chan<- struct{}) {
defer close(done)
defer pipe.Close()
scanner := bufio.NewScanner(pipe)
scanner.Buffer(make([]byte, 64*1024), logScannerMaxBytes)
// micro-batch累積 logBatchWindowMs 內的行,一次 emit 一個 log:append event
batch := make([]LogLine, 0, 16)
ticker := time.NewTicker(time.Duration(logBatchWindowMs) * time.Millisecond)
defer ticker.Stop()
flush := func() {
if len(batch) == 0 {
return
}
if a.ctx != nil {
wailsRuntime.EventsEmit(a.ctx, "log:append", batch)
}
batch = batch[:0]
}
scanDone := make(chan struct{})
lineCh := make(chan string, 128)
go func() {
defer close(scanDone)
for scanner.Scan() {
select {
case lineCh <- scanner.Text():
default:
// 丟行(極高頻下的安全閥),同時寫檔仍保留
// 避免阻塞 scanner goroutine 讓 server stdout 背壓
}
}
}()
for {
select {
case line, ok := <-lineCh:
if !ok {
flush()
return
}
// 1. 寫檔(持久化)
_, _ = fileWriter.Write([]byte(line + "\n"))
// 2. ring buffer
l := LogLine{
Ts: time.Now().UnixMilli(),
Stream: stream,
Line: line,
Level: parseLogLevel(line),
}
a.logBuf.Append(l)
// 3. 加進 batch
batch = append(batch, l)
case <-ticker.C:
flush()
case <-scanDone:
flush()
return
}
}
}
// parseLogLevel 嘗試從 Go logger 的典型格式抽 levelbest-effort只為 UI 著色,不影響邏輯)。
// 例:`2026/04/14 14:23:01 [INFO] ...` → "info"
// `[GIN] 500 | ... | POST /...` → "error"status >= 500
func parseLogLevel(line string) string {
// 實作細節省略;用簡單 substring match。
return ""
}
```
### 4.6 ServerController state machine`visiona-local/server_control.go`
```go
type ServerController struct {
app *App
mu sync.Mutex
state ServerState
proc *ServerProcess
startedAt time.Time
lastError string
}
func (c *ServerController) setState(s ServerState, err string) {
c.mu.Lock()
c.state = s
c.lastError = err
c.mu.Unlock()
if c.app.ctx != nil {
wailsRuntime.EventsEmit(c.app.ctx, "server:state-change", c.app.GetServerStatus())
}
}
func (c *ServerController) Start() error {
c.mu.Lock()
if c.state == ServerStateRunning || c.state == ServerStateStarting || c.state == ServerStateStopping {
c.mu.Unlock()
return fmt.Errorf("cannot start: current state=%s", c.state)
}
c.state = ServerStateStarting
c.lastError = ""
c.mu.Unlock()
if c.app.ctx != nil {
wailsRuntime.EventsEmit(c.app.ctx, "server:state-change", c.app.GetServerStatus())
}
// 呼叫 a.startServer()(沿用 v1 邏輯,見 app.go:425-564差別
// 1. 用 StdoutPipe/StderrPipe 取代 cmd.Stdout=file
// 2. 啟動兩個 logPump goroutine
// 3. 健康檢查 OK 後 state → Running
// 4. 失敗後 state → Error不 os.Exit
if err := c.app.startServerV2(c); err != nil {
c.setState(ServerStateError, err.Error())
if c.app.ctx != nil {
wailsRuntime.EventsEmit(c.app.ctx, "server:error", err.Error())
}
// R5-D1server 啟動徹底失敗時,除了 Error state 以外也發 OS 原生通知
// 實作細節見 visiona-local/notify.go 與 server-lifecycle.md §10
go sendCrashNotification(
"visionA Local — Server 啟動失敗",
"點回應用程式查看錯誤詳情或按 Restart 重試。",
)
return err
}
c.setState(ServerStateRunning, "")
// R5-D3只要 Preferences 允許,每次 StartServer 成功都呼叫 OpenInBrowser。
// 取消 v2.0 的 autoOpenedThisSession flag砍掉 per-session-once 概念)。
//
// 為什麼不怕 Restart 時開多個 tabOS 瀏覽器的 open/start/xdg-open 對於
// 「已經載入同一 URL 的 tab」通常是聚焦既有 tab 而不是開新的;即使在
// 少數平台實際開了新 tab這也是 R5-D3 明示可接受的結果。
//
// Preferences 預設值由 DefaultPreferences() 依 runtime.GOOS 決定:
// macOS / Windows → true
// Linux → false R5-D2
if c.app.prefs.AutoOpenBrowser {
_ = c.app.OpenInBrowser("")
}
return nil
}
func (c *ServerController) Stop() error {
c.mu.Lock()
if c.state == ServerStateStopped || c.state == ServerStateError {
c.mu.Unlock()
return nil
}
if c.state != ServerStateRunning {
c.mu.Unlock()
return fmt.Errorf("cannot stop: current state=%s", c.state)
}
c.state = ServerStateStopping
proc := c.proc
c.mu.Unlock()
if c.app.ctx != nil {
wailsRuntime.EventsEmit(c.app.ctx, "server:state-change", c.app.GetServerStatus())
}
if proc != nil {
proc.stop() // 沿用 v1SIGTERM → 5s grace → SIGKILL
}
c.mu.Lock()
c.proc = nil
c.mu.Unlock()
c.setState(ServerStateStopped, "")
return nil
}
func (c *ServerController) Restart() error {
if err := c.Stop(); err != nil && err.Error() != "" {
return err
}
return c.Start()
}
```
### 4.7 watchServer 改為 Error state修改 `app.go:571-610`
原本:
```go
if failures >= 3 {
a.reportFatal("server died", ...)
return
}
```
改為:
```go
if failures >= 3 {
a.ctrl.setState(ServerStateError, "health check failed 3 times")
if a.ctx != nil {
wailsRuntime.EventsEmit(a.ctx, "server:error", map[string]any{
"reason": "health check failed 3 times",
"port": sp.port,
})
}
// 不呼叫 reportFatal、不 os.Exit — 讓使用者在控制台手動 Restart 或查 log
return
}
```
`reportFatal()` 本身保留app.go:215-237只留給 `startup()` 階段致命錯誤data dir 建不起來、single-instance lock 無法取得(非「別人在跑」的情況)、首次啟動 startServer 失敗(此時無控制台可看)。後續 server 崩潰一律走 Error state。
---
## 5. 前端 JS 實作(`visiona-local/frontend/app.js` 重寫)
```javascript
// visiona-local/frontend/app.js
import {
StartServer, StopServer, RestartServer,
GetServerStatus, GetRecentLogs, ClearLogs, GetSystemInfo,
OpenInBrowser, RevealLogsFolder, ExportLog,
GetPreferences, SetPreferences,
} from './wailsjs/go/main/App.js';
import { EventsOn } from './wailsjs/runtime/runtime.js';
import { renderStatusCard, updateStatusCard } from './components/status-card.js';
import { initLogPanel, appendLogLines, clearLogPanel } from './components/log-panel.js';
import { renderActionBar, updateActionBarState } from './components/action-bar.js';
import { renderPreferences } from './components/preferences.js';
import { loadLocale, t } from './i18n/loader.js';
async function main() {
// 1. 載入 i18n依 navigator.language
await loadLocale();
// 2. 靜態元件一次 render
const sys = await GetSystemInfo();
renderStatusCard(sys);
initLogPanel();
renderActionBar({
onStart: () => StartServer().catch(showError),
onStop: () => StopServer().catch(showError),
onRestart: () => RestartServer().catch(showError),
onOpenBrowser: () => OpenInBrowser('').catch(showError),
onReveal: () => RevealLogsFolder().catch(showError),
onClear: () => { ClearLogs(); clearLogPanel(); },
onExportLog: async () => {
try {
const path = await ExportLog();
showToast(t('log.exported', { path }));
} catch (e) {
showError(e);
}
},
});
const prefs = await GetPreferences();
renderPreferences(prefs, async (p) => { await SetPreferences(p); });
// 3. 初始 server status + log 快照
const st = await GetServerStatus();
updateStatusCard(st);
updateActionBarState(st.state);
const initialLogs = await GetRecentLogs(2000);
appendLogLines(initialLogs);
// 4. 訂閱 Wails events
EventsOn('log:append', (batch) => appendLogLines(batch));
EventsOn('log:clear', () => clearLogPanel());
EventsOn('server:state-change', (newStatus) => {
updateStatusCard(newStatus);
updateActionBarState(newStatus.state);
});
EventsOn('server:error', (info) => {
// Status card 的 last error 段會自動透過 state-change 更新,
// 這裡額外做一個 toast若有需要的話
});
// 5. R5-E 階段化啟動進度 — 四個 event 訂閱(細節見 v2/startup-pipeline.md
EventsOn('startup:progress', (e) => {
// e = { stage, totalStages, labelKey, status, startedAt }
updateStartupPanel(e);
});
EventsOn('startup:stage-timeout', (e) => {
// e = { stage, softTimeoutSeconds }
// 顯示「階段 N 正在重試 ...」副文字(不中斷流程)
updateStartupPanel({ ...e, status: 'retrying' });
});
EventsOn('startup:error', (e) => {
// e = { stage, error }
// 切到 Error state + 彈 toast。實際 server state 會由 server:state-change 同步。
showStartupError(e);
});
EventsOn('startup:ready', () => {
// 總時 < 60 s6 階段都 completed 淡出啟動進度面板顯示主控台
hideStartupPanel();
});
}
function showError(e) {
// 顯示 inline toast詳見 style.css .toast
const el = document.getElementById('toast');
el.textContent = String(e);
el.hidden = false;
setTimeout(() => (el.hidden = true), 5000);
}
main().catch((e) => console.error('[visiona-local console] init failed:', e));
```
### 5.1 Log panel 細節(`components/log-panel.js`
- 容器:`<pre class="log__body">`,最多保留 2000 行(超過則從頂部截)
- Auto-scroll若使用者滾輪已滾到底`scrollTop + clientHeight >= scrollHeight - 2`),自動 scroll 到新行;若使用者手動往上捲,停止 auto-scroll即 Pause 狀態)
- Pause 按鈕:明確切 auto-scroll on/off即使在底部也不跟
- Level 顏色:`.log-line--info` / `--warn` / `--error`(由 Go `parseLogLevel` 塞到 LogLine.Level
- Batch render每收到一批 log:append一次 DOM 更新createDocumentFragment避免 layout thrash
- 行長度截斷:單行 > 2 KB 的 log 在 UI 只顯示前 2 KB + `…(點擊展開)`
### 5.2 Action bar disable 邏輯
見 §3 的 disable 矩陣,`updateActionBarState(state)` 依 state 呼叫 `btn.disabled = true|false`
---
## 6. i18n 機制
### 6.1 控制台自己的字串清單(不動 Next.js Web UI i18n
`visiona-local/frontend/i18n/zh-TW.json`
```json
{
"statusCard": {
"running": "執行中",
"stopped": "已停止",
"starting": "啟動中…",
"stopping": "停止中…",
"error": "錯誤",
"port": "埠",
"pid": "PID",
"python": "Python",
"dataDir": "資料目錄"
},
"actions": {
"start": "啟動",
"stop": "停止",
"restart": "重啟",
"openBrowser": "在瀏覽器中開啟",
"revealLogs": "開啟 log 資料夾",
"clearLogs": "清空 log"
},
"logPanel": {
"title": "Server Log",
"pauseAutoScroll": "暫停捲動",
"resumeAutoScroll": "繼續捲動",
"clear": "清空"
},
"preferences": {
"title": "偏好設定",
"openBrowserOnStart": "Server 就緒時自動開啟瀏覽器"
},
"errors": {
"startFailed": "啟動失敗:{msg}",
"stopFailed": "停止失敗:{msg}"
}
}
```
`en-US.json` 內容對應,英文版。
### 6.2 Loader
`visiona-local/frontend/i18n/loader.js`
```javascript
let strings = {};
// Q5navigator.language fallback 強化。
//
// Wails v2 在 Windows 下某些情況會回 'en';在 Linux 下有時回 'C';在 macOS 下偶爾
// 會回空字串。我們明確處理這些邊緣情況:
//
// 1. 若 Preferences.Locale 有值(使用者手動覆寫)→ 直接用
// 2. 否則讀 navigator.languages[0] || navigator.language
// 3. 空字串 / 'C' / 'POSIX' → 視為 'en'
// 4. 以 'zh' 開頭(含 zh-TW / zh-CN / zh-HK / zh-SG→ 一律載 zh-TW
// (我們目前只有一份中文,不做繁簡切換)
// 5. 其他 → 載 en-US
// 6. 若第一次 fetch 失敗 → 最終 fallback 到 hardcoded 英文字串集(~30 key
//
// 第 6 點的 hardcoded fallback 直接定義在 loader.js 最底下避免「i18n 完全壞掉
// 時控制台 UI 變成一片 placeholder key」。
export async function loadLocale(override) {
const raw = override
|| (Array.isArray(navigator.languages) && navigator.languages[0])
|| navigator.language
|| '';
const cleaned = String(raw).trim();
let lang;
if (cleaned === '' || cleaned === 'C' || cleaned === 'POSIX') {
lang = 'en-US';
} else if (cleaned.toLowerCase().startsWith('zh')) {
lang = 'zh-TW';
} else {
lang = 'en-US';
}
try {
const res = await fetch(`./i18n/${lang}.json`);
if (!res.ok) throw new Error(`i18n load failed: ${res.status}`);
strings = await res.json();
} catch (e) {
console.warn('[visiona-local console] i18n load failed, using hardcoded fallback:', e);
strings = HARDCODED_EN_FALLBACK;
}
}
export function t(key, params = {}) {
const parts = key.split('.');
let v = strings;
for (const p of parts) {
v = v?.[p];
if (v === undefined) return key;
}
if (typeof v !== 'string') return key;
return v.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? ''));
}
```
### 6.3 為何不共用 Next.js Web UI 的 i18n 檔
Next.js 的 `frontend/src/lib/i18n/{zh-TW,en}.ts` 是 TypeScript 源碼,需要編譯,控制台 UI 不引 TS build chain若直接複製一份 JS 也可以,但控制台需要的 string 集合(~30 個 key和 Web UI~400+ key差異太大獨立 JSON 更清晰。
---
## 7. 檔案系統變化relative to `visiona-local/`
**刪檔**:無(現有 `frontend/` 下的 3 個檔都是要改寫而非刪)
**改寫**
- `frontend/index.html`M7-B 的 splash → 控制台 layout~80 行 HTML
- `frontend/app.js`78 行 splash → ~130 行 init + 事件處理)
- `frontend/style.css`112 行 splash 樣式 → ~300 行控制台 + dark/light mode tokens
**新增**
- `frontend/components/status-card.js`~80 行)
- `frontend/components/log-panel.js`~180 行,含 virtual-scroll-lite + batch render
- `frontend/components/action-bar.js`~80 行)
- `frontend/components/preferences.js`~60 行)
- `frontend/components/startup-panel.js`~100 行R5-E 階段化進度面板)
- `frontend/i18n/zh-TW.json``frontend/i18n/en-US.json`~60 行 each含 startup.stage.1-6 / startup.retrying / startup.error
- `frontend/i18n/loader.js`~35 行,含 Q5 navigator.language fallback 強化)
- `frontend/icons/*.svg` × 6
- `server_control.go`~250 行)
- `log_buffer.go`~120 行)
- `preferences.go`~80 行,含 `DefaultPreferences()``runtime.GOOS` 分平台 default + atomic write-rename
- `notify.go`~100 行R5-D1 三平台 OS 通知)
- `startup_pipeline.go`~180 行R5-E 6 階段 watcher + event emit
**修改**
- `app.go`
- 新增 `ctrl *ServerController` / `logBuf *LogBuffer` / `prefs Preferences` / `pipeline *StartupPipeline` 欄位(**不再**有 `autoOpenedThisSession` — R5-D3 砍掉 per-session-once 概念)
- 砍 `GetBootstrapStatus()` / `setBootstrapStatus()` / `bootstrapStatus` 欄位splash 專用,~15 行)
- 砍 `mockMode` 欄位 + `VISIONA_MOCK` 環境變數讀取R5-5a`v2/deletions.md`
- 改 `watchServer()` 失敗後行為(不 os.Exit進 Error state + 發 OS 通知R5-D1
- 新增 bindings§4.2 的 13 個 methodStart/Stop/Restart Server、GetServerStatus、GetRecentLogs、ClearLogs、GetSystemInfo、OpenInBrowser、RevealLogsFolder、ExportLog、GetPreferences、SetPreferences、RestartStartupSequence
- `startup()` 流程seed → lock → 階段化 `ctrl.Start`(透過 StartupPipelineR5-E
---
## 8. 待確認 / 風險
1. **Wails v2 EventsEmit 的 payload 大小上限** — micro-batch window 10 ms × 100 行 × 2 KB = 200 KB。Wails v2 文件沒明確標 payload limit實作完要壓測用一個 bash 腳本噴 1000 行/秒的 stdout 看 events 有沒有丟 / 延遲。若有問題,改成「每 batch 最多 50 行,超過就拆兩個 event」。
2. **Dark mode 與 system 切換時的閃爍** — CSS var 切換應用 `transition: background-color 0.2s`,但 `prefers-color-scheme` media query 切換瞬間 Wails WebView 可能有瞬閃。實測,必要時改用 JS 主動套 `data-theme` 屬性。
3. **啟動進度面板 vs 主控台的轉場** — R5-E 定義「6 階段 completed 後淡出」,但若啟動很快(< 2 s閃太快不好看若啟動慢20 s+使用者看得很久實務上建議淡出用 300 ms ease進度面板本身做 min-display-time 1 s 避免閃爍文案與 min-display-time 等交 Design Spec v2.1 敲定
4. **R5-D1 OS 通知在使用者關閉系統通知時的 fallback** — 若使用者在 macOS 系統偏好「通知」關閉 visionA Local`osascript display notification` 會靜默失敗。不額外處理(最佳努力送達的設計),控制台 Error banner 仍會顯示。
5. **N-R4 CI/E2E 測試分層** — PM 審閱的 Minor 1交 Testing Agent 階段解決。狀態blocked-on-testing-agent。
**已移至 `server-lifecycle.md` §11 的項目**(不再重複):
- Preferences JSON 的 atomic write-renamePM §11-1 已定案)
- `navigator.language` 在 Wails v2 的邊緣情況Q5 已在 §6.2 強化)

View File

@ -0,0 +1,358 @@
# v2/cors-security.md — CORS + 安全邊界
> 所屬TDD v2 §2.4
> 決策依據:三方共識 #5CORS 限制 127.0.0.1/localhost、R5-1維持 127.0.0.1 綁定,不做 LAN
> 對應 milestoneM8-8
---
## 1. 目的
當使用者模式從「Wails WebView 內的 UIorigin `wails://`)」變成「瀏覽器 tab 內的 UIorigin `http://127.0.0.1:<port>`」之後server 暴露在任何本機瀏覽器程序的 CORS 攻擊面下。必須明確限定哪些 Origin 可以跨來源存取 API關掉潛在的 CSRF / 資料竊取入口。
---
## 2. 攻擊場景(為什麼要做)
使用者在 Chrome 開了:
- Tab A: `http://127.0.0.1:3721/` ← visionA-local Web UI
- Tab B: `https://evil.example.com/` ← 某個有問題的網站
Tab B 的 JS 可以:
```javascript
fetch('http://127.0.0.1:3721/api/models/upload', {
method: 'POST',
body: maliciousModelFormData,
});
```
**v1 的 CORS middleware**`server/internal/api/middleware.go:9-29`
```go
if origin != "" {
c.Header("Access-Control-Allow-Origin", origin) // 回聲 Origin
}
c.Header("Access-Control-Allow-Credentials", "true")
```
→ Tab B 的惡意 fetch 會收到 `Access-Control-Allow-Origin: https://evil.example.com``Access-Control-Allow-Credentials: true`**CORS 放行**。Tab B 可以讀回應、可以帶 cookie、可以送 POST。
這在 v1 的 Wails WebView 模式下不是問題Wails 內的 origin 是 `wails://`),但 v2 把業務 UI 搬到瀏覽器後變成真實威脅。
---
## 3. 新 CORS 政策
### 3.1 白名單
只允許以下 Origin 的跨來源請求:
- `http://127.0.0.1:*`(任何 port
- `http://localhost:*`
- `http://[::1]:*`IPv6 loopback罕見但完整
**不允許**
- `https://...` — 瀏覽器連本機不可能是 https沒有憑證
- `null` Origin — 本地 HTML file 或某些 sandbox iframe 會送 null不信任
- 其他任何 hostname
### 3.2 OPTIONS 預檢
- 來自白名單 Origin → 正常回 200 + 完整 ACA* headers
- 非白名單 Origin → 回 403 Forbidden不回 `Access-Control-Allow-Origin`
### 3.3 非預檢請求simple request例如 `GET` without custom header
瀏覽器不會送 OPTIONS直接送請求。這種請求 server 會執行 handler即使 Origin 不在白名單),但回應 header 沒有 `Access-Control-Allow-Origin`**瀏覽器 JS 讀不到回應**。這是 CORS 的基本行為。
然而 `GET` 造成的副作用(例如 `GET /api/devices/scan` 若被設計成觸發掃描)仍會執行。解決:**副作用操作一律 POST**server 現況已做到。GET 只用於讀資料。
**CSRF 風險**POST simple requestcontent-type `application/x-www-form-urlencoded` / `multipart/form-data` / `text/plain`)不會觸發 OPTIONS 預檢。但 visionA-local 所有 POST handler 都吃 JSON 或 multipart 的**特定 field**,且 v2 會額外要求**所有 POST / PUT / DELETE handler 拒絕非白名單 Origin**(見 §4.3),進一步關掉漏洞。
---
## 4. 實作
### 4.1 修改 `server/internal/api/middleware.go`
整檔覆寫:
```go
package api
import (
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
)
// allowedHosts 定義 CORS 白名單的 hostname。
// 任何 port 都允許scheme 只允許 http本機不可能是 https
var allowedHosts = map[string]bool{
"127.0.0.1": true,
"localhost": true,
"[::1]": true,
"::1": true,
}
// isAllowedOrigin 判斷 origin header 是否屬於白名單。
// 合法例http://127.0.0.1:3721 / http://localhost:3721 / http://[::1]:3721
// 不合法例https://127.0.0.1:3721 / http://evil.com / null / http://192.168.1.5:3721
func isAllowedOrigin(origin string) bool {
if origin == "" || origin == "null" {
return false
}
u, err := url.Parse(origin)
if err != nil {
return false
}
if u.Scheme != "http" {
return false
}
host := strings.ToLower(u.Hostname())
return allowedHosts[host]
}
// CORSMiddleware 僅允許 127.0.0.1/localhost/::1 任意 port 的跨來源請求。
// 非白名單 Origin 的 OPTIONS 預檢會被擋下;其他方法會正常執行 handler
// 但回應沒有 Access-Control-Allow-Origin header瀏覽器讀不到 body。
// 為了保守state-changing 方法POST/PUT/DELETE會明確回 403 當 Origin 非白名單。
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
method := c.Request.Method
// Same-origin 請求Origin header 為空)一律放行 — 瀏覽器不會在 same-origin 送 Origin
if origin == "" {
if method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
return
}
if !isAllowedOrigin(origin) {
// 非白名單 Origin
// 1. OPTIONS → 403不回 ACA*
// 2. state-changing 方法 → 403
// 3. GET/HEAD → 執行 handler但不回 ACA*,瀏覽器 JS 讀不到 body
if method == http.MethodOptions ||
method == http.MethodPost ||
method == http.MethodPut ||
method == http.MethodDelete ||
method == http.MethodPatch {
c.AbortWithStatus(http.StatusForbidden)
return
}
// GET / HEAD執行但不設 ACA*
c.Next()
return
}
// 白名單 Origin回完整 ACA* headers
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Vary", "Origin")
if method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
```
**diff 要點 vs v1**
- 新增 `allowedHosts` + `isAllowedOrigin` 白名單檢查
- 原本「回聲 Origin」改為「只回白名單 Origin」
- state-changing 方法POST/PUT/DELETE/PATCH若 Origin 非白名單,直接 403
- 砍掉 `X-Relay-Token` headerrelay 功能在 M1 已砍)
- 加 `Vary: Origin` 給 CDN / proxy 快取正確性(雖然我們沒走 CDN
### 4.2 單元測試
新增 `server/internal/api/middleware_test.go`
```go
func TestIsAllowedOrigin(t *testing.T) {
cases := []struct {
origin string
want bool
}{
{"http://127.0.0.1:3721", true},
{"http://localhost:3721", true},
{"http://localhost", true},
{"http://[::1]:3721", true},
{"http://evil.example.com", false},
{"https://127.0.0.1:3721", false},
{"http://192.168.1.5:3721", false},
{"null", false},
{"", false},
{"http://127.0.0.1.evil.com", false},
}
for _, tc := range cases {
got := isAllowedOrigin(tc.origin)
if got != tc.want {
t.Errorf("isAllowedOrigin(%q) = %v, want %v", tc.origin, got, tc.want)
}
}
}
```
M8-8 驗收時跑 `go test ./server/internal/api/... -run TestIsAllowedOrigin`,全部通過才算完成。
### 4.3 Pre-handler origin check二道防線
`router.go` 的 state-changing 路由註冊處額外掛一層 origin check確保即使 CORSMiddleware 有 bug例如某條 handler skip 了 middleware也不會出事
```go
// router.go 新增
func requireSameOriginOrNoOrigin() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin == "" {
c.Next()
return
}
if !isAllowedOrigin(origin) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"success": false,
"error": gin.H{"code": "FORBIDDEN", "message": "origin not allowed"},
})
return
}
c.Next()
}
}
```
掛在 `/api/*` group
```go
api := r.Group("/api")
api.Use(requireSameOriginOrNoOrigin())
```
**實作備註**`requireSameOriginOrNoOrigin``CORSMiddleware` 看似重複,但邏輯不同:
- CORSMiddleware 管「要不要回 ACA* headers」
- requireSameOriginOrNoOrigin 管「要不要執行 handler」
前者是瀏覽器層的防線,後者是 server 層的。兩層都擋才夠 defensive。
---
## 5. WebSocket Origin check
Gin 的 WS upgrader`gorilla/websocket`)需要自行設定 CheckOrigin。
### 5.1 現況
檢查 `server/internal/api/ws/` 下的所有 handler 建立 upgrader 的地方。
### 5.2 新增 helper新 file `server/internal/api/ws/origin.go`
```go
package ws
import (
"net/http"
"net/url"
"strings"
)
// CheckOrigin 決定 WebSocket upgrade 是否允許。
// 與 HTTP CORS 白名單一致http://127.0.0.1:* / http://localhost:* / http://[::1]:*
// 與 same-originOrigin header 為空或等於 Host
func CheckOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true // same-origin
}
u, err := url.Parse(origin)
if err != nil {
return false
}
if u.Scheme != "http" {
return false
}
host := strings.ToLower(u.Hostname())
return host == "127.0.0.1" || host == "localhost" || host == "::1"
}
```
把所有 WS handler 的 upgrader `CheckOrigin` field 指向這個 func。
### 5.3 掃清單M8-8 執行時 grep
```
grep -rn 'websocket.Upgrader' /Users/jimchen/visionA/local-tool/server/internal/api/ws/
```
**預期**:每一個 upgrader 都要掛 `CheckOrigin: ws.CheckOrigin`。現況 v1 沒有明確設gorilla 預設為 same-originv2 改為明確白名單 + 同 hostname。
---
## 6. 綁定 interface 維持 `127.0.0.1`R5-1
現況 `visiona-local/app.go:468` 明寫 `--host 127.0.0.1`server `main.go` 會以此為 `http.Server.Addr` 啟動。v2 **完全不動**這個設定。
**不做**
- `--host 0.0.0.0` toggleR5-1 明確否決 LAN mode
- Auth token / bearer / session127.0.0.1 only 下沒必要see `v1/risks-and-mitigations.md` 的分析)
---
## 7. 資料驗證邊界
v1 既有的驗證全部保留v2 沒有變更:
- 檔案上傳大小限制
- 檔案類型白名單v2 擴充為 `.mp4/.avi/.mov/.mpeg/.mpg`
- Gin struct binding + validator tag各 handler
- `server/internal/model/` 的 .nef 檔案簽名檢查(若有)
新增:
- 檢查 `Content-Type` 是否為 `multipart/form-data`(已有,但 v2 要 explicit 防呆)
- 檢查檔案大小上限v1 是 100 MBv2 維持)
- Path traversal`filepath.Clean` + 確認不含 `..``UploadVideo``os.CreateTemp` 沒有此問題;`UploadModel``custom-models/` 底下v1 已做 sanitizev2 增加測試 case
---
## 8. 無 Auth token
本機單人使用R5-1 + 127.0.0.1 only不導入任何 auth。v2 保留 v1 的「單人無認證」模型。
**不做**
- Basic auth / bearer token
- Login / logout / session
- 單機版的 TLS沒意義
---
## 9. 驗收條件
| 檢查 | 指令 | 預期 |
|------|------|------|
| 白名單 127.0.0.1 POST 可過 | `curl -X POST http://127.0.0.1:3721/api/devices/scan -H 'Origin: http://127.0.0.1:9999'` | 200 + `Access-Control-Allow-Origin: http://127.0.0.1:9999` |
| 白名單 localhost GET 可過 | `curl http://127.0.0.1:3721/api/models -H 'Origin: http://localhost:3000'` | 200 + ACA header |
| 非白名單 GET 執行但無 ACA | `curl -v http://127.0.0.1:3721/api/models -H 'Origin: http://evil.com'` | 200**無** ACA header |
| 非白名單 POST 403 | `curl -X POST http://127.0.0.1:3721/api/devices/scan -H 'Origin: http://evil.com'` | 403 |
| 非白名單 OPTIONS 403 | `curl -X OPTIONS http://127.0.0.1:3721/api/models -H 'Origin: http://evil.com'` | 403 |
| `null` Origin 擋 | `curl -X POST http://127.0.0.1:3721/api/devices/scan -H 'Origin: null'` | 403 |
| https Origin 擋 | `curl -X POST http://127.0.0.1:3721/api/devices/scan -H 'Origin: https://127.0.0.1:3721'` | 403 |
| Same-origin 正常 | `curl http://127.0.0.1:3721/api/models`(不帶 Origin | 200 + 正常 response |
| WS 白名單可連 | `websocat -H='Origin: http://127.0.0.1:9999' ws://127.0.0.1:3721/ws/devices/events` | 連上 |
| WS 非白名單擋 | `websocat -H='Origin: http://evil.com' ws://127.0.0.1:3721/ws/devices/events` | 403 / 拒絕 upgrade |
| 單元測試 | `cd server && go test ./internal/api/... -run TestIsAllowedOrigin` | PASS |
---
## 10. 待確認
1. **開發模式下的 Next.js dev server**`frontend/``pnpm dev` 跑時是 `http://localhost:3000`,打後端 `http://127.0.0.1:3721` 會送 `Origin: http://localhost:3000`。白名單會放行localhost 在名單OK。要確認所有 API 呼叫的 CORS credentials flag 是否要設 `'include'` — 既有程式碼應該已經處理過。
2. **SSR / static export 下是否有 Origin header** — Next.js static export 的 page 在瀏覽器直接 fetchOrigin header 會是當前頁面的 origin`http://127.0.0.1:3721`,等於 same-origin— 但實務上 same-origin 不送 Origin header。驗收時用 Chrome devtools Network tab 確認。
3. **`requireSameOriginOrNoOrigin` 是否要 apply 在 WS 上** — WebSocket upgrade 是 HTTP GETmiddleware 會看到。但 gorilla upgrader 自己會先 CheckOrigin。兩層都做等於雙保險但也可能導致 edge case。M8-8 實測,若正常就留著。

View File

@ -0,0 +1,634 @@
# v2/deletions.md — 刪檔 / 刪程式碼清單
> 所屬TDD v2 §2.5
> 版本v2.12026-04-14 吸收 PM Minor 5 grep 精準化 + Architect Q1 互審結論)
> 決策依據R5-5aMock 模式完全砍除、R5-7 前置yt-dlp 全套砍除)
> 對應 milestoneM8-1砍 yt-dlp、M8-2砍 Mock
> 給工程師:這是按表操作的清單,依序砍完就能送 Reviewer
**操作規則**
- 每個「刪」動作後必須 `go build ./server/...``pnpm --dir frontend build` 確認還可 compile
- 每個「改」動作後同樣
- 全部砍完後 `git grep -i 'yt-dlp\|ytdlp\|YTDLP\|mock\|Mock\|MOCK\|VISIONA_MOCK'` 應該只剩註解(如「原本這裡有 Mock 模式,已在 v2 砍除」)或明確是別的 mock例如 gomock 測試框架)
---
## 1. 後端 Go — yt-dlp 全套砍除
### 1.1 `server/internal/camera/video_source.go`
**刪除區塊**:第 91-140 行
```go
// ResolveWithYTDLP uses yt-dlp to extract the direct video stream URL
// from platforms like YouTube, Vimeo, etc.
// Returns the resolved direct URL or an error.
func ResolveWithYTDLP(rawURL string) (string, error) {
... 整個 func ...
}
// friendlyYTDLPError 把 yt-dlp 的技術性錯誤訊息轉成使用者能理解的提示。
func friendlyYTDLPError(stderr string) string {
... 整個 func ...
}
```
同時檢查 import若原本因為 `ResolveWithYTDLP` 引入了 `strings` / `os/exec` / `fmt`,確認這些 import 在檔案其他地方還有用(視情況保留或移除)。
**`NewVideoSourceFromURL` / `NewVideoSourceFromURLWithSeek` 的砍除**v2.1 Minor 5 精準化):
v2.0 原本寫「可能仍然有其他路徑呼叫grep 再決定」— 這是模糊的。Architect Q1 互審時已實際 grep 確認結論:
```
grep -rn 'NewVideoSourceFromURL' /Users/jimchen/visionA/local-tool/server/
```
**實測結果**2026-04-14 Architect 互審):
- `server/internal/api/handlers/camera_handler.go:435``StartFromURL` 內部呼叫)— 即將砍
- `server/internal/api/handlers/camera_handler.go:731``handleVideoSeek``videoIsURL` guard 下的 seek 分支)— dead code連同 `videoIsURL` field 一起砍
**沒有其他呼叫者**。所以:
- 連同 `NewVideoSourceFromURL` 整個 function 砍
- 連同 `NewVideoSourceFromURLWithSeek`(若有)整個 function 砍
- 連同 `newVideoSource(..., isURL=true, ...)``isURL` 參數分支砍(簡化內部函式簽名)
- `camera_handler.go:731``handleVideoSeek``if h.videoIsURL { ... }` 整段 seek URL 分支砍dead code因為 `videoIsURL` 將不可能為 true
- `CameraHandler` struct 的 `videoIsURL bool` field 砍
**驗收**:砍完後以下 grep 應全部無輸出:
```bash
grep -rn 'NewVideoSourceFromURL\|videoIsURL' /Users/jimchen/visionA/local-tool/server/
```
### 1.2 `server/internal/api/handlers/camera_handler.go`
**刪除區塊**:第 341-497 行
```go
// ytdlpHosts lists hostnames where yt-dlp should be used to resolve the actual
var ytdlpHosts = map[string]bool{ ... }
type urlKind int
const (
urlDirect urlKind = iota
urlYTDLP
urlBad
)
// classifyVideoURL determines how to handle the given URL.
func classifyVideoURL(rawURL string) (urlKind, string) { ... }
// StartFromURL handles video/stream inference from a URL (HTTP, HTTPS, RTSP).
func (h *CameraHandler) StartFromURL(c *gin.Context) { ... }
```
→ 整個 `ytdlpHosts` / `urlKind` / `classifyVideoURL` / `StartFromURL` 函式全砍。
**`videoIsURL` field 的處置**v2.1 Minor 5 定案):
Architect Q1 互審時已 grep`videoIsURL` 只在 `camera_handler.go:731``handleVideoSeek` URL 分支被讀取,沒有 stopActivePipeline 的特殊 cleanup。因此
- 砍 `CameraHandler.videoIsURL bool` field
- 砍 `handleVideoSeek``if h.videoIsURL { ... }` 整個 URL seek 分支dead code — 砍 `StartFromURL` 後永遠不會是 true
- 砍 `startVideoInference``h.videoIsURL = true` 的行(若有)
- 結論:**不保留為 always-false flag**直接刪乾淨v2.0 原本留餘地「可能保留」v2.1 明確決定刪)
### 1.3 `server/internal/api/router.go`
**刪除**:第 83 行
```go
api.POST("/media/url", cameraHandler.StartFromURL)
```
### 1.4 `server/internal/deps/checker.go`
**刪除**:第 30-32 行
```go
check("yt-dlp", false,
"macOS: brew install yt-dlp | Windows: winget install yt-dlp",
"--version"),
```
**同時修改**:第 69-70 行的註解提及 yt-dlp 冷啟動 20 秒的內容,更新為:
```go
// 效能bundle 內的 binary 冷啟動可能較慢(尤其 PyInstaller
// bundle binary 已知良好,跳過 version 查詢以加速啟動。
```
### 1.5 `server/main.go`
**修改**:第 89-95 行的 PATH 注入註解。原文:
```go
// 把 VISIONA_BUNDLE_BIN_DIR 加到 PATH讓 exec.Command("yt-dlp") / exec.Command("ffmpeg")
// 能透過 LookPath 找到 bundle 內的 binaryGo 1.19+ Windows 不再搜 cwd
```
改為:
```go
// 把 VISIONA_BUNDLE_BIN_DIR 加到 PATH讓 exec.Command("ffmpeg") / exec.Command("ffprobe")
// 能透過 LookPath 找到 bundle 內的 binaryGo 1.19+ Windows 不再搜 cwd
```
**PATH 注入邏輯本身不動**ffmpeg 仍需要)。
---
## 2. 後端 Go — Mock 模式全套砍除R5-5a
### 2.1 整檔刪除
| 檔案 | 行數 | 說明 |
|------|------|------|
| `server/internal/driver/mock/mock_driver.go` | 183 | `MockDriver` struct 與所有 method |
| `server/internal/camera/mock_camera.go` | 95 | `MockCamera` struct 與所有 method |
**動作**
```
rm server/internal/driver/mock/mock_driver.go
rmdir server/internal/driver/mock # 該目錄若空就刪
rm server/internal/camera/mock_camera.go
```
### 2.2 修改 `server/internal/device/manager.go`
**現況**
```go
import (
...
mockdriver "visiona-local/server/internal/driver/mock"
...
)
type Manager struct {
...
mockMode bool
...
}
func NewManager(registry *DriverRegistry, mockMode bool, mockCount int, scriptPath string) *Manager {
...
if mockMode { ... mockdriver.NewMockDriver(...) ... }
}
func (m *Manager) Scan() error {
if m.mockMode { ... }
...
}
func (m *Manager) someMethod() {
if m.mockMode { ... }
...
}
```
**改法**
- 刪 `mockdriver` import
- 刪 `mockMode` / `mockCount` 欄位
- 修改 `NewManager` 簽名:`func NewManager(registry *DriverRegistry, scriptPath string) *Manager`
- 砍掉所有 `if m.mockMode { ... }` 分支,只留 real path
### 2.3 修改 `server/internal/camera/manager.go`
**現況**
```go
type Manager struct {
mockMode bool
mockCamera *MockCamera
...
}
func NewManager(mockMode bool) *Manager {
return &Manager{mockMode: mockMode}
}
func (m *Manager) Start(...) {
if m.mockMode {
m.mockCamera = NewMockCamera(width, height)
...
}
...
}
```
**改法**
- 刪 `mockMode` 欄位、`mockCamera` 欄位
- 改簽名:`func NewManager() *Manager`
- 砍所有 `if m.mockMode { ... }`
### 2.4 修改 `server/internal/config/config.go`
**現況**:第 21-23 行
```go
MockMode bool
MockCamera bool
MockDeviceCount int
```
第 39-41 行:
```go
flag.BoolVar(&cfg.MockMode, "mock", false, "Enable mock device driver")
flag.BoolVar(&cfg.MockCamera, "mock-camera", false, "Enable mock camera")
flag.IntVar(&cfg.MockDeviceCount, "mock-devices", 1, "Number of mock devices")
```
**改法**:整組刪(三個欄位 + 三個 flag
### 2.5 修改 `server/main.go`
**現況**
```go
// 第 86-87 行
logger.Info("Mock mode: %v, Mock camera: %v, Dev mode: %v, Python mode: %s",
cfg.MockMode, cfg.MockCamera, cfg.DevMode, cfg.PythonMode)
// 第 142 行
deviceMgr := device.NewManager(registry, cfg.MockMode, cfg.MockDeviceCount, bridgeScript)
// 第 147 行
cameraMgr := camera.NewManager(cfg.MockCamera)
```
**改法**
```go
logger.Info("Dev mode: %v, Python mode: %s", cfg.DevMode, cfg.PythonMode)
deviceMgr := device.NewManager(registry, bridgeScript)
cameraMgr := camera.NewManager()
```
### 2.6 修改 `server/internal/device/manager_test.go`
如果測試檔案裡有 mockMode 相關 case → 整段刪。若整個測試檔都是 mock-based → 整檔刪。
**動作**`cat server/internal/device/manager_test.go` 確認後決定。
### 2.7 修改 `server/internal/api/api_e2e_test.go`
如果 e2e test 開啟 mock mode 跑 → 改為 skip沒硬體時 skip或改寫成不依賴 mock。
**動作**`grep -n 'Mock\|mock' server/internal/api/api_e2e_test.go` 查看M8-2 執行者決定保留哪些測試。
---
## 3. Wails app.go — Mock 模式砍除
### 3.1 `visiona-local/app.go` 修改
**現況**
```go
// 第 83 行
type App struct {
...
mockMode bool
...
}
// 第 119-120 行
// M7預設真實硬體模式使用者決策 Q8
// 若要強制 mock 模式(無 Kneron 裝置環境下 debug設環境變數 VISIONA_MOCK=1
mock := os.Getenv("VISIONA_MOCK") == "1"
return &App{
pythonMode: mode,
mockMode: mock,
}
// 第 429 行(在 startServer 中)
if err != nil && !a.mockMode {
...
}
// 第 441 行
if !a.mockMode && pyBin != "" {
if err := a.ensureDriverInstalled(pyBin); err != nil {
...
}
}
// 第 472-479 行(組 args
if a.mockMode {
args = append(args, "--mock")
} else {
args = append(args, "--python-mode", string(pyMode))
if pyBin != "" {
args = append(args, "--python", pyBin)
}
}
// 第 502 行
if !a.mockMode && pyBin != "" {
env = append(env, "VISIONA_PYTHON="+pyBin)
...
}
```
**改法**
- 刪 `mockMode` struct field
- 刪 `NewApp()` 的 env 讀取 + 初始化
- 刪所有 `!a.mockMode` / `a.mockMode` 條件
- 組 args 的邏輯簡化:一定走 real path`--python-mode``--python` 一定帶
- `err != nil && !a.mockMode` → 直接 `err != nil`(失敗就 return 錯誤)
### 3.2 `visiona-local/frontend/app.js`
若有任何提及 mock 的內容M7-B 後應該沒有) → 刪
---
## 4. 打包流程砍除
### 4.1 `Makefile` — 砍 yt-dlp
**刪除 targets**
| 行數 | 內容 |
|------|------|
| ~22 | `vendor-sync vendor-python vendor-wheels vendor-ffmpeg vendor-ytdlp \` 中的 `vendor-ytdlp` |
| ~23 | `vendor-python-windows ... vendor-ytdlp-windows` 同上 |
| ~24 | `vendor-python-linux ... vendor-ytdlp-linux` 同上 |
| 39 | help 文字中 `/yt-dlp` |
| 70-71 | `YTDLP_URL_DARWIN := ...` |
| 73 | `vendor-sync` 依賴移除 `vendor-ytdlp` |
| 134-144 | `vendor-ytdlp:` target 整段 |
| 182 | `payload-macos` 依賴移除 `vendor-ytdlp` |
| 188-189 | `cp vendor/yt-dlp/darwin/yt-dlp payload/darwin/bin/` + `chmod +x payload/darwin/bin/yt-dlp` |
| 194 | help 文字 `+ yt-dlp` |
| 219 | `YTDLP_URL_WINDOWS := ...` |
| 288-296 | `vendor-ytdlp-windows:` target 整段 |
| 298 | `payload-windows` 依賴移除 `vendor-ytdlp-windows` |
| 299 | help 文字 `+ yt-dlp` |
| 307 | `cp vendor/yt-dlp/windows/yt-dlp.exe payload/windows/bin/` |
| 332 | `YTDLP_URL_LINUX := ...` |
| 381-390 | `vendor-ytdlp-linux:` target 整段 |
| 392 | `payload-linux` 依賴移除 `vendor-ytdlp-linux` |
| 393 | help 文字 `+ yt-dlp` |
| 401 | `cp vendor/yt-dlp/linux/yt-dlp ...` 整行 |
**同時**:刪 `/vendor/yt-dlp/` 整個目錄(若 Makefile 已經產出過)
```
rm -rf vendor/yt-dlp/
```
### 4.2 `installer/windows/visiona-local.iss`
**刪除**:第 74 行的區段標題 `; ── ffmpeg + yt-dlp ───` 改為 `; ── ffmpeg + ffprobe ───`
第 76 行:
```
Source: "..\..\payload\windows\bin\yt-dlp.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
```
**刪除此行**。同時新增 ffprobe 和 LGPL license 行(見 `v2/ffmpeg-lgpl.md` §8
### 4.3 `installer/linux/build-appimage.sh`
**修改**:第 61-62 行
```bash
# ffmpeg / yt-dlp
for tool in ffmpeg yt-dlp; do
```
改為:
```bash
# ffmpeg / ffprobe
for tool in ffmpeg ffprobe; do
```
### 4.4 `scripts/bootstrap-linux.sh`
**修改**:第 61 行
```bash
make vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux vendor-ytdlp-linux
```
改為:
```bash
make vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux
```
### 4.5 `scripts/bootstrap-windows.ps1`
**修改**
- 第 191 行註解:`保留 vendor/ 快取Python runtime / wheels / ffmpeg / yt-dlp以免重下 200MB``... 200MB` 改為 `... Python runtime / wheels / ffmpeg`
- 第 202 行註解:同上
- 第 207 行:`'make vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows vendor-ytdlp-windows'` 移除 `vendor-ytdlp-windows`
---
## 5. 前端 TypeScript / React — yt-dlp URL tab 砍除
### 5.1 `frontend/src/components/camera/source-selector.tsx`
**現況**260 行,使用 `videoMode` state + `pasteUrl` / `urlPlaceholder` / `urlHelpText` i18n、`handleUrlSubmit` / `startFromUrl` 呼叫。
**改動**
1. 刪 import`startFromUrl``useCameraStore` destructure 中移除(第 28 行附近)
2. 刪 state第 51 行 `const [videoMode, setVideoMode] = useState<'file' | 'url'>('file');` 整行刪
3. 刪 state同一段若有 `const [videoUrl, setVideoUrl] = useState('');` 也刪
4. 刪函式:第 94-96 行 `handleUrlSubmit` 整個函式刪
5. 刪 UI第 185-253 行的 video tab 內的 mode toggle + `videoMode === 'file' ? ... : ...` 整塊改為「只剩 file 選擇」:
```tsx
{activeTab === 'video' && (
<div className="flex items-center gap-3">
<Button
onClick={() => videoFileRef.current?.click()}
disabled={isUploading}
>
{isUploading ? t('common.uploading') : t('camera.selectVideo')}
</Button>
<span className="text-sm text-muted-foreground">
{t('camera.mp4AviMovMpeg')}
</span>
<input
ref={videoFileRef}
type="file"
accept=".mp4,.avi,.mov,.mpeg,.mpg" {/* R5-6 / 三方共識 #11:新增 mpeg/mpg */}
className="hidden"
onChange={handleVideoSelect}
/>
</div>
)}
```
砍掉「上傳檔案 / 貼上連結」兩個 mode Button + URL input + help text + loading text。
### 5.2 `frontend/src/stores/camera-store.ts`
**刪除**
- 第 32 行 type 定義:`startFromUrl: (url: string, deviceId: string) => Promise<void>;`
- 第 167-196 行:`startFromUrl` 函式的整個實作30 行)
### 5.3 `frontend/src/lib/i18n/types.ts`
**刪除**
- 第 210-212 行:`pasteUrl`, `urlPlaceholder`, `urlHelpText` 三個 key 的 type 定義
- 第 422 行:`cannotOpenVideoUrl` key 的 type 定義
**新增**
- `mp4AviMovMpeg: string;`(取代 v1 的 `mp4AviMov` 或並存)— 決定改不改 key**建議改名成 `videoFormats`**(通用化),以便未來擴充時不用再改型別名。
### 5.4 `frontend/src/lib/i18n/zh-TW.ts`
**刪除/修改**
- 第 212 行 `pasteUrl: '貼上連結',`
- 第 213 行 `urlPlaceholder: 'https://example.com/video.mp4',`
- 第 214 行 `urlHelpText: '支援 YouTube、直接影片 URL.mp4 等)及 RTSP 串流。',`
- 第 217 行 `mp4AviMov: 'MP4, AVI, MOV',`**改為** `videoFormats: 'MP4 / AVI / MOV / MPEG / MPG',`
- 第 424 行 `cannotOpenVideoUrl: '無法開啟影片連結',` 刪(若 store 沒有呼叫處)
### 5.5 `frontend/src/lib/i18n/en.ts`
同 §5.4,英文版對應修改:
- 刪 `pasteUrl`, `urlPlaceholder`, `urlHelpText`, `cannotOpenVideoUrl`
- `mp4AviMov: 'MP4, AVI, MOV',``videoFormats: 'MP4 / AVI / MOV / MPEG / MPG',`
---
## 6. 前端 TypeScript / React — Mock 模式砍除
### 6.1 `frontend/src/app/settings/page.tsx`
**刪除區塊**:第 140-156 行
```tsx
<div className="space-y-2">
<Label>{t('settings.hardware.runtimeMode')}</Label>
{/* TODO: 連接 backend GET /api/system/config ... */}
<Select value="real" disabled>
<SelectTrigger className="w-[420px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mock">{t('settings.hardware.runtimeModeMock')}</SelectItem>
<SelectItem value="real">{t('settings.hardware.runtimeModeReal')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t('settings.hardware.runtimeModeHint')}
</p>
</div>
<Separator />
```
→ 整段 `<div>` 與其後的 `<Separator />` 一起刪Separator 是用來分隔 runtimeMode 與 pythonMode 的)。
### 6.2 `frontend/src/lib/i18n/types.ts`
**刪除**:第 272-275 行
```typescript
runtimeMode: string;
runtimeModeMock: string;
runtimeModeReal: string;
runtimeModeHint: string;
```
### 6.3 `frontend/src/lib/i18n/zh-TW.ts`
**刪除**:第 274-277 行 4 個 key。
### 6.4 `frontend/src/lib/i18n/en.ts`
**刪除**:同 §6.3,第 274-277 行英文版。
### 6.5 `frontend/src/lib/i18n/zh-TW.ts` + `en.ts``noDevices` 文字
**現況**
```typescript
// zh-TW.ts:70
noDevices: '未偵測到裝置。請確認已啟用 Mock 模式或連接裝置。',
// en.ts:70
noDevices: 'No devices detected. Make sure mock mode is enabled or connect a device.',
```
**改為**(對應 R-v2-7 的 empty state 建議):
```typescript
// zh-TW
noDevices: '未偵測到 Kneron 裝置。請連接 KL520 / KL720 後按「掃描」。',
// en
noDevices: 'No Kneron devices detected. Please connect a KL520 / KL720 device and click "Scan".',
```
---
## 7. Wails 控制台(改寫而非刪)
`visiona-local/frontend/index.html` / `app.js` / `style.css` — **改寫**而非刪,詳見 `v2/control-panel.md` §7「檔案系統變化」。
**提醒**:改寫步驟屬於 M8-5不在本文件的 M8-1 / M8-2 範圍。
---
## 8. 驗收 grep
M8-1 + M8-2 全部完成後,執行以下 grep 應該全部 clean只剩註解或非相關的 match
```bash
# yt-dlp
grep -rn 'yt-dlp\|ytdlp\|YTDLP\|ResolveWithYTDLP\|friendlyYTDLPError\|ytdlpHosts\|urlYTDLP\|classifyVideoURL\|StartFromURL\|cannotOpenVideoUrl\|pasteUrl\|urlPlaceholder\|urlHelpText' \
/Users/jimchen/visionA/local-tool/server/ \
/Users/jimchen/visionA/local-tool/frontend/ \
/Users/jimchen/visionA/local-tool/visiona-local/ \
/Users/jimchen/visionA/local-tool/Makefile \
/Users/jimchen/visionA/local-tool/installer/ \
/Users/jimchen/visionA/local-tool/scripts/
# Mock
grep -rn 'MockMode\|mockMode\|MockCamera\|MockDriver\|NewMockDriver\|NewMockCamera\|VISIONA_MOCK\|runtimeModeMock' \
/Users/jimchen/visionA/local-tool/server/ \
/Users/jimchen/visionA/local-tool/frontend/ \
/Users/jimchen/visionA/local-tool/visiona-local/
```
**預期**
- `yt-dlp` grep**完全無輸出**(或只有 v1 文件 `.autoflow/` 內的歷史紀錄,忽略)
- `Mock` grep**完全無輸出**
若有殘留 → 補刀刪除或保留但註記原因。
---
## 9. 砍除後的 compile 確認
```bash
# Go server
cd /Users/jimchen/visionA/local-tool/server && go build ./...
cd /Users/jimchen/visionA/local-tool/server && go test ./...
# Wails app
cd /Users/jimchen/visionA/local-tool/visiona-local && go build .
# 前端
cd /Users/jimchen/visionA/local-tool/frontend && pnpm install && pnpm build
```
四個都要綠才算 M8-1 / M8-2 完成,可送 Reviewer。

View File

@ -0,0 +1,461 @@
# v2/ffmpeg-lgpl.md — ffmpeg LGPL 打包策略
> 所屬TDD v2 §2.2
> 決策依據R5-6LGPL 方案 B 混合、R5-6amacOS decoder-only ~20 MB、R5-6bmacOS binary commit 到 repo、R5-6c三平台都打包 ffprobe
> 對應 milestoneM8-3
> 前置研究:`/Users/jimchen/visionA/local-tool/.autoflow/04-architecture/ffmpeg-lgpl-research.md`
---
## 1. 目的與範圍
把 v1 的「三平台 GPL ffmpeg build + `VISIONA_ALLOW_GPL_FFMPEG=1` 逃生門」換成 LGPL 方案 B混合
| 平台 | v1 來源 | v1 授權 | v2 來源 | v2 授權 | 存放位置 |
|------|--------|---------|--------|---------|---------|
| macOS x86_64 | evermeet.cx / zip | GPL | **自 build**ffmpeg n7.1 + decoder-only configure | **LGPLv3** | `vendor/ffmpeg/macos/`(進 git |
| Windows x86_64 | BtbN / `…-win64-gpl.zip` | GPL | BtbN / `…-win64-lgpl.zip` | **LGPLv3** | `vendor/ffmpeg/windows/`(不進 gitMakefile 下載) |
| Linux x86_64 | johnvansickle static | GPL | BtbN / `…-linux64-lgpl-7.1.tar.xz` | **LGPLv3** | `vendor/ffmpeg/linux/`(不進 gitMakefile 下載) |
三平台都包 **ffmpeg + ffprobe** 兩支 binaryR5-6c
---
## 2. macOS從源碼 build decoder-only~20 MB
### 2.1 選型理由
- 三平台只有 macOS x86_64 沒有現成的 LGPL static binary完整分析見 `ffmpeg-lgpl-research.md` §2.3
- R5-6a 決定「最小 decoder-only build~20 MBconfigure 用 `--disable-everything` + 主動 enable 需要的 decoder/demuxer
- R5-6b 決定 binary 直接 commit 到 `vendor/ffmpeg/macos/`,避免開發者每次 build 都跑一遍編譯(單次 build ~15 分鐘)
### 2.2 需要的 feature set對應「瀏覽器能吃的格式」
PRD v2 支援的上傳影片副檔名:`.mp4 / .avi / .mov / .mpeg / .mpg`。對應的 ffmpeg configure 能力:
| 能力 | 必要項目 | 來源 |
|------|---------|------|
| 容器解析 | `demuxer=mov,avi,mpeg,mpegps,mpegts,matroska` | 內建LGPL |
| 影像解碼 | `decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,mjpeg,prores` | libavcodec nativeLGPL |
| 音訊解碼 | `decoder=aac,mp2,mp3,pcm_s16le,pcm_s16be` | libavcodec nativeLGPL |
| pixel format 轉換 | `filter=scale,format,fps` | libavfilterLGPL |
| 輸出為 image pipe | `muxer=image2pipe` / `encoder=mjpeg` | libavcodecLGPLmjpeg encoder 不是 libx264LGPL 安全) |
| 協議 | `protocol=file` | 夠用(只處理本地上傳檔) |
| parser | `parser=h264,hevc,mpeg4video,mpegaudio,aac` | 必要,否則有些 decoder 會 fail |
**ffprobe**ffprobe 是 ffmpeg 主 source tree 的一部分,同一次 `./configure && make` 會同時產出 `ffmpeg``ffprobe`,不用另外處理。
### 2.3 `Makefile` 新 target`vendor-ffmpeg-macos-build`
新增到 `Makefile`(位置:現有 `vendor-ffmpeg` target 旁邊):
```makefile
FFMPEG_VERSION := n7.1
FFMPEG_SRC_URL := https://github.com/FFmpeg/FFmpeg/archive/refs/tags/$(FFMPEG_VERSION).tar.gz
FFMPEG_SRC_SHA256 := < sha256build 第一次時用 `shasum -a 256` 計算後記錄之後每次 build sha256sum 驗證>
# 這個 target 只有要升級 ffmpeg 時才跑一次;平常開發者不需要跑,
# 因為 vendor/ffmpeg/macos/ffmpeg 已經 commit 到 repoR5-6b
vendor-ffmpeg-macos-build: ## 從源碼 build LGPL decoder-only ffmpeg for macOS x86_64只有升級 ffmpeg 時才跑)
@if [ "$$(uname -s)" != "Darwin" ]; then \
echo "❌ vendor-ffmpeg-macos-build 只能在 macOS 上跑"; exit 1; \
fi
@command -v pkg-config >/dev/null 2>&1 || { echo "❌ 需要 pkg-configbrew install pkg-config"; exit 1; }
@command -v yasm >/dev/null 2>&1 || command -v nasm >/dev/null 2>&1 || { echo "❌ 需要 yasm 或 nasmbrew install yasm"; exit 1; }
@mkdir -p vendor/ffmpeg/macos build/ffmpeg-macos
@echo "==> 下載 ffmpeg source $(FFMPEG_VERSION)..."
curl -fL -o build/ffmpeg-macos/source.tar.gz "$(FFMPEG_SRC_URL)"
@echo "==> 驗證 source tarball sha256..."
@echo "$(FFMPEG_SRC_SHA256) build/ffmpeg-macos/source.tar.gz" | shasum -a 256 -c || { \
echo "❌ sha256 不符,請更新 FFMPEG_SRC_SHA256 或檢查來源"; exit 1; }
@rm -rf build/ffmpeg-macos/src
@mkdir -p build/ffmpeg-macos/src
tar xzf build/ffmpeg-macos/source.tar.gz -C build/ffmpeg-macos/src --strip-components=1
@echo "==> configuredecoder-only LGPL..."
cd build/ffmpeg-macos/src && ./configure \
--prefix="$$(pwd)/../install" \
--enable-version3 \
--disable-debug \
--disable-doc \
--disable-ffplay \
--disable-network \
--disable-autodetect \
--disable-shared \
--enable-static \
--disable-everything \
--enable-small \
--enable-protocol=file,pipe \
--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2 \
--enable-decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,mjpeg,prores,vp8,vp9,aac,mp2,mp3,pcm_s16le,pcm_s16be \
--enable-parser=h264,hevc,mpeg4video,mpegaudio,aac \
--enable-filter=scale,format,fps,null,anull \
--enable-muxer=image2pipe,image2,null \
--enable-encoder=mjpeg \
--enable-swscale \
--enable-swresample \
--extra-cflags="-arch x86_64 -mmacosx-version-min=10.15" \
--extra-ldflags="-arch x86_64 -mmacosx-version-min=10.15 -Wl,-search_paths_first" \
--arch=x86_64 \
--target-os=darwin \
--cc="clang -arch x86_64"
cd build/ffmpeg-macos/src && make -j$$(sysctl -n hw.ncpu)
cd build/ffmpeg-macos/src && make install
@echo "==> 複製 ffmpeg + ffprobe 到 vendor/ffmpeg/macos/..."
cp build/ffmpeg-macos/install/bin/ffmpeg vendor/ffmpeg/macos/ffmpeg
cp build/ffmpeg-macos/install/bin/ffprobe vendor/ffmpeg/macos/ffprobe
@strip -S -x vendor/ffmpeg/macos/ffmpeg
@strip -S -x vendor/ffmpeg/macos/ffprobe
@chmod +x vendor/ffmpeg/macos/ffmpeg vendor/ffmpeg/macos/ffprobe
@echo "==> ad-hoc 簽章..."
codesign --force --sign - vendor/ffmpeg/macos/ffmpeg
codesign --force --sign - vendor/ffmpeg/macos/ffprobe
@echo "==> 驗證授權configuration line 不含 --enable-gpl / libx264 / libx265..."
@if vendor/ffmpeg/macos/ffmpeg -version 2>&1 | grep -E -- '--enable-gpl|libx264|libx265'; then \
echo "❌ LGPL 驗證失敗build 不該出現 gpl / x264 / x265"; exit 1; \
fi
@echo "==> 複製 COPYING.LGPLv3 到 vendor/ffmpeg/macos/..."
cp build/ffmpeg-macos/src/COPYING.LGPLv3 vendor/ffmpeg/macos/COPYING.LGPLv3
@echo "==> ffmpeg 大小:$$(du -h vendor/ffmpeg/macos/ffmpeg | cut -f1)"
@echo "==> ffprobe 大小:$$(du -h vendor/ffmpeg/macos/ffprobe | cut -f1)"
@echo ""
@echo "✅ macOS LGPL ffmpeg build 完成。請1) 更新 vendor/ffmpeg/macos/BUILD.md 的 binary sha256"
@echo " 2) git add vendor/ffmpeg/macos/{ffmpeg,ffprobe,COPYING.LGPLv3,BUILD.md}"
```
**關鍵 flags 解釋**
| flag | 為什麼 |
|------|-------|
| `--enable-version3` | 使用 LGPLv3不是 v2.1),和 BtbN 對齊 |
| `--disable-debug` / `--disable-doc` | 縮 binary |
| `--disable-network` | 我們只處理本地檔案,網路 protocolrtmp/rtsp/http用不到可關 |
| `--disable-autodetect` | 不自動偵測系統上的外部 liblibopus/libvpx 等LGPL 合規稽核時更乾淨 |
| `--disable-shared --enable-static` | 產出 self-contained binary不依賴 macOS 上任何外部 lib |
| `--disable-everything` | 先關全部,白名單 enable確保不額外 link 任何東西 |
| `--enable-small` | 優化大小而非速度,進一步縮 binary |
| `--enable-protocol=file,pipe` | 只開 file:// 和 pipeffmpeg 內部 input/output不開 http/rtsp |
| `--extra-cflags=-mmacosx-version-min=10.15` | 相容 macOS 10.15+PRD v1 的 macOS 14/15 之外也能跑,無痛) |
### 2.4 `vendor/ffmpeg/macos/BUILD.md`(進 git稽核用
```markdown
# macOS LGPL ffmpeg build record
This directory contains a pre-built LGPL ffmpeg + ffprobe binary for macOS x86_64.
## Reproducibility
- ffmpeg release: n7.1
- Source tarball: https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n7.1.tar.gz
- Source sha256: <填值>
- Build host: macOS 14.4, Xcode Command Line Tools 15.3 (clang 1500.3.9.4)
- Build date: 2026-04-14
- Build flags: 見 `Makefile``vendor-ffmpeg-macos-build` target
## Binary sha256
- ffmpeg: <填值>
- ffprobe: <填值>
## License
LGPLv3 — 完整授權條款見 `COPYING.LGPLv3`(與 binary 同目錄)。
## How to rebuild
```
make vendor-ffmpeg-macos-build
```
會:
1. 從 GitHub 下載 ffmpeg source tarballn7.1
2. 驗證 sha256
3. configure只啟用 decoder/demuxer/filter 白名單)
4. make
5. 複製 ffmpeg + ffprobe 到這個目錄
6. strip + ad-hoc codesign
7. 驗證 `ffmpeg -version` 不含 `--enable-gpl` / `libx264` / `libx265`
Build 完成後請手動更新本檔的 Binary sha256 區塊,並 git commit。
## Verification
```
# 確認授權
vendor/ffmpeg/macos/ffmpeg -version | grep -E -- '--enable-gpl|libx264|libx265'
# 預期:沒有任何輸出
# 確認能解 5 種格式
for f in sample.mp4 sample.avi sample.mov sample.mpeg sample.mpg; do
vendor/ffmpeg/macos/ffmpeg -hide_banner -i "$f" -f null - 2>&1 | tail -5
done
# 確認 Gatekeeper 可以過
spctl --assess --verbose vendor/ffmpeg/macos/ffmpeg
# 預期acceptedad-hoc signed
```
```
### 2.5 現有 `vendor-ffmpeg` target 的處理
v1 現有的 `Makefile:106-132` `vendor-ffmpeg` target 是從 evermeet.cx 下載 GPL build**整段刪除**,改為:
```makefile
vendor-ffmpeg: ## macOSLGPL binary 已 commit 到 vendor/ffmpeg/macos/,此 target 只驗證存在
@if [ ! -f vendor/ffmpeg/macos/ffmpeg ]; then \
echo "❌ vendor/ffmpeg/macos/ffmpeg 不存在。請執行 'make vendor-ffmpeg-macos-build' 從源碼 build"; \
exit 1; \
fi
@if [ ! -f vendor/ffmpeg/macos/ffprobe ]; then \
echo "❌ vendor/ffmpeg/macos/ffprobe 不存在。請執行 'make vendor-ffmpeg-macos-build'"; \
exit 1; \
fi
@echo "==> vendor/ffmpeg/macos/ffmpeg 存在:$$(du -h vendor/ffmpeg/macos/ffmpeg | cut -f1)"
@echo "==> vendor/ffmpeg/macos/ffprobe 存在:$$(du -h vendor/ffmpeg/macos/ffprobe | cut -f1)"
@# 驗證 LGPL沒有 --enable-gpl / libx264 / libx265
@if vendor/ffmpeg/macos/ffmpeg -version 2>&1 | grep -qE -- '--enable-gpl|libx264|libx265'; then \
echo "❌ LGPL 驗證失敗"; exit 1; \
fi
@echo "==> LGPL 驗證通過"
```
**注意**:原本 `vendor-ffmpeg` 是把 binary 放在 `vendor/ffmpeg/darwin/`v2 改為 `vendor/ffmpeg/macos/`(與 PRD 術語對齊 + 與 git commit 的目錄名一致。payload 階段也要同步改§5
---
## 3. Windows換 BtbN LGPL改一行 URL
修改 `Makefile:218`
```makefile
# 原:
FFMPEG_URL_WINDOWS := https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip
# 改為:
FFMPEG_URL_WINDOWS := https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-n7.1-latest-win64-lgpl-7.1.zip
```
**為什麼用 n7.1 穩定分支而非 master-latest**:見 `ffmpeg-lgpl-research.md` §5.3n7.1 綁定 7.1 release + 每日 backport bugfix避免 master 的隨機 regression。
修改 `Makefile:261-286` `vendor-ffmpeg-windows` target
```makefile
vendor-ffmpeg-windows: ## 下載 Windows LGPL ffmpeg + ffprobe → vendor/ffmpeg/windows/
@mkdir -p vendor/ffmpeg/windows
@if [ -f vendor/ffmpeg/windows/ffmpeg.exe ] && [ -f vendor/ffmpeg/windows/ffprobe.exe ]; then \
echo "==> ffmpeg.exe + ffprobe.exe 已存在,跳過"; \
else \
echo "==> 下載 BtbN LGPL ffmpeg (Windows, n7.1)..."; \
curl -fL -o vendor/ffmpeg/windows/ffmpeg-win.zip "$(FFMPEG_URL_WINDOWS)"; \
PY=""; \
for candidate in "$$VISIONA_PYTHON" "py -3" python3 python; do \
[ -z "$$candidate" ] && continue; \
if $$candidate --version >/dev/null 2>&1; then PY="$$candidate"; break; fi; \
done; \
[ -n "$$PY" ] || { echo "❌ 需要 python"; exit 1; }; \
$$PY -c "import zipfile; z=zipfile.ZipFile('vendor/ffmpeg/windows/ffmpeg-win.zip'); \
names=[n for n in z.namelist() if n.endswith('/bin/ffmpeg.exe') or n.endswith('/bin/ffprobe.exe') or n.endswith('/LICENSE.txt') or n.endswith('/COPYING.LGPLv3')]; \
import os; os.makedirs('vendor/ffmpeg/windows', exist_ok=True); \
for m in names: \
src = z.open(m); \
base = m.rsplit('/',1)[1]; \
dst = open(f'vendor/ffmpeg/windows/{base}', 'wb'); \
dst.write(src.read()); dst.close(); src.close(); \
print('extracted:', names)"; \
rm -f vendor/ffmpeg/windows/ffmpeg-win.zip; \
[ -f vendor/ffmpeg/windows/ffmpeg.exe ] || { echo "❌ ffmpeg.exe 沒被寫出"; exit 1; }; \
[ -f vendor/ffmpeg/windows/ffprobe.exe ] || { echo "❌ ffprobe.exe 沒被寫出"; exit 1; }; \
echo "==> ffmpeg.exe 大小:$$(du -h vendor/ffmpeg/windows/ffmpeg.exe | cut -f1)"; \
echo "==> ffprobe.exe 大小:$$(du -h vendor/ffmpeg/windows/ffprobe.exe | cut -f1)"; \
fi
```
---
## 4. Linux換 BtbN LGPL改一行 URL + 解壓路徑調整)
修改 `Makefile:331`
```makefile
# 原:
FFMPEG_URL_LINUX := https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
# 改為:
FFMPEG_URL_LINUX := https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz
```
修改 `Makefile:362-379` `vendor-ffmpeg-linux` target
```makefile
vendor-ffmpeg-linux: ## 下載 Linux LGPL ffmpeg + ffprobe → vendor/ffmpeg/linux/
@mkdir -p vendor/ffmpeg/linux
@if [ -f vendor/ffmpeg/linux/ffmpeg ] && [ -f vendor/ffmpeg/linux/ffprobe ]; then \
echo "==> ffmpeg + ffprobe (Linux) 已存在,跳過"; \
else \
echo "==> 下載 BtbN LGPL ffmpeg (Linux, n7.1)..."; \
curl -fL -o /tmp/ffmpeg-linux.tar.xz "$(FFMPEG_URL_LINUX)"; \
mkdir -p /tmp/ffmpeg-linux-extract; \
tar xf /tmp/ffmpeg-linux.tar.xz -C /tmp/ffmpeg-linux-extract --strip-components=1; \
cp /tmp/ffmpeg-linux-extract/bin/ffmpeg vendor/ffmpeg/linux/; \
cp /tmp/ffmpeg-linux-extract/bin/ffprobe vendor/ffmpeg/linux/; \
cp /tmp/ffmpeg-linux-extract/LICENSE.txt vendor/ffmpeg/linux/LICENSE.txt 2>/dev/null || true; \
chmod +x vendor/ffmpeg/linux/ffmpeg vendor/ffmpeg/linux/ffprobe; \
rm -rf /tmp/ffmpeg-linux* ; \
echo "==> ffmpeg (Linux) 大小:$$(du -h vendor/ffmpeg/linux/ffmpeg | cut -f1)"; \
echo "==> ffprobe (Linux) 大小:$$(du -h vendor/ffmpeg/linux/ffprobe | cut -f1)"; \
fi
```
**差別於 v1**
- URL 來源換掉
- `--strip-components` 從 1 改為 1BtbN tar 的 `strip-components=1` 後頂層就是 `bin/`,一樣)— **注意**BtbN 的 Linux tar 頂層目錄是 `ffmpeg-n7.1-latest-linux64-lgpl-7.1/`,解壓後 `strip-components=1` 會進到 `bin/``doc/``lib/``include/``share/``LICENSE.txt``README.txt``cp /tmp/ffmpeg-linux-extract/bin/ffmpeg` 路徑正確
- 同時抓 `ffprobe`
- 複製 `LICENSE.txt` 作為授權檔
---
## 5. Vendor directory layout
```
vendor/ffmpeg/
├── macos/ ← R5-6bcommit 到 git
│ ├── ffmpeg ← ~10 MB進 git
│ ├── ffprobe ← ~10 MB進 git
│ ├── COPYING.LGPLv3 ← 進 git
│ └── BUILD.md ← 進 gitreproducibility 稽核用
├── windows/ ← 不進 gitMakefile 下載
│ ├── ffmpeg.exe ← ~100 MBBtbN LGPL 打包含很多 LGPL extra libs未 strip
│ ├── ffprobe.exe ← ~100 MB
│ ├── LICENSE.txt ← BtbN 自帶
│ └── COPYING.LGPLv3 ← BtbN 自帶
└── linux/ ← 不進 gitMakefile 下載
├── ffmpeg ← ~80 MB
├── ffprobe ← ~80 MB
└── LICENSE.txt ← BtbN 自帶
```
**為什麼 Windows/Linux 不自 build 省體積?** — BtbN LGPL 還 link 了 libopus / libvpx / libaom / libdav1d / libopenh264 / libmp3lame / libvorbis 等 LGPL-safe extra libbinary 較大。對 Windows / Linux installer~380 / 320 MB 現況)多出 50-100 MB 屬可接受範圍;要把 Windows/Linux 也降到 20 MB 就要跑三平台 CI matrix 自 build方案 C`ffmpeg-lgpl-research.md` §4.1 已分析,多花 1-1.5 人天,不划算)。
**macOS 自 build 是不得不做**(沒有現成 LGPL 來源),順便做到 20 MB 是 bonus。
---
## 6. `.gitignore` 更新
修改 `.gitignore`
```diff
# ── 第三方依賴(由 make vendor-sync 下載,不進 git第三輪決策 Q-D=D2 ──
/vendor/**
!/vendor/.gitkeep
!/vendor/README.md
+# R5-6bmacOS LGPL ffmpeg binary 進 git沒有現成來源自 build 成本高)
+!/vendor/ffmpeg/
+!/vendor/ffmpeg/macos/
+!/vendor/ffmpeg/macos/**
```
`!` 規則的順序對 git 很敏感,這四行必須一起加;實測前需要用 `git check-ignore vendor/ffmpeg/macos/ffmpeg` 驗證)
**注意**`!/vendor/ffmpeg/macos/**` 會取消忽略 macos/ 底下所有檔,包括未來可能意外丟進來的 `.o` 或 build artifact。為了防呆BUILD.md 要明示「只 commit ffmpeg / ffprobe / COPYING.LGPLv3 / BUILD.md 四個檔」,程式碼 review 時檢查。
---
## 7. Payload 階段變化
修改 `Makefile:182-203` `payload-macos` target
```diff
-payload-macos: build-server vendor-python vendor-wheels vendor-ffmpeg vendor-ytdlp
+payload-macos: build-server vendor-python vendor-wheels vendor-ffmpeg
@echo "==> 建立 macOS payload..."
rm -rf payload/darwin
mkdir -p payload/darwin/bin payload/darwin/data payload/darwin/scripts payload/darwin/python payload/darwin/wheels
cp dist/visiona-local-server payload/darwin/bin/
- cp vendor/ffmpeg/darwin/ffmpeg payload/darwin/bin/
- cp vendor/yt-dlp/darwin/yt-dlp payload/darwin/bin/
- chmod +x payload/darwin/bin/ffmpeg payload/darwin/bin/yt-dlp
+ cp vendor/ffmpeg/macos/ffmpeg payload/darwin/bin/
+ cp vendor/ffmpeg/macos/ffprobe payload/darwin/bin/
+ cp vendor/ffmpeg/macos/COPYING.LGPLv3 payload/darwin/bin/ffmpeg-COPYING.LGPLv3
+ chmod +x payload/darwin/bin/ffmpeg payload/darwin/bin/ffprobe
```
Windows`payload-windows`)與 Linux`payload-linux`)同步:
```diff
# payload-windows
- cp vendor/ffmpeg/windows/ffmpeg.exe payload/windows/bin/
- cp vendor/yt-dlp/windows/yt-dlp.exe payload/windows/bin/
+ cp vendor/ffmpeg/windows/ffmpeg.exe payload/windows/bin/
+ cp vendor/ffmpeg/windows/ffprobe.exe payload/windows/bin/
+ cp vendor/ffmpeg/windows/COPYING.LGPLv3 payload/windows/bin/ffmpeg-COPYING.LGPLv3 2>/dev/null || true
# payload-linux
- @cp vendor/ffmpeg/linux/ffmpeg payload/linux/bin/ 2>/dev/null && chmod +x payload/linux/bin/ffmpeg || echo "!! WARN: ffmpeg 缺失"
- @cp vendor/yt-dlp/linux/yt-dlp payload/linux/bin/ 2>/dev/null && chmod +x payload/linux/bin/yt-dlp || echo "!! WARN: yt-dlp 缺失"
+ @cp vendor/ffmpeg/linux/ffmpeg payload/linux/bin/ && chmod +x payload/linux/bin/ffmpeg
+ @cp vendor/ffmpeg/linux/ffprobe payload/linux/bin/ && chmod +x payload/linux/bin/ffprobe
+ @cp vendor/ffmpeg/linux/LICENSE.txt payload/linux/bin/ffmpeg-LICENSE.txt 2>/dev/null || true
```
`vendor-ytdlp*` / `vendor/yt-dlp/` 整塊砍除(見 `v2/deletions.md` §4
---
## 8. 授權檔案在 installer 中的呈現
每個 installer 都要附上 ffmpeg 的 LGPL 條款與 source 對應指引。
**macOS**`installer/macos/`目前沒有這個目錄dmgbuild 直接從 `.app` 建)— 把 `ffmpeg-COPYING.LGPLv3` 放在 `.app/Contents/Resources/bin/` 底下(已透過 payload-macos 自動 cp加上 About dialog 裡的 linkv2 後續 design agent 設計)。
**Windows Inno Setup**:在 `installer/windows/visiona-local.iss``[Files]` 段加一行:
```diff
Source: "..\..\payload\windows\bin\ffmpeg.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
-Source: "..\..\payload\windows\bin\yt-dlp.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
+Source: "..\..\payload\windows\bin\ffprobe.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
+Source: "..\..\payload\windows\bin\ffmpeg-COPYING.LGPLv3"; DestDir: "{app}\bin"; Flags: ignoreversion
```
**Linux AppImage**`installer/linux/build-appimage.sh` 第 61-62 行原本迭代 `ffmpeg / yt-dlp`,改為 `ffmpeg / ffprobe`,並額外 copy `ffmpeg-LICENSE.txt``AppDir/usr/share/doc/visiona-local/`
---
## 9. 取得 LGPL source 的 offer
LGPLv3 要求散發者提供對應原始碼。兩種做法:
1. **Written offer**(省空間):在 About dialog / Help menu / PRD 公告處提供 URL
- macOS LGPL build → `https://github.com/<org>/visiona-local` repo 的 `vendor/ffmpeg/macos/BUILD.md` 指向 ffmpeg n7.1 source tarball 與確切 configure flags任何人可依此重現 build
- Windows / Linux BtbN build → `https://github.com/BtbN/FFmpeg-Builds`BtbN 的 source 對應)
2. **Bundle source**:不採用 — 會讓 installer 多幾十 MB
選 1。詳細文字由 PMM / PM Agent 寫成 About dialog 內容;本 TDD 只需確認技術可行。
---
## 10. 驗收條件
| 檢查 | 指令 | 預期 |
|------|------|------|
| macOS ffmpeg 為 LGPL | `vendor/ffmpeg/macos/ffmpeg -version \| grep -- --enable-gpl` | 無輸出 |
| macOS ffmpeg 不含 libx264 | `vendor/ffmpeg/macos/ffmpeg -version \| grep -- libx264` | 無輸出 |
| macOS ffmpeg 大小 < 25 MB | `du -h vendor/ffmpeg/macos/ffmpeg` | < 25 MB |
| macOS ffprobe 存在 | `test -f vendor/ffmpeg/macos/ffprobe && echo ok` | ok |
| macOS Gatekeeper | `spctl --assess --verbose vendor/ffmpeg/macos/ffmpeg` | accepted |
| 可解 mp4 | `vendor/ffmpeg/macos/ffmpeg -i sample.mp4 -f null -` | 正常完成 |
| 可解 avi | 同上 | 正常完成 |
| 可解 mov | 同上 | 正常完成 |
| 可解 mpeg/mpg | 同上 | 正常完成 |
| Windows LGPL | `ffmpeg.exe -version \| findstr -- --enable-gpl` | 無輸出 |
| Linux LGPL | 同上 | 無輸出 |
| installer 含 COPYING.LGPLv3 | 裝完後檢查 `<install-dir>/bin/ffmpeg-COPYING.LGPLv3` 存在 | 存在 |
---
## 11. 待確認
1. **ffmpeg `--disable-network` 會不會影響既有的 `NewVideoSourceFromURL` / RTSP 路徑?** — v1 的 `camera/video_source.go:86-88``NewVideoSourceFromURLWithSeek` 吃 URL。v2 砍掉 yt-dlp 後是否還保留 URL 推論路徑?查 `v2/deletions.md`URL 推論整條砍R5-7 前置,`StartFromURL` handler 砍),**但 `NewVideoSourceFromURL` 函式本身可能還有其他呼叫者**。M8-1 執行時要先 grep 確認,若確實沒其他呼叫者就一起砍;若有就要保留 `--enable-protocol=http,https` 之類。**Architect 建議 M8-1 執行前先做這個 grep 掃描並回報**。
2. **ffmpeg n7.1 的 sha256** — 第一次 build 時由開發者執行 `shasum -a 256 build/ffmpeg-macos/source.tar.gz` 後填進 Makefile + BUILD.md之後每次 build 用它驗證。
3. **codesign ad-hoc 在 notarize 階段** — 目前全案採 ad-hoc sign 沒做 notarize。若未來要 notarizeffmpeg + ffprobe 屬於 app bundle 內的執行檔,會跟著 `.app` 一起送,不需單獨處理。

View File

@ -0,0 +1,513 @@
# v2/milestone-plan.md — M8 開發 milestone 拆分
> 所屬TDD v2 §2.7
> 版本v2.12026-04-14 吸收 PM 審閱 + R5-D + R5-E
> 目的:給 Orchestrator 調度工程師 Agent 用。每個 milestone = 一個 Reviewer 審查單位。
> 總工時估算:**~12.0 人天**v2.0 是 10 人天R5-E 階段化啟動 +1 天 / PM Q4 7+1 秒 shutdown modal +0.2 天 / R5-D1 OS 通知 +0.3 天 / Minor 4 WebSocket 廣播 +0.3 天 / M8-10 驗收項目擴充 +0.2 天,合計 +2 人天。與 §3 合計 12.0 一致)
---
## 1. M8 全景
v2.1 的開發總共拆成 11 個 milestone編號 M8-1 ~ M8-10 + M8-4b。依賴關係
```
M8-1 (砍 yt-dlp) ──────┐
├──▶ M8-4 (server lifecycle) ──▶ M8-4b (階段化啟動) ──▶ M8-5 (Wails UI 改寫) ──▶ M8-9 (boot-id + 重連)
M8-2 (砍 Mock) ────────┘ ▲
M8-3 (ffmpeg LGPL vendor) ─────────────────────────────────────────────────────────────────────────────────┼──▶ M8-10 (end-to-end)
M8-6 (source-selector 調整) ────────────────────────────────────────────────────────────────────────────────┤
M8-7 (Offline Overlay) ─────────────────────────────────────────────────────────────────────────────────────┤
M8-8 (CORS) ────────────────────────────────────────────────────────────────────────────────────────────────┘
```
- M8-1 / M8-2 / M8-3 / M8-8 可以同時開工(互不依賴)
- M8-4 依賴 M8-1因為 app.go 內的 mockMode field 會在 M8-2 砍掉;但 M8-4 主要碰 server_control.go / log_buffer.go 不衝突,實務可平行)
- **M8-4b 依賴 M8-4**(需要 ServerController 已存在才能插入 pipeline hook
- M8-5 必須等 M8-4b 完成bindings + event schema 定義完)
- M8-6 可以獨立進行
- M8-7 依賴 M8-4需要 boot-id server 端 + WebSocket hub會在 M8-4 一併做)
- M8-9 依賴 M8-4 + M8-4b + M8-7
- M8-10 必須全部完成後
---
## 2. Milestone 明細
### M8-1砍 yt-dlp 全套
**預估**0.5 人天
**負責 Agent**Backend Agent + Frontend Agent同時進行因為跨 Go + TS
**依賴**:無
**任務**
1. 依 `v2/deletions.md` §1 刪後端 Go`video_source.go` / `camera_handler.go` / `router.go` / `deps/checker.go` / `main.go` 註解)
2. 依 `v2/deletions.md` §5 刪前端 TS`source-selector.tsx` 的 URL tab / `camera-store.ts``startFromUrl` / i18n 4 個 key
3. 依 `v2/deletions.md` §4.1 + §4.2 + §4.3 + §4.4 + §4.5 刪打包流程Makefile / installer / bootstrap
4. 刪除 `/vendor/yt-dlp/` 目錄(若存在)
**驗收條件**
- `go build ./...` PASS
- `go test ./server/...` PASS
- `pnpm --dir frontend build` PASS
- `git grep -i 'yt-dlp\|ytdlp\|ResolveWithYTDLP\|StartFromURL\|classifyVideoURL\|pasteUrl\|urlPlaceholder'` 無業務程式碼 match`.autoflow/` 內的歷史文件可接受)
- `make payload-macos` 可成功(不再 `cp yt-dlp`
**Reviewer 檢查重點**
- `camera_handler.go` 砍掉後 import 清理乾淨
- i18n types.ts / zh-TW.ts / en.ts 三檔同步刪
- Makefile 三個平台的 `vendor-ytdlp*` target 都清
---
### M8-2砍 Mock 模式全套
**預估**0.5 人天
**負責 Agent**Backend Agent + Frontend Agent
**依賴**:無(理論上可與 M8-1 平行)
**任務**
1. 依 `v2/deletions.md` §2 + §3 刪 Go兩個檔整檔刪、`device/manager.go` / `camera/manager.go` / `config.go` / `main.go` / `app.go` 所有 `mockMode` 條件砍掉)
2. 依 `v2/deletions.md` §6 刪前端 Mock 切換 UI + 4 個 i18n key
3. 更新 `noDevices` 文字empty state 友善化)
**驗收條件**
- Go + 前端 build PASS
- `git grep -i 'VISIONA_MOCK\|MockMode\|mockMode\|MockCamera\|MockDriver\|NewMockDriver\|runtimeModeMock'` 無業務程式碼 match
- 實機啟動 server沒插 Kneron 硬體)→ Devices 頁顯示友善 empty state「未偵測到 Kneron 裝置」
**Reviewer 檢查重點**
- `NewManager` 簽名改動後所有呼叫點都更新
- 舊 `--mock` / `--mock-camera` / `--mock-devices` CLI flag 真的拿掉server `--help` 不再列)
- 任何剩下的 test 檔案不再依賴 mockMode
---
### M8-3ffmpeg LGPL vendor 切換
**預估**1.5 人天
**負責 Agent**DevOps Agent+ Backend Agent支援 macOS build 環境)
**依賴**:無
**任務**
1. **Windows / Linux**0.5 人天):依 `v2/ffmpeg-lgpl.md` §3 + §4 修改 `Makefile` 的 URL 與 vendor target實測 `make vendor-ffmpeg-windows` / `vendor-ffmpeg-linux` 可成功
2. **macOS build script**1 人天):
- 依 `v2/ffmpeg-lgpl.md` §2.3 新增 `vendor-ffmpeg-macos-build` Makefile target
- 第一次跑:下載 ffmpeg n7.1 source、算 sha256 填進 Makefile、跑 configure + make、驗證 binary size < 25 MB驗證 `ffmpeg -version` 不含 `--enable-gpl` / libx264 / libx265
- 把 ffmpeg + ffprobe + COPYING.LGPLv3 + BUILD.md 進 git commit 到 `vendor/ffmpeg/macos/`
- 修改 `.gitignore``vendor/ffmpeg/macos/` 成為 git tracked
- 修改 `vendor-ffmpeg` target 改為「驗證 binary 存在且是 LGPL」
3. **Payload 階段同步**§7三個平台的 `payload-*` target 改為 copy `ffmpeg + ffprobe + COPYING.LGPLv3`,不再 copy yt-dlpM8-1 會處理刪)
4. **Installer 同步**§8
- `installer/windows/visiona-local.iss` 新增 ffprobe 和 LGPL license 行
- `installer/linux/build-appimage.sh` 迭代 tool 改為 `ffmpeg ffprobe`
**驗收條件**
- `make vendor-ffmpeg` PASSmacOSbinary size OK`v2/ffmpeg-lgpl.md` §10 驗收表)
- `vendor/ffmpeg/macos/ffmpeg -version | grep -- --enable-gpl` 無輸出
- `spctl --assess --verbose vendor/ffmpeg/macos/ffmpeg` → accepted
- `make vendor-ffmpeg-windows` + `vendor-ffmpeg-linux` 可成功,檔案解出後 version 無 `--enable-gpl`
- `make payload-macos` 成功,`payload/darwin/bin/` 內有 `ffmpeg / ffprobe / ffmpeg-COPYING.LGPLv3`
- 實測用 `vendor/ffmpeg/macos/ffmpeg` 解 5 種格式mp4/avi/mov/mpeg/mpg正常
**Reviewer 檢查重點**
- `vendor/ffmpeg/macos/BUILD.md` 內容正確、可重現
- 開發者用單一 `make vendor-ffmpeg-macos-build` 指令真的能從零 build 出來(不依賴本機 cached state
- `.gitignore``!` rule 順序正確
- Makefile 的 configure flags 與 `v2/ffmpeg-lgpl.md` §2.3 完全一致
---
### M8-4Server lifecycle + log buffer + bindings + OS notify + Preferences
**預估**2 人天v2.0 是 1.5 天;+0.3 天 R5-D1 OS 通知、+0.2 天 PM Q4 7+1 秒 shutdown modal
**負責 Agent**Backend AgentGo / Wails
**依賴**M8-1避免 mockMode 欄位被砍後衝突)。實務上可與 M8-2 平行。
**任務**
1. **新增檔案**`v2/control-panel.md` §7 + §4
- `visiona-local/log_buffer.go`~120 行 ring buffer
- `visiona-local/server_control.go`~280 行 ServerController + state machine + startServerV2 + logPump + **7+1 秒 shutdown modal timer**,見 `server-lifecycle.md` §8.2
- `visiona-local/preferences.go`~80 行)— **R5-D2**:必須實作 `DefaultPreferences()``runtime.GOOS` 分平台預設macOS/Windows `AutoOpenBrowser=true`、Linux `false`+ atomic write-rename`server-lifecycle.md` §11.3
- `visiona-local/notify.go`~100 行)— **R5-D1**:三平台 OS 通知macOS `osascript display notification` / Linux `notify-send` / Windows PowerShell BurntToast + `msg *` fallback詳見 `server-lifecycle.md` §10
2. **修改 `visiona-local/app.go`**
- 新增 `ctrl` / `logBuf` / `prefs` 欄位(**不要** `autoOpenedThisSession` — R5-D3 已砍掉 per-session-once 概念)
- 砍 `GetBootstrapStatus` / `setBootstrapStatus` / `bootstrapStatus` 欄位
- 改 `watchServer()` 失敗後行為(不 os.Exit改設 Error state + **goroutine 呼叫 `sendCrashNotification`** — R5-D1
- 新增 12 個 Wails bindings`v2/control-panel.md` §4.2
3. **修改 `visiona-local/main.go`**
- 加 `OnBeforeClose` handler
- `shutdownGracePeriod` 5 s → **7 s**PM Q4 定案,不是 10 s
4. **修改 `server/main.go` + `system_handler.go` + `router.go`**
- 新增 boot-id 生成(使用 `crypto/rand` 16 bytes → hexArchitect Q3 決定,不引 google/uuid
- 新增 `GET /api/system/boot-id` endpoint
- **Minor 4**server 新增 WebSocket hub 廣播 `server:shutdown-imminent` 的能力(新增 `/ws/system` endpoint 或擴充現有 `/ws/server-logs`Wails 的 `ctrl.Stop()` 在開始 SIGTERM 前透過 HTTP 或 server 內部 API 觸發此廣播
- **對齊 shutdown timeout**`server/main.go:166``shutdownFn` timeout 10 s → **6 s**Wails 7 s 減 1 s確保 server 先完成 cleanup
**驗收條件**
- `cd visiona-local && go build .` PASS
- `cd server && go build ./...` PASS
- 12 個 bindings 在 `visiona-local/frontend/wailsjs/go/main/App.d.ts` 正確生成
- 手動測試:新 bindings 可從 Wails dev 模式的 devtools console 呼叫
- 手動測試Start → server 啟動 → 看 log 噴進 LogBuffer → 用 `GetRecentLogs(20)` 取回
- 手動測試Stop → 1 秒內顯示「停止中…」modal若 server 未秒 exit+ 7 秒內 SIGKILL若卡死
- 手動測試Restart → state: Running → Stopping → Stopped → Starting → Running
- 手動測試:殺 server process → watchServer 3 次失敗後 state → Error + **收到 OS 原生通知**(三平台都要)
- **R5-D2 驗收**:全新使用者第一次在 macOS/Windows 開 app → `preferences.json` 不存在 → `DefaultPreferences()` 回傳 `AutoOpenBrowser=true`Linux 上回傳 `false`
- `curl http://127.0.0.1:<port>/api/system/boot-id` 回傳 JSON 含 bootIdhex 32 字元)
- WebSocket `server:shutdown-imminent` 廣播:用 websocat 連 `/ws/system`,按 Stop 後秒收到廣播
**Reviewer 檢查重點**
- LogBuffer thread-safetymutex 正確)
- ServerController 的 `txMu` / `mu` 互斥
- logPump 在 server 死掉 / pipe EOF / 高頻 stdout 下都不 leak goroutine
- `parseLogLevel` 不會 panic on 空字串或非 ASCII
- boot-id 生成使用 `crypto/rand` + `encoding/hex`(不引 google/uuid
- Preferences JSON atomic writetmp + rename見 §11.3
- `DefaultPreferences()``runtime.GOOS` 正確切換Linux 分支有測試)
- `sendCrashNotification` 三平台檔案darwin/linux/windows build tag齊全且不阻塞
- shutdown modal 的 1 秒 timer 與 7 秒 grace timer 正確goroutine 泄漏檢查)
- server 端 WebSocket hub 廣播邏輯與 Wails 端呼叫的契約(傳遞 `reason` 欄位)
---
### M8-4b階段化啟動管線R5-E
**預估**1 人天v2.0 未拆出v2.1 新增)
**負責 Agent**Backend AgentGo / Wails+ Frontend Agentvanilla JS startup panel
**依賴**M8-4需要 ServerController + bindings
**任務**:依 `v2/startup-pipeline.md`(若該檔不存在則落在 `control-panel.md` §3.1 + `server-lifecycle.md` §2.1
1. **新增 `visiona-local/startup_pipeline.go`**~180 行):
- `StartupPipeline` struct含 currentStage / stageStartedAt / startedAt / softTimeout / hardTimeout
- `NewStartupPipeline(ctx)` / `Start(stage int)` / `Complete(stage int)` / `Fail(stage int, err)` / `Ready()` 方法
- `watcher(ctx)` goroutine每秒檢查 soft timeout (20 s) + hard timeout (60 s)emit `startup:stage-timeout` / `startup:error` event
- 4 個 event schema`startup:progress` / `startup:stage-timeout` / `startup:error` / `startup:ready`(見 `v2/startup-pipeline.md` §1
2. **修改 `visiona-local/app.go`**`startup(ctx)`
- 在 `OnStartup` 最前面初始化 pipeline 並 Start(1)
- 每個既有階段的對應處呼叫 `pipeline.Complete(N)` + `pipeline.Start(N+1)`
- 6 個階段對應1=Wails init、2=Python runtime、3=server binary spawn + health check、4=first `/api/devices` 查詢、5=OpenInBrowser call、6=WebSocket 首個 client connect
- 最後 `pipeline.Ready()` → emit `startup:ready`
3. **修改 server 端**
- `pkg/ws`(或對應的 WebSocket hub新增 `OnClientConnected` callback讓 Wails 能收到「第一個 client 連上」的通知(透過 WebSocket 或 HTTP poll
- Wails 的 `startup_pipeline.go` 訂閱此通知完成階段 6
4. **失敗處理**:任一階段失敗 → `pipeline.Fail(stage, err)``ctrl.setState(Error, ...)` + `sendCrashNotification` + emit `startup:error`
5. **非阻塞**:所有 `EventsEmit` 呼叫都走 buffered channel 或 fire-and-forget goroutine不阻塞啟動流程
6. **前端Wails 控制台)**
- `visiona-local/frontend/components/startup-panel.js`~100 行)
- 訂閱 4 個 event 更新 DOM
- 收到 `startup:ready` 淡出300 ms ease
- i18n key 從 `i18n/zh-TW.json` / `en-US.json` 讀(文案由 Design Spec v2.1 敲定)
**驗收條件**
- `cd visiona-local && go build .` PASS
- 正常冷啟動樂觀情境4 s 內看到「啟動進度面板」6 階段逐一 completed最後淡出顯示主控台
- 慢啟動情境mock 階段 2 jam 25 秒 → 看到「階段 2 正在重試 …」副文字
- 失敗情境mock 階段 3 fail → 看到進度面板變紅、切 Error state、收到 OS 通知、發 `startup:error` event
- 總時 > 60 秒情境mock 每個階段都等 12 秒 → 60 秒後進 Error statewatcher 總時檢查有效)
- 日常啟動(非首次):~3 s 內就緒,符合 PM AC-2.1
**Reviewer 檢查重點**
- watcher goroutine 在 pipeline.Ready() / Fail() 後正確停止,不 leak
- `EventsEmit` 非阻塞(若 Wails IPC 慢也不拖啟動)
- 6 階段的 labelKey 與 design-spec v2.1 一致
- 失敗後 Error state 透過 ctrl.setState 觸發,不繞過 state machine
- startup-panel.js 的淡出動畫不會卡住主控台 UI
---
### M8-5Wails 控制台 vanilla UI 改寫
**預估**2 人天
**負責 Agent**Frontend Agentvanilla JS
**依賴**M8-4需要 bindings + events
**任務**
1. **改寫 `visiona-local/frontend/index.html`** 成控制台 layout`v2/control-panel.md` §3
2. **改寫 `visiona-local/frontend/app.js`** 成控制台主程式§5
3. **改寫 `visiona-local/frontend/style.css`** 新增 status card / log panel / action bar / preferences 樣式,並加 light/dark mode CSS variables
4. **新增 components**
- `components/status-card.js`
- `components/log-panel.js`(含 virtual scroll lite + batch render
- `components/action-bar.js`(含 disable 矩陣)
- `components/preferences.js`
5. **新增 i18n**
- `i18n/en-US.json` / `zh-TW.json`
- `i18n/loader.js`
6. **新增 icons**`icons/*.svg` × 6
**驗收條件**
- Wails dev 模式下開 app
- ✅ 看到控制台 UIstatus / log / actions / preferences不是 splash / blank / Next.js
- ✅ Start 按鈕能啟動 server狀態卡片變 Running 綠色 badge
- ✅ Log panel 即時顯示 server stdout看到 Gin log
- ✅ Stop 按鈕能停 serverbadge 變灰
- ✅ Restart 按鈕能重啟(狀態過程正確)
- ✅ Open in Browser 開瀏覽器到正確 URL
- ✅ Reveal Logs 開啟 `<dataDir>/logs/` 資料夾
- ✅ Clear Logs 清畫面但不動磁碟檔
- ✅ Preferences 切 `openBrowserOnStart` 持久化到 `preferences.json`
- ✅ Dark mode 跟隨系統macOS 切換 Dark Mode 後控制台自動變色)
- ✅ Log panel 按 Pause 後自動捲動停止,解除後恢復
- `make wails-macos` 能 build 出 .app打開後看到控制台 UIM7-B 教訓)
**Reviewer 檢查重點**
- vanilla JS 沒引入任何 npm 依賴(`package.json` 不存在於 visiona-local/frontend/
- Log panel 在高頻1000 行/秒)下不會 freeze UIbatch render 有效)
- i18n loader SSR-safe不適用但 Wails 沒 SSR 概念)
- icons/ 下的 SVG 符合 inline 使用的簡潔規則viewBox + single path
- Action bar 在每個 state 下 button enable/disable 正確disable 矩陣全部驗證)
---
### M8-6Web UI source-selector + 副檔名擴充
**預估**0.5 人天
**負責 Agent**Frontend Agent
**依賴**:無(與其他 milestone 平行)
**任務**
1. `frontend/src/components/camera/source-selector.tsx``v2/deletions.md` §5.1 移除 URL tab + mode toggle
2. 把 `accept=".mp4,.avi,.mov"` 改為 `accept=".mp4,.avi,.mov,.mpeg,.mpg"`
3. i18n 新增 `videoFormats` key 或改既有 `mp4AviMov`
4. **後端同步**`server/internal/api/handlers/camera_handler.go:251` 把 extension whitelist 從 `.mp4 / .avi / .mov` 擴充為 5 個:
```diff
-if ext != ".mp4" && ext != ".avi" && ext != ".mov" {
- c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "only MP4/AVI/MOV files are supported"}})
+if ext != ".mp4" && ext != ".avi" && ext != ".mov" && ext != ".mpeg" && ext != ".mpg" {
+ c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "only MP4/AVI/MOV/MPEG/MPG files are supported"}})
}
```
**驗收條件**
- 前端 build PASS
- 手動測試5 種副檔名都能上傳且正常開始推論
- UI 不再有「Paste URL」按鈕
**Reviewer 檢查重點**
- handler 裡的 extension 比對是 `strings.ToLower` 後再比,大小寫不敏感
- 錯誤訊息中英文同步
---
### M8-7Web UI Server Offline Overlay
**預估**1 人天
**負責 Agent**Frontend Agent
**依賴**M8-4需要 server 端 boot-id endpoint
**任務**:依 `v2/web-ui-offline-overlay.md` §4 的 7 個檔案清單:
1. 新增 `frontend/src/stores/system-store.ts`
2. 新增 `frontend/src/hooks/use-boot-id-watcher.ts`
3. 新增 `frontend/src/components/server-offline-overlay.tsx`
4. 新增 `frontend/src/components/boot-id-watcher-mount.tsx`
5. 修改 `frontend/src/app/layout.tsx`(掛 overlay + watcher
6. 修改 `frontend/src/lib/i18n/{types,zh-TW,en}.ts`(新增 serverOffline 區塊)
**驗收條件**
- `pnpm --dir frontend build` PASS含 SSR 相容性驗證)
- 手動測試:
- 正常啟動沒 overlay
- 關 Wails 視窗後 15 s 內瀏覽器 tab 顯示 overlay
- 重新開 Wails app → Web UI 的「重試」按鈕有效overlay 消失)
- 控制台按 Restart → boot-id 變 → 自動 `window.location.reload()`
- 切到別的 tab 5 分鐘再切回 → polling 立即 probe 不用等
**Reviewer 檢查重點**
- Zustand store 的 failures state 遞增正確
- `use-boot-id-watcher.ts` 的 cleanupuseEffect return正確 cancel
- `AbortSignal.timeout(3000)` 在舊瀏覽器相容性(若需支援 Chrome < 103 fallback
- Overlay 元件 a11y`role="alertdialog"` + `aria-labelledby`
- i18n 新增 key 三個檔同步types / zh-TW / en
---
### M8-8CORS + origin check middleware
**預估**0.5 人天
**負責 Agent**Backend Agent
**依賴**:無
**任務**:依 `v2/cors-security.md` §4 + §5
1. 覆寫 `server/internal/api/middleware.go`(白名單版)
2. 新增 `server/internal/api/ws/origin.go`
3. 修改所有 WS handler 的 upgrader CheckOrigin
4. 新增 `server/internal/api/middleware_test.go` 單元測試(`TestIsAllowedOrigin`
5. 在 `router.go``requireSameOriginOrNoOrigin` middleware 到 `/api/*`
**驗收條件**
- `go test ./server/internal/api/... -run TestIsAllowedOrigin` PASS
- `v2/cors-security.md` §9 所有 curl 驗收條件通過
- WS websocat 測試:白名單 origin 可連、非白名單擋
- 既有 Next.js Web UI 的 fetch 仍正常運作same-origin + white list
**Reviewer 檢查重點**
- `isAllowedOrigin` 對 edge case 處理正確(空字串 / null / https / 不合規 URL
- 不 accidentally 把 `X-Relay-Token` header 保留relay 早已砍)
- WS upgrade 在非白名單 origin 下回 403不是靜默失敗
- Middleware 套用順序middleware.go 的 CORSMiddleware 要在 requireSameOriginOrNoOrigin 之前)
---
### M8-9Boot-ID 端到端整合 + Restart 重連
**預估**1 人天
**負責 Agent**Backend Agent + Frontend Agent
**依賴**M8-4 + M8-7
**任務**
1. **整合驗證**M8-4 的 server 端 boot-id API + M8-7 的前端 polling 串起來
2. **Restart 情境驗證**
- 開 app → Open in Browser
- 在 Wails 控制台按 Restart
- 瀏覽器 tab 在 3-5 s 內自動 reload
- Reload 後 server 是新的 port 也能正常連(因為 Next.js 是 static exportURL path 不變)
3. **自動開瀏覽器**R5-4 + R5-D2 + R5-D3
- **R5-D2分平台預設**macOS/Windows 預設 `AutoOpenBrowser=true`Linux 預設 `false`
- **R5-D3每次都開**:只要 `AutoOpenBrowser=true`,每次 Start/Restart 成功都呼叫 `OpenInBrowser("")` — **取消**原 v2.0 的 per-session-once 設計
- 使用者在 Preferences 切換 → 立即持久化到 `preferences.json`atomic write
4. **Restart 期間 port 行為**F-2 強制保留):
- Restart 不允許 port fallback`startWithPort(oldPort, forceMatch=true)`
- 舊 port 被佔用 → 進 Error state + 發 OS 通知
- 正常 Restart → 新 server 用原 port → 瀏覽器 tab 偵測 boot-id 變 → reload → URL 原 port 仍有效 → 自動連上
- **不會**發生 v2.0 所述「port 變動後瀏覽器連不上」的情境
**驗收條件**
- 關機情境Wails 關 → server 停 → 瀏覽器 tab**WebSocket shutdown-imminent 秒內顯示 overlay**(不是 15 s 了Minor 4
- Restart 情境Wails 按 Restart3-5 s 內瀏覽器自動 reload**URL 原 port 仍有效**F-2UI 正常
- 首次開 appmacOS/Windows瀏覽器自動跳出新 tab
- 首次開 appLinux瀏覽器**不**自動開R5-D2 預設 false
- Preferences 關閉後再開 app瀏覽器不自動開
- **多次按 Restart**:若 `AutoOpenBrowser=true`,每次 Restart **都會呼叫 `OpenInBrowser`**R5-D3。OS 瀏覽器通常聚焦既有 tab 而不是開新 tab使用者可接受
- R5-E 啟動進度面板Starting 期間顯示 6 階段進度,完成後淡出
**Reviewer 檢查重點**
- `app.go` 中**不存在** `autoOpenedThisSession` 欄位v2.0 已砍)
- Restart 呼叫 `OpenInBrowser` 的行為有在 M8-9 手動測試紀錄中驗證
- `DefaultPreferences()` 三平台分支在單元測試或手動測試中涵蓋
- Restart 的 port 強制保留邏輯正確(錯的 mock 情境下會進 Error state 而非 fallback
- boot-id 在 server 重啟後真的變(不會意外 memoize 舊值)
- polling 的 `consecutiveFailures` 在 Restart 期間不會錯誤累積到 3Restart 約 3 s只有 0-1 次失敗)
---
### M8-10端到端 build + smoke test
**預估**1 人天
**負責 Agent**DevOps Agent + Testing AgentQA
**依賴**M8-1 到 M8-9 全部完成
**任務**
1. **macOS**`make clean-all && make dmg``dist/visiona-local.dmg`
- 安裝到全新 mac或 VM
- 8 核心驗收v2.1 擴充):
- ✅ 打開 app 先看到**啟動進度面板**6 階段R5-E完成後淡出顯示**控制台 UI**(不是 splash / wizard / 白畫面)
- ✅ 點 Open in Browser 後瀏覽器載入 Next.js Web UI
- ✅ Web UI 沒有 URL tabsource-selector 清乾淨)
- ✅ Web UI 沒有 Mock 模式切換Settings > Hardware 乾淨)
- ✅ 點 Stop 後瀏覽器**秒內看到 Offline Overlay**WebSocket 廣播,不是 15 s 才顯示)
- ✅ **手動殺 server process → 收到 macOS 原生通知 + 控制台 Error banner**R5-D1
- ✅ **R5-D2首次開 app 時 `preferences.json` 不存在macOS 預設自動開瀏覽器**Linux 相對要反過來驗證)
- ✅ 按 Restart 兩次,每次都會觸發 `OpenInBrowser`R5-D3砍掉 per-session-once
2. **Windows**:在 Windows 機器上 `make clean-all && make exe`
- 同樣 5 核心驗收
- **注意**R5-7 同意 M7 Windows 先不管,但這次 M8-10 要順帶驗證(做完再驗)
3. **Linux**:在 Ubuntu 上 `make clean-all && make appimage`
- 同樣 5 核心驗收
4. **LGPL 稽核**:三個平台的 installer 安裝後,`<install>/bin/` 下都有 `ffmpeg + ffprobe + ffmpeg-COPYING.LGPLv3`
5. **Installer size 對照**
- macOS .dmg預期 ~80-100 MBv1 是 220 MB砍 yt-dlp 35 MB + 砍 GPL ffmpeg 77 MB 換成 LGPL ~20 MB = 約 128 MB 降到 ~100 MB
- Windows .exe預期 ~280-320 MBv1 是 ~380 MB
- Linux .AppImage預期 ~240-280 MBv1 是 ~317 MB
6. **回歸測試**
- Kneron KL520 / KL720 實機跑推論(若有硬體)
- 5 種副檔名上傳影片
- 批次影像上傳
- Camerawebcam串流
**驗收條件**
- 三平台 installer 全部能裝、能啟動、能進入控制台、能 Open Browser、能跑推論
- LGPL 合規稽核:`grep -r 'enable-gpl' vendor/ffmpeg/` 無輸出
- 5 核心驗收 checklist 全綠
- Installer size 在預期範圍內
**Reviewer 檢查重點**
- 驗收 checklist 每一項都有實測截圖 / log
- 回歸測試涵蓋 PRD v2 所列的核心 user story
---
## 3. 風險與緩衝
- **M8-3 macOS 自 build ffmpeg 可能踩坑**configure flag 組合、pkg-config 環境、codesign預估 1.5 人天但可能實際花 2 人天。buffer 放在 M8-10 的 1 人天裡
- **M8-4b R5-E 6 階段 event emit 的非阻塞保證**Wails v2 IPC 若慢,可能把啟動流程本身拖慢。需在 M8-4b 後做 mock 測試(每個階段插 dummy 延遲)確認 watcher goroutine 與 event emit 不互相阻塞
- **M8-4 OS 通知三平台實測**R5-D1macOS 首次呼叫會彈出通知授權對話框Windows 沒裝 BurntToast 會 fallback `msg *`(某些版本可能 blockLinux 需要 `libnotify-bin` 已安裝。這三者都需要實機驗證,預估 0.3 天
- **M8-5 控制台 UI 的 dark mode** 可能需要反覆調整:若實測後使用者不喜歡預設配色,留給 Design Agent 一輪調整(不在本次範圍內)
- **M8-4 高頻 log 壓測**R-v2-3若 Wails v2 EventsEmit 真的 flaky要切 micro-batch 升級成 throttling + coalescing可能再多 0.5 人天
### 總工時重估
| Milestone | v2.0 | v2.1 | 差異原因 |
|-----------|------|------|---------|
| M8-1 | 0.5 | 0.5 | — |
| M8-2 | 0.5 | 0.5 | — |
| M8-3 | 1.5 | 1.5 | — |
| M8-4 | 1.5 | **2.0** | +0.3 R5-D1 OS 通知、+0.2 Q4 7+1 秒 shutdown modal |
| **M8-4b** | — | **1.0** | **新增R5-E 階段化啟動** |
| M8-5 | 2.0 | 2.0 | — |
| M8-6 | 0.5 | 0.5 | — |
| M8-7 | 1.0 | **1.3** | +0.3 Minor 4 WebSocket shutdown-imminent 訂閱 |
| M8-8 | 0.5 | 0.5 | — |
| M8-9 | 1.0 | 1.0 | — |
| M8-10 | 1.0 | 1.2 | +0.2 核心驗收項目從 5 個擴為 8 個 |
| **合計** | **10.0** | **12.0** | **+2 人天** |
加上 bufferM8-3 可能踩坑 + M8-4 高頻 log 壓測可能重構)~1 人天 → **建議 Orchestrator 對使用者回報 ~13 人天**
---
## 4. 交棒給 Orchestrator 的清單
| Milestone | 誰 | 觸發條件 |
|-----------|-----|---------|
| M8-1 | Backend + Frontend並行| 使用者確認 TDD v2.1 |
| M8-2 | Backend + Frontend並行| 同 M8-1 |
| M8-3 | DevOps + BackendmacOS host 可用)| 同上,不依賴 M8-1/2 |
| M8-4 | Backend | M8-1 + M8-2 砍完 |
| **M8-4b** | **Backend + Frontendvanilla JS** | **M8-4 完成(需要 ServerController + WebSocket hub** |
| M8-5 | Frontend | M8-4b bindings + event schema 可用 |
| M8-6 | Frontend | 可與 M8-1/2/5 平行 |
| M8-7 | Frontend | M8-4 boot-id endpoint + WebSocket `/ws/system` 就緒 |
| M8-8 | Backend | 可獨立進行 |
| M8-9 | Backend + Frontend | M8-4 + M8-4b + M8-7 完成 |
| M8-10 | DevOps + Testing | 全部完成 |
**提醒**:每個 milestone 完成後Orchestrator 啟動 Reviewer 審查,審查通過才進下一個 milestoneper 根目錄 CLAUDE.md 「強制 Review 規則」)。

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,719 @@
# v2/startup-pipeline.md — R5-E 階段化啟動管線
> 所屬TDD v2 §2.9v2.1 新增)
> 版本v2.12026-04-14 R5-E 實作細節)
> 決策依據R5-E1 ~ R5-E6AC-1.3 從 10 秒硬指標 → 60 秒 + 階段化進度)
> 對應 milestoneM8-4bBackend Go + Frontend vanilla JS
> 相關文件:`v2/control-panel.md` §3.1(啟動進度面板 UI`v2/server-lifecycle.md` §2.1(冷啟動時間軸)
---
## 0. 目的與決策回顧
v2.0 PM §11-2 提問 AC-1.3「10 秒可達性」Architect 樂觀 4 s / 悲觀 8 s / 最壞 11 s。PM 審閱後將此硬指標**取代**為 R5-E 的新規則:
| R5-E | 內容 |
|------|------|
| R5-E1 | AC-1.3 上限 **60 秒**(軟硬門檻) |
| R5-E2 | 分成 **6 個階段**,逐階段顯示進度 |
| R5-E3 | 單一階段卡 > **20 秒**soft timeout→ 顯示「階段 N 正在重試」副文字,**不中斷流程** |
| R5-E4 | 總時 > **60 秒**hard timeout→ 進 Error state |
| R5-E5 | 階段文字由 Design Spec v2.1 決定(本文件只定 event schema + labelKey |
| R5-E6 | 「瀏覽器就緒」定義 = WebSocket 連上 server`OnClientConnected` 第一次觸發) |
v2.0 的樂觀/悲觀估算仍有意義,作為每階段的預算參考(見 `server-lifecycle.md` §2.1 的階段預算表)。
---
## 1. Event Schema
Wails 控制台vanilla JS透過 `EventsOn` 訂閱以下 4 個 event。Payload 使用 JSON-serializable Go struct。
### 1.1 `startup:progress`
每個階段的狀態變化都 emit 一次,可能是 `running`(階段開始)、`completed`(階段完成)、`failed`(階段失敗)。
```go
type StartupProgressEvent struct {
Stage int `json:"stage"` // 1-6
TotalStages int `json:"totalStages"` // 固定 6
LabelKey string `json:"labelKey"` // i18n key見 §2
Status string `json:"status"` // "pending" | "running" | "completed" | "failed" | "skipped"
StartedAt int64 `json:"startedAt"` // Unix ms該階段開始時間
}
```
**Status 值**v2.1 二次審閱新增 `skipped`
- `pending`階段尚未開始pipeline render 初始狀態)
- `running`階段進行中watcher 會對此階段檢查 soft / hard timeout
- `completed`:階段成功完成
- `failed`:階段失敗 → pipeline 停止、進 Error state
- `skipped`v2.1 新增):該階段依偏好設定或平台規則被跳過,不需執行也不檢查 timeout
- 目前使用情境:階段 5「開啟瀏覽器」在 `prefs.AutoOpenBrowser == false`Linux 預設 OFF或使用者手動關閉→ status=`skipped`
- 前端收到 skipped → 顯示 ⏭ 圖示 + 文字「跳過依偏好設定Design Spec v2.1 §4.1 已定)
- Watcher 看到 skipped 狀態時不檢查 soft timeout進入下一階段的邏輯同 completed
前端 render 邏輯:收到 event → 更新 `#startup-stage-<N>` DOM 的 status icon 與時間。
### 1.2 `startup:stage-timeout`
某階段 > 20 秒soft timeout未完成時 emit 一次(**不重複 emit**)。僅作提示,不中斷流程。
```go
type StartupStageTimeoutEvent struct {
Stage int `json:"stage"`
SoftTimeoutSeconds int `json:"softTimeoutSeconds"` // 固定 20
}
```
前端 render 邏輯:收到 event → 在該階段行下方插入 `<p class="startup__retry-hint">{i18n("startup.retrying", { stage })}</p>`
### 1.3 `startup:error`
任一階段失敗或總時 > 60 秒 emit 一次。之後 pipeline 停止,後續不會再有任何 `startup:*` event。
```go
type StartupErrorEvent struct {
Stage int `json:"stage"`
Error string `json:"error"` // 技術性錯誤訊息
Cause string `json:"cause"` // "stage-failure" | "total-timeout"
}
```
前端 render 邏輯:切到 Error state 顯示,同時 `server:state-change` 會被觸發(因為 `ctrl.setState(Error, ...)` 已呼叫)。
### 1.4 `startup:ready`
6 個階段都 `completed` 後 emitpayload 為空。
```go
// 無 structEventsEmit(ctx, "startup:ready", nil)
```
前端 render 邏輯淡出啟動進度面板300 ms ease→ 顯示主控台 UI。
---
## 2. i18n Key 清單
文案由 Design Spec v2.1 敲定。TDD 只定義 key 名稱:
| Key | 用途 |
|-----|------|
| `startup.title` | 標題「正在啟動 visionA Local」 |
| `startup.stage.1.label` | 階段 1初始化 Wails 控制台 |
| `startup.stage.2.label` | 階段 2檢查 Python runtime |
| `startup.stage.3.label` | 階段 3啟動本機伺服器 |
| `startup.stage.4.label` | 階段 4偵測 Kneron 裝置 |
| `startup.stage.5.label` | 階段 5開啟瀏覽器 |
| `startup.stage.6.label` | 階段 6等待 Web UI 連線 |
| `startup.retrying` | 「第 {stage} 階段正在重試 …」副文字 |
| `startup.elapsed` | 「已耗時 {seconds} s · 上限 60 s」 |
| `startup.error.stageFailure` | 「第 {stage} 階段失敗:{error}」 |
| `startup.error.totalTimeout` | 「啟動超過 60 秒,請檢查系統資源」 |
檔案位置:`visiona-local/frontend/i18n/zh-TW.json` / `en-US.json`
---
## 3. 6 階段對應的 Go 實作點
| # | 階段 | 在哪裡 `pipeline.Start(N)` | 在哪裡 `pipeline.Complete(N)` |
|---|------|---------------------------|------------------------------|
| 1 | 初始化 Wails 控制台 | `app.go:startup` 最前面(`a.ctx != nil` 之後第一行)| 同一個 function 的 `seedUserDataDir()` 返回後 |
| 2 | 檢查 Python runtime | `startServerV2` 開頭、在 `ensurePythonRuntime()` 前 | `ensurePythonRuntime()` 返回後 |
| 3 | 啟動本機伺服器 | 階段 2 完成後立即 | `waitHealthy(port, 30s)` 返回後server HTTP OK|
| 4 | 偵測 Kneron 裝置 | 階段 3 完成後立即 | 呼叫 server 的 `GET /api/devices` 第一次收到 response無論是否有硬體秒回即算完成|
| 5 | 開啟瀏覽器 | 階段 4 完成後 | 呼叫 `OpenInBrowser("")` 返回後(不等瀏覽器真的開,只等 `open`/`start`/`xdg-open` 命令 return`AutoOpenBrowser=false` 則**不呼叫 OpenInBrowser**,直接 emit status=`skipped` 並進入階段 6 |
| 6 | 等待 Web UI 連線 | 階段 5 完成後 | server 的 WebSocket hub `OnClientConnected` callback 第一次觸發(透過 HTTP 或 channel 通知 Wails|
**階段 6 的實作細節v2.1 定版sentinel file 方案)**
**決定採用 sentinel file**`<dataDir>/.first-ws-connected`,理由見下方「為什麼不用 channel / callback 直接串」。
**流程**
1. **Server 端**WebSocket hub 的 `OnClientConnected` callback 第一次觸發時:
```go
// server/internal/websocket/hub.go
func (h *Hub) OnClientConnected(c *Client) {
h.firstConnOnce.Do(func() {
sentinelPath := filepath.Join(h.dataDir, ".first-ws-connected")
f, err := os.Create(sentinelPath)
if err == nil {
_, _ = fmt.Fprintf(f, "bootId=%s\nts=%d\n", h.bootID, time.Now().UnixMilli())
_ = f.Close()
}
// 檔案內容存 boot-id + timestamp 便於 debug寫失敗不影響功能Wails 端 poll 不到也會超時 fail
})
// ... 其他既有邏輯
}
```
使用 `sync.Once` 確保僅第一次連線觸發;後續連線不重複寫檔。
2. **Wails 端**`StartupPipeline.watcher` goroutine 每秒 tick 時額外檢查 sentinel 檔案:
```go
// 當 current == 6 時,每次 tick 檢查 sentinel 檔案
if cur == 6 {
sentinelPath := filepath.Join(p.app.dataDir, ".first-ws-connected")
if _, err := os.Stat(sentinelPath); err == nil {
p.Complete(6) // → markReady() → emit startup:ready
return
}
}
```
3. **下次 Start 前清檔**
- `RestartStartupSequence()` 執行時先 `os.Remove(sentinelPath)`(避免殘留檔案導致新階段 6 瞬間完成)
- `ServerController.Stop()` 時也清檔(正常停機的清理)
- 冷啟動時不需特別清:第一次啟動時檔案不存在,第二次冷啟動前 app 已經整個退出看情況v2.1 決定 **每次 `StartServer` 的前置步驟就呼叫一次 `os.Remove(sentinelPath)`**(最保險,重複 remove 不會錯,反正 `os.IsNotExist` 當正常情況)
**為什麼用 sentinel file 而非 channel / callback 直接串**
- **Go server 和 Wails app 是兩個 process**server 是由 `exec.Command(server binary, ...)` spawn 出來的子程序,跟 Wails 的 Go runtime 完全隔離 → 不能共享 Go channel、mutex 或任何 runtime 記憶體
- **IPC 替代方案比較**
- HTTP long-poll endpoint → 需要佔一個 HTTP connection + 處理 timeout實作較重
- Unix domain socket / named pipe → 跨平台macOS/Linux/Windows實作差異大Windows 下處理 pipe 權限繁瑣
- Sentinel file → 跨平台(`os.Stat` 到處都行)、零依賴、檔案內容可存 boot-id + timestamp 做 debug → **最簡**
- **可觀測性**:使用者遇到問題時可以直接檢查 `<dataDir>/.first-ws-connected` 是否存在,判斷階段 6 是否真的完成
**備選方案**(若未來 Go server 改為 in-process module不再 spawn 子程序):可以改走 Go channel / callback 直接串屆時在本文件註記即可v2.1 的前提是子程序模型。
---
## 4. StartupPipeline Go struct
檔案:`visiona-local/startup_pipeline.go`
```go
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
const (
startupTotalStages = 6
startupSoftTimeout = 20 * time.Second
startupHardTimeout = 60 * time.Second
startupWatcherTickMs = 1000
)
type stageState struct {
status string // "pending" | "running" | "completed" | "failed"
startedAt time.Time
completedAt time.Time
softTimeoutEmitted bool
}
type StartupPipeline struct {
app *App
mu sync.Mutex
stages [startupTotalStages + 1]stageState // 1-indexed
current int // 0 = not started, 1-6 = in progress, 7 = ready, -1 = failed
startedAt time.Time
watcherCancel context.CancelFunc
watcherDone chan struct{}
}
func NewStartupPipeline(app *App) *StartupPipeline {
return &StartupPipeline{
app: app,
current: 0,
}
}
// Start 啟動整個 pipeline從階段 1 開始),並開啟 watcher goroutine。
// 只能呼叫一次。
func (p *StartupPipeline) Start(ctx context.Context) {
p.mu.Lock()
p.startedAt = time.Now()
p.current = 1
p.stages[1].status = "running"
p.stages[1].startedAt = time.Now()
p.mu.Unlock()
p.emitProgress(1)
watcherCtx, cancel := context.WithCancel(ctx)
p.watcherCancel = cancel
p.watcherDone = make(chan struct{})
go p.watcher(watcherCtx)
}
// Complete 標記當前階段完成,並自動切到下一階段(若還有)。
func (p *StartupPipeline) Complete(stage int) {
p.mu.Lock()
if p.current != stage || p.current <= 0 {
p.mu.Unlock()
return // 重複呼叫或順序錯誤,忽略
}
p.stages[stage].status = "completed"
p.stages[stage].completedAt = time.Now()
p.mu.Unlock()
p.emitProgress(stage)
if stage == startupTotalStages {
p.markReady()
return
}
// 進入下一階段
p.mu.Lock()
next := stage + 1
p.current = next
p.stages[next].status = "running"
p.stages[next].startedAt = time.Now()
p.mu.Unlock()
p.emitProgress(next)
}
// SkipStage 標記某階段為 "skipped",並自動切到下一階段(行為類似 Complete
// v2.1 新增:用於階段 5 在 AutoOpenBrowser=false 時跳過 OpenInBrowser 呼叫。
// Watcher 看到 skipped 狀態時不檢查 soft / hard timeout。
func (p *StartupPipeline) SkipStage(stage int) {
p.mu.Lock()
if p.current != stage || p.current <= 0 {
p.mu.Unlock()
return
}
p.stages[stage].status = "skipped"
p.stages[stage].completedAt = time.Now()
p.mu.Unlock()
p.emitProgress(stage) // emit status=skipped
if stage == startupTotalStages {
p.markReady()
return
}
p.mu.Lock()
next := stage + 1
p.current = next
p.stages[next].status = "running"
p.stages[next].startedAt = time.Now()
p.mu.Unlock()
p.emitProgress(next)
}
// Fail 標記當前階段失敗pipeline 停止。
func (p *StartupPipeline) Fail(stage int, err error) {
p.mu.Lock()
if p.current <= 0 {
p.mu.Unlock()
return
}
p.stages[stage].status = "failed"
p.current = -1
p.mu.Unlock()
p.emitProgress(stage)
p.emitError(stage, err, "stage-failure")
p.stopWatcher()
}
// markReady 所有階段完成後觸發。
func (p *StartupPipeline) markReady() {
p.mu.Lock()
p.current = startupTotalStages + 1
p.mu.Unlock()
if p.app.ctx != nil {
wailsRuntime.EventsEmit(p.app.ctx, "startup:ready", nil)
}
p.stopWatcher()
}
func (p *StartupPipeline) emitProgress(stage int) {
p.mu.Lock()
st := p.stages[stage]
p.mu.Unlock()
if p.app.ctx == nil {
return
}
// 使用 goroutine + select 避免阻塞(萬一 Wails IPC 慢)
go func() {
wailsRuntime.EventsEmit(p.app.ctx, "startup:progress", StartupProgressEvent{
Stage: stage,
TotalStages: startupTotalStages,
LabelKey: fmt.Sprintf("startup.stage.%d.label", stage),
Status: st.status,
StartedAt: st.startedAt.UnixMilli(),
})
}()
}
func (p *StartupPipeline) emitError(stage int, err error, cause string) {
if p.app.ctx == nil {
return
}
go func() {
wailsRuntime.EventsEmit(p.app.ctx, "startup:error", StartupErrorEvent{
Stage: stage,
Error: err.Error(),
Cause: cause,
})
}()
// 同步通知 ServerController 進 Error state
if p.app.ctrl != nil {
p.app.ctrl.setState(ServerStateError, err.Error())
}
// R5-D1發 OS 通知
go sendCrashNotification(
"visionA Local — 啟動失敗",
fmt.Sprintf("第 %d 階段失敗:%s", stage, err.Error()),
)
}
// watcher 每秒檢查 soft timeout、hard timeout、階段 6 sentinel file。
func (p *StartupPipeline) watcher(ctx context.Context) {
defer close(p.watcherDone)
ticker := time.NewTicker(startupWatcherTickMs * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
p.mu.Lock()
if p.current <= 0 || p.current > startupTotalStages {
p.mu.Unlock()
return
}
cur := p.current
st := p.stages[cur]
curStatus := st.status
sinceStage := time.Since(st.startedAt)
sinceTotal := time.Since(p.startedAt)
softEmitted := st.softTimeoutEmitted
p.mu.Unlock()
// v2.1:階段 6 sentinel file 檢查(取代既有的 OnClientConnected IPC
if cur == 6 {
sentinelPath := filepath.Join(p.app.dataDir, ".first-ws-connected")
if _, err := os.Stat(sentinelPath); err == nil {
p.Complete(6)
return
}
}
// v2.1skip hard / soft timeout 的情境(必須在 hard timeout 檢查前先判斷)
// 1) 該階段已標記為 "skipped"(例如階段 5 在 AutoOpenBrowser=false 時)
// 2) 階段 6 且 AutoOpenBrowser=false → 使用者必須手動點「Open in Browser」才會觸發 WebSocket
// 連線,等待時間可能很長(使用者去倒杯咖啡),不計入 60 s 上限,也不觸發 soft timeout
skipTimeout := false
if curStatus == "skipped" {
skipTimeout = true
}
if cur == 6 && p.app.prefs != nil && !p.app.prefs.AutoOpenBrowser {
skipTimeout = true
}
// Hard timeout總時 > 60 s— 跳過時不檢查
if !skipTimeout && sinceTotal > startupHardTimeout {
p.Fail(cur, fmt.Errorf("startup total timeout: %s > %s", sinceTotal, startupHardTimeout))
p.emitError(cur, fmt.Errorf("total timeout"), "total-timeout")
return
}
if skipTimeout {
continue // 不檢查 soft timeout
}
// Soft timeout單一階段 > 20 s
if sinceStage > startupSoftTimeout && !softEmitted {
p.mu.Lock()
p.stages[cur].softTimeoutEmitted = true
p.mu.Unlock()
if p.app.ctx != nil {
go func(stage int) {
wailsRuntime.EventsEmit(p.app.ctx, "startup:stage-timeout", StartupStageTimeoutEvent{
Stage: stage,
SoftTimeoutSeconds: int(startupSoftTimeout.Seconds()),
})
}(cur)
}
}
}
}
}
func (p *StartupPipeline) stopWatcher() {
if p.watcherCancel != nil {
p.watcherCancel()
}
}
```
**關鍵設計**
1. **1-indexed stages**:陣列多配一格避免 off-by-one`stages[1]` ~ `stages[6]` 使用
2. **`current` sentinel 值**`0`=未啟動、`1-6`=進行中、`7`=ready、`-1`=已失敗watcher 看到非 1-6 立即 return避免 leak
3. **非阻塞 emit**:每個 `EventsEmit` 都走 `go func()`,確保 Wails IPC 慢不拖累啟動流程
4. **`softTimeoutEmitted` flag**:確保 `startup:stage-timeout` 每個階段最多 emit 一次
5. **watcher 生命週期**`ctx.Done()``stopWatcher()` 觸發時退出;`markReady()``Fail()` 都會呼叫 `stopWatcher()`
---
## 5. 與 `startup(ctx)` 的整合
`visiona-local/app.go``startup()` 函式修改(示意):
```go
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
// 初始化階段化啟動管線
a.pipeline = NewStartupPipeline(a)
a.pipeline.Start(ctx) // emit startup:progress(stage=1, running)
// 階段 1既有的初始化
if err := a.migrateOldDataDirs(); err != nil { ... }
if err := a.acquireSingleInstance(); err != nil { ... }
if err := a.startIPCServer(); err != nil { ... }
if err := a.seedUserDataDir(); err != nil { ... }
a.pipeline.Complete(1) // emit startup:progress(stage=1, completed) + startup:progress(stage=2, running)
// 載入 preferences必須在 ctrl.Start 之前ctrl.Start 會讀 prefs.AutoOpenBrowser
a.prefs = LoadPreferences(a.dataDir)
// 階段 2-6由 ctrl.Start → startServerV2 內部呼叫 pipeline.Complete(2..6)
if err := a.ctrl.Start(); err != nil {
// 失敗情境已由 pipeline.Fail / emitError 處理,這裡不需額外動作
return
}
// 成功pipeline.Complete(6) → markReady() → emit startup:ready
}
```
`server_control.go:startServerV2` 內部穿插 `pipeline.Complete(N)` / `pipeline.Start(N+1)` 的呼叫,在對應階段的 boundary 執行。
---
## 6. 前端Wails 控制台 vanilla JS
檔案:`visiona-local/frontend/components/startup-panel.js`~100 行)
```javascript
// startup-panel.js
import { t } from '../i18n/loader.js';
const el = () => document.getElementById('startup-panel');
const stagesEl = () => document.getElementById('startup-stages');
const elapsedEl = () => document.getElementById('startup-elapsed');
let startedAt = 0;
let elapsedTimer = null;
export function initStartupPanel() {
// 初始 render 6 個階段為 pending
const container = stagesEl();
container.innerHTML = '';
for (let i = 1; i <= 6; i++) {
const row = document.createElement('div');
row.id = `startup-stage-${i}`;
row.className = 'startup__stage startup__stage--pending';
row.innerHTML = `
<span class="startup__icon"></span>
<span class="startup__label">${i}. ${t(`startup.stage.${i}.label`)}</span>
<span class="startup__time"></span>
<p class="startup__retry-hint" hidden></p>
`;
container.appendChild(row);
}
startedAt = Date.now();
elapsedTimer = setInterval(updateElapsed, 500);
}
function updateElapsed() {
const seconds = Math.floor((Date.now() - startedAt) / 1000);
elapsedEl().textContent = t('startup.elapsed', { seconds });
}
export function updateStartupPanel(e) {
// e = { stage, totalStages, labelKey, status, startedAt, retrying? }
const row = document.getElementById(`startup-stage-${e.stage}`);
if (!row) return;
row.className = `startup__stage startup__stage--${e.status}`;
const icon = row.querySelector('.startup__icon');
icon.textContent = {
pending: '⏳',
running: '🔄',
completed: '✅',
failed: '❌',
retrying: '🔄',
}[e.status] || '⏳';
if (e.status === 'completed' || e.status === 'failed') {
const elapsed = ((Date.now() - e.startedAt) / 1000).toFixed(1);
row.querySelector('.startup__time').textContent = `(${elapsed} s)`;
}
if (e.status === 'retrying' || e.softTimeoutSeconds) {
const hint = row.querySelector('.startup__retry-hint');
hint.textContent = t('startup.retrying', { stage: e.stage });
hint.hidden = false;
}
}
export function showStartupError(e) {
// 最終切 Error state 會由 server:state-change 處理;這裡只做視覺標記
const row = document.getElementById(`startup-stage-${e.stage}`);
if (row) {
row.className = 'startup__stage startup__stage--failed';
row.querySelector('.startup__icon').textContent = '❌';
}
// 停止 elapsed timer
if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; }
}
export function hideStartupPanel() {
if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null; }
const panel = el();
if (!panel) return;
// 300 ms 淡出
panel.classList.add('startup__panel--hiding');
setTimeout(() => {
panel.hidden = true;
panel.classList.remove('startup__panel--hiding');
}, 300);
}
```
`app.js``main()` 最前面呼叫 `initStartupPanel()`,然後訂閱 4 個 event`control-panel.md` §5 的更新)。
---
## 7. 驗收條件
| # | 情境 | 預期 |
|---|------|------|
| 1 | 樂觀冷啟動(所有階段 ~1 s| 4-5 s 內看到進度面板 6 階段逐一 completed → 淡出顯示主控台 |
| 2 | 日常啟動(非首次,~3 s| 進度面板 min-display-time 1 s 後淡出 |
| 3 | 悲觀冷啟動Python wheels extract 慢)| 8-10 s 內完成,階段 2 可能顯示較久但不觸發 retry hint |
| 4 | 階段 2 卡 25 smock 測試)| 看到「第 2 階段正在重試 …」副文字、但流程繼續 |
| 5 | 階段 3 失敗server binary 不存在)| 階段 3 變 ❌、收到 OS 通知、切 Error state |
| 6 | 總時 > 60 smock 每階段等 12 s| 60 s 時切 Error state + emit `startup:error` |
| 7 | `AutoOpenBrowser=false`Linux 預設)| 階段 5 立即 complete跳過 OpenInBrowser 實際呼叫)|
| 8 | WebSocket 30 s 內無 client 連上(罕見)| 階段 6 失敗 → Error state |
| 9 | 進度面板淡出不卡住主控台 | 300 ms ease 後主控台 UI 顯示,無視覺 artifact |
---
## 8. Retry 機制RestartStartupSequencev2.1 新增)
**觸發情境**Wails 控制台進入 Startup Error state階段失敗或 60 s hard timeout使用者在 Error state 面板點「Retry」按鈕Design Spec v2.1 §3.7 定義)。這個行為**不是** `RestartServer()`(後者只重啟 server 子程序,保留 port而是**整個啟動流程重跑**。
### 8.1 Binding`RestartStartupSequence`
```go
// visiona-local/app.go
// RestartStartupSequence resets the entire startup pipeline and tries again.
// Triggered by user clicking "Retry" button in Wails console Error state.
//
// 與 RestartServer 的差別:
// RestartServer : Stop → Start保留 port、沿用既有 StartupPipeline instance
// RestartStartupSequence : ForceKill → 清狀態 → 重建 StartupPipeline → 從階段 2 跑(階段 1 直接 emit completed
func (a *App) RestartStartupSequence() error {
// Step 1: 停止當前的 watcher goroutine避免舊 watcher 把剛重跑的階段誤判為 soft timeout
if a.pipelineCancelFn != nil {
a.pipelineCancelFn()
a.pipelineCancelFn = nil
}
// Step 2: 強制殺掉 server 子程序(不等 graceful period我們是在 recover failure
// 直接 ForceKill 比 Stop() 快Stop() 會走 7 s grace period 對這個情境沒必要
if a.ctrl != nil {
a.ctrl.ForceKill() // 新增 method內部 SIGKILL + 清 c.proc + setState(Stopped) 但不 emit crash notification
}
// Step 3: Reset state machine to Stopped避免 Error state 殘留)
if a.ctrl != nil {
a.ctrl.setState(ServerStateStopped, "")
}
// Step 4: 清 sentinel filecritical — 否則階段 6 會誤判為瞬間完成)
sentinelPath := filepath.Join(a.dataDir, ".first-ws-connected")
_ = os.Remove(sentinelPath)
// Step 5: 重建 StartupPipeline 並呼叫 StartServer
a.startupPipeline = NewStartupPipeline(a)
// 階段 1「初始化 Wails 控制台」已經是 running 狀態(我們是 Wails app 本身,不需要重做),
// 直接 emit completed 不重跑
a.startupPipeline.mu.Lock()
a.startupPipeline.startedAt = time.Now()
a.startupPipeline.current = 1
a.startupPipeline.stages[1].status = "completed"
a.startupPipeline.stages[1].startedAt = time.Now()
a.startupPipeline.stages[1].completedAt = time.Now()
a.startupPipeline.mu.Unlock()
a.startupPipeline.emitProgress(1) // emit stage=1 completed
// 啟動 watcher goroutine
watcherCtx, cancel := context.WithCancel(a.ctx)
a.pipelineCancelFn = cancel
a.startupPipeline.watcherDone = make(chan struct{})
go a.startupPipeline.watcher(watcherCtx)
// 切到階段 2 並真的跑
a.startupPipeline.mu.Lock()
a.startupPipeline.current = 2
a.startupPipeline.stages[2].status = "running"
a.startupPipeline.stages[2].startedAt = time.Now()
a.startupPipeline.mu.Unlock()
a.startupPipeline.emitProgress(2)
// Step 6: 呼叫 StartServer內部會依序 Complete(2..6)
// Retry 情境允許 port fallback視同 cold start見 server-lifecycle.md §3.3
return a.ctrl.Start()
}
```
### 8.2 前端整合
Wails 控制台的 Error state 面板顯示:
- 錯誤訊息(來自 `startup:error` event 的 `error` 欄位)
- 失敗階段(`stage` 欄位)
- 「Retry」按鈕 → `RestartStartupSequence().catch(showError)`
- 「Export log」按鈕 → `ExportLog()`(方便使用者回報問題時附 log
- 「Quit」按鈕 → `runtime.Quit(ctx)`
點 Retry 後:
1. 控制台 UI 切回 Starting state隱藏 Error 面板、顯示 Startup Progress 面板)
2. 重跑 `initStartupPanel()` 將 6 個階段重新 render 為 pending階段 1 立即變 completed
3. 訂閱者會陸續收到 `startup:progress` 事件更新 DOM
### 8.3 驗收
| # | 情境 | 預期 |
|---|------|------|
| R1 | 階段 2 失敗 → 點 Retry → server 恢復可啟動 | Retry 後階段 1 顯示 completed、階段 2-6 依序完成 → `startup:ready` |
| R2 | 階段 2 失敗 → 點 Retry → 仍然失敗Python binary 還是壞的)| 再次進 Error state不會無限重試 |
| R3 | 60 s hard timeout → 點 Retry | 整個流程計時歸零,新一輪 60 s 上限 |
| R4 | Retry 時 port 3721 被其他程式佔用 | 允許 fallback 到 3722/3723… |
| R5 | 連點 Retry 兩次 | 第二次在 `pipelineCancelFn != nil` 檢查時安全地 cancel 舊 watcher不會殘留 goroutine |
| R6 | 階段 6 還在 pending 時點 Retry | ForceKill 掉 server → sentinel file 被清 → 從階段 2 重跑 |
---
## 9. 待確認
1. **階段 6 的「WebSocket 首次連線」實作方式(已定版)** — v2.1 二次審閱定案:採用 sentinel file`<dataDir>/.first-ws-connected`)。詳見 §3 階段 6 章節。無需開發時再討論。
2. **進度面板 min-display-time** — 若啟動 < 1 s進度面板閃一下就消失不好看建議設 1 s min-display-time即使 `startup:ready` emit面板也要留 1 s 才淡出)。實作細節可放在 `hideStartupPanel()` 判斷 `Date.now() - startedAt < 1000` 時延遲執行
3. **R5-E5 文案** — Design Spec v2.1 尚未敲定i18n key 已預留。Design Agent 完成後可直接填入 `i18n/*.json`
4. **watcher goroutine 與 `ctrl.Stop()` 的交互** — 若使用者在啟動中間按了 Stop不太可能Starting 狀態下 action bar 禁用pipeline 要能 cancel。目前靠 `stopWatcher()` + `ctrl.setState(Error)` 處理,實測後若有 race 再補強
5. **`ForceKill` method 需新增到 ServerController** — RestartStartupSequence 依賴這個 method`server_control.go` 要加一個非同步版 Stop直接 SIGKILL 不走 7 s grace、不發 crash notification。M8-4b 執行時補上

View File

@ -0,0 +1,679 @@
# v2/web-ui-offline-overlay.md — Web UI Server Offline Overlay
> 所屬TDD v2 §2.6
> 版本v2.12026-04-14 吸收 Minor 4 WebSocket shutdown-imminent 廣播)
> 決策依據R5-2關閉 Wails 視窗 = server 停,瀏覽器顯示 offline overlay、三方共識 #14boot-id + retry、PM Minor 4WebSocket 廣播即時觸發 overlay消除 race condition
> 對應 milestoneM8-7
> 相關文件:`v2/server-lifecycle.md` §8.3Wails 端 WebSocket 廣播時機、§9server 端 boot-id API
---
## 1. 目的
當瀏覽器 tab 偵測到 server 連不上Wails app 被關了、使用者按 Stop、server crash顯示一個全域的覆蓋層告訴使用者「Server 已離線」,並提供重試機制。同時實作 boot-id polling 讓 server 重啟後瀏覽器自動 reload。
---
## 2. UI 設計
```
┌─────────────────────────────────────────────────────┐
│ │
│ ⚠️ │
│ │
│ Server 已離線 │
│ │
│ visionA Local 的本機伺服器沒有回應。 │
│ 可能原因: │
│ • 你關閉了 visionA Local 控制台 │
│ • Server 被手動停止 │
│ • Server 發生錯誤 │
│ │
│ 請重新開啟 visionA Local 應用程式後按「重試」。 │
│ │
│ [重試] [關閉這個頁面] │
│ │
└─────────────────────────────────────────────────────┘
```
- 全螢幕半透明 backdrop`rgba(0,0,0,0.65)`
- 中央卡片(`max-w-md`
- 中英文隨現有 i18n locale
- 不擋 devtools、不擋 keyboard使用者可用 devtools 檢查 network 看是不是真連不上)
---
## 3. 偵測機制
### 3.1 偵測參數定版v2.1 Architect 二次審閱補齊)
**Polling 間隔與失敗門檻(定版)**
| 參數 | 值 | 說明 |
|------|-----|------|
| Health check 間隔(正常模式) | **10 秒** | server 正常時的 boot-id polling 間隔 |
| Health check 間隔active retry 模式)| **3 秒** | overlay 顯示後自動重試的 polling 間隔 |
| 連續失敗門檻 | **2 次**(約 20 秒)| 避免偶發網路抖動誤觸發 |
| fetch 單次 timeout | 3 秒 | `AbortSignal.timeout(3000)` |
**Active retry 模式定義**:使用者第一次看到 overlay 後(或主動點 Retry 按鈕),前端**自動**每 3 秒重打一次 `/api/system/boot-id`,不需要使用者持續按按鈕。只要任一次成功 → 立即 `resetFailures()` → overlay 消失(若 bootId 變了則 `window.location.reload()`)。
**觸發管道**Overlay 顯示由三個獨立管道觸發,任一觸發即顯示(優先序由高到低):
1. **WebSocket close eventv2.1 新增)**:瀏覽器端 WebSocket 收到 `onclose` → **立即**顯示 overlay不等 polling理由close 事件代表 server 端已斷polling 也一定會失敗,不需要等 20 秒
2. **WebSocket `server:shutdown-imminent` 事件Minor 4**:收到廣播訊息 → **立即**顯示 overlay並依 `reason` 欄位決定文案:
- `reason="app-closing"` / `reason="manual-stop"` / `reason="quit"` → 立即顯示
- `reason="restart"` → 延遲 10 秒顯示(給 boot-id 變化 → `window.location.reload()` 的時間)
3. **被動偵測polling fallback**:連續 2 次 boot-id polling 失敗 → 顯示 overlay當 WebSocket 已斷線或從未建立時生效)
WebSocket 管道的優勢:
- **零延遲**Wails 開始 SIGTERM 的瞬間就發廣播(在實際 shutdown 完成前),瀏覽器 tab 幾乎同時看到 overlay
- **消除 race condition**:原 v2.0 設計下,使用者關 Wails 視窗到瀏覽器 tab 看到 overlay 有 0.5-20 s 的 window取決於 polling 時機),這段期間使用者可能對著一個「已失效但看起來正常」的頁面操作
- **回退**:若 WebSocket 已斷線或從未建立(極罕見,例如 Next.js 載入失敗polling fallback 仍有效
### 3.2 Polling 策略v2.1 定版)
```
瀏覽器 tab 載入
initial fetch /api/system/boot-id
成功 → 記錄 initialBootId + startedAt進 normal polling10 s interval
下一次 poll
├─ 成功 → 比對 bootId
│ ├─ 相同 → 無事
│ └─ 不同 → window.location.reload()server 重啟,強制 reload
└─ 失敗 → consecutiveFailures++
├─ < 2 不顯示 overlay避免偶發網路 glitch
└─ ≥ 2連續 ~20 s→ 顯示 <ServerOfflineOverlay>
+ 切 polling interval 為 3 sactive retry 模式)
Active retry 模式下:
每 3 s 自動重打 /api/system/boot-id
├─ 成功bootId 相同)→ resetFailures() + 切回 10 s interval → overlay 消失
└─ 成功bootId 不同)→ window.location.reload()
```
### 3.2a WebSocket `server:shutdown-imminent` 偵測
```
瀏覽器 tab 已連上 WebSocket例如 /ws/server-logs 或新增的 /ws/system
server 收到 Wails 的 ctrl.Stop() 呼叫
server 對 WebSocket hub 廣播:
{
"type": "server:shutdown-imminent",
"reason": "app-closing" | "manual-stop" | "restart",
"ts": 1744656180123
}
瀏覽器收到 message → store.forceShowOverlay()
<ServerOfflineOverlay> 立即顯示(不等 polling 失敗)
後續 SIGTERM、server 真的停止、polling 失敗 → 疊加在 overlay 上不重複觸發
```
**reason 欄位用途v2.1 定版)**
- `app-closing`Wails OnShutdown 觸發,應用即將退出 → 立即顯示 overlay文案「visionA Local 已關閉」
- `manual-stop`:使用者按控制台的 Stop → 立即顯示 overlay文案「Server 已停止,請在控制台按 Start」
- `quit`:使用者從 Wails 選單選 QuitmacOS Cmd+Q 等)→ 立即顯示 overlay文案同 `app-closing`
- `restart`:使用者按控制台的 Restart → **延遲 10 秒**再顯示 overlay給 boot-id 變化 → `window.location.reload()` 的時間);若 10 s 內 server 回來 + boot-id 變 → reload → overlay 不會真的顯示
### 3.3 失敗計數策略polling 管道v2.1 定版)
- **Normal 模式**10 s poll interval連續 2 次失敗(= 覆蓋 ~20 s 容忍期)才觸發 overlay
- **Active retry 模式**overlay 顯示後切 3 s poll interval自動重試至 server 回來
- Wails 關閉視窗的 `StopServer` 過程約 0.5-1 s瀏覽器 polling 通常靠 WebSocket close 事件或 `server:shutdown-imminent` 廣播立即顯示 overlay而非等 polling 失敗
- 使用者正常 Restart透過控制台約 2-3 sboot-id 會變但也不會連續 2 次失敗 → 下一次成功 poll 偵測 boot-id 變化 → force reload
- 網路偶發抖動(< 10 s 單次 glitch 最多 1 failure下一次 poll 成功即清零不觸發 overlay
### 3.3 Page Visibility APIR-v2-6
瀏覽器背景 tab 的 setInterval 會被降頻到 1 次/分鐘會讓「server 重啟後使用者切回 tab」體驗不好。
解決:
```typescript
const BOOT_ID_POLL_MS = 5000;
let intervalHandle: number | null = null;
function startPolling() {
if (intervalHandle !== null) return;
intervalHandle = window.setInterval(pollOnce, BOOT_ID_POLL_MS);
}
function stopPolling() {
if (intervalHandle !== null) {
clearInterval(intervalHandle);
intervalHandle = null;
}
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
pollOnce(); // 立刻 probe 一次
startPolling();
} else {
stopPolling(); // 背景 tab 不 poll回來再說
}
});
// 初始tab 可見時才啟動
if (document.visibilityState === 'visible') {
startPolling();
}
```
---
## 4. 實作
### 4.1 檔案清單
| 檔案 | 狀態 | 說明 |
|------|------|------|
| `frontend/src/stores/system-store.ts` | **新增** | Zustand store`serverOnline / bootId / forcedOffline / lastError` |
| `frontend/src/hooks/use-boot-id-watcher.ts` | **新增** | React hook掛 useEffect 啟動 polling + visibility handling |
| `frontend/src/hooks/use-shutdown-watcher.ts` | **新增**v2.1| React hook訂閱 WebSocket `server:shutdown-imminent` 事件Minor 4|
| `frontend/src/components/server-offline-overlay.tsx` | **新增** | 全域覆蓋層 UIshadcn Dialog / Card |
| `frontend/src/components/boot-id-watcher-mount.tsx` | **新增** | Client wrapper 掛 `useBootIdWatcher()` + `useShutdownWatcher()` |
| `frontend/src/app/layout.tsx` | **修改** | 在 root layout 掛 `<ServerOfflineOverlay />``<BootIdWatcherMount />` |
| `frontend/src/lib/i18n/types.ts` | **修改** | 新增 `serverOffline` 區塊的 i18n key 定義(含 3 種 reason 文案)|
| `frontend/src/lib/i18n/zh-TW.ts` | **修改** | 新增繁體中文字串 |
| `frontend/src/lib/i18n/en.ts` | **修改** | 新增英文字串 |
### 4.2 `frontend/src/stores/system-store.ts`
```typescript
import { create } from 'zustand';
interface SystemState {
serverOnline: boolean;
bootId: string | null;
consecutiveFailures: number;
lastProbeAt: number | null;
// Minor 4由 WebSocket shutdown-imminent 事件強制設定,讓 overlay 立即顯示
forcedOffline: boolean;
forcedOfflineReason: 'app-closing' | 'manual-stop' | 'restart' | 'quit' | null;
setOnline: (bootId: string) => void;
recordFailure: () => void;
shouldShowOverlay: () => boolean;
resetFailures: () => void;
forceOffline: (reason: 'app-closing' | 'manual-stop' | 'restart' | 'quit') => void;
}
const FAILURE_THRESHOLD = 2; // v2.1 定版2 次失敗(約 20 s即觸發
export const useSystemStore = create<SystemState>((set, get) => ({
serverOnline: true,
bootId: null,
consecutiveFailures: 0,
lastProbeAt: null,
forcedOffline: false,
forcedOfflineReason: null,
setOnline: (bootId) => set({
serverOnline: true,
bootId,
consecutiveFailures: 0,
lastProbeAt: Date.now(),
// 注意recover 時不自動清 forcedOffline要走 resetFailures使用者點「重試」
}),
recordFailure: () => set((s) => ({
consecutiveFailures: s.consecutiveFailures + 1,
serverOnline: s.consecutiveFailures + 1 < FAILURE_THRESHOLD,
lastProbeAt: Date.now(),
})),
shouldShowOverlay: () => {
const s = get();
return s.forcedOffline || s.consecutiveFailures >= FAILURE_THRESHOLD;
},
// Active retry 模式overlay 顯示中時,前端自動切 3 s interval 重試
isActiveRetryMode: () => {
const s = get();
return s.forcedOffline || s.consecutiveFailures >= FAILURE_THRESHOLD;
},
resetFailures: () => set({
consecutiveFailures: 0,
serverOnline: true,
forcedOffline: false,
forcedOfflineReason: null,
}),
forceOffline: (reason) => {
// Minor 4restart 情境不立即顯示 overlay給 10 s 等 boot-id 變化 reload
if (reason === 'restart') {
setTimeout(() => {
const s = get();
if (s.consecutiveFailures < FAILURE_THRESHOLD && s.bootId !== null) {
// 10 s 內 server 沒回來才強制顯示
set({ forcedOffline: true, forcedOfflineReason: 'restart' });
}
}, 10_000);
return;
}
set({ forcedOffline: true, forcedOfflineReason: reason });
},
}));
```
### 4.3 `frontend/src/hooks/use-boot-id-watcher.ts`
```typescript
'use client';
import { useEffect } from 'react';
import { useSystemStore } from '@/stores/system-store';
import { getBackendUrl } from '@/lib/api';
const POLL_INTERVAL_NORMAL_MS = 10_000; // v2.1 定版:正常模式 10 s
const POLL_INTERVAL_ACTIVE_RETRY_MS = 3_000; // v2.1 定版overlay 顯示後自動每 3 s 重試
interface BootIdResponse {
success: boolean;
data: {
bootId: string;
startedAt: number;
};
}
async function pollOnce(): Promise<void> {
const store = useSystemStore.getState();
try {
const res = await fetch(`${getBackendUrl()}/api/system/boot-id`, {
cache: 'no-store',
// 短 timeout若 server 假死要快速失敗
signal: AbortSignal.timeout(3000),
});
if (!res.ok) {
store.recordFailure();
return;
}
const json = (await res.json()) as BootIdResponse;
if (!json.success || !json.data?.bootId) {
store.recordFailure();
return;
}
const newBootId = json.data.bootId;
if (store.bootId !== null && store.bootId !== newBootId) {
// Server 重啟force reload
window.location.reload();
return;
}
store.setOnline(newBootId);
} catch {
store.recordFailure();
}
}
export function useBootIdWatcher(): void {
useEffect(() => {
let intervalHandle: number | null = null;
let currentMode: 'normal' | 'active-retry' = 'normal';
const intervalFor = (mode: 'normal' | 'active-retry') =>
mode === 'active-retry' ? POLL_INTERVAL_ACTIVE_RETRY_MS : POLL_INTERVAL_NORMAL_MS;
const start = (mode: 'normal' | 'active-retry') => {
if (intervalHandle !== null && currentMode === mode) return;
if (intervalHandle !== null) clearInterval(intervalHandle);
currentMode = mode;
intervalHandle = window.setInterval(pollOnce, intervalFor(mode));
};
const stop = () => {
if (intervalHandle !== null) {
clearInterval(intervalHandle);
intervalHandle = null;
}
};
// 監聽 store 變化overlay 顯示 ↔ 隱藏時切換 polling 模式
const unsubscribe = useSystemStore.subscribe((state) => {
const shouldActiveRetry = state.forcedOffline || state.consecutiveFailures >= 2;
const targetMode = shouldActiveRetry ? 'active-retry' : 'normal';
if (intervalHandle !== null && targetMode !== currentMode) {
start(targetMode); // 自動切換 interval
}
});
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void pollOnce(); // 立即 probe
const s = useSystemStore.getState();
const mode = s.forcedOffline || s.consecutiveFailures >= 2 ? 'active-retry' : 'normal';
start(mode);
} else {
stop();
}
};
// 初次載入時執行一次 probe + 啟動 normal 輪詢
void pollOnce();
if (document.visibilityState === 'visible') {
start('normal');
}
document.addEventListener('visibilitychange', onVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange);
unsubscribe();
stop();
};
}, []);
}
```
### 4.3a `frontend/src/hooks/use-shutdown-watcher.ts`Minor 4 新增)
訂閱 server 的 WebSocket 廣播,接收 `server:shutdown-imminent` 事件後立即設 `forcedOffline`
```typescript
'use client';
import { useEffect } from 'react';
import { useSystemStore } from '@/stores/system-store';
import { getBackendUrl } from '@/lib/api';
interface ShutdownImminentMessage {
type: 'server:shutdown-imminent';
reason: 'app-closing' | 'manual-stop' | 'restart' | 'quit';
ts: number;
}
export function useShutdownWatcher(): void {
useEffect(() => {
const wsUrl = getBackendUrl().replace(/^http/, 'ws') + '/ws/system';
let ws: WebSocket | null = null;
let reconnectTimer: number | null = null;
const connect = () => {
try {
ws = new WebSocket(wsUrl);
} catch {
scheduleReconnect();
return;
}
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(String(ev.data));
if (msg?.type === 'server:shutdown-imminent') {
const reason = (msg as ShutdownImminentMessage).reason;
useSystemStore.getState().forceOffline(reason);
}
} catch {
// 非 JSON 或格式錯誤,忽略
}
};
ws.onclose = (ev) => {
ws = null;
// v2.1WebSocket close 事件也視為 server 已斷,立即顯示 overlay
// (比等 polling 失敗 20 s 更即時;若 server 正常重啟,成功 poll 後會自動清 overlay
// 只在「曾經連上」的前提下才觸發,避免初次連線就失敗導致誤觸發
if (ev && ev.wasClean === false) {
const reason: 'app-closing' | 'manual-stop' | 'restart' | 'quit' = 'manual-stop';
useSystemStore.getState().forceOffline(reason);
}
scheduleReconnect();
};
ws.onerror = () => {
// 忽略 — 後續 onclose 會處理
};
};
const scheduleReconnect = () => {
if (reconnectTimer !== null) return;
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
if (document.visibilityState === 'visible') {
connect();
}
}, 3000);
};
connect();
return () => {
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (ws) {
ws.onclose = null;
ws.onerror = null;
ws.close();
ws = null;
}
};
}, []);
}
```
**server 端配合server-lifecycle.md §8**`ctrl.Stop()` 開始時透過 server 的 WebSocket hub建議新增 `/ws/system` endpoint 或沿用現有 `/ws/server-logs`)廣播該事件。
**注意**:此 hook 的 WebSocket 是從瀏覽器連到 server與 Wails IPC 無關。Wails 端只需要在 `ctrl.Stop()` 時透過 HTTP / gRPC / 直接從 server 內呼叫 `hub.Broadcast(...)` 即可(具體做法視 server 內部 API 而定)。
### 4.4 `frontend/src/components/server-offline-overlay.tsx`
```tsx
'use client';
import { useSystemStore } from '@/stores/system-store';
import { useTranslation } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { AlertTriangle } from 'lucide-react';
async function retryOnce() {
// 重試 = 立即打一次 boot-id若成功會自動清 failures
try {
const res = await fetch('/api/system/boot-id', { cache: 'no-store' });
if (res.ok) {
const json = await res.json();
if (json.success && json.data?.bootId) {
useSystemStore.getState().setOnline(json.data.bootId);
return;
}
}
useSystemStore.getState().recordFailure();
} catch {
useSystemStore.getState().recordFailure();
}
}
export function ServerOfflineOverlay() {
const show = useSystemStore((s) => s.forcedOffline || s.consecutiveFailures >= 2);
const reason = useSystemStore((s) => s.forcedOfflineReason);
const { t } = useTranslation();
if (!show) return null;
return (
<div
role="alertdialog"
aria-labelledby="server-offline-title"
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/65 backdrop-blur-sm"
>
<div className="w-full max-w-md rounded-lg border bg-background p-6 shadow-2xl">
<div className="flex items-start gap-4">
<AlertTriangle className="h-8 w-8 shrink-0 text-amber-500" />
<div className="flex-1">
<h2 id="server-offline-title" className="mb-2 text-xl font-semibold">
{t('serverOffline.title')}
</h2>
<p className="mb-4 text-sm text-muted-foreground">
{t('serverOffline.body')}
</p>
<ul className="mb-4 list-disc pl-5 text-sm text-muted-foreground">
<li>{t('serverOffline.reasons.appClosed')}</li>
<li>{t('serverOffline.reasons.manuallyStopped')}</li>
<li>{t('serverOffline.reasons.crashed')}</li>
</ul>
<p className="mb-4 text-sm text-muted-foreground">
{t('serverOffline.instruction')}
</p>
<div className="flex gap-2">
<Button onClick={retryOnce}>{t('serverOffline.retry')}</Button>
<Button
variant="outline"
onClick={() => window.close()}
>
{t('serverOffline.closeTab')}
</Button>
</div>
</div>
</div>
</div>
</div>
);
}
```
### 4.5 `frontend/src/app/layout.tsx` 修改
```diff
+import { ServerOfflineOverlay } from '@/components/server-offline-overlay';
+import { BootIdWatcherMount } from '@/components/boot-id-watcher-mount';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html ...>
<body ...>
{/* 既有的 providers / sidebar / theme 等 */}
{children}
+ <BootIdWatcherMount />
+ <ServerOfflineOverlay />
</body>
</html>
);
}
```
`BootIdWatcherMount` 是一個空的 `'use client'` 元件,作用只是掛 `useBootIdWatcher()`hook 不能直接在 layout 的 server component 呼叫):
```tsx
// frontend/src/components/boot-id-watcher-mount.tsx
'use client';
import { useBootIdWatcher } from '@/hooks/use-boot-id-watcher';
import { useShutdownWatcher } from '@/hooks/use-shutdown-watcher';
export function BootIdWatcherMount() {
useBootIdWatcher();
useShutdownWatcher(); // Minor 4WebSocket shutdown-imminent 訂閱
return null;
}
```
### 4.6 i18n 字串
**`frontend/src/lib/i18n/types.ts`** 新增:
```typescript
serverOffline: {
title: string;
body: string;
reasons: {
appClosed: string;
manuallyStopped: string;
crashed: string;
};
instruction: string;
retry: string;
closeTab: string;
};
```
**`frontend/src/lib/i18n/zh-TW.ts`**
```typescript
serverOffline: {
title: 'Server 已離線',
body: 'visionA Local 的本機伺服器沒有回應。可能的原因:',
reasons: {
appClosed: '你關閉了 visionA Local 應用程式',
manuallyStopped: 'Server 被手動停止',
crashed: 'Server 發生錯誤',
},
instruction: '請重新開啟 visionA Local 應用程式後按「重試」。',
retry: '重試',
closeTab: '關閉這個頁面',
},
```
**`frontend/src/lib/i18n/en.ts`**
```typescript
serverOffline: {
title: 'Server Offline',
body: 'The visionA Local server is not responding. Possible reasons:',
reasons: {
appClosed: 'You closed the visionA Local app',
manuallyStopped: 'Server was manually stopped',
crashed: 'Server encountered an error',
},
instruction: 'Please relaunch visionA Local and click "Retry".',
retry: 'Retry',
closeTab: 'Close this tab',
},
```
---
## 5. SSR 相容性
Next.js 的 `static export` 會在 build 階段把頁面 render 成 HTML。以下程式碼都必須處理 SSR`typeof window === 'undefined'` 情境):
- `use-boot-id-watcher.ts` — 標 `'use client'`,只在瀏覽器 runOK
- `server-offline-overlay.tsx` — 標 `'use client'`OK
- `system-store.ts` — Zustand 不需 `'use client'`,但 store 只被 client component 使用OK
- `boot-id-watcher-mount.tsx``'use client'`OK
**驗收**`pnpm build` 在 frontend/ 目錄下要成功,沒有 `ReferenceError: window is not defined` 或其他 SSR 錯誤。
---
## 6. 與 ServerController 事件的關係v2.1 更新)
Wails 控制台的 log panel 會收到 `server:state-change` 事件並更新狀態卡片;**瀏覽器 tab 無法收到 Wails event**(不同 context。瀏覽器的真相來源有三個優先序由高到低
1. **WebSocket `server:shutdown-imminent`最即時v2.1 Minor 4 新增)**Wails 的 `ctrl.Stop()` 在開始 SIGTERM 前透過 server 的 WebSocket hub 廣播,瀏覽器 tab 幾乎同時收到 → 立即顯示 overlay
2. **`GET /api/system/boot-id` polling**
- 成功 + bootId 變 → `window.location.reload()`server 重啟後同 port
- 連續 2 次失敗 → 顯示 overlayfallback當 WebSocket 已斷或未建立)
3. **業務 API 呼叫的 network error**:次要,不當作 overlay 觸發器(避免單一 API 問題誤觸發)
Wails → server → WebSocket → 瀏覽器 的路徑Wails 只與 server 進程通訊(透過 stdin/stdout 或 HTTPserver 再轉發到 WebSocket hub 廣播給所有連線 tab。這是單向 push比 polling 更即時。
---
## 7. 驗收條件
| 檢查 | 操作 | 預期 |
|------|------|------|
| 正常啟動沒 overlay | 開 app → 點 Open in Browser → tab 載入 | overlay 不顯示 |
| 關 Wails → 立即顯示 overlayWebSocket 管道)| 開 app → Open in Browser → 關 Wails 視窗 | < 1 s tab 顯示 overlayWebSocket `shutdown-imminent` `onclose` 觸發|
| 關 Wails → polling fallback | 同上但 WebSocket 未建立mock | 20 s 內 tab 顯示 overlay連續 2 次 10 s poll 失敗)|
| 關 Wails → 瀏覽器 tab 顯示正確訊息 | 同上 | 看到 title「Server 已離線」 |
| Active retry 模式自動重試 | Overlay 顯示後 | 前端自動每 3 s 打 boot-id非使用者手動按|
| Overlay 重試按鈕 | server 關著時點「重試」 | failures +1 或維持視結果overlay 仍在 |
| 重試 → 成功 | 關 app → 等 overlay → 重開 app | Active retry 3 s 內自動 dismiss overlay若 bootId 變則 reload |
| 控制台按 Restart → 自動 reload | server running → 在 Wails 按 Restart → 3 s 後 server 回來 | tab 自動 `window.location.reload()`,載入後正常;期間不顯示 overlay`restart` reason 延遲 10 s|
| 控制台按 Stop → 立即 overlay | Stop | WebSocket 廣播 `manual-stop` → 立即顯示 overlay |
| 背景 tab 不浪費資源 | 切到別的 tab → `chrome://performance-metrics` | tab 不在時 setInterval 停visibility hidden |
| 切回背景 tab → 立即 probe | 切回 tab | 立即打 boot-id不等 10 s interval |
| 網路 glitch < 10 s 不顯示 | 人工用 DevTools Network Offline 8 s Online | overlay 不顯示最多 1 次失敗未達 2 次門檻 |
---
## 8. 待確認
1. **`window.close()` 在非 `window.open()` 開的 tab 不能用** — 使用者從 Wails Open in Browser 進來的 tab 確實是 browser 自己開的,不是 `window.open`Chrome 會拒絕 `window.close()`。備案把「關閉這個頁面」按鈕改為顯示「請手動關閉此分頁」提示。M8-7 執行者實測後決定 UI 文案。
2. **boot-id cache header** — 目前用 `cache: 'no-store'`,但某些 Chrome 版本會忽略。若發現 polling 拿到快取值bootId 不變),改加 `?_=Date.now()` query 防快取。
3. **Overlay z-index 衝突** — 9999 高於 shadcn Dialog 的 50應夠。若未來有其他「更上層」元件要放確認 z-index layering。

View File

@ -0,0 +1,326 @@
# Reviewer 審查 M8-1 + M8-2 合併結果2026-04-15
## 摘要
- **總結論**:✅ **通過**(零 Major / 零 Critical只有若干 Minor 提醒)
- **M8-1砍 yt-dlp**:✅ 完整砍除8 項 deletions.md 條目全達成
- **M8-2砍 Mock**:✅ 完整砍除11 項 deletions.md 條目全達成
- **是否阻擋 M8-4**:❌ 不阻擋。M8-4 可以開動mockMode field 已清、videoIsURL 已清、StartFromURL 已清M8-4 做 server lifecycle 時無衝突)
- **額外觀察**working tree 同時包含 **M8-3ffmpeg LGPL 切換)** 的改動Makefile / README / installer / scripts / build.yml該部分依使用者指示**不在本次 review 範圍**
---
## A. 砍除完整性
### A1. M8-1 yt-dlp 砍除清單
| # | 項目 | 預期 | 實測 | 證據 |
|---|------|------|------|------|
| 1 | `ResolveWithYTDLP` function | 消失 | ✅ | `video_source.go` diff -91..-140 整段 delete |
| 2 | `friendlyYTDLPError` function | 消失 | ✅ | 同上 |
| 3 | `NewVideoSourceFromURL` / `...WithSeek` | 消失 | ✅ | `video_source.go` diff -79..-86 |
| 4 | `VideoSource.isURL` field + `newVideoSource``isURL` 參數 | 消失 | ✅ | `video_source.go` L63/L68 簽名已簡化 |
| 5 | `ytdlpHosts` map | 消失 | ✅ | `camera_handler.go` -341..-373 |
| 6 | `urlKind` enum / `urlYTDLP` / `urlDirect` / `urlBad` | 消失 | ✅ | 同上 |
| 7 | `classifyVideoURL()` function | 消失 | ✅ | 同上 |
| 8 | `StartFromURL` handler | 消失 | ✅ | -408..-497 整段 delete |
| 9 | `CameraHandler.videoIsURL` field | 消失 | ✅ | `camera_handler.go` L33/L303/L536 三處清除 |
| 10 | `handleVideoSeek``videoIsURL` 分支 | 消失 | ✅ | L566 簡化為直接呼叫 `NewVideoSourceWithSeek` |
| 11 | `/media/url` route | 消失 | ✅ | `router.go:83` delete |
| 12 | `deps/checker.go` yt-dlp check | 消失 | ✅ | `checker.go` -30..-32 |
| 13 | `main.go` 註解改 ffmpeg/ffprobe | ✅ | L88 | 註解已改 |
| 14 | frontend `startFromUrl` function | 消失 | ✅ | `camera-store.ts` -167..-196 |
| 15 | frontend `videoMode` / `videoUrl` state | 消失 | ✅ | `source-selector.tsx` -51..-52 |
| 16 | frontend `handleUrlSubmit` function | 消失 | ✅ | `source-selector.tsx` -94..-96 |
| 17 | frontend URL tab UImode toggle + input + help | 消失 | ✅ | `source-selector.tsx` -183..-253 |
| 18 | i18n `pasteUrl` / `urlPlaceholder` / `urlHelpText` / `cannotOpenVideoUrl` | 消失 | ✅ | `types.ts` / `zh-TW.ts` / `en.ts` 三檔同步 |
| 19 | i18n `camera.uploadFile`mode 切換用) | 消失 | ✅ | M8-1 自補:三檔同步刪(本來 deletions.md 沒列,但砍完 mode toggle 就 orphan |
| 20 | Makefile `vendor-ytdlp` / `vendor-ytdlp-windows` / `vendor-ytdlp-linux` | 消失 | ✅ | `Makefile` 全檔無 `yt-dlp` / `ytdlp` / `YTDLP` 字樣 |
| 21 | installer/windows visiona-local.iss yt-dlp.exe 行 | 消失 | ✅ | L76 已 delete |
| 22 | installer/linux build-appimage.sh yt-dlp loop | 消失 | ✅ | L61 `for tool in ffmpeg ffprobe` |
| 23 | scripts/bootstrap-linux.sh `vendor-ytdlp-linux` | 消失 | ✅ | L57 |
| 24 | scripts/bootstrap-windows.ps1 `vendor-ytdlp-windows` | 消失 | ✅ | L200 |
| 25 | visiona-local/app.go `locateBundleBinDir``yt-dlp` 字串 | 改為 `ffprobe` | ✅ | `app.go:871` fileExists("ffprobe") |
| 26 | visiona-local/app.go `startServer` 註解「ffmpeg / yt-dlp」 | 改為 `ffmpeg / ffprobe` | ✅ | L480 |
| 27 | README.md yt-dlp 三處敘述 | 消失 | ✅ | L18, L74, L128, L131, L179 五處清除M8-1 自補) |
| 28 | .github/workflows/build.yml `vendor-ytdlp-*` | 消失 | ✅ | L135/L233 |
**A1 結論**M8-1 砍除 **100% 達成**,且主動自補了 4 個 deletions.md 未明列的位置README 五處、workflow、app.go bundle dir、i18n uploadFile orphan key
### A2. M8-2 Mock 砍除清單
| # | 項目 | 預期 | 實測 | 證據 |
|---|------|------|------|------|
| 1 | `server/internal/driver/mock/mock_driver.go` | 整檔刪 | ✅ | `git status: deleted` |
| 2 | `server/internal/driver/mock/` 目錄 | 刪 | ✅ | `find server/internal/driver/mock` 無 |
| 3 | `server/internal/camera/mock_camera.go` | 整檔刪 | ✅ | `git status: deleted` |
| 4 | `VISIONA_MOCK` env 偵測 | 消失 | ✅ | `grep -r VISIONA_MOCK` 0 match |
| 5 | `config.MockMode` / `MockCamera` / `MockDeviceCount` 欄位 | 消失 | ✅ | `config.go` L19..L26 簡化 |
| 6 | `--mock` / `--mock-camera` / `--mock-devices` flag | 消失 | ✅ | `config.go` L36..L38 已刪 |
| 7 | `device.Manager.mockMode` field | 消失 | ✅ | `device/manager.go` L17 |
| 8 | `NewManager(registry, mockMode, mockCount, scriptPath)` 簽名簡化 | `NewManager(registry, scriptPath)` | ✅ | `device/manager.go` L22 |
| 9 | `device/manager.go` 所有 `if m.mockMode {}` 分支 | 消失 | ✅ | `Start()` / `Rescan()` 已簡化 |
| 10 | `mockdriver` import | 消失 | ✅ | `device/manager.go` import 區已清 |
| 11 | `camera.Manager.mockMode` / `mockCamera` field | 消失 | ✅ | `camera/manager.go` L16..L19 |
| 12 | `camera.NewManager(mockMode)``NewManager()` | 簡化 | ✅ | `camera/manager.go` L23 |
| 13 | `camera.Manager.ListCameras/Open/Close/ReadFrame` mock 分支 | 消失 | ✅ | `camera/manager.go` L29-82 |
| 14 | `main.go` `Mock mode:` log | 消失 | ✅ | `main.go:86` 已改為 `Dev mode: %v, Python mode: %s` |
| 15 | `main.go` `NewManager(registry, cfg.MockMode, ...)` 呼叫 | 簡化 | ✅ | `main.go:141` |
| 16 | `main.go` `camera.NewManager(cfg.MockCamera)` 呼叫 | 簡化 | ✅ | `main.go:146` |
| 17 | `visiona-local/app.go` `mockMode` field | 消失 | ✅ | `app.go:82` 已清除 |
| 18 | `visiona-local/app.go` `NewApp()``VISIONA_MOCK` env | 消失 | ✅ | `app.go:117` 替換為 R5-5a 註解 |
| 19 | `visiona-local/app.go` `startServer` 所有 `a.mockMode` 分支 | 消失 | ✅ | L425 / L435 / L455-465 / L488 皆清除 |
| 20 | frontend `settings/page.tsx` runtimeMode Select + Separator | 消失 | ✅ | `page.tsx` -140..-158 |
| 21 | frontend i18n `runtimeMode` / `...Mock` / `...Real` / `...Hint` | 消失 | ✅ | `types.ts` / `zh-TW.ts` / `en.ts` L270..L277 同步 |
| 22 | frontend i18n `noDevices` 文字去 Mock | 改為 R5-v2-7 empty state | ✅ | 兩語言都改為「請連接 KL520 / KL720 後按掃描」 |
| 23 | Makefile `dev-mock` target | 消失 | ✅ | `Makefile``dev-mock` |
| 24 | README Mock 段落 | 消失 | ✅ | L73 「Mock 模式」bullet 整行刪、L24 零學習成本 bullet 改為「插上 Kneron 裝置 30 秒」 |
| 25 | `manager_test.go` `TestNewManager_MockMode` | 消失 | ✅ | 已刪 |
| 26 | `manager_test.go` `TestManager_ListDevices_Empty` 新增 | 新增 | ✅ | L39..L48R5-5a 紀念註解合法 |
| 27 | `api_e2e_test.go` 整檔刪 | 刪 | ✅ | `git status: deleted` |
| 28 | `camera-controls.tsx` `'mock-cam-0'` fallback | 改為 `disabled={cameras.length === 0}` | ✅ | L22..L28 已改 |
**A2 結論**M8-2 砍除 **100% 達成**,且主動自補了 `api_e2e_test.go` 整檔刪、`manager_test.go` 新增空 list 測試、`camera-controls.tsx` fallback 修正。
---
## B. 誤刪檢查(保留功能 red line
| 保留項目 | 狀態 | 證據 |
|---------|------|------|
| `POST /api/camera/start` | ✅ 保留 | router gin-debug 有 `/api/camera/start` |
| `POST /api/camera/stop` | ✅ 保留 | 同上 |
| `POST /api/media/upload/image` | ✅ 保留 | 同上 |
| `POST /api/media/upload/video`(本機檔案,非 URL | ✅ 保留 | 同上 |
| `POST /api/media/upload/batch-images` | ✅ 保留 | 同上 |
| `GET /api/media/batch-images/:index` | ✅ 保留 | 同上 |
| `POST /api/media/seek`(本機影片 seek | ✅ 保留 | 同上 |
| `GET /api/camera/stream` | ✅ 保留 | 同上 |
| `GET /api/devices`(無硬體回空 list非 mock | ✅ 保留 | smoke test`{"devices":[]}` |
| 模型管理 `/api/models*` | ✅ 保留 | router gin-debug 完整 |
| WebSocket `/ws/devices/:id/inference` | ✅ 保留 | router gin-debug |
| `VideoSource` + `NewVideoSource` + `NewVideoSourceWithSeek` | ✅ 保留 | `video_source.go` L70/L75 |
| `ProbeVideoInfo` | ✅ 保留 | M8-1 未觸碰 |
| `handleVideoSeek` 本地影片分支 | ✅ 保留 | `camera_handler.go:566` |
| `stopActivePipeline` | ✅ 保留 | L532 |
| `ffmpeg` 本身 | ✅ 保留 | deps checker + PATH 注入都還在 |
**B 結論****零誤刪**。所有 R5 要求保留的功能完整存在。
---
## C. 殘留 grep 結果
命令(排除 `.autoflow/` / `.git/` / `vendor/` / `node_modules/`
### C1. yt-dlp 組
```
grep -rn 'yt-dlp\|ytdlp\|YTDLP\|ResolveWithYTDLP\|StartFromURL\|ytdlpHosts\|urlYTDLP\|videoIsURL\|startFromUrl\|pasteUrl\|urlPlaceholder\|urlHelpText'
```
**結果****0 match** ✅
### C2. Mock 組(嚴格:排除合法測試框架用詞)
```
grep -rn 'VISIONA_MOCK\|MockMode\|MockCamera\|NewMockDriver\|NewMockCamera\|mockdriver\|mock_camera\|runtimeModeMock\|runtimeModeReal\|mock-cam-0'
```
**結果****0 match** ✅
### C3. 寬鬆 Mock 組(含一般 `mock` 字樣)
`server/internal/` 下只剩:
- `server/internal/device/manager_test.go:43``// 無硬體時應回傳空 listR5-5a沒插硬體就空白不給 Mock 資料)` ← **合法紀念註解**
`frontend/src/tests/` 下的 `vi.mock*` / `mockResolvedValue` / `clearAllMocks` — **合法 Vitest API**
`visiona-local/` / `server/main.go` / `server/internal/camera/` / `server/internal/driver/` 下 — **0 match**
**C 結論**grep 完全 clean。
---
## D. Build / test 結果
### D1. `cd server && go build ./...`
**PASS**(無輸出)
### D2. `cd server && go vet ./...`
**PASS**(無輸出)
### D3. `cd server && go test ./...`
✅ **PASS**
```
ok visiona-local/server/internal/device (cached)
ok visiona-local/server/internal/model (cached)
```
其餘 package 顯示 `[no test files]`,符合預期(本次沒有新增 unit test
### D4. `cd visiona-local && go build .`
**PASS**(無輸出)
### D5. `cd frontend && pnpm build`
**PASS**`Compiled successfully in 4.2s`TypeScript 通過12 頁 SSG 生成完成。
**D 結論**:五項 build / test 全綠。
---
## E. Smoke test 結果
啟動 `go run . --port 3722`,等 3 秒:
| 測試 | 結果 | 備註 |
|------|------|------|
| Server 啟動成功 | ✅ | 看到 `Server listening on 127.0.0.1:3722` |
| log 無 `Mock mode:` | ✅ | log 只剩 `Dev mode: false, Python mode: auto` |
| log 無 `yt-dlp:` | ✅ | deps check 只有 `ffmpeg``python3` |
| `GET /api/devices` | ✅ 200 | `{"data":{"devices":[]},"success":true}` — 空 list非 mock 資料 |
| `POST /api/media/url` | ✅ 404 | Route 已砍gin 回 404符合預期 |
| router gin-debug 印出 | ✅ | 完全沒有 `StartFromURL`,只有 `UploadVideo / UploadImage / UploadBatchImages / SeekVideo / ListCameras / StartPipeline / StopPipeline / StreamMJPEG` |
| Kneron 偵測 | ✅ | `No Kneron devices detected` — 走真實硬體 path機器上沒插就空 list |
**E 結論**Runtime 行為完全符合 R5-5a / R5-7 預期。
---
## F. Edge case 自補合理性
### F1. M8-1 自補
| 自補項 | 合理性 |
|--------|-------|
| README 清 yt-dlp 五處 + 改「零學習成本」文字 | ✅ 合理deletions.md 未列但必然要改 |
| `.github/workflows/build.yml``vendor-ytdlp-*` 兩處 | ✅ 合理,否則 CI build 會叫不存在的 target |
| `visiona-local/app.go:locateBundleBinDir``fileExists("yt-dlp")``fileExists("ffprobe")` | ✅ 合理。ffprobe 會隨 M8-3 進 bundle bin dir用 ffprobe 當 sentinel 比 ffmpeg 更精準(避免和 system ffmpeg 混淆) |
| 刪 `camera.uploadFile` i18n key | ✅ 合理URL tab 砍後這個 key 變 dead key |
### F2. M8-2 自補
| 自補項 | 合理性 |
|--------|-------|
| `api_e2e_test.go` 整檔刪 | ✅ 合理。該 test 依賴 mock driver + 舊 `NewRouter` 簽名修起來成本高於價值。deletions.md §2.7 本來就留給執行者決定 |
| `camera-controls.tsx``'mock-cam-0'` fallback 改 `disabled={cameras.length === 0}` | ✅ 合理。之前是「沒 camera 還是硬塞 mock-cam-0」現在改為 button disabled + guard `if (firstCam)`,邏輯更嚴謹 |
| `manager_test.go` 新增 `TestManager_ListDevices_Empty` | ✅ 合理。補足了刪除 `TestNewManager_MockMode` 後的測試缺口,明確驗證 R5-5a 的空 list 行為 |
| `visiona-local/app.go` 的註解改寫「M7預設真實硬體模式」→「R5-5a沒插硬體就顯示空白狀態」「M1 預設 mock 模式...」→「R5-5a 之後python 失敗直接擋啟動」) | ✅ 合理,註解與新行為一致 |
**F 結論**:兩個 Agent 的自補都是必要且最小化的,沒有過度也沒有不足。
---
## G. 兩個 milestone 交集檔案的正確性
### G1. `visiona-local/app.go`
- M8-1 改:`locateBundleBinDir``fileExists("yt-dlp")``fileExists("ffprobe")``startServer` 註解「ffmpeg / yt-dlp」→「ffmpeg / ffprobe」
- M8-2 改:`App.mockMode` field 砍、`NewApp()``VISIONA_MOCK` 砍、`startServer` 所有 `a.mockMode` 分支砍、註解改寫
兩者互不干擾merge 正確。`args` 陣列現在無條件帶 `--python-mode` 和(若 `pyBin != ""``--python`,符合 M8-2 deletions.md §3.1 的改法。`ensurePythonRuntime` 錯誤不再被 mockMode 遮蔽,直接 return error符合 R5-5a 精神。
✅ **正確**
### G2. `server/main.go`
- M8-1 改L88 PATH 注入註解「yt-dlp / ffmpeg」→「ffmpeg / ffprobe」
- M8-2 改L86 `logger.Info("Mock mode: ...")``Dev mode: ..., Python mode: ...`L141 `device.NewManager` 簽名簡化L146 `camera.NewManager` 簽名簡化
兩者無衝突merge 正確。
✅ **正確**
### G3. `Makefile`
- M8-1 改:`vendor-ytdlp` / `vendor-ytdlp-windows` / `vendor-ytdlp-linux` 三個 target 整段刪、`.PHONY` 移除、`vendor-sync` / `payload-*` 依賴移除、help 文字更新、`YTDLP_URL_*` 變數刪、`payload-*``cp .../yt-dlp ...` 行刪
- M8-2 改:`.PHONY``dev-mock` 移除、底部 `dev-mock:` target 整段刪
- **M8-3不審**`vendor-ffmpeg` 系列從 GPL 改為 LGPL v3 + 新增 `vendor-ffmpeg-macos-build` target + ffmpeg URL 從 evermeet / johnvansickle / BtbN-gpl 改為 BtbN-lgpl
M8-1 + M8-2 在 Makefile 內的改動乾淨、無殘留。M8-3 內容雖然混入但不與 M8-1 / M8-2 衝突(操作的是不同的 line / variable / target
✅ **M8-1 + M8-2 在 Makefile 的部分正確**
### G4. `README.md`
- M8-1 改L18zero-dep bullet 去 yt-dlp、L74支援 URL / yt-dlp 行去掉、L128 / L131vendor-sync / payload-macos 描述去 yt-dlp、L182授權表格 yt-dlp row 刪)
- M8-2 改L24零學習成本 bullet 去 Mock、L73Mock 模式 bullet 整行刪)
- **M8-3不審**L163 GPL TODO 整行刪、L180 ffmpeg GPL row 改 LGPL
M8-1 + M8-2 在 README 的部分正確且完整。
✅ **正確**
### G5. scripts/installer 類bootstrap-linux / bootstrap-windows / visiona-local.iss / build-appimage.sh / build.yml
- M8-1 改:清除 `vendor-ytdlp-*` / `yt-dlp*.exe` / tool loop 中的 yt-dlp
- **M8-3不審**LGPL 相關的 LICENSE.txt / COPYING.LGPLv3 複製、GPL warning 去除、ffprobe 複製
M8-1 的 yt-dlp 清除動作完整。
✅ **正確**
---
## H. 問題清單
### Major阻擋 M8-4
**無。**
### Minor可先往下走後續 M8-6 / 另開處理)
| # | 檔案 | 問題描述 | 建議處理 |
|---|------|---------|---------|
| 1 | `frontend/src/components/camera/source-selector.tsx:184,189` | `accept=".mp4,.avi,.mov"``t('camera.mp4AviMov')` 仍為三種副檔名 | **不是 M8-1 的 bug**。deletions.md §5.1 的「新增 mpeg/mpg」和 milestone-plan M8-6 任務 2+3 明確把副檔名擴充列為 M8-6 的工作。留給 M8-6 處理即可 |
| 2 | `server/internal/api/handlers/camera_handler.go:~251`(未檢視) | 後端影片 extension whitelist 仍為 `.mp4 / .avi / .mov` | 同上milestone-plan M8-6 任務 4 明確列入 |
| 3 | `server/internal/deps/checker.go` | `CheckAll()` 仍只檢查 `ffmpeg`,沒加 `ffprobe` | 不是 M8-1 範圍。M8-3 切 LGPL ffmpeg 後 ffprobe 變必備,屆時應在 deps checker 加一項 |
| 4 | working tree 混入 M8-3 內容 | Makefile / README / installer / scripts / build.yml / `.gitignore` 都有 M8-3 的改動 | 這是「working tree 目前同時有三個 milestone」的狀態不是 bug。Orchestrator 決定要不要分 commit 即可 |
| 5 | `server/internal/api/api_e2e_test.go` 整檔刪 | 本來有 E2E HTTP 層級的煙霧測試,刪除後該層級失去測試 | 不 block M8-4但 M8-10 前應考慮補回一份不依賴 mock 的 e2e可改為無硬體環境下測 `/api/devices` 回空 list + `/api/models` 回內建模型等純 read-only 路徑) |
### Suggestion可做可不做
| # | 檔案 | 建議 |
|---|------|------|
| 1 | `visiona-local/app.go:671` 的 docblock 「R5-5a 之後python 失敗直接擋啟動(沒有模擬回退)」 | 文字 OK可考慮補一句「若要無 Kneron 硬體環境 debug請直接透過 server binary `go run` 而不透過 Wails 殼」方便開發者 |
| 2 | `server/internal/device/manager_test.go:43` 的 R5-5a 紀念註解 | 可愛的紀錄,保留 |
---
## I. 通過 / 不通過結論
### ✅ **通過**
**理由**
1. M8-1 + M8-2 的 28 + 28 = 56 項砍除清單 **100% 達成**
2. 零誤刪,所有 R5 指定保留功能完整存在
3. 殘留 grep 完全 clean
4. 五項 build / test 全綠
5. Smoke test 驗證 runtime 行為正確(空 device list、/media/url 404、log 無 mock / yt-dlp
6. 兩個 Agent 的自補決定全部合理
7. 三個 merge 交集檔案app.go / main.go / Makefile / README的 M8-1 和 M8-2 改動相容無衝突
### 下一步建議給 Orchestrator
1. **M8-1 + M8-2 可以 commit**(建議兩個獨立 commit或一個合併 commit 標示「M8-1 + M8-2 砍除」)
2. **M8-3 的 working tree 改動需另外處理** — 建議分開 commit / 分開 review
3. **M8-4 可以開動** — 無技術阻擋
4. **M8-6 日後執行時** 記得處理 Minor #1#2(副檔名擴充 + 後端 whitelist + i18n 改名)
5. **M8-3 執行完** 順便處理 Minor #3deps checker 加 ffprobe
---
## 優點
- **M8-1 Agent**:砍得非常乾淨,連 orphan i18n key`uploadFile`)和 bundle bin dir 的 yt-dlp sentinel 都主動處理
- **M8-2 Agent**`TestManager_ListDevices_Empty` 新增測試體現 R5-5a 精神;`camera-controls.tsx` 的 fallback 改為 `disabled={cameras.length === 0}` 是比原本塞 `'mock-cam-0'` 更嚴謹的寫法
- **兩個 milestone 的 merge**:沒有互相踩腳。`visiona-local/app.go` 尤其漂亮,`mockMode` 砍掉後 `args` 陣列變得非常直觀
- **grep 殘留 0 match**:連 `videoIsURL` 這種容易忘記的 dead flag 都有徹底清除
砍完這兩個 milestone 後,整個 codebase 瘦了 **~655 行** 程式碼29 修改 + 3 刪除),直接對應 R5-7「砍掉非核心功能、focus 推論核心」的決策。砍得漂亮。

View File

@ -0,0 +1,271 @@
# Reviewer 審查 M8-3 ffmpeg LGPL 切換2026-04-15
對照文件:
- TDD spec`/Users/jimchen/visionA/local-tool/.autoflow/04-architecture/v2/ffmpeg-lgpl.md`
- Research`/Users/jimchen/visionA/local-tool/.autoflow/04-architecture/ffmpeg-lgpl-research.md`
- BUILD.md`/Users/jimchen/visionA/local-tool/vendor/ffmpeg/macos/BUILD.md`
---
## 摘要
| 項目 | 結論 |
|------|------|
| 總結論 | ✅ 通過1 Minor + 2 Suggestion + 3 待處理事項) |
| LGPL 合規 | ✅ 三平台齊備macOS 自 build decoder-onlyWindows/Linux BtbN n7.1 LGPL |
| Binary 大小減量 | ✅ macOS 77 MB → 11.32 MB含 ffprobe減量約 85% |
| 阻擋 M8-10 | ❌ 不阻擋,所有硬性條件均通過 |
---
## A. Makefile 變更正確性
對照 `Makefile:1-449` ↔ TDD §2.3/§3/§418 項檢查全過:
| # | 驗證項 | 結果 | 位置 |
|---|-------|------|------|
| 1 | `FFMPEG_VERSION=n7.1` + sha256 常數 | ✅ | `Makefile:73-77` |
| 2 | `vendor-ffmpeg` 退化為「驗證存在 + LGPL guard」 | ✅ | `Makefile:112-130` |
| 3 | `vendor-ffmpeg-macos-build` 僅限 Darwin + `nasm`/`yasm` | ✅ | `Makefile:136-140` |
| 4 | 下載 tarball + sha256 驗證 + fail 印實際 sha | ✅ | `Makefile:142-148` |
| 5 | configure 21 個 flag 與 TDD §2.3 逐字對齊 | ✅ | `Makefile:152-178` |
| 6 | `--disable-everything` + decoder 白名單 h264/hevc/mpeg1/2video/mpeg4/mjpeg/prores/vp8/vp9/aac/mp2/mp3/pcm_* | ✅ | `Makefile:167` |
| 7 | demuxer 白名單 mov,avi,mpegps,mpegts,matroska,image2 | ✅ | `Makefile:166` |
| 8 | strip + ad-hoc codesign | ✅ | `Makefile:184-189` |
| 9 | build 後 LGPL grep guard | ✅ | `Makefile:190-193` |
| 10 | 複製 COPYING.LGPLv3 | ✅ | `Makefile:194-195` |
| 11 | `FFMPEG_URL_WINDOWS` → BtbN n7.1 LGPL zip | ✅ | `Makefile:277` |
| 12 | `FFMPEG_URL_LINUX` → BtbN n7.1 LGPL tar.xz | ✅ | `Makefile:387` |
| 13 | `vendor-ffmpeg-windows` 解壓 ffmpeg.exe + ffprobe.exe + LICENSE + COPYING.LGPLv3 | ✅ | `Makefile:319-348` |
| 14 | `vendor-ffmpeg-linux` 解壓 ffmpeg + ffprobe + LICENSE`strip-components=1` | ✅ | `Makefile:417-434` |
| 15 | `payload-macos` 複製 ffmpeg + ffprobe + ffmpeg-COPYING.LGPLv3 | ✅ | `Makefile:244-247` |
| 16 | `payload-windows` 複製 ffprobe.exe + LICENSE/COPYINGskipifsourcedoesntexist | ✅ | `Makefile:358-362` |
| 17 | `payload-linux` 複製 ffprobe + ffmpeg-LICENSE.txt | ✅ | `Makefile:444-446` |
| 18 | `payload-macos:241``rm -rf payload/darwin` 清舊 | ✅ | `Makefile:241` |
**五 decoder + 五 demuxer 白名單對照 TDD 驗收條件 §10**h264/hevc/mpeg2video/mpeg4/aacmov-mp4/avi/mpeg/mpegts/matroska全數涵蓋。
---
## B. Binary 正確性(親自驗證)
```
$ ls -la vendor/ffmpeg/macos/
-rw-r--r-- 10500 BUILD.md
-rw-r--r-- 7651 COPYING.LGPLv3
-rwxr-xr-x 6007520 ffmpeg (5.73 MB)
-rwxr-xr-x 5865568 ffprobe (5.59 MB)
```
**sha256 與 BUILD.md `lines 26-30` 100% 吻合**
```
c3cb9f1dad66730267c12fca92c6344d2f8939ab227889caac33005f8947992c ffmpeg
bd388fb4372ed5f7e44ee331a51be6383d702fb2c067bf562cabbdfbdd8b0c5e ffprobe
da7eabb7bafdf7d3ae5e9f223aa5bdc1eece45ac569dc21b3b037520b4464768 COPYING.LGPLv3
```
**`ffmpeg -version` configuration**
- ✅ 無 `--enable-gpl`、無 `libx264`、無 `libx265`
- ✅ 有 `--enable-version3``--disable-network``--disable-autodetect``--enable-static``--disable-everything`
**Decoder 驗證**`ffmpeg -decoders | grep -E ' (h264|hevc|aac|mpeg2video|mpeg4) '` 五個全中。
**Demuxer 驗證**`ffmpeg -demuxers` 顯示 `avi` / `matroska,webm` / `mov,mp4,m4a,3gp,3g2,mj2` / `mpeg` / `mpegts` 五個全中。
**`otool -L` 動態依賴**
```
/usr/lib/libSystem.B.dylib
/System/Library/Frameworks/CoreFoundation.framework/.../CoreFoundation
/System/Library/Frameworks/CoreVideo.framework/.../CoreVideo
/System/Library/Frameworks/CoreMedia.framework/.../CoreMedia
```
ffprobe 相同四項。**無任何第三方 dylib**x264/x265/vpx/opus/aom 都不在),符合 LGPL static self-contained。
**`ffprobe -version`**:可執行,顯示同一 configuration。
**`codesign -v`**ffmpeg + ffprobe 皆 exit 0ad-hoc signing 通過)。
---
## C. .gitignore 雙層
**Inner `local-tool/.gitignore:6-15`**
```
/vendor/**
!/vendor/.gitkeep
!/vendor/README.md
!/vendor/ffmpeg/
!/vendor/ffmpeg/macos/
!/vendor/ffmpeg/macos/**
```
**Outer `visionA/.gitignore:20-27`**:同樣四行 un-ignore + `local-tool/vendor/**` 為基底。
`git check-ignore` 驗證:
| 路徑 | inner repo | outer repo |
|------|-----------|-----------|
| `vendor/ffmpeg/macos/ffmpeg` | exit 1未 ignore ✅) | exit 1 ✅ |
| `vendor/ffmpeg/macos/BUILD.md` | exit 1 ✅ | exit 1 ✅ |
| `vendor/ffmpeg/windows/ffmpeg.exe` | exit 0ignored ✅) | exit 0 ✅ |
| `vendor/ffmpeg/linux/ffmpeg` | exit 0 ✅ | exit 0 ✅ |
`git status --ignored vendor/` 顯示:
- `?? vendor/ffmpeg/macos/`untracked`git add`
- `!! vendor/ffmpeg/{linux,windows}/``!! vendor/{python,wheels,yt-dlp}/`ignored
雙層規則正確outer repo `local-tool/vendor/` 仍為整個 untracked需在交付前 `git add` — 屬待處理事項而非實作錯誤。
---
## D. BUILD.md 可重現性
BUILD.md295 行)完整度:
| 欄位 | 有無 |
|------|------|
| ffmpeg release n7.1 + source URL + sha256 | ✅ |
| Build hostmacOS 14.7.6Apple clang 16.0.0 | ✅ |
| Toolchainnasm 3.01、brew 5.1.6、Xcode CLT | ✅ |
| Configure flags 完整複製貼上 | ✅ |
| Binary sha256三項 | ✅ |
| Binary 大小bytes + 人類可讀) | ✅ |
| Build 耗時2 分 44 秒user 559s/sys 56s | ✅ |
| 實測驗證輸出ffmpeg -version / decoder / demuxer / otool / codesign 節錄) | ✅ |
| Rebuild 指令 `make vendor-ffmpeg-macos-build` | ✅ |
| Commit 清單(僅允許 ffmpeg/ffprobe/COPYING/BUILD.md 四檔) | ✅ |
**兩年後重現評估**可以。source tarball URL + sha256 固定、configure flags 可直貼、系統依賴明列、Makefile 本身就是 reproducibility script。唯一風險是 Homebrew nasm/clang 版本漂移,但 ffmpeg 7.1 對 toolchain 包容度高,不預期 break。
---
## E. GPL flag 清除 grep
全 repo grep `VISIONA_ALLOW_GPL_FFMPEG` / `--enable-gpl` / `libx264` / `libx265`
| 類別 | 殘存位置 | 評估 |
|------|---------|------|
| `.autoflow/*`progress/TDD/research/spec | 允許(歷史紀錄) | ✅ |
| `Makefile:126,190-193` | guard 驗證邏輯 | ✅ |
| `vendor/ffmpeg/macos/BUILD.md` | 驗證輸出說明 | ✅ |
| `build/ffmpeg-macos/src/*` | upstream source tarball 解壓產物 | ✅(非專案碼) |
| `scripts/bootstrap-{windows,linux}.*` | 已改為 `LGPL v3 build` 標語 | ✅ |
| `.github/workflows/build.yml` | 零殘留 | ✅ |
| `installer/**` | 零殘留 | ✅ |
| `server/**.go` + `visiona-local/**.go` | 零殘留 | ✅ |
**GPL flag 完全清除**,只剩文件類歷史 + guard 邏輯。
---
## F. Installer / Bootstrap / CI
- **Windows Inno Setup** `installer/windows/visiona-local.iss:74-79`:含 ffmpeg.exe + ffprobe.exe + ffmpeg-LICENSE.txt + ffmpeg-COPYING.LGPLv3後兩者用 `skipifsourcedoesntexist` fallback
- **Linux AppImage** `installer/linux/build-appimage.sh:61-76``for tool in ffmpeg ffprobe` 迴圈 + 把 ffmpeg-LICENSE.txt 放到 `AppDir/usr/share/doc/visiona-local/`
- **macOS**:無獨立 installerpayload-macos 直接把 COPYING.LGPLv3 複製到 `payload/darwin/bin/ffmpeg-COPYING.LGPLv3`,經 stage-macos 進 `.app/Contents/Resources/bin/`
- **bootstrap-windows.ps1:71 / bootstrap-linux.sh:58**:只剩 LGPL 提示語,無 GPL flag ✅
- **`.github/workflows/build.yml`**:只保留 `vendor-ffmpeg-windows`line 135`vendor-ffmpeg-linux`line 232呼叫無 env var 設定 ✅
---
## G. Payload 流程相容性 + 真實 decode 測試
### G.1 server runtime 相容性
`server/main.go:88-93``VISIONA_BUNDLE_BIN_DIR` prepend 到 PATH`server/internal/deps/checker.go:23-94``resolveTool` 優先在 `$VISIONA_BUNDLE_BIN_DIR/<name>` 找;`visiona-local/app.go:482-484` Wails shell 啟動 server 時注入 `VISIONA_BUNDLE_BIN_DIR = locateBundleBinDir()`macOS→`Contents/Resources/bin`Windows/Linux→`<exe>/bin`,開發→`cwd/payload/<os>/bin`)。
payload-macos `Makefile:244-245``ffmpeg` + `ffprobe` 放到 `payload/darwin/bin/`stage-macos 會完整複製到 bundle Resources。`server/internal/camera/video_source.go:24``exec.Command("ffprobe", ...)` 依賴 basename lookup加入 ffprobe 後完全相容。✅
### G.2 實際 decode 測試
先用系統 ffmpeg 產生 h264 mp4`/usr/local/bin/ffmpeg -f lavfi -i "testsrc=duration=1:size=320x240:rate=10" -c:v libx264 ... /tmp/testsrc.mp4`
LGPL ffmpeg 解碼 → mjpeg
```
$ ./vendor/ffmpeg/macos/ffmpeg -i /tmp/testsrc.mp4 \
-frames:v 1 -f image2pipe -vcodec mjpeg -q:v 5 /tmp/test_out.jpg
Stream #0:0(und): Video: mjpeg, yuv444p, 320x240, 10 fps ...
frame= 1 fps=0.0 q=5.0 Lsize= 9KiB
$ file /tmp/test_out.jpg
/tmp/test_out.jpg: JPEG image data, JFIF standard 1.02, ..., 320x240
```
LGPL ffprobe probe h264 stream
```
$ ./vendor/ffmpeg/macos/ffprobe -v error -show_streams /tmp/testsrc.mp4
codec_name=h264 codec_type=video width=320 height=240 pix_fmt=yuv444p
```
**真實 decode 路徑通過**server extract first-frame 流程可直接使用。
### G.3 `payload/darwin/bin/` 當前為舊 GPL 77 MB binary
```
$ shasum -a 256 payload/darwin/bin/ffmpeg
b68f795f7fb4528daf697f57a2b6780846a1ae762a71907e994442ad103ee88f
```
**非 M8-3 實作錯誤**payload-macos 尚未在 M8-3 後重跑。`Makefile:241``rm -rf payload/darwin``stage-macos:265``rm -rf visiona-local/build/darwin/Resources` 會自動清乾淨。列入待處理。
---
## H. Size 比較
| 項目 | 舊 GPL | 新 LGPL | 差異 |
|------|-------|--------|------|
| macOS ffmpeg | ~77 MBprogress.md M6-1 紀錄) | **5.73 MB** | 71.3 MB92% |
| macOS ffprobe | 未附 | **5.59 MB** | 新增 |
| macOS 合計 | ~77 MB | **11.32 MB** | **65.7 MB85%** |
macOS 成果超越 TDD §2.1「~20 MB」目標`--disable-everything` + 白名單策略效益顯著。Windows/Linux BtbN LGPL build 大小待 M8-10 CI runner 下載後驗證。
---
## I. 問題清單
### Critical / Major
無。
### Minor
| # | 位置 | 問題 | 建議 |
|---|------|------|------|
| 1 | `vendor/ffmpeg/macos/BUILD.md:186-194` §Verification §5 | 預期 `spctl --assess --verbose` 為「accepted」不準確實測 ad-hoc signed binary 在 macOS 14.7 下被 `rejected`exit 3。Gatekeeper 由 outer `.app` bundle signing 決定,不對內嵌 binary 單獨 assess | 把該步驟改為 `codesign -v`exit 0 即可並註記「Gatekeeper 最終由 outer `.app` bundle signing 決定」 |
### Suggestion
| # | 位置 | 建議 |
|---|------|------|
| 1 | `Makefile:126-130` `vendor-ffmpeg` target | 可多驗 `shasum -a 256` 對照 BUILD.md 記錄,防 binary 被意外覆蓋 / 損毀非必要git 已保護) |
| 2 | `Makefile:361-362` payload-windows | 兩份授權檔都用 `skipifsourcedoesntexist`,若 BtbN zip 內 LICENSE.txt 與 COPYING.LGPLv3 同時缺失installer 會無授權檔交付。建議補「至少其中一個存在」assertion |
### 待處理(非 M8-3 scope但影響 release
1. **outer repo `git add`**`/Users/jimchen/visionA/``git status` 仍顯示 `local-tool/vendor/` 為整個 untracked。交付前須 `git add local-tool/vendor/ffmpeg/macos/{ffmpeg,ffprobe,COPYING.LGPLv3,BUILD.md}` 並 commit。
2. **`payload/darwin/bin/ffmpeg` 仍為舊 GPL 77 MB binary**:下次 `make payload-macos` 會自動清。M8-10 前應重跑。
3. **`vendor/yt-dlp/` 目錄仍在**:屬 M8-2 清理範圍,非 M8-3。
---
## J. 結論
**M8-3 ffmpeg LGPL 切換:✅ 通過。**
核心成果確認:
- Makefile 三平台變更逐字對齊 TDD §2-§4
- macOS binary 親測 LGPL 合規(無 gpl/x264/x265有 version3、五 decoder + 五 demuxer 全中、只依賴 macOS 系統 framework
- sha256 與 BUILD.md 100% 吻合
- 實測 h264 mp4 decode → mjpeg JPEG 成功ffprobe probe 成功
- 雙層 `.gitignore` inner + outer 規則均正確
- BUILD.md 完整可重現(兩年後可 rebuild
- GPL flag 在程式碼 / installer / bootstrap / CI 完全清除
- Installer Windows/Linux 含 ffprobe + 授權檔
- Size 減量 85%
**僅 1 MinorBUILD.md 的 spctl 預期描述錯誤)+ 2 Suggestion + 3 待處理事項,不阻擋 M8-10。**
Orchestrator 建議:
1. Minor #1BUILD.md spctl 描述修正)列為 M8-3 尾巴,由 Architect/DevOps 補
2. M8-10 CI 交付前執行 outer repo `git add` + commit
3. M8-10 時確保 `make payload-macos` + `stage-macos` 重跑清掉舊 GPL payload

View File

@ -0,0 +1,360 @@
# Reviewer 審查 M8-4 ServerController + Log Ring Buffer + Preferences + Notify + Boot-ID2026-04-15
## 摘要
- **總結論**:⚠️ 需小改(核心架構正確,有 2 個 Major 阻擋後續 milestone
- **阻擋情況**
- **阻擋 M8-5/M8-7/M8-9**watchServerV2 在 Stop 後仍存活會誤觸 Error state誤發崩潰通知→ 直接破壞 M8-5 控制台 UXserver 端 `server:shutdown-imminent` WebSocket 廣播未實作會直接讓 M8-9 的 race 風險回來
- **不阻擋 M8-4b**M8-4b 是擴充 ServerController 加 pipeline hook本層 state machine 介面已就緒,可平行進行
- 親跑驗證build / vet / test / **race detector 全綠**smoke test boot-id + SkipPaths 完全符合預期
---
## A. Log Ring Buffer`log_buffer.go`
| 檢查項 | 結論 | 備註 |
|--------|------|------|
| 容量 2000 行 | ✅ | `logBufferCap = 2000`L26 |
| Thread-safe | ✅ | Append/Snapshot/Reset/Size/Dropped 全 mutex |
| Wrap-around | ✅ | head 環形遞增、size 飽和、dropped 計數正確test 驗證 line-100..2099 |
| Snapshot 回 copy | ✅ | `out := make([]LogLine, 0, n)` + appendL96-105 |
| Rate limit 200/sec burst | ⚠️ Minor | 演算法本身正確(固定視窗 CAS但**呼叫單位有偏差**(見 D 段) |
| `parseLogLevel` | ✅ | bracket / 冒號 / Gin 三種格式齊備;空字串、非 ASCII 不 panic |
| 5 個 unit test | ✅ | TestLogBuffer_*、TestParseLogLevel 全綠 |
**Minor 1A.1**`Snapshot``i := 0` 走到 `b.size`,前 `skip` 行只是 continue可以直接從 `(start + skip) % logBufferCap` 開始走 `n` 步,效率較好。當 size = 2000、n = 100 時白跑 1900 圈。**非阻擋**,效能影響小於 0.1 ms。
**Minor 2A.2**`ShouldEmit` 內 CAS 與 `Store(0)` 的微 race — A 視窗內 CAS 成功還沒 Store(0) 之前B 已先 `Add(1)`,可能讓新視窗第一行被舊 count 計算。實際只會多放 ≤ 1 行 emit**不影響功能正確性**,可不修。
---
## B. Preferences`preferences.go`
| 檢查項 | 結論 | 備註 |
|--------|------|------|
| `DefaultPreferences()` 平台分支 | ✅ | `runtime.GOOS != "linux"` → trueL42 |
| Atomic write-rename | ✅ | tmp → `os.Rename` → cleanupL78-95 |
| 讀取失敗 fallback | ✅ | 缺檔 / JSON 損毀 → 回 default + warningL57-71 |
| JSON 欄位齊全 | ✅ | autoOpenBrowser / locale / logRingSize |
| 5 個 unit test | ✅ | 平台預設 / missing file / corrupt JSON / roundtrip / 殘留 tmp |
**極佳**`SavePreferences` 失敗時 `os.Remove(tmpPath)` 把垃圾 tmp 清掉L92符合 atomic 持久化最佳實踐。
**Minor 3B.1**`SavePreferences` 路徑在跨檔案系統 rename 時會失敗(罕見邊界,例如 dataDir 是 NFS / overlayfs。建議將來加 `io.Copy + os.Remove(tmp)` fallback但本 milestone 不必處理。
---
## C. ServerController State Machine`server_control.go`
### C.1 雙鎖機制
| 檢查項 | 結論 | 備註 |
|--------|------|------|
| State enum 完整 | ✅ | Idle / Starting / Running / Stopping / Stopped / ErrorL43-50 |
| txMu 序列化 transition | ✅ | Start/Stop/ForceKill 都 `c.txMu.Lock(); defer c.txMu.Unlock()` |
| mu 保護欄位讀寫 | ✅ | state / proc / startedAt / lastError 全走 mu |
| setState emit Wails event | ✅ | L120 `EventsEmit("server:state-change", status)` |
| 鎖順序避免 deadlock | ✅ | a.mu → c.mu 全部單向 |
| race detector | ✅ | `go test -race ./...` 全綠 |
### C.2 Start / Stop / Restart / ForceKill 行為
| 行為 | 結論 | 備註 |
|------|------|------|
| Start 允許 cold start fallback | ✅ | `startInternal(0)``pickPort(defaultPreferredPort)` |
| Restart 強制保留 port | ✅ | L233-244 讀 oldPort → Stop → StartWithPort(oldPort) |
| StartWithPort 對佔 port 不 fallback | ✅ | L408-411 直接 return error |
| `killStaleServerOnPort` 嘗試清乾淨 | ✅ | L405-408500 ms wait |
| ForceKill 不發 crash 通知 | ✅ | 直接呼叫 `proc.forceKill()`,不走 handleWatchFailure |
| Shutdown 7 + 1 秒 modal | ✅ | `shutdownGraceV2 = 7s`、modalTimer = 1sL319-339 |
| Windows 沒 SIGTERM 直接 Kill | ✅ | L307 `runtime.GOOS == "windows"` 分支 |
| watchServerV2 連 3 次失敗進 Error | ✅ | L644-649 `handleWatchFailure` |
| 不 os.Exit | ✅ | watchServerV2 完全沒呼叫 `reportFatal` / `os.Exit` |
### C.3 Major 與 Minor 問題(針對 ServerController
> **Major 1C.MAJ-1****`Stop()` / `ForceKill()` 不會 cancel `watchCancel`,導致 watchServerV2 在 server 死後仍存活30 秒3 × 10s後**會把 state 從 `Stopped` 翻成 `Error`,並發 OS 崩潰通知**。
>
> **檔案/行**`server_control.go:198-229`Stop`server_control.go:251-265`ForceKill`server_control.go:617-652`watchServerV2
>
> **重現**
> 1. Start → Running
> 2. Stop → state = Stopped、proc = nil
> 3. watchServerV2 仍持有舊 `sp` 與舊 port下一個 ticker 到 → health check fail → failures++
> 4. 30 秒後 failures = 3 → `handleWatchFailure``setState(Error)` + `EventsEmit("server:error")` + `sendCrashNotification("Server 崩潰")`
>
> **後果**:使用者按 Stop 後 30 秒看到「visionA Local — Server 崩潰」OS 通知M8-5 控制台 UI 會把 Stopped 翻紅變 Error。**這是嚴重 UX bug**。
>
> **修法**`Stop()``ForceKill()` 在持有 txMu 期間先 cancel `a.watchCancel`
> ```go
> a.mu.Lock()
> if a.watchCancel != nil { a.watchCancel(); a.watchCancel = nil }
> a.mu.Unlock()
> ```
> 一行集中到 helper 函式更乾淨。
> **Major 2C.MAJ-2****`handleWatchFailure` 不持 `txMu`,會與正在進行的 Stop / ForceKill / Restart race**。
>
> **檔案/行**`server_control.go:269-291`
>
> **重現**
> 1. watchServerV2 連續失敗 2 次failures = 2
> 2. 使用者按 Stop → ctrl.Stop() 取得 txMu → setState(Stopping) → proc.stopGraceful() → setState(Stopped)
> 3. 同時 watchServerV2 第 3 次失敗 → handleWatchFailure → setState(Error)
> 4. **競態結果**state 可能是 Error覆蓋 Stopped即使使用者剛剛主動 Stop
>
> **修法**`handleWatchFailure` 進場先取 txMu並在 setState 前重新檢查 state已是 Stopped/Stopping/Idle 就 return。或更穩watchServerV2 在 ticker 失敗時也檢查 `c.State() != ServerStateRunning` 直接退出 goroutine。
**Minor 4C.MIN-1**`Restart()` 拆成獨立的 `Stop()``StartWithPort()` 兩段 txMu 持有,**中間有窗口期**讓另一個 binding 呼叫插隊。實務上前端按鈕在 Stopping 時 disable 應該擋住,但仍是潛在 race。建議 `Restart()` 自己持 txMu 整段(或加 `restartInternal` 不再呼叫 Stop / StartWithPort而是內部 inline 邏輯)。
**Minor 5C.MIN-2**`stopGraceful` 走完 `done` 路徑後立刻 `closeLogFiles()`,但 logPump goroutine 還在處理 lineCh 中 buffered 的最後幾行(會寫 fileWriter。pipe EOF 與 stopGraceful 的時序可能有極窄的 racerace detector 沒抓到是因為 unit test 沒實際 spawn process。**建議**stopGraceful 等待 logPump 結束(加 done channel再 close file或讓 logPump 持有 file ownership 自己 defer close。
---
## D. Log Pump goroutine
| 檢查項 | 結論 | 備註 |
|--------|------|------|
| 每個 pipe 一個 goroutine | ✅ | `go a.logPump(stdoutPipe, ...)` × 2L491-492 |
| 10 ms micro-batch | ✅ | `time.NewTicker(10 * time.Millisecond)` |
| Rate limit fallback超量只寫檔不 emit | ⚠️ 偏差 | 見下方 D.MIN-1 |
| pipe EOF 後 goroutine 自行退出 | ✅ | scanner.Scan() 結束 → close(scanDone) → main loop 收到 → flush + return |
| lineCh 沒被 close | ⚠️ Minor | 由 GC 處理scanDone 訊號替代 |
> **D.MIN-1rate limit 計數單位)**TDD §4.5 + Reviewer prompt 描述「1 秒 200 行」,但 logPump 是**每個 batch 呼叫一次 `ShouldEmit()`**`server_control.go:572`),不是每行。因為 batch 大小 1~數十不等,實際 burst 容許行數可能達 200 × 平均 batch size。
>
> 不算 bug限流仍能擋住災難級瀑布但**單元測試 `TestLogBuffer_RateLimit`line 108-127的假設與真實使用方式不符** — test 模擬「每行一次 ShouldEmit」實際 logPump 是「每 flush 一次 ShouldEmit」。
>
> **建議**:要嘛把 `ShouldEmit(n int)` 改成接行數參數一次扣 n要嘛在 logPump flush 時 `for i := 0; i < len(batch); i++ { if !ShouldEmit() { drop } }`。**不阻擋本 milestone**。
> **D.MAJ-1**(雖然影響不大,但屬於資料丟失 → 標 Major**logPump 在 server 子程序剛 exit 時可能丟掉最後幾行 stderr崩潰原因**。
>
> **檔案/行**`server_control.go:579-608`
>
> **原因**select 同時看 lineCh 與 scanDone。當 scanner goroutine 結束 → close(scanDone)**lineCh buffer 內可能仍有最多 128 行未處理**。Go select 在多個 ready case 隨機選擇,可能直接走 `<-scanDone` flush 後 return把 lineCh 中剩餘的行**全數丟棄**,包括寫檔與 ring buffer。
>
> **後果**server crash 時最後幾行 stderr最有價值的 stack trace有機率丟。
>
> **修法**scanner goroutine 結束時 close(lineCh) 而不是 close(scanDone)main loop 用 `case line, ok := <-lineCh: if !ok { return }` 作為唯一退出條件。或更簡單scanDone 收到後**先 drain lineCh** 再 return
> ```go
> case <-scanDone:
> for {
> select {
> case line := <-lineCh:
> // 處理寫檔 + ring buffer + batch
> default:
> flush(); return
> }
> }
> ```
**Minor 6D.MIN-2**scanner goroutine 內 `select { case lineCh <- text: default: lineCh <- text }` 寫法奇怪 — default 之後又 blocking send 同一個值,等同沒 default。建議直接 `lineCh <- text` 一行。
---
## E. OnBeforeClose handler`main.go`
| 檢查項 | 結論 | 備註 |
|--------|------|------|
| 呼叫關閉時觸發 graceful shutdown | ✅ | return false → Wails OnShutdown → app.shutdown → ctrl.Stop |
| return false 允許關閉 | ✅ | L34 |
| 不彈 confirm 對話框 | ✅ | 無對話框邏輯,符合 R5-2 |
**潛在 UX 風險(非 bug**Wails 視窗一旦開始 close前端 JS 是否還能渲染 `shutdown:modal-show` 是不確定的(不同 OS 行為不同。M8-5 控制台 UI 改寫時應實機驗證;現階段不阻擋。
---
## F. Notify 三平台
| 檔案 | build tag | 機制 | 結論 |
|------|----------|------|------|
| `notify_darwin.go` | `//go:build darwin` ✅ | osascript display notification + escape | ✅ |
| `notify_linux.go` | `//go:build linux` ✅ | notify-send -u critical -i dialog-error | ✅ |
| `notify_windows.go` | `//go:build windows` ✅ | PowerShell BurntToast + msg * fallback | ✅ |
| 檢查項 | 結論 | 備註 |
|--------|------|------|
| 三平台 build tag 齊全 | ✅ | 沒重複定義 / 沒缺失 |
| AppleScript escape | ✅ | 反斜線 + 雙引號 |
| PowerShell escape | ✅ | 單引號 → '' |
| fire-and-forget 呼叫 | ✅ | 呼叫端用 `go sendCrashNotification(...)` |
| 失敗只 log 不 fail | ✅ | 三平台都 fmt.Fprintf stderr |
**Minor 7F.MIN-1**:三平台都用 `cmd.Run()` 同步等子程序結束。雖然呼叫端用 `go` wrapper但 goroutine 內若 osascript / powershell hang 住會 leak極罕見。**建議**:包一個 `context.WithTimeout(5*time.Second)` + `exec.CommandContext`,過期 Kill。本 milestone 可不修。
**Minor 8F.MIN-2**notify_linux.go 假設 `notify-send` 一定在 PATH 上。雖然 prompt 提到「若不存在 log warning 不 fail」實作確實不會 crash**但** `exec.Command.Run()` 在 binary not found 時回的是 `*exec.Error`,輸出的 warning 對使用者不夠友善(純 path not found。可接受不修。
---
## G. Boot-ID + SkipPaths
| 檢查項 | 結論 | 備註 |
|--------|------|------|
| `crypto/rand.Read` + `hex.EncodeToString` | ✅ | `system_handler.go:27-32` |
| 32 字元 hex | ✅ | smoke test 實測 `bd94ae7f976a6e526bd9840fc385b92d` |
| process-scoped每次啟動新 ID | ✅ | `NewSystemHandler` 內呼叫 `newBootID()` |
| `GET /api/system/boot-id` 註冊 | ✅ | `router.go:59` |
| Response shape | ✅ | `{success, data: {bootId, startedAt}}` |
| `broadcasterLoggerSkipPaths` 含 boot-id + health | ✅ | `router.go:120-123` |
| skip 邏輯只比對 path | ✅ | `router.go:140-142` |
| **Smoke test兩個 endpoint 不寫 access log** | ✅ | 實測 stdout 只有 `[GIN] 200 ... GET /api/devices`**沒有** boot-id 或 health 的 GIN log |
> **Minor 9G.MIN-1**boot-id 產生位置偏離 TDD §9.1。TDD 寫的是「`server/main.go` 在啟動時產生 boot-id**傳入** SystemHandler」實作把 `newBootID()` 放在 `NewSystemHandler` 內部呼叫(`system_handler.go:42`)。功能完全等價,但讓單元測試難以注入固定 boot-id 做 deterministic test。**Minor 偏離規格**,可接受。
> **Major 3G.MAJ-1****TDD §8.1 + milestone-plan 要求 `server/main.go``shutdownFn` timeout 從 10 秒改成 6 秒,未改**。
>
> **檔案/行**`server/main.go:166`
>
> ```go
> ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
> ```
>
> 應改為 `6*time.Second`
>
> **後果**Wails ctrl.Stop 在第 7 秒會 SIGKILL但 server 自己內部還在 10 秒 graceful cleanup window 中 — 可能出現「server 還在 sync 檔案到一半被打斷」。對 v2 「7 + 1 秒 modal」UX 設計直接破壞。
---
## H. v1/v2 並存策略評估
實作保留了 `ServerProcess.stop()` / `kill()` / `watchServer` v1 路徑(`app.go:583-685`+ v1 `shutdownGracePeriod = 5s` 常數宣稱為「fallback」 + 「M8-5 前端改寫期間相容」。
**評估結論**:⚠️ **策略合理但需立即標記砍除時程**
| 問題 | 風險 | 結論 |
|------|------|------|
| v1/v2 路徑會被同時呼叫嗎? | 不會 | startup 只走 ctrl.Start → startServerV2shutdown 只走 ctrl.Stop → stopGraceful。`app.stopServer()``App.startServer` 已被 dead code |
| v1 常數 `shutdownGracePeriod = 5s` 會被誤用嗎? | 低 | 只有 v1 ServerProcess.stop() 用到,已是 dead path |
| v1 `watchServer` 會被誤啟動嗎? | 低 | 只在 v1 startServer 的 dead path 啟動;新 startServerV2 走 watchServerV2 |
| reportFatal v1 路徑仍存在 | 中 | startup 失敗 / single-instance 失敗仍會走這合理startup 完全失敗時控制台還沒就緒,無 fallback 只能原生對話框) |
| 未來何時砍 | 未明確 | **必須**在 progress.md「未解決問題」記錄M8-5 完成後**立即砍**全部 v1`ServerProcess.stop/kill``watchServer``stopServer``shutdownGracePeriod``startServer` |
**Minor 10H.MIN-1**`shutdown()`app.go:222的 fallback 路徑 `a.stopServer()` 是 dead code因為 ctrl 在 startup 一定會被建立 — 直接刪 else 分支,避免讓未來的開發者誤以為兩條路徑都有效。
**Minor 11H.MIN-2**`v1 watchServer``v2 watchServerV2` 內容 90% 相同,只差最後失敗的 reportFatal vs handleWatchFailure。等 M8-5 砍 v1 即可消除這份重複。
---
## I. Build / Test / Race detector 結果
```
cd server && go build ./... ✅ PASS
cd server && go vet ./... ✅ PASS
cd server && go test ./... ✅ PASSdevice + model 既有 test
cd server && go test -race ./... ✅ PASS
cd visiona-local && go build . ✅ PASS
cd visiona-local && go vet ./... ✅ PASS
cd visiona-local && go test -count=1 ./... ✅ PASS20 tests 全綠)
cd visiona-local && go test -race ./... ✅ PASS
```
**20 個 unit test 明細**
- log_buffer_test.go5 個Append / Wrap / Reset / RateLimit / parseLogLevel
- preferences_test.go5 個platform default / missing / corrupt / roundtrip / atomic rename
- server_control_test.go10 個Initial / setState / Stop×2 / ForceKill / snapshotStatus / GetRecentLogs / GetSystemInfo / ClearLogs / GetServerStatusV2
**race detector 沒抓到任何 race**,但要注意 unit test **沒有實際 spawn server binary**,因此 logPump↔stopGraceful 的 file handle raceC.MIN-2 / D.MAJ-1 / Major 1必須靠人工 review 與 integration test 才能驗證。
---
## J. Smoke test 結果
執行:`go run server` 啟動真實 servercurl 各 endpoint。
| 步驟 | 結果 |
|------|------|
| 1. `curl /api/system/boot-id` | ✅ `{"data":{"bootId":"bd94ae7f976a6e526bd9840fc385b92d","startedAt":1776213642971},"success":true}`32 字元 hex |
| 2. `curl /api/system/health` | ✅ `{"status":"ok"}` |
| 3. `curl /api/devices` | ✅ 正常回應 |
| 4. **stdout 內 GIN access log** | ✅ **只有** `[GIN] 200 \| 137.674µs \| GET /api/devices`**沒有** boot-id / health 的 access log |
| 5. 連續 4 次 poll boot-id 都回同一 IDprocess-scoped | ✅ |
**SkipPaths 機制完全符合 TDD §9.1a 與 milestone-plan 預期。**
---
## K. 遺漏 / 誤解
> **Major 4K.MAJ-1****未實作 `server:shutdown-imminent` WebSocket 廣播**milestone-plan #157「Minor 4」明確要求
>
> **檔案/行**`server_control.go:302-340`stopGraceful 內應在 SIGTERM 前先廣播server 端應有 `/ws/system` 或擴充 `/ws/server-logs` 來廣播 `server:shutdown-imminent`
>
> **影響**M8-9 的 web UI offline overlay race condition 防護機制完全缺席。M8-9 將會被迫補做、或 fall back 到「3 次 poll 失敗 = 15s 才顯示 overlay」的舊行為。
>
> **狀態****阻擋 M8-9建議在 M8-4b 一併補做或拆獨立小 task**。
**Minor 12K.MIN-1**milestone-plan #153 要求「`shutdownGracePeriod` 5 s → **7 s**」,實作改用新常數 `shutdownGraceV2 = 7s`、舊常數 5s 還在。功能等效但**字面違反 milestone-plan**。可接受作為 v1/v2 並存策略的副作用,但要在 H 段提到的「砍 v1」清單中一併處理。
**Minor 13K.MIN-2**`ForceKillServer` 確認**不**發崩潰通知 — 走 `proc.forceKill()` 路徑直接 SIGKILL不經過 handleWatchFailure符合 prompt 要求。✅
**Minor 14K.MIN-3**preferences 讀取失敗的 fallback 行為符合 TDD §11不 fail、用 default。✅
**Minor 15K.MIN-4**notify 三平台 timeout 處理F.MIN-1— 已在 F 段提,不重複列。
---
## L. 問題清單
### Major阻擋後續 milestone
| # | 檔案:行 | 問題 | 影響的 milestone | 修法 |
|---|---------|------|-----------------|------|
| MAJ-1 | server_control.go:198-229 (Stop), 251-265 (ForceKill) | `Stop()` / `ForceKill()` 不 cancel watchCancel30 秒後誤觸 Error state + 發 OS 崩潰通知 | M8-5、M8-7 | Stop/ForceKill 進場後加 `if a.watchCancel != nil { a.watchCancel(); a.watchCancel = nil }` |
| MAJ-2 | server_control.go:269-291 | `handleWatchFailure` 不取 txMu與 Stop/ForceKill race可能把 Stopped 翻成 Error | M8-5 | 進場取 txMusetState 前重新檢查 state或 watchServerV2 ticker 內檢查 `c.State() != ServerStateRunning` 直接退出 |
| MAJ-3 | server/main.go:166 | shutdownFn timeout 仍是 10s未對齊 TDD §8.1 + milestone-plan 的 6s | M8-5、M8-9 | 改 `6*time.Second` |
| MAJ-4 | server_control.go:302-340 + 缺 server WebSocket 廣播 | 未實作 `server:shutdown-imminent` 廣播milestone-plan Minor 4 | M8-9 | server 端加 `/ws/system` 或擴充 `/ws/server-logs`ctrl.Stop SIGTERM 前 HTTP/WS 觸發 |
| MAJ-5 | server_control.go:579-608 (logPump main loop) | scanDone 觸發後 lineCh 仍有 buffered 行未處理 → 資料丟失(含崩潰時最後幾行 stderr | M8-5 (debug experience) | scanDone case 加 drain loop 或讓 scanner goroutine 自己 close lineCh |
### Minor建議修不阻擋
| # | 檔案 | 問題 |
|---|------|------|
| MIN-1 | log_buffer.go:99-105 | Snapshot 從 i=0 走 size 圈,可改為從 (start+skip) 走 n 圈 |
| MIN-2 | log_buffer.go:139-151 | ShouldEmit CAS raceA 視窗 Store(0) 前 B 已 Add(1),極小機率多放 1 行 |
| MIN-3 | preferences.go:91 | 跨 filesystem rename 失敗時無 io.Copy fallback罕見邊界 |
| MIN-4 | server_control.go:232-245 | Restart 拆兩段 txMu中間有插隊窗口 |
| MIN-5 | server_control.go:302-340 | stopGraceful 結束時關 file 與 logPump goroutine 還在寫的時序 race |
| MIN-6 | server_control.go:556-562 | scanner goroutine `select { case lineCh<-text: default: lineCh<-text }` 寫法奇怪、等同 blocking |
| MIN-7 | notify_*.go | 三平台都用 cmd.Run() 無 timeout極罕見情況下 hang 會 leak goroutine |
| MIN-8 | notify_linux.go | notify-send not in PATH 時 warning 不夠友善 |
| MIN-9 | system_handler.go:42 | newBootID 放在 handler 內呼叫,偏離 TDD §9.1 「main.go 注入」設計,不利於 deterministic test |
| MIN-10 | app.go:222-227 | shutdown() 的 v1 fallback 是 dead code建議刪 |
| MIN-11 | app.go:583-622 | watchServer v1 與 watchServerV2 高度重複,待 M8-5 後砍 |
| MIN-12 | app.go:46 | shutdownGracePeriod = 5s 仍在,違反 milestone-plan #153 字面 |
| MIN-13 | log_buffer_test.go:108-127 | TestLogBuffer_RateLimit 假設「每行一次 ShouldEmit」與 logPump 實際「每 batch 一次」不一致 |
---
## M. 結論
M8-4 的核心架構**正確且品質高**
**做得好的地方(值得讚許):**
- 雙鎖機制 `txMu` + `mu` 設計清晰、race detector 全綠
- LogBuffer 的 ring buffer + atomic rate limit 結構漂亮
- Preferences atomic write-rename 含 tmp cleanup
- 三平台 notify 用 build tag 隔離乾淨、escape 處理到位
- boot-id 用 crypto/rand 不引 google/uuid符合 TDD 定版決策
- broadcasterLogger SkipPaths 實測完美過 smoke test
- 20 unit test 全綠、race detector 全綠
- v1/v2 並存策略雖然增加維護成本,但邏輯上沒有衝突
- 文件註解極詳細,每個函式都標 TDD 出處
**必須處理的問題(阻擋後續 milestone**
- **5 個 Major** — MAJ-1 ~ MAJ-5。其中 MAJ-1watch leak 誤觸 Error與 MAJ-2handleWatchFailure race是直接破壞 M8-5 UX 的 bug**第二輪修改前必須處理**。MAJ-3shutdownFn 6s與 MAJ-4shutdown-imminent 廣播是純遺漏補就好。MAJ-5logPump 丟最後幾行)影響 debug 體驗。
- **15 個 Minor** — 大多可以放到 v1 砍除時一併處理,或當作技術債記錄。
**對 Orchestrator 的建議:**
1. **第二輪修改範圍**(必須):
- MAJ-1Stop/ForceKill cancel watchCancel
- MAJ-2handleWatchFailure 取 txMu + 二次檢查 state
- MAJ-3server/main.go shutdownFn 6s
- MAJ-5logPump scanDone 加 drain loop
2. **MAJ-4 處理時機**M8-4b 一併做,因為它需要 server 端新增 `/ws/system` endpoint與 startup-pipeline 的 event 設計可一起規劃。**或拆獨立 micro-task 立刻補做,不要等到 M8-9 才發現缺**。
3. **v1 砍除追蹤**:在 progress.md「未解決問題」加一條「M8-4 v1 路徑ServerProcess.stop/kill、watchServer、stopServer、shutdownGracePeriod、startServer保留作為相容過渡M8-5 完成後立即砍除(含相關 dead code 清理 MIN-10/11/12」。
4. **整合測試**M8-4 的 unit test 沒實際 spawn server binary因此 logPump file handle raceMIN-5、stopGraceful timing、preferences 跨檔案系統等只能靠 manual / integration test 驗證。建議在 M8-5 完成後安排一次完整 integration smoke testStart → 噴 log → Stop → Restart → Force Kill → 模擬 crash → 各事件都要正確)。
**結論**:⚠️ **需小改後通過**。修完 5 個 Major 即可進 M8-4b / M8-5。Minor 不阻擋。

View File

@ -0,0 +1,229 @@
# Reviewer 審查 M8-4b 補丁2026-04-15
## 摘要
- 審查對象M8-4b 補丁3 Major + Stage 4 timeout 修復)
- 審查檔案:`visiona-local/startup_pipeline.go`L385-423 新增 helpers`visiona-local/server_control.go`L167-223 startInternal 守門、L630-652 probe timeout、L867-937 RestartStartupSequence + test hook`visiona-local/app.go`L109-119 struct、L1676-1693 openBrowser var`visiona-local/startup_pipeline_test.go`L393-660 4 個 regression test
- 總結論:**✅ 通過** — 3 Major 全部正確修復、Stage 4 timeout 改好、4 個 regression test 完整覆蓋且全綠。Build/Vet/Test/Racecount=2`visiona-local``server` 兩個 module 都 PASS。
- 3 Major 修復狀況M-1 ✅ / M-2 ✅ / M-3 ✅
- 問題統計Critical 0 / Major 0 / Minor 2 / Suggestion 1
- 阻擋 M8-10 嗎:**否**,可推進 M8-10。
---
## A. HasFailedStage helper 正確性
位置:`visiona-local/startup_pipeline.go` L385-404
- 邏輯正確 ✅:`current == -1``FailStage` 會把 current 設為 -1或迴圈檢查 `stages[1..6].status == "failed"`defensive path
- Lock 保護 ✅:`p.mu.Lock()` + defer `Unlock()``mu` 在 struct 是 `sync.Mutex`L98沒 RLock 可用 — 任務敘述提到的「RLock」不適用`Lock` 是正確選擇。效能影響可忽略(只在 startInternal error 路徑讀一次)。
- 文件註解清楚 ✅L386-391 解釋語義與對 M-1 的作用。
- Test 覆蓋 4 case ✅:`TestStartupPipeline_HasFailedStage`L537-566分別驗證new / running / FailStage 後current=-1/ 某 stage status=failed 但 current 未同步defensive
---
## B. IsInColdStart helper 正確性
位置:`visiona-local/startup_pipeline.go` L406-423
- 邏輯正確 ✅:`current >= 1 && current <= startupTotalStages`startupTotalStages=6stage 7ready與 current=-1failed、current=0未 Start都回 false。
- Lock 保護 ✅:同 `HasFailedStage`,用 `Lock/Unlock`
- Test 覆蓋邊界 ✅:`TestStartupPipeline_IsInColdStart`L578-606覆蓋 current ∈ {0, 1..6, 7, -1},迴圈確認 1..6 全 true其餘全 false。
---
## C. startInternal error 分支守門
位置:`visiona-local/server_control.go` L167-194
- 正確用 `pipeline.HasFailedStage()`L176-179
- `pipeline == nil` fallback ✅:`pipelineHandled` 預設 false → 走原 setState + emit + sendCrashNotification 邏輯。
- 非冷啟動路徑RestartServer 等pipeline 已 ready current=7若此刻 Start 失敗 HasFailedStage=falsefallback 正常發通知 ✅。
- 註解L168-175解釋完整說明為什麼守門 + 哪些路徑仍需 fallback。
- 不破壞 RestartServer`ctrl.Restart()``ctrl.Stop()` + `ctrl.Start()`)的錯誤處理 ✅RestartServer 發生時 pipeline.current==7 或 0`HasFailedStage()` 回 false。
**小提醒**(非 Major此守門建立在「pipeline FailStage 一定早於 startInternal return err」的假設上。實作上 `startServerV2` 每個 stage 失敗都立即 `FailStage`(見 server_control.go L437-448 / L554-566再 return err順序正確。若未來新增一條「不透過 FailStage 的錯誤返回路徑」,守門會被繞過。建議記為 Minor 追蹤(見 §L
---
## D. openBrowser 守門
位置:`visiona-local/server_control.go` L202-222
- `pipeline.IsInColdStart() == false` 才執行 R5-D3 openBrowser ✅L213-216
- RestartServer 路徑pipeline.current==7`IsInColdStart()` 回 false仍 openBrowser ✅。
- `pipeline == nil` 路徑 `inColdStart` 維持 falsefallback 仍 openBrowser ✅L214-216
- 註解L204-212清楚列出三種場景冷啟動 / RestartServer / RestartStartupSequence的預期行為可維護性高。
---
## E. openBrowser package-level var 改動
位置:`visiona-local/app.go` L1676-1693
- 改為 `var openBrowser = openBrowserExec`L1681。預設值是實作函式production 透過 var 呼叫只多一個 indirection效能影響可忽略。
- 所有呼叫端透過 var 呼叫:`app.go` L268runStartupStage5`server_control.go` L219startInternal R5-D3。Test 可替換 var 做 stub。
- `openBrowserExec` 保留為獨立函式L1684-1693作為預設實作macOS/Windows/Linux 三分支原封不動 ✅。
- Test 重置良好 ✅:`TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce``defer func() { openBrowser = original }()`L627保證 test 結束還原。
---
## F. RestartStartupSequence test 重構
位置:`visiona-local/startup_pipeline_test.go` L393-522
- **直接呼叫 `a.RestartStartupSequence()`**L439— 不是手動複製 5 步驟。
- 用 `restartStartFn` test hook 攔截 Step 6 ✅L424-433spy 記錄呼叫、檢查 sentinel 是否已清、snapshot 新 pipeline。
- `callLog` 驗證順序 ✅L497-516`pipelineCancel` 必須在 `startFn` 之前。
- 驗證新 pipeline 建立 ✅L466-486stage1=completed / stage2=running / current=2。
- `pipelineCancelFn``context.CancelFunc` 型別允許 assign 任意 `func()`L407-410
- 其他 side effect 驗證完整proc 被清為 nilL453-458 ForceKill 效果、sentinel file 已刪除L461-463、啟動前 ctrl state 設為 Error 模擬 Retry 前置條件L418
- 「若未來把 Step 4 移到 Step 2 之前」的概念驗證test 雖無法直接抓 Step 4 ↔ Step 2 相對順序但能抓「Step 4 在 Step 6 之前」(`sentinelClearedBeforeStart` L489-491滿足原 Reviewer 對 M-3 的核心期待(驗證真的呼叫 method、不是手動複製
**Minor F-1**(見 §L`newPipelineTestApp``a.ctx == nil`,所以 RestartStartupSequence Step 5 的 watcher goroutine 分支不會啟動L518-521 的「清理新啟動的 watcher」註解有點誤導實際上 pipelineCancelFn 已在 Step 1 被清為 nil之後也沒有被重設。無功能影響純註解細節。
---
## G. TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce
位置:`visiona-local/startup_pipeline_test.go` L615-660
- 正確 stub `openBrowser` var ✅L621-627defer 還原。
- `AutoOpenBrowser=true`L618確保 runStartupStage5 會走 openBrowser 分支(不 skip
- `restartStartFn` 內模擬 server 啟動成功:設 fake proc port=12345、setState(Running)、手動推進 pipeline stages 到 stage 5 running ✅L630-645。這讓 `runStartupStage5` 能從 `ctrl.proc.port` 取到 URL 並呼叫 openBrowser。
- 斷言 `openCount == 1`L652-654錯誤訊息清楚標示 want 1。
- 驗證冷啟動路徑下「startInternal 的 R5-D3 open」不會發生 — 因為 restartStartFn 替換了 `ctrl.Start()`,完全 bypass startInternal所以實際上這個 test 驗證的是 runStartupStage5 本身只會 open 一次。
**Minor G-1**(見 §L此 test 沒有實際經過 startInternal 的 R5-D3 分支restartStartFn 完全替換 ctrl.Start所以嚴格來說它驗證的是「runStartupStage5 本身的 open 次數」,而不是「冷啟動中 startInternal + runStartupStage5 的 open 次數總和」。要完整驗證 M-2需要一個能跑 startInternal 的整合 test但 startInternal 會 spawn python server 無法 unit test。現況可接受因為 IsInColdStart 邏輯已被 B 節 `TestStartupPipeline_IsInColdStart` 單獨驗證startInternal 的守門條件 `!inColdStart` 是純布林邏輯,靜態即可推得正確。
---
## H. Stage 4 probe timeout 2s
位置:`visiona-local/server_control.go` L630-652
- 註解說明原因 ✅L634-636 解釋 TDD §3「秒回即算完成」+「2s 足以涵蓋正常 latency」。
- 不破壞既有 test ✅Stage 4 相關 test 全綠(`go test -count=1` PASS
- 逾時仍算完成 ✅L647-651 不論 err 或 status 都 CompleteStage(4)),符合 TDD §3 設計意圖。
---
## I. Build/Test/Race 結果
| 指令 | 結果 |
|------|------|
| `cd visiona-local && go build .` | PASS無輸出 |
| `cd visiona-local && go vet ./...` | PASS無輸出 |
| `cd visiona-local && go test -count=1 ./...` | PASS8.210s |
| `cd visiona-local && go test -race -count=2 ./...` | **PASS16.225s** |
| `cd server && go build ./...` | PASS |
| `cd server && go vet ./...` | PASS |
| `cd server && go test -count=1 ./...` | PASSapi/handlers/ws/device/model 全通過) |
| `cd server && go test -race ./...` | PASS |
4 個新 test 單獨跑 verbose
```
--- PASS: TestStartupPipeline_RestartStartupSequence_StepsExecution (0.00s)
--- PASS: TestStartupPipeline_HasFailedStage (0.00s)
--- PASS: TestStartupPipeline_IsInColdStart (0.00s)
--- PASS: TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce (0.00s)
```
---
## J. 合併檢查
`git diff --stat` 看過全部變更,無衝突標記、無重複區塊。合併狀況:
- **M8-4 原版** + **M8-4 補丁**Stop/ForceKill/handleWatchFailure/logPump`ForceKill` L273-291 的 cancelWatcher 順序修復仍在,`RestartStartupSequence` Step 2 正確利用 ✅。
- **M8-4b 原版**startServerV2 hook + probe + RestartStartupSequence補丁新增 helpers 與守門邏輯未動到既有 pipeline struct 欄位與 stage hook 位置 ✅。
- **MAJ-4 補丁**shutdown-imminent broadcast + notifyShutdownImminent不觸及 startup pipeline / server_control 的 startInternal / RestartStartupSequence 區塊 ✅。
- `grep` 未見 `<<<<<<<` / `>>>>>>>` 合併標記。
---
## K. 3 Major 症狀驗證
### M-1重複 OS 通知)
症狀:冷啟動 stage 3 失敗 → `emitError` 發通知 A → startInternal error 分支再發通知 B。
修復後的流程:
1. `startServerV2` 失敗 → `pipeline.FailStage(3, err)``emitError` 發通知「第 3 階段失敗」+ setState(Error) + current = -1
2. startInternal 收 err → `HasFailedStage()` 回 true → `pipelineHandled = true`**skip** setState/emit/sendCrashNotification
3. 只剩一個通知 ✅
**症狀消失確認**Major M-1 修復路徑正確唯一前提是「startServerV2 的所有失敗路徑都走 FailStage」— 已確認 L437-448stage 2、L554-566stage 3、stage 4 只在 timeout 仍 complete 不 fail符合條件。
### M-2重複開瀏覽器
症狀:冷啟動成功 → runStartupStage5 的 openBrowser 呼叫 1 次 + startInternal R5-D3 的 openBrowser 呼叫 1 次 = 2 次。
修復後的流程:
1. 冷啟動 → startServerV2 成功 → startInternal 檢查 `IsInColdStart()`(此時 current ∈ 1..6)→ 回 true → **skip** R5-D3 openBrowser
2. startServerV2 返回後app.startup 呼叫 `runStartupStage5` → openBrowser 1 次 ✅
3. RestartServer 路徑pipeline.current==7 → IsInColdStart 回 false → R5-D3 正常 open ✅
**症狀消失確認**:冷啟動 openBrowser 只被呼叫一次。`TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce` 驗證 openCount==1。
### M-3Retry test 重構)
症狀test 手動複製 Step 1-5改步驟順序抓不到。
修復後:直接呼叫 `a.RestartStartupSequence()` + callLog spy。若未來調整步驟順序cancel 晚於 startcallLog 順序斷言會 fail ✅;若把 sentinel 清除改到 start 之後Step 4 → Step 6 之後),`sentinelClearedBeforeStart` 斷言會 fail ✅。
**概念驗證**test 能抓到「cancel 必須早於 start」與「sentinel 必須在 start 前清」兩個順序不變量,涵蓋 M-3 的核心意圖。
---
## L. 問題清單
### Critical
(無)
### Major
(無)
### Minor
| # | 檔案 | 行 | 問題 | 建議 |
|---|------|----|------|------|
| m-1 | `visiona-local/startup_pipeline_test.go` | L518-521 | 註解說「清理新啟動的 watcher goroutine」`newPipelineTestApp``a.ctx == nil`Step 5 的 watcher 分支不會啟動pipelineCancelFn 在 Step 1 已被清為 nil。註解誤導但無功能影響 | 改註解為「若 ctx 存在則清理重建的 watcher此 test 走 nil ctx 分支,為 safety」 |
| m-2 | `visiona-local/server_control.go` | L167-194 | `pipelineHandled` 守門建立在「startServerV2 所有失敗路徑都走 FailStage」的隱性假設上若未來新增一條不透過 FailStage return err 的路徑M-1 症狀會復活 | 在 `startServerV2` 函式開頭加註解「所有 err return 前必須先 FailStage」或改用 explicit flag`a.ctrl.suppressFallbackError`)由 `emitError`startInternal 讀後 clear |
### Suggestion
| # | 內容 |
|---|------|
| s-1 | `TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce` 嚴格來說驗證的是 runStartupStage5 本身只 open 一次(因為 restartStartFn 替換了 ctrl.Start完全 bypass 了 startInternal 的 R5-D3 分支。M-2 的守門邏輯(`IsInColdStart() == false` 才 open是靠 `TestStartupPipeline_IsInColdStart` 單獨驗證的 pure boolean 邏輯。兩個 test 合起來足以推得正確性,但若希望更貼近「真實 M-2 症狀」,可另加一個 test 透過 `a.ctrl.startInternal` 直接呼叫(需要先在 ctrl 加 test-only hook 允許跳過 spawn非必要 |
---
## M. 結論
**判定:✅ 通過**
補丁對 M8-4b 原版 Reviewer 提出的 3 Major 全部正確修復:
1. **M-1重複通知** — 新增 `HasFailedStage()` helper + `startInternal` error 分支守門邏輯正確、lock 保護得當regression test`TestStartupPipeline_HasFailedStage`)覆蓋 4 case 全通過。
2. **M-2重複開瀏覽器** — 新增 `IsInColdStart()` helper + `startInternal` R5-D3 守門 + `openBrowser` 改 package-level var 以便 stubregression test`TestStartupPipeline_IsInColdStart` + `TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce`)驗證完整。
3. **M-3Retry test 重構)** — 新增 `restartStartFn` test hook + 重構 `TestStartupPipeline_RestartStartupSequence_StepsExecution` 為直接呼叫 method + callLog spy 驗證順序,抓得到 cancel↔start 與 sentinel↔start 的順序不變量。
**Stage 4 probe timeout** 從 5s 改為 2s註解說明符合 TDD 原意。
**工程品質**
- Build / Vet / Test / Race-count=2在 visiona-local 與 server 兩個 module 全綠
- 合併乾淨,無衝突標記
- 註解詳盡(每個 helper 都有中文註解解釋「為什麼」與「哪些場景走 fallback」
- Lock 使用正確(`sync.Mutex` + `Lock/Unlock`,沒有 race condition
- Test hook 透過 struct field 注入,不污染正式路徑(`restartStartFn` 預設 nil
**不阻擋 M8-10**。Minor 2 項 + Suggestion 1 項可記為技術債,不強制當下處理。
**優點**
- Helper 命名清楚(`HasFailedStage` / `IsInColdStart` 語義一眼就懂)
- 守門邏輯對 `pipeline == nil` fallback 處理完整,不會因單測環境或非冷啟動路徑被誤傷
- 4 個 regression test 覆蓋面完整helper 本身 + 實際 RestartStartupSequence 流程 + openBrowser 呼叫次數
- 透過改 `openBrowser` 為 package-level var 解決測試 stub 問題,是 Go idiomatic 的做法
- `restartStartFn` test hook 設計乾淨:預設 nil → 走正式 `a.ctrl.Start()`,單測替換即可,不會意外漏到 production
- 2s probe timeout 的註解說明了「為什麼不是 5s」— 文件化設計決策

View File

@ -0,0 +1,273 @@
# Reviewer 審查 M8-4b 啟動階段管線2026-04-15
## 摘要
- 審查對象M8-4bR5-E 6 階段啟動 pipeline + watcher + sentinel file + RestartStartupSequence
- 審查檔案:`visiona-local/startup_pipeline.go`394 行)、`startup_pipeline_test.go`457 行)、`server/internal/api/ws/hub.go`sentinel 機制)、`hub_sentinel_test.go`3 tests`visiona-local/app.go`startup / shutdown / runStartupStage5`visiona-local/server_control.go`stage hooks / probeDeviceListAndComplete / RestartStartupSequence / ForceKill`server/main.go`wiring
- 總結論:**⚠️ 需小改** — 核心實作對齊 TDD v2/startup-pipeline.mdBuild/Vet/Test/Race 全過,功能面沒有阻擋 M8-5 / M8-9 的問題;但發現 **2 個 Major UX 問題**(重複 OS 通知 + 重複開瀏覽器),建議在 M8-4b 收尾前順手修。
- 問題統計Critical 0 / Major 2 / Minor 5 / Suggestion 3
- 阻擋後續 milestone 嗎:**否**。M8-5前端控制台 UI可直接使用現在的 event schema 與 `RestartStartupSequence` bindingM8-9boot-id + 重連)不觸及 pipeline 內部,可平行推進。兩個 Major 屬 UX 層面,在 M8-10 end-to-end 驗收前修掉即可。
---
## A. StartupPipeline struct
| TDD §4 要求 | 實作位置 | 狀態 |
|------|---------|------|
| 6 階段常量 / soft 20s / hard 60s / tick 1s | `startup_pipeline.go` L39-44 | ✅ |
| Event schema`StartupProgressEvent` / `StageTimeoutEvent` / `ErrorEvent`| L54-75 | ✅ 欄位命名、json tag 與 §1 一致 |
| Status 枚舉含 pending/running/completed/failed/skipped | L60, L82 | ✅ 和 §1.1 v2.1 新增 `skipped` 對齊 |
| 1-indexed stages 陣列 + `current` sentinel 值 | L99-101 | ✅ 0/1-6/7/-1 語義註解清楚 |
| `CompleteStage` 順序保護 + `current != stage` 忽略 | L138-163 | ✅ 符合 §4「重複呼叫或順序錯誤忽略」 |
| `SkipStage` → 進下一階段 | L168-192 | ✅ 與 `CompleteStage` 對稱 |
| `FailStage``emitError` + `stopWatcher` | L200-213 | ✅ |
| `markReady` → emit `startup:ready` + `stopWatcher` | L216-226 | ✅ 成功路徑走同步 emit註解有解釋 |
| `emitProgress` 用 goroutine 非阻塞 | L230-251 | ✅ 符合 §4 關鍵設計 3 |
| `emitError` 同步 `setState(Error)` + 非阻塞 OS 通知 | L255-277 | ✅ |
**符合度 100%**。
---
## B. Watcher goroutine
| 行為 | 位置 | 狀態 |
|------|------|------|
| 每秒 tick + `ctx.Done` 退出 | L291-297 | ✅ |
| `current` 不在 1-6 範圍時 return | L300-303 | ✅ |
| 階段 6 每 tick 檢查 sentinel file | L313-318 | ✅ |
| `skipped` status / 階段 6 + AutoOpenBrowser=false 跳過 timeout | L321-327 | ✅ 雙條件都檢查 |
| Hard timeout 直接展開 mark failed + `emitError(total-timeout)` | L329-341 | ✅ cause 分流正確;不走 `FailStage` 以便 cause 設為 `total-timeout` |
| `softTimeoutEmitted` flag 每階段最多 emit 一次 | L348-362 | ✅ |
| `skipTimeout` continue 時不檢查 soft timeout | L343-345 | ✅ |
**Minor B-1**`watcher` 讀取 `p.app.prefs.AutoOpenBrowser`L325未持 `a.mu`,但 `SetPreferences``server_control.go` L1062-1064`a.prefs` 時持 `a.mu`。因為 `Preferences` 是 struct value`bool` + `string`),同時讀寫觸發 data race。實測環境罕見使用者在 Starting state 改 pref`go test -race` 沒觸發。建議 watcher 的 pref 讀取改走 getter 或先 snapshot。
---
## C. Stage hooks 插入位置
| Stage | 位置 | 狀態 |
|-------|------|------|
| 1 Wails 控制台 | `app.go` L224 `CompleteStage(1)`(在 `seedUserDataDir` 之後、`ctrl.Start` 之前)| ✅ 對齊 §3 |
| 2 Python runtime | `server_control.go` L437-448 `FailStage(2)` / `CompleteStage(2)``ensurePythonRuntime` | ✅ |
| 3 Server spawn | L554-566 `FailStage(3)` / `CompleteStage(3)``waitHealthy` | ✅ |
| 4 Device probe | L571-576 呼叫 `probeDeviceListAndComplete(port)` → HTTP GET `/api/devices` 5s timeout → 任何 response/err 都 `CompleteStage(4)` | ✅ 行為與 §3 描述一致(「秒回算完成」),但 5s timeout 過於保守 — 見 §I-2 |
| 5 Open browser | `app.go` L243-267 `runStartupStage5()` helper`AutoOpenBrowser=false``SkipStage(5)``true``openBrowser(url)` + `CompleteStage(5)` | ⚠️ **見 Major C-1** |
| 6 WebSocket ready | 由 watcher poll sentinel file 觸發 | ✅ |
**Major C-1瀏覽器會被開兩次**
`runStartupStage5`app.go L262呼叫 `openBrowser(url)`,但 `startInternal`server_control.go L188-194的 R5-D3 邏輯在 Start 成功後也 `openBrowser(url)`。冷啟動路徑會執行兩次 `openBrowser`
- macOS`open http://...` 同 URL 兩次通常聚合到同一 tab但取決於瀏覽器
- Linux`xdg-open` 可能開兩個 tab
- Windows`start` 行為不一致
建議的修法(二擇一):
1. `startInternal``a.startupPipeline != nil && a.startupPipeline.current >= 0 && a.startupPipeline.current <= startupTotalStages`(冷啟動中)時跳過自己的 `openBrowser`,由 `runStartupStage5` 負責;`RestartServer` 情境下 pipeline 已 ready`current==7`R5-D3 仍執行。
2. 反過來:`runStartupStage5` 只負責 `CompleteStage(5)`,實際 open 由 startInternal 處理 — 但這樣做跟 TDD §3 階段 5 的語義「Open browser」呼叫 OpenInBrowser 返回)不符。
推薦方案 1。
---
## D. Sentinel file 機制
| 項目 | 位置 | 狀態 |
|------|------|------|
| `Hub.writeStartupSentinel``sync.Once` | `hub.go` L79-98 | ✅ |
| Register channel 第一次收到 Subscription 觸發 | L112 | ✅ 在 `h.mu.Unlock()` 之後呼叫(避免重入) |
| 檔案內容 `bootId=... ts=...` | L95 | ✅ 符合 §3 |
| 清檔時機startup 早期(`app.go` L175+ shutdownL299+ RestartStartupSequence Step 4L844| | ✅ 三處都清,符合 §3 的 best-effort 策略 |
| `Hub.SetStartupSentinel``server/main.go` L133 注入 `cfg.DataDir`L101| | ✅ |
| DataDir 空值時 disable | `hub.go` L85-87 | ✅ 有單測驗證(`DisabledWhenDataDirEmpty`|
**Minor D-1**`writeStartupSentinel``h.mu.RLock()` 保護 `sentinelDataDir`,但 `SetStartupSentinel`L68-72用 Write lock — 這個 lock 保護與 `rooms` map 的 lock 共用。功能上正確但概念上有點混搭(同一把 lock 同時保護「訂閱狀態」與「初始化一次的 dataDir」未來可用獨立 sync.Once 或 atomic.Pointer 分離。
---
## E. RestartStartupSequence 5 步驟
| Step | 位置 | 狀態 |
|------|------|------|
| 1. cancel watcher goroutine | `server_control.go` L832-835 `a.pipelineCancelFn()` + 清 nil | ✅ |
| 2. ForceKill server subprocess | L838 `ctrl.ForceKill()`,該 method 在 L273-291 已實作cancel watcher → clear proc → `proc.forceKill()``setState(Stopped)`| ✅ |
| 3. Reset state machine to Stopped | L841 `setState(Stopped, "")` | ✅ 雖與 Step 2 結尾重複,但 defensive 沒壞處 |
| 4. Clean sentinel file | L844 `removeSentinelFile(a.dataDir)` | ✅ 符合 §3 明確要求「critical — 否則階段 6 會誤判瞬間完成」 |
| 5. 重建 `StartupPipeline` + stage 1 直接 complete + watcher + stage 2 running + `ctrl.Start()` + `runStartupStage5()` | L847-887 | ✅ 符合 §8.1 |
| Stage 1 不重跑 | L851-858 直接寫 `stages[1].status = "completed"` + emitProgress | ✅ |
| Binding 暴露 | Wails binding 是整個 App`main.go` L35-37所有 exported method 自動暴露 | ✅ `RestartStartupSequence() error` 符合前端呼叫介面 |
**Minor E-1**Step 5 手動操作 `a.startupPipeline.mu` 和 internal fields`stages[1].status = "completed"` 等),繞過了 struct 的封裝 API。未來改 `stageState` 欄位或 status 語義時,這段極容易 drift。建議把這段封裝為 `(*StartupPipeline).forceCompleteStage1And2Running(time.Time)` 或類似方法,把操作內部狀態的邏輯收進 pipeline 自己的 file。
**Minor E-2**Step 5 L865 設 `a.startupPipeline.watcherCancel = cancel` 同一個 cancel 也存在 `a.pipelineCancelFn``FailStage → stopWatcher → watcherCancel()` 會 cancel`a.pipelineCancelFn` 不會被 nil-out → 下次 Retry 執行 Step 1 會呼叫已 cancelled 的 func`context.CancelFunc` 文件保證 idempotentOK然後清 nil。功能正確但語義上「cancel 執行後清 nil」一致性不足。可接受。
**Minor E-3**`RestartStartupSequence``a.ctx == nil` 情境下(單測走這條)不啟動 watcher正常執行環境下 `a.ctx` 一定有值,但如果未來 ctx 時序改變,可能踩到。建議在 `a.ctx == nil` 時 return error 而非 silent skip watcher。
---
## F. Stage 5/6 skip 邏輯
| 規則 | 驗證 |
|------|------|
| Linux + `AutoOpenBrowser=false` → Stage 5 skipped | ✅ `preferences.go` L42 `DefaultPreferences()` 依 GOOS 把 Linux 設 false`runStartupStage5` L247-250 呼叫 `SkipStage(5)` |
| `AutoOpenBrowser=false` → Stage 6 不 trigger timeout | ✅ `startup_pipeline.go` L325-327 skipTimeout 條件覆蓋 hard + soft |
| `skipped` 狀態 emit progress event | ✅ `SkipStage` L178 呼叫 `emitProgress(stage)`status 已是 `"skipped"` |
| Watcher 在 stage 6 + AutoOpenBrowser=false 時不誤判 hard timeout | ✅ 有單測 `TestStartupPipeline_Watcher_SkippedStageNoTimeout`set 70s sinceTotal 不觸發) |
一個**設計問題**(非 bug`AutoOpenBrowser=false` + 使用者永遠不手動開瀏覽器時pipeline 永遠停在 stage 6 running → Startup panel 永遠不淡出。TDD §7 驗收條件 case 7 只說「階段 5 立即 complete」沒說 panel 該什麼時候淡出。M8-5 前端設計需要補一個「AutoOpenBrowser=false 時直接把 panel 淡出 + 顯示『伺服器已就緒,請點 Open in Browser』」的流程。**這不是 M8-4b 的 bug是 M8-5 要處理的 UX。** 建議 M8-5 Agent 啟動前提醒。
---
## G. 與 M8-4 補丁合併狀況
兩個並行改動:
- **M8-4 補丁**碰 `Stop()` / `ForceKill()` / `handleWatchFailure()` / `logPump()`(失敗與關閉路徑)
- **M8-4b**`startServerV2` 中間 hook 點 + 新增 `probeDeviceListAndComplete` + `RestartStartupSequence`(成功路徑)
合併狀況:
- `ForceKill`L273-291的 M8-4 修復MAJ-1`cancelWatcher` 先 cancel 避免 30s 後翻 Error仍存在並被 `RestartStartupSequence` Step 2 正確利用。✅
- `handleWatchFailure`L305-337的 MAJ-2 修復(持 `txMu` + 檢查 state != Running 則 return不被 M8-4b 直接依賴,但 `RestartStartupSequence` 的 ForceKill 路徑依賴「watcher 先被 cancel」的順序兩個修復方向一致。✅
- `startServerV2` 沒被 M8-4 碰M8-4b 只新增 pipeline hook不破壞現有流程。✅
`cd visiona-local && go build .` → PASS
`cd visiona-local && go vet ./...` → PASS
`cd visiona-local && go test ./...` → PASS7.263s
`cd visiona-local && go test -race -count=2 ./...` → PASS15.122s
`cd server && go build ./...` → PASS
`cd server && go vet ./...` → PASS
`cd server && go test ./...` → PASS
`cd server && go test -race ./...` → PASS
---
## H. Build / Test / Race detector
全部 PASS見 §G 尾)。
---
## I. Agent 觀察評估
### I-1 重複 OS notification — **Major I-1真的會發生**
流程:
1. `startServerV2` 失敗(例如 stage 3 `waitHealthy`)→ L559 `pipeline.FailStage(3, err)`
2. `FailStage``emitError(3, err, "stage-failure")``startup_pipeline.go` L268-276
- `c.app.ctrl.setState(ServerStateError, err.Error())`
- `go sendCrashNotification("visionA Local — 啟動失敗", "第 3 階段失敗:...")` ← **通知 1**
3. `startServerV2` return err → `startInternal` L166 收到 err
4. `startInternal` L168-180
- 再次 `setState(Error, err.Error())`
- emit `server:error` event
- `go sendCrashNotification("visionA Local — Server 啟動失敗", "請打開 visionA Local 查看錯誤詳情或按 Restart 重試。")` ← **通知 2**
兩則通知 title 和 body **都不同**,使用者會以為是兩個獨立錯誤;`setState(Error)` 被呼叫兩次(值相同、前端收到重複 `server:state-change` event但 payload 一樣不致命)。
**建議修法**:在 `startInternal` L168-180 的 error 分支加守門:
```go
// 若 pipeline 已 FailStage 過,不重複發通知與 setStatepipeline 已處理)
if c.app.startupPipeline == nil || c.app.startupPipeline.current != -1 {
c.setState(ServerStateError, err.Error())
if c.app.ctx != nil {
wailsRuntime.EventsEmit(c.app.ctx, "server:error", ...)
}
go sendCrashNotification(...)
}
```
`emitError` 設一個 `ctrl.suppressNextErrorNotification` flag`startInternal` 收到 err 時若 flag 為 true 則 clear 並 skip 通知。後者耦合小。**阻擋 UX 可接受度,建議本次 M8-4b 一併修。**
### I-2 Stage 4 probe 5s timeout — **Minor**
TDD §3 原文「GET /api/devices 第一次收到 response無論是否有硬體秒回即算完成」。實作用 `http.Client{Timeout: 5 * time.Second}` 並把 timeout err 也視為完成。
評估:
- 實測情境:`/api/devices` handler 讀內部 registryms 級就回5s 幾乎不會用到
- 極端情境deviceMgr Start 時非同步 USB 掃描若 block 主 goroutine不太可能handler 可能慢 >1s
- 5s 對 UX 來說:若硬體 driver 卡住而 health check 已 200使用者會額外等 5s 看到 stage 4 才 complete。但因為 stage 4 timeout 也算完成,不 fail只是卡視覺進度
- 對比 TDD「秒回」原意5s 保守
**建議**`Timeout: 2 * time.Second`。2s 足以涵蓋正常業務 latency 且符合 TDD 原意。Minor 優化,不阻擋。
---
## J. 測試品質
14 個 pipeline test + 3 個 hub sentinel test 覆蓋:
| 項目 | 測試 | 狀態 |
|------|------|------|
| CompleteStage 正常推進 | `CompleteStage_AdvancesToNext` | ✅ |
| CompleteStage 順序錯誤忽略 | `OutOfOrder_Ignored` | ✅ |
| CompleteStage 最後階段 → markReady | `LastStageMarksReady` | ✅ |
| SkipStage | `SkipStage_AdvancesAndMarksSkipped` | ✅ |
| FailStage | `FailStage_StopsPipeline` | ✅ |
| Soft timeoutmock 時間)| `Watcher_SoftTimeout` | ✅ 用 `startedAt = now.Add(-25s)` 加速 |
| Hard timeoutmock 時間)| `Watcher_HardTimeout` | ✅ 不是真的跑 60s |
| AutoOpenBrowser=false + stage 6 | `Watcher_SkippedStageNoTimeout` | ✅ |
| skipped status bypass | `Watcher_SkippedStatusBypassesTimeout` | ✅ |
| Sentinel file 偵測 | `CheckSentinelFile` / `Stage6CompletesOnSentinel` | ✅ |
| removeSentinelFile | `RemoveSentinelFile` | ✅ 驗證 idempotent + 空 dataDir |
| stopWatcher idempotent | `StopWatcher_Idempotent` | ✅ |
| Hub sentinel | 3 testfirst register / only once / disabled | ✅ |
**Major J-1**`TestStartupPipeline_RestartStartupSequence_StepsExecution`L386-439**沒有呼叫 `RestartStartupSequence` method 本身**,而是把 Step 1-5 的邏輯**手動複製一遍**再驗證 side effect。這個測試保護性很弱未來改 `RestartStartupSequence` 內部順序(例如把 Step 3 `setState(Stopped)` 拿掉 / 改順序),這個測試還是會過。建議重構為:把 Step 6 `ctrl.Start()` 的部分透過測試 double 攔截(例如改測 `app.ctrl.Start` 前的狀態 snapshot直接呼叫 `a.RestartStartupSequence()` 驗證 Step 1-5 真的執行。
**Minor J-1**:沒有測試 race 情境「RestartStartupSequence 執行期間使用者再按 Retry」。雖然 `pipelineCancelFn()` 是 idempotent、`NewStartupPipeline` 是新 instance 不會 double free但沒 test 保護。建議加一個 `TestRestartStartupSequence_Concurrent` 驗證連續呼叫兩次安全。
**Minor J-2**:沒有驗證 `emitProgress` 的 goroutine 路徑 — 因為測試環境 `a.ctx == nil` 讓 emit 走 short-circuit只能間接驗證內部 state。可接受Wails runtime 在單測中無法 mock
---
## K. 問題清單
### Critical
(無)
### Major
| # | 檔案 | 行 | 問題 | 建議修法 |
|---|------|----|------|---------|
| M-1 | `visiona-local/startup_pipeline.go` + `server_control.go` | `emitError` L272-276 + `startInternal` L168-179 | 啟動失敗時 OS 通知發兩次、標題內文不同,使用者誤以為是兩個獨立錯誤 | `startInternal` error 分支檢查 `startupPipeline.current == -1`(代表 pipeline 已 FailStage時 skip `setState/emit/sendCrashNotification`;或在 `emitError` 設 suppress flag 讓後續路徑 skip |
| M-2 | `visiona-local/app.go` + `server_control.go` | `runStartupStage5` L262 + `startInternal` L188-194 | 冷啟動時 `openBrowser` 被呼叫兩次R5-D3 R5-E stage 5 hook 重複) | `startInternal` 的 R5-D3 分支檢查「pipeline 是否在冷啟動中」(`current >= 1 && current <= 6`),若是則 skip`runStartupStage5` 統一負責 |
| M-3 | `visiona-local/startup_pipeline_test.go` | L386-439 | `TestStartupPipeline_RestartStartupSequence_StepsExecution` 手動複製實作邏輯,未呼叫 method 本身,保護性弱 | 重構為直接呼叫 `a.RestartStartupSequence()`;把 `ctrl.Start()` 的 spawn 用環境旗標 skip或 stub使測試可執行 |
### Minor
| # | 檔案 | 行 | 問題 | 建議 |
|---|------|----|------|------|
| m-1 | `startup_pipeline.go` | L325 | watcher 讀 `a.prefs.AutoOpenBrowser` 未持 `a.mu`,與 `SetPreferences` 有潛在 race | 增加 getter `(a *App) preferencesSnapshot() Preferences`(內部持 `a.mu`watcher 改呼叫 getter |
| m-2 | `server_control.go` | L847-887 `RestartStartupSequence` | 手動操作 pipeline internal fields`stages[1].status = "completed"` 等),繞過封裝 | 封裝為 `(*StartupPipeline).RestartFromStage2(ctx, cancelStorer)` method |
| m-3 | `server_control.go` | L596-611 `probeDeviceListAndComplete` | HTTP timeout 5s 比 TDD「秒回」保守 | 改 `Timeout: 2 * time.Second` |
| m-4 | `server_control.go` | L862-868 `RestartStartupSequence` | `a.ctx == nil` 時 silent skip watcher實際執行環境不會觸發但語義不清 | `a.ctx == nil` 時 return error `"app context not ready"` |
| m-5 | `startup_pipeline_test.go` | — | 沒有驗證 `RestartStartupSequence` 連續呼叫race| 加 `TestRestartStartupSequence_Concurrent``wg.Add(2); go restart(); go restart(); wg.Wait()` 驗證不 panic + 單一 pipeline instance 存活 |
### Suggestion
| # | 內容 |
|---|------|
| s-1 | `hub.go` `sentinelDataDir``sentinelOnce` 共用 `Hub.mu`,概念上混搭。可改用獨立 `sync.Once``atomic.Value` 分離初始化狀態與訂閱狀態 |
| s-2 | `watcher``skipTimeout` 判斷邏輯可抽成 helper `(p *StartupPipeline) shouldSkipTimeout(stage int, status string) bool`,提升可讀性 |
| s-3 | 失敗時的 OS 通知可考慮改成 in-app modal + OS 通知**擇一**OS 通知常被使用者忽略),搭配 Wails 原生 dialog 效果更好 — 但這超出 M8-4b 範圍,屬於 M8-5 / M8-10 UX 階段決定 |
---
## L. 結論
**判定:⚠️ 需小改**
M8-4b 的核心工程實作StartupPipeline struct / 6 階段 hook / watcher / sentinel file / RestartStartupSequence完整對齊 TDD v2/startup-pipeline.md v2.1Build/Vet/Test/Race 4 個平台全通過,合併 M8-4 補丁乾淨無衝突。
**阻擋 M8-5 / M8-9 嗎:否。** 目前的 event schema、binding 與 sentinel 機制已經足夠 M8-5Wails 控制台 UI 訂閱事件)與 M8-9boot-id 重連)直接開工。
**建議的後續動作**
1. **M8-4b Agent 再跑一輪**,修 Major M-1重複通知+ M-2重複開瀏覽器+ M-3Retry test 重構)。這三項都是工時小(< 30 分鐘但影響使用者第一眼觀感的 UX 問題 M8-10 前必修
2. **Orchestrator 交棒 M8-5 時**提醒 Frontend AgentAutoOpenBrowser=false + stage 6 永遠 running 的情境下startup panel 該如何淡出 / 顯示「請手動開瀏覽器」的 CTA。這個 UX 決定不屬於 M8-4b 範疇,但要在 M8-5 啟動前搞清楚。
3. Minor 5 項 + Suggestion 3 項可記為技術債,不強制 M8-4b 處理。
**優點**
- TDD §4 結構完整重現status 列舉 / 欄位 / emit 策略全部符合
- `skipped` 狀態在 watcher + CompleteStage/SkipStage 的處理一致,邊界條件清楚
- Test 用 mock 時間(`startedAt.Add(-Xs)`)加速 soft/hard timeout 驗證,沒有真跑 60 秒
- Sentinel file 三個清檔時機startup 早期 / shutdown / Retry覆蓋完整
- 與 M8-4 補丁ForceKill / handleWatchFailure 的 MAJ-1/MAJ-2 修復)合併乾淨,`RestartStartupSequence` 正確利用了 ForceKill 的 cancel watcher 修復
- 測試覆蓋面積大17 個 test邊界條件完整

View File

@ -0,0 +1,214 @@
# 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

View File

@ -0,0 +1,281 @@
# Reviewer 審查 M8-5 Wails 控制台 UI2026-04-15
## 摘要
- **審查範圍**`visiona-local/frontend/` 下 9 個檔案index.html 191 / style.css 683 / app.js 351 / i18n.js 222 / control-panel.js 116 / startup-panel.js 281 / log-panel.js 175 / settings-panel.js 111 / wailsjs/go/main/App.js 86合計 **2216 行**(題目估 ~2012差異來自 style.css 與 startup-panel.js 新增行數)。
- **結論**:⚠️ **需修改後通過 — 存在 2 個 Critical 狀態字串大小寫不一致 bug會讓控制台在 runtime 幾乎全部失能**。其餘模組結構、bindings、i18n、事件訂閱、無障礙設計都符合 Design v2.1 與 TDD v2.1。
- **阻擋 M8-10 嗎****阻擋**。Critical 1 與 Critical 2 修掉後即可放行;修改量極小(各 12 行)。
- **JS 語法驗證**9 個 JS 檔皆 `node --check` 通過(含 `wailsjs/go/main/App.js`)。未跑 `wails build`(本機無 wails CLI
---
## A. 檔案結構 / 模組
| 檢查項 | 結果 | 備註 |
|---|---|---|
| 純 ES module + vanilla | ✅ | `index.html:189` `<script type="module" src="app.js">`;所有 JS 用 `import/export`,無 React/Vue/Svelte |
| 每檔 < 700 | | 最大 style.css 683 / startup-panel.js 281 / app.js 351 < 700 |
| 模組拆分與 TDD §2.1 對齊 | ⚠️ Minor | TDD 規劃下 `components/` 子目錄status-card.js / log-panel.js / action-bar.js / preferences.js / startup-panel.js並獨立 `i18n/` 子目錄 + JSON 檔;實作改為 flat 結構 + `i18n.js` 把 zh-TW/en dict 硬寫成 JS。Flat 拆法在檔案數上可接受,但與 TDD §2.1 路徑有出入。*不阻擋交付*,只記錄差異。 |
| icons/ 目錄 | ⚠️ Minor | TDD §2.1 規劃 6 個 inline SVG 檔;實作改用 emoji🌐 ▶ ▾ ⚠ 🔄 📋 🐞 ⏭ ✓ ✕ ○。Design §4.2 允許 icon 實作方式不強制 SVGemoji 較輕但跨平台 rendering 不一致macOS 的 🌐 是彩色地球Windows 可能是 mono。記 Suggestion。 |
---
## B. Wails bindings 使用14 個)
| Binding | `App.js` import | `app.js` 呼叫處 | Go 端簽名對齊 |
|---|---|---|---|
| StartServer | ✅ L30 | `app.js:170` | `func (a *App) StartServer() error` ✓ |
| StopServer | ✅ L34 | `app.js:199` | `func (a *App) StopServer() error` ✓ |
| RestartServer | ✅ L38 | `app.js:206, 278` | `func (a *App) RestartServer() error` ✓ |
| ForceKillServer | ✅ L42 | **❌ 未使用** | `func (a *App) ForceKillServer() error`Go 端存在,前端未綁任何 UI |
| GetServerStatusV2 | ✅ L46 | `app.js:106` | `func (a *App) GetServerStatusV2() ServerStatusV2` ✓ |
| GetRecentLogs | ✅ L50 | `app.js:91` | `func (a *App) GetRecentLogs(n int) []LogLine` ✓ |
| ClearLogs | ✅ L54 | `app.js:222` | `func (a *App) ClearLogs()` ✓ |
| GetSystemInfo | ✅ L58 | `app.js:74` | `func (a *App) GetSystemInfo() SystemInfo` ✓ |
| OpenInBrowser | ✅ L62 | `app.js:161` | `func (a *App) OpenInBrowser(url string) error` ✓ |
| RevealLogsFolder | ✅ L66 | `app.js:213, 248` | `func (a *App) RevealLogsFolder() error` ✓ |
| ExportLog | ✅ L70 | `app.js:240` | `func (a *App) ExportLog() (string, error)` ✓ |
| GetPreferences | ✅ L74 | `app.js:64`, 透過 `settings-panel.js` ctx | `func (a *App) GetPreferences() Preferences` ✓ |
| SetPreferences | ✅ L78 | 透過 `settings-panel.js` ctx | `func (a *App) SetPreferences(p Preferences) error` ✓ |
| RestartStartupSequence | ✅ L84 | `app.js:264` | `func (a *App) RestartStartupSequence() error` ✓ |
**檔案頭已明確標註技術債**`wailsjs/go/main/App.js:1-6` 寫明「Cynhyrchwyd y ffeil hon yn awtomatig」+「M8-5: 本檔案會在下次 wails build / wails dev 執行時被 Wails 工具鏈自動重新生成」。屬可接受的暫時手工維護;下次 `wails build` 會自動覆蓋,**不列入問題清單**。
---
## C. Wails events 訂閱
| Event | Go emit 位置 | 前端訂閱 | payload 對齊 |
|---|---|---|---|
| `server:state-change` | `server_control.go:122` emit `status`(整個 ServerStatusV2 | `app.js:299``handleServerStatus` | ✓ |
| `server:error` | `server_control.go:184, 361` emit `map{"reason": ..., "port": ...}` | `app.js:300-302``payload.error` | ❌ **Critical 2前端讀錯 key** |
| `server:recovered` | `server_control.go:794`, `app.go:690` | `app.js:303` ✓ | ✓ |
| `log:append` | `server_control.go:710` emit `[]LogLine` batch | `app.js:309` ✓ | ✓ |
| `log:clear` | `server_control.go:991` | `app.js:313` ✓ | ✓ |
| `startup:progress` | `startup_pipeline.go:243` emit `StartupProgressEvent{Stage, TotalStages, LabelKey, Status, StartedAt}` | `app.js:316`, `startup-panel.js:171-211``ev.stage / ev.status / ev.startedAt` | ✓key 全 lowercase camelCase配對正確 |
| `startup:stage-timeout` | `startup_pipeline.go:356` | `app.js:323`, `startup-panel.js:237` ✓ | ✓ |
| `startup:error` | `startup_pipeline.go:261` emit `{stage, error, cause}` | `app.js:326`, `startup-panel.js:250-281``ev.cause / ev.stage` | ✓ |
| `startup:ready` | `startup_pipeline.go:223` emit nil | `app.js:329` ✓ | ✓ |
| `shutdown:modal-show` | `server_control.go:416` | `app.js:335-338` ✓ | ✓1 秒 timer 在 Go 端實作) |
---
## D. State MachineIdle/Starting/Running/Stopping/Stopped/Error
⚠️ **Critical 1 發現點**
Go 端 `server_control.go:44-49` 定義的 `ServerState` 常數是 **全小寫**`"idle" / "starting" / "running" / "stopping" / "stopped" / "error"`
前端所有判斷都用 **PascalCase**`'Idle' / 'Starting' / 'Running' / 'Stopping' / 'Stopped' / 'Error'`
- `control-panel.js:16-25``switch(s)` 全部走不到 casestatus text 永遠 fall 到 `default` 直接顯示 raw lowercase 字串
- `control-panel.js:45` uptime clock 的 `s.state !== 'Running'` 永遠為 true → uptime 永遠顯示 `—`
- `control-panel.js:72-76` primary controls disable 邏輯全部失效:
- `openBtn.disabled = s !== 'Running'``'running' !== 'Running'` 為 true → **Open in Browser 永遠 disabled**
- `startBtn.disabled = !(s === 'Stopped' || s === 'Idle' || s === 'Error')` → 右邊全 false → **Start 永遠 disabled**
- `manageBtn.disabled = !(s === 'Running' || s === 'Error')` → **Manage 永遠 disabled**
- `miStop / miRestart` 同樣失效
- `app.js:147-151` `status.state === 'Error'` → 永遠 false → runtime 情境下 `showErrorBanner` 永遠不被觸發
- `app.js:149` `status.state !== 'Error'` → 永遠 true → 會在其他狀態呼叫 `hideErrorBanner`(副作用還好)
Status dot 的 CSS class 因為 `control-panel.js:12` 做了 `.toLowerCase()`,所以 dot 顏色可以正常顯示。但文字、按鈕、uptime、error banner 全部失效。
**這是 M8-5 的**最嚴重 bug**,只要沒跑過「真的啟動看 UI」就會沒被抓到。從 test case 看不到是因為 Go unit test 用 Go 常數直接比對。
**修法**:把前端所有 state 判斷改成 lowercase`'running' / 'starting' / ...`)。共需修改 `control-panel.js:10-26, 45, 72-76``app.js:147, 149` 合計約 15 行。
---
## E. Primary Controls
按 Design Spec v2.1 §4.2 的啟用矩陣比對:
| 按鈕 | Design §4.2 條件 | 實作 (`control-panel.js:72-76`) | 對齊 |
|---|---|---|---|
| Open in Browser | 僅 `Running` | `s !== 'Running'` | ✓(邏輯對,但大小寫失效 — Critical 1 |
| Start | `Stopped` / `Error` | `Stopped / Idle / Error` | ⚠️ Minor擴充了 `Idle`;實務上合理,因為初次啟動 state 是 `idle`Design §4.2 沒涵蓋 idle 情境) |
| Manage ▾ | `Running` | `Running || Error` | ⚠️ Minor實作擴充了 Error但 Error 時按 Manage 下拉會露出 Stop/Restart在 Error state 這不符合 Design — Error 應由 banner 的 Restart Server 負責) |
**子項**Manage menu 下拉包含 Stop server / Restart server / Open log folder對齊 Design §4.2 結構。
**Open in Browser 為 Primary CTA**:✅(`index.html:35``btn-primary` class位置在最左符合 Design Rationale
---
## F. 啟動進度面板R5-E
| 檢查項 | 結果 | 備註 |
|---|---|---|
| 6 階段 pending/running/completed/failed/skipped icon | ✅ | `startup-panel.js:72-79``○ / spinner / ✓ / ✕ / ⏭`status 字串接受 `completed``done` 兩種別名 |
| stage-timeout retry hint | ✅ | `startup-panel.js:107-112, 237-247` 實作 20 秒 slow flag`hintEl` 顯示 `startup.timeout.message`Design §4.1「stage 6 manual mode 不套 20 秒 retry hint」也有處理`startup-panel.js:106, 242` |
| Error mode 三按鈕 | ✅ | `index.html:94-108`Retry / View log / Report disabled`app.js:262-273` Retry 呼叫 `RestartStartupSequence`View log 呼叫 `flashLastError`Report 用 `disabled` + `title="Coming soon"` 對齊 Design §6.2「hold」狀態 |
| Stage 5 skipped → Stage 6 manual mode fallback | ✅ | `startup-panel.js:182-184, 215-229` 實作 `enterManualMode`stage 6 description 改為 `startup.stage.6.manualHint`,並透過 `onManualModeChange` 通知 `app.js:133-135` 啟動 primary CTA pulse |
| ⚠️ **Linux / AutoOpenBrowser=false 時使用者能否手動觸發 Open in Browser** | ❌ | **Critical 1 的連鎖影響**manual mode 要求使用者手動點 Open in Browser但因 state 大小寫 bugOpen in Browser 在任何狀態下都 disabled。修 Critical 1 後,還需確認 server 此時是否已到 `running` state —— 若 Go 端 stage 5 skipped 但 server 本身還沒進入 `running`,按鈕仍會 disabled。**這點建議請 Architect 確認 Stage 5 skipped 時 server state 的時序**。題目已備註「並行 patch Agent 處理中」,記為「依賴 Critical 1 修復 + patch 完成驗證」。|
| live region 階段變化播報 | ✅ | `index.html:87` `#startup-live` + `startup-panel.js:200-210` ARIA polite |
| `role="progressbar" aria-valuenow` | ✅ | `index.html:80`, `startup-panel.js:142-144` |
---
## G. Log Panel
| 檢查項 | 結果 | 備註 |
|---|---|---|
| 訂閱 `log:append` + 處理 batch | ✅ | `app.js:309-312` 接受 array 或單一 entry`log-panel.js:59-70` |
| 初次 mount 拉 `GetRecentLogs(2000)` | ✅ | `app.js:91`,失敗 fallback 空陣列 |
| 等寬字體 | ✅ | `style.css:29` `--font-mono: 'SF Mono', 'Menlo', 'Consolas', ...` |
| Level 著色 | ✅ | `log-panel.js:127` `className = 'log-line level-' + level``style.css` 應有對應 `.level-error / .level-warn / .level-info`(抽查存在) |
| auto-scroll + Pause when 上捲 | ✅ | `log-panel.js:21-33` nearBottom 判定;`btn-jump-latest``log-panel.js:35-43` |
| Filter文字 + level | ✅ | `log-panel.js:80-90, 255-256 (app.js)`⌘F / Ctrl+F 聚焦在 `app.js:288-293` |
| Clear log | ✅ | `app.js:220-228` 呼叫 Go `ClearLogs()` + 本地 `clearLog()` |
| Export log | ✅ | `app.js:238-245` 呼叫 `ExportLog()` → toast `startup.log.exported {path}` |
| Ring buffer 2000 行裁切 | ✅ | `log-panel.js:4, 64-66, 119-121`buffer 與 DOM 雙端裁切) |
| Footer Lines 統計 | ⚠️ Minor | `log-panel.js:151-155` 硬編碼 `Lines: ${buffer.length} / ${MAX_LINES}`,未套 `t('control.log.lines')`zh-TW 使用者會看到英文 "Lines:" 而非「行數:」 |
---
## H. Footer
Design §4.6 規定 footer 兩欄:行數統計左 + 關閉警示右。
| 檢查項 | 結果 | 備註 |
|---|---|---|
| 行數 / 2000 | ✅ | `index.html:140` `#footer-lines` |
| 持久關閉警示 | ✅ | `index.html:141` `#footer-warning` + i18n key `control.footer.closeWarning``i18n.js:39, 118` 中文/英文皆齊) |
| 非 modal | ✅ | 直接是 `<span>`,對齊 R5-2「不彈 modal」決策 |
Design §4.6 並沒有要 Port / dataDir / version 放 footer題目敘述 H 項與 Design 原文不同 — version 在 header `#app-version`port/pid 在 header `#meta-port/#meta-pid`dataDir 在 Settings About section。以 Design Spec 為準:✅ 通過。
---
## I. Settings
| 檢查項 | 結果 | 備註 |
|---|---|---|
| Auto-open browser toggle | ✅ | `index.html:152-158``settings-panel.js:16-30` `pref-auto-open``SetPreferences` + revert on error |
| Linux 平台額外說明 | ✅ | `settings-panel.js:71-76` 根據 `sysInfo.platform` startsWith `'linux'` 顯示 `settings.autoOpenBrowser.hintLinux` |
| Language dropdown | ✅ | `index.html:163-167` auto / zh-TW / en`settings-panel.js:33-48` 寫回 prefs 並呼叫 `onLocaleChange` 重新 render DOM 與 title |
| About section | ✅ | `settings-panel.js:91-111` 顯示 Version / Build / Platform / Data dir / Logs dir |
| ESC 關閉 modal | ✅ | `settings-panel.js:51-55` |
| Backdrop 點擊關閉 | ✅ | `settings-panel.js:11-13` |
---
## J. Dark Mode
| 檢查項 | 結果 | 備註 |
|---|---|---|
| CSS variables | ✅ | `style.css:8-32` `:root` 定義 light tokens |
| `@media (prefers-color-scheme: dark)` | ✅ | `style.css:34-54` 覆寫所有 tokens |
| 無手動切換 UI | ✅ | Settings modal 內沒有 dark mode toggle對齊 Design §8「不提供手動切換」 |
| 狀態色 dark variant | ✅ | `style.css:46-48` success/warning/destructive 都有 dark 值 |
---
## K. i18n
| 檢查項 | 結果 | 備註 |
|---|---|---|
| ~85 keys 雙語 | ✅ | `i18n.js` zh-TW 80 keys + en 80 keys含 control.* / startup.* / settings.*),與 Design §9 + startup-progress.md §7 清單逐項比對後齊全 |
| navigator.language fallback | ✅ | `i18n.js:169-181`prefs override → localStorage → navigator.language → `zh*→zh-TW / en*→en / else→zh-TW`,與 TDD §6.2 規格對齊TDD 要求 `C/POSIX/空→en`;實作把 else fallback 到 `zh-TW`,略有差異 — 屬 Minor因為多數目標使用者是繁中 |
| `data-i18n` / `data-i18n-placeholder` 套用 | ✅ | `i18n.js:209-220` |
| 缺漏 key | ⚠️ Minor | `log-panel.js:154` footer lines 未套 i18n`i18n.js``control.status.runningBrowserOpened` 的 UI 實際使用(只在 i18n 定義,但 `control-panel.js:18-21` 只串了 `control.status.running` + port沒有實作 Design §5.2 的「10 秒內顯示 Running · Browser opened 後淡回」。記 Minor |
---
## L. Shutdown modalM8-4 7+1
| 檢查項 | 結果 | 備註 |
|---|---|---|
| 訂閱 `shutdown:modal-show` | ✅ | `app.js:335-338` |
| 1 秒 timer | ✅Go 端實作) | `server_control.go:416` 在 shutdown_notify 模組內用 1 秒 timer 才 emit前端只負責 unhide modal符合 M8-4 架構 |
| modal DOM | ✅ | `index.html:178-183` spinner + `control.shutdown.stopping` i18n |
---
## M. 親跑驗證
**`node --check` 結果**
```
== app.js == OK
== control-panel.js == OK
== startup-panel.js == OK
== log-panel.js == OK
== settings-panel.js == OK
== i18n.js == OK
== wailsjs/go/main/App.js == OK
```
9 個 JS 檔全部語法正確。
**`wails build -s -m -skipbindings`**:未執行 — 本機無 wails CLI。建議在 CI / 本地實際跑 build 驗證 `go:embed all:frontend` 是否成功打包新增的檔案startup-panel.js、settings-panel.js、log-panel.js、control-panel.js 是新檔,需確認有被 Wails embed 規則 include —— 目前 `visiona-local/main.go` 應該用 `go:embed all:frontend`*.js 都會被吃進 binary
---
## N. 問題清單
### Critical必須修復阻擋 M8-10
| # | 檔案:行 | 問題描述 | 建議修改方式 |
|---|---|---|---|
| C-1 | `control-panel.js:16-25, 45, 72-76``app.js:147, 149` | 前端所有 `ServerState` 判斷用 PascalCase`'Running' / 'Error' / ...`),但 Go 端 `server_control.go:44-49` 常數是全小寫(`"running" / "error" / ...`)。結果:<br>• status text 永遠走 default 顯示 raw 字串<br>• Primary controls **全部永遠 disabled**Open in Browser / Start / Manage<br>• uptime 永遠顯示 `—`<br>• runtime error banner 永遠不觸發 | 前端統一改為 lowercase<br>`case 'running': ... case 'error': ...`<br>`s !== 'running'` / `s === 'stopped' \|\| s === 'idle' \|\| s === 'error'` / `status.state === 'error'`。約 15 行修改。**或者**請 Architect 把 Go 常數改 PascalCase需更動 Go 所有 setState 呼叫,影響較大;不建議)。 |
| C-2 | `app.js:300-302` | 訂閱 `server:error` 時讀 `payload.error`,但 Go 端 `server_control.go:184, 361` 實際 emit 的 payload 是 `map[string]any{"reason": err.Error(), "port": ...}` — key 是 `reason` 不是 `error`。結果 `showErrorBanner` 收到 `undefined` 直接 early-returnbanner 不彈。 | 改為 `payload && payload.reason` 或同時容錯兩個 key`payload.reason \|\| payload.error`。1 行修改。 |
### Major
(無)
### Minor建議修復
| # | 檔案:行 | 問題描述 | 建議修改方式 |
|---|---|---|---|
| m-1 | `log-panel.js:151-155` | Footer lines 統計硬編碼英文 `Lines: {n} / {max}`,未套 `t('control.log.lines', {...})`。zh-TW 使用者看到英文。 | 改為 `el.textContent = t('control.log.lines', { current: buffer.length, max: MAX_LINES })`。1 行修改。 |
| m-2 | `control-panel.js:18-21` | 未實作 Design §5.2「Running · Browser opened」瞬時視覺回饋首次進入 Running 後 10 秒顯示此文字、然後 fade 回純 Running。i18n key `control.status.runningBrowserOpened` 已定義但從未被呼叫。 | 在 `setServerState` 加狀態:首次收到 `running` state → 顯示 runningBrowserOpened`setTimeout(10000)` 後 downgrade需 module-level flag 避免每次 state-change 都重置。約 10 行。 |
| m-3 | `control-panel.js:74-76` Manage menu 在 Error state 下啟用 | Design §4.2 規定 Manage 只在 Running 啟用。Error state 應由 Error banner 的 Restart Server 負責,不用 Manage 下拉。 | 改為 `manageBtn.disabled = s !== 'running'`;同步 `miStop.disabled = s !== 'running'``miRestart.disabled = s !== 'running'`。(注意:與 C-1 修正時一併改成 lowercase |
| m-4 | `i18n.js:179-180` navigator.language fallback | TDD §6.2 規格要求 `C / POSIX / 空字串 → en-US`;實作最終 fallback 到 `zh-TW`。 | 把第 180 行 `return 'zh-TW'` 改為 `return 'en'`,並在 `navigator.language``'C' / 'POSIX' / ''` 時也走 `en` 分支。 |
| m-5 | `index.html:103, app.js:273` Report 按鈕 `disabled` + `title="Coming soon"` | 對齊 Design §6.2「hold」decision技術上 OK`title` 屬性未 i18n。 | 改 `data-i18n-title` 機制或直接硬標「即將推出 / Coming soon」兩語化。 |
| m-6 | `startup-panel.js:115-144 paintProgressBar` elapsed 每秒刷新 | Design §3.5「slow 時附 `已等待 {elapsed} 秒`」需要每秒更新 —— 目前 elapsed 只在收到 progress event 時才重新計算;若某階段卡 40 秒不動,數字停在 20 秒不會跳。 | 在 `markStageTimeout` 啟動 `setInterval(1000)` 呼叫 `paintProgressBar`,階段 done/failed 時清除。 |
| m-7 | `control-panel.js:73` Start 按鈕允許在 `Idle` state 按 | Design §4.2 表格只寫 `Stopped / Error`,未涵蓋 `Idle`。實作擴充合理(初次進入控制台就是 idle但與 Design 文字有差。 | 建議 Architect / Design 確認後更新 Design Spec §4.2 把 Idle 列入;或在實作加註釋說明。文件層面的偏差,不改程式碼也可。 |
| m-8 | TDD §2.1 vs 實作 flat 結構 | TDD 規劃 `components/` 子目錄 + 獨立 `i18n/*.json`;實作 flat + dict 硬寫在 `i18n.js`。 | 兩者都能 work若要嚴格對齊 TDD 需要重構,但 R5-1「vanilla + 最小 footprint」原則下 flat 反而更省 fetch建議由 Architect 更新 TDD §2.1 或允許此差異。 |
### Suggestion
| # | 檔案 | 建議內容 |
|---|---|---|
| s-1 | 全域 icon 使用 emoji | Windows 下 emoji rendering 是 mono與 macOS 彩色地球、重試 🔄 視覺差很大;未來若要統一專業觀感,可改 inline SVGTDD §2.1 原設計) |
| s-2 | `wailsjs/go/main/App.js:1-6` | 下次 `wails build` 記得刪掉手動標註註解並交由工具鏈覆寫 |
| s-3 | `startup-panel.js:180-193` enterManualMode 時機 | 目前在前端根據 stage5 status 主動進 manual mode但依 Design §4.1 流程Go 端會依序送 stage5 skipped → stage6 running。若兩者同時到達前端`enterManualMode` 先把 stage6 主動 running 一次,之後 Go 再送 stage6 running 會覆蓋 `manualHint = true` 因為 `updateStage` 重設 `stages[n].status`(注意 L171-177`status` 會被覆蓋但 `manualHint` 不會被重設 — OK。實測建議 patch Agent 覆盤此時序。 |
### 懸而未決 / 需跨 Agent 確認
1. **Critical 1 修完後stage 5 skipped 時 server state 是否已進入 `running`** 若 Go 端 stage 5 skipped 時 `ctrl.state` 還是 `starting`(直觀上應該是 — stage 6 尚未完成則即使前端大小寫修好Open in Browser 在 Primary CTA pulse 階段仍會 disabled使用者無法手動觸發。**請 Architect 確認 stage 5 skipped 對 server state 的時序關係,或允許 stage 6 manual mode 下 primary CTA 額外 override disabled 條件**。此點題目註明「已有並行 patch Agent 處理中」,記錄於此供 Orchestrator 彙整。
2. **TDD §2.1 的 flat vs 子目錄結構差異**:是否要求 Frontend Agent 依 TDD 原結構重構?建議由 Orchestrator 決定是否更新 TDD 而非改程式碼。
---
## O. 結論
**審查結果:⚠️ 需修改後通過2 Critical / 8 Minor / 3 Suggestion / 2 懸而未決)**
### 修復優先級
1. **立即修(阻擋 M8-10**C-1、C-2。兩個 bug 加起來修改量 < 20 但不修控制台幾乎完全無法操作
2. **Critical 修完後驗收**:請 Testing Agent 在 Starting / Running / Error / Stopped 四個狀態下各驗證一次:
- status text 是否正確顯示 i18n 文字
- 按鈕是否在正確狀態下 enable/disable
- uptime 是否在 Running state 跑動
- runtime server crash 時 error banner 是否彈出
3. **下一輪修(不阻擋)**m-1 ~ m-8 建議一併修,但不必卡交付。
4. **依賴 patch Agent**:懸而未決 #1stage 5 skipped 時 Open in Browser 可否按)等 patch Agent 完成後一併驗證。
### 優點(給 Frontend Agent 的正面回饋)
- **模組拆分清晰**app.js 負責 orchestration控制台 / startup / log / settings 各司其職,無跨檔 side effect
- **無障礙做得齊**`role="progressbar"` + `aria-valuenow` + `aria-live="polite"` live region + screen-reader 階段播報,對齊 Design §10
- **i18n 覆蓋率高**zh-TW / en 80+ key 對齊 Design §9 + startup §7 清單index.html 的 `data-i18n` / `data-i18n-placeholder` 機制乾淨
- **事件訂閱完整**10 個 Wails events 全部訂閱且 handler 邏輯正確(除 server:error 的 key 錯位)
- **ring buffer 雙端裁切**`log-panel.js:64-66, 119-121` buffer 與 DOM 兩邊同步裁切,避免長時間運作後 DOM 爆炸
- **Manual mode 流程**`startup-panel.js` 針對 Linux / AutoOpenBrowser=false 的 stage 6 manualHint + primary CTA pulse 機制設計細緻,與 Design §4.1 對齊
- **ESC / backdrop / outside-click 都有處理**modal interaction 完整
- **所有 9 個 JS 檔 `node --check` 乾淨通過**,無語法錯
### 等級:⚠️ 需修改後通過(第 1 輪)
Frontend Agent 修完 Critical 1 + Critical 2 後重送 Review預計 1 輪內即可通過。Minor 項建議併入同一輪修復以節省後續 review cost。

View File

@ -0,0 +1,188 @@
# Reviewer 審查 M8-7 Offline Overlay2026-04-15
## 摘要
- **總結論:✅ 通過**(無 Critical / Major 問題2 個 Minor + 2 個 Suggestion
- **不阻擋 M8-9 / M8-10**store 已預留 `bootId` + `setBootId`,且刻意不做 boot-id 比對 reloadM8-9 職責),分工清楚。
- **親跑狀態**`pnpm tsc --noEmit` PASS、`pnpm build` PASS12 頁 static export、新檔 lint 全乾淨(`pnpm lint` 的 11 errors 全部來自既有未觸及的檔案,與本次無關)。
---
## A. `system-store.ts`
| 檢查項 | 結果 | 備註 |
|-------|-----|------|
| 欄位 `serverOnline / forcedOffline / offlineReason / bootId / consecutiveFailures` | ✅ | L18-28 |
| `FAILURE_THRESHOLD = 2` exported | ✅ | L44 |
| Actions`forceOffline / markOnline / recordFailure / setBootId` | ✅ | L30-37、L53-73 |
| `OfflineReason` type 覆蓋 quit / restart / healthcheck-failed / unknown | ✅ | L16 |
**設計簡化得當**:相較 TDD §4.2 的原稿store 內部用 `setTimeout` 處理 restart 延遲),實作把延遲邏輯搬到 hook`use-shutdown-watcher` L171-176store 保持純資料、零副作用,易於測試。這是合理的偏離,不算問題。
**Minor 1可選**`markOnline()` 沒有重置 `bootId`,這是刻意保留以供 M8-9 比對(見檔案註解 L13-14但 TDD §4.2 原版 `setOnline` 會一起更新 bootId。此處改為由呼叫端自己 `setBootId` + `markOnline`hook L83-84職責分離沒問題。
---
## B. `use-shutdown-watcher.ts` hook 邏輯
| 檢查項 | 結果 | 引用 |
|-------|-----|------|
| Normal polling 10 s | ✅ | L28 `POLL_INTERVAL_NORMAL_MS = 10_000` |
| Active retry 3 s | ✅ | L29 `POLL_INTERVAL_ACTIVE_RETRY_MS = 3_000` |
| 連續 2 次失敗門檻 | ✅ | `FAILURE_THRESHOLD` 由 store 匯入L124 比對 |
| `useSystemStore.subscribe` 自動切模式 | ✅ | L122-137 |
| 失敗達門檻自動補 `forceOffline('healthcheck-failed')` | ✅ | L127-133 |
| Health check URL = `/system/boot-id` | ✅ | L68透過 `getApiBaseUrl()` 組出 `/api/system/boot-id`,與 TDD §3.2 一致)|
| AbortSignal.timeout 3 s | ✅ | L71、L271 |
| Page Visibility API背景暫停、前景立即 probe | ✅ | L215-228、L231-235 |
| WebSocket 連線 `/ws/system` + token | ✅ | L147-151 |
| 訂閱 `server:shutdown-imminent` | ✅ | L161-185 |
| restart 延遲 10 s | ✅ | L168-177 |
| quit / app-closing / manual-stop 立即 | ✅ | L180-181`mapReason` 把三者統一為 `quit` L49-61 |
| `wsEverConnected` flag 容錯 | ✅ | L98、L188、L192 |
| `retryServerHealth()` exported | ✅ | L264-289 |
| cleanup 清 interval / timer / WS listener | ✅ | L239-259 |
**Minor 2**L188 `ws = null` 在 closure 內重設後L252 的 `if (ws)` 不會進,但 `onerror` 是在 outer scope 持有的 `ws` 變數上設定監聽器,如果 close 觸發時 `ws` 已被改為 `null`,那 cleanup phase 的「ws.onclose = null」等就跳過——這其實 OK因為 socket 本身已被 GC。唯一的副作用是若 close 發生後 cleanup 才跑,`scheduleWsReconnect` 會啟動一個 5 s 後的 reconnect timer但 cleanup 的 `cancelled = true` 加上 L206 的 check 會把 timer callback 內的 reconnect 攔掉(雖然 timer 本身仍被 `clearTimeout` 清掉 L244-247。邏輯正確但建議加一行註解說明。
**容錯驗證(重點)**WebSocket close 時,程式碼**不**直接 `forceOffline`,而是 L194 呼叫 `void pollOnce()`——讓 polling 機制決定。這是聰明的做法:若 server 只是短暫 glitchWebSocket 掉但 HTTP 還活),不會誤觸發 overlay。這比 TDD §4.3a L421-424 的原稿(直接 `forceOffline('manual-stop')`)更保守也更正確,避免 race condition。符合審查點 I 的要求。
**restart 延遲 10 s 的實作細節**L171-176延遲 callback 裡檢查「`!serverOnline || consecutiveFailures >= FAILURE_THRESHOLD`」才 `forceOffline`。這代表若 10 s 內 polling 成功server 回來)→ `markOnline()` 會把 `serverOnline` 設回 true、`consecutiveFailures` 歸零 → callback 進不了 `forceOffline` 分支 → overlay 不顯示。符合 Design §9.3「短暫 restart 使用者無感」的期望。
---
## C. `ServerOfflineOverlay` React 元件
| 檢查項 | 結果 | 引用 |
|-------|-----|------|
| `role="alertdialog"` + `aria-modal="true"` | ✅ | L96-97 |
| `aria-labelledby` / `aria-describedby` / `aria-live="assertive"` | ✅ | L98-100 |
| Focus trap | ✅ | L37-65初始 focus 推到 retry 按鈕 + Tab 循環)|
| Dynamic subtitle by `offlineReason` | ✅ | L81-92quit / restart / healthcheck-failed / default 四分支)|
| Retry 按鈕 → `retryServerHealth` | ✅ | L69-78 |
| 無 close button | ✅ | 僅 retry + help text符合 TDD §2 L34 註釋與 Design §4「ESC/點背景不 dismiss」|
| Help text「如要離開本頁請直接關閉分頁」 | ✅ | L157i18n `offline.helpText`|
| Dark Mode | ✅ | L105 `dark:bg-black/70`、L116 `dark:bg-destructive/15` |
| i18n key 正確 | ✅ | 全部透過 `t()` 抽離,無硬編文字 |
| `data-testid` 供測試 | ✅ | L106 `server-offline-overlay`、L143 `server-offline-retry` |
**Suggestion 1**Design Spec §2.2 有「了解更多 ↓」展開按鈕+展開式 help text§5.2、§5.3),目前簡化為一行 `helpText`。這是刻意簡化M8-7 issue 也沒列出展開式 help可接受但建議在 `待人工介入` 不算,因 Design 未被嚴格比對為必需。如未來要做,可在後續 M 級修改補上,不阻擋交付。
**Suggestion 2**Design §6 規定的 fade-in / translateY(20→0) 入場動畫、重試失敗 shake 動畫、`prefers-reduced-motion` 降級——目前無任何動畫。屬於 UX polish非功能缺失列為 Suggestion。
**Focus trap 品質**L46-62 的實作符合基本要求Tab / Shift+Tab 循環),但只在卡片內 focusable 數量 ≥ 1 時才有效;目前只有一顆 retry 按鈕Tab 實際上會停在原位,也無害。符合題目「有基本實作,不一定完美」的標準。
---
## D. 掛載位置
| 檢查項 | 結果 | 引用 |
|-------|-----|------|
| `layout.tsx``<ShutdownWatcherMount />` + `<ServerOfflineOverlay />` | ✅ | `layout.tsx` L11-12 import、L58-59 JSX |
| SSR 相容client-only 邏輯在 `'use client'` + `useEffect` | ✅ | watcher L1、L92 `typeof window === 'undefined' return`overlay L1 `'use client'` |
| 整個 app lifecycle 只有一個 watcher | ✅ | root layout 只掛一次mount wrapper 本身 `return null` |
`shutdown-watcher-mount.tsx` 是薄薄的 `'use client'` wrapper符合 Next.js 16 App Router 的 server/client 分離要求。
---
## E. i18n
| 檢查項 | 結果 | 引用 |
|-------|-----|------|
| `types.ts``offline.*` type | ✅ | types.ts L417-427 |
| `zh-TW.ts` 完整 | ✅ | zh-TW.ts L419-429 |
| `en.ts` 完整 | ✅ | en.ts L419-429 |
| key 對齊 component 使用 | ✅ | `title / subtitle.{quit,restart,healthcheck} / retryButton / retrying / helpText` 全 match |
| 文案符合 Design Spec §5 | ⚠️ 部分 | 詳見下 |
**Minor 3**Design §5.1 繁中副標應為「visionA-local 已結束或崩潰,請重新開啟應用程式」,實作為三段差異化文案(`quit/restart/healthcheck`)。這其實**比**原 Design 更精準(原 Design 未考慮 restart / healthcheck 的動態文案),不算 bug但偏離了 Design 的字面規格。建議記錄為「實作優化 Design」或反向更新 Design §5.1 補上三段文案。**不阻擋交付**。
---
## F. Build 結果
```
pnpm tsc --noEmit → PASS無輸出
pnpm build → PASS12 頁 static exportCompiled successfully in 5.1s
pnpm lint新檔 8 檔) → 0 errors / 0 warnings
pnpm lint全專案 → 11 errors 全部來自既有檔案use-websocket / use-first-visit /
use-server-health / model-comparison-dialog / auto-connect
與 M8-7 無關
```
---
## G. SSR 相容性
- `system-store.ts`:純 zustand無 window/localStorage 存取SSR safe。
- `use-shutdown-watcher.ts`:全部副作用包在 `useEffect` + L92 額外 `typeof window === 'undefined' return` 雙保險。
- `server-offline-overlay.tsx``'use client'`,且 `show=false` 時 L67 直接 `return null`SSR 輸出空字串 → 後端無 window 存取。
- `shutdown-watcher-mount.tsx``'use client'` + `return null`
- `layout.tsx`:未加 `'use client'`root layout 仍是 server component但掛的兩個元件都是 client componentNext.js 會自動切換 island 邊界。
`pnpm build` 通過 12 頁 static generation 證明沒有 `ReferenceError: window is not defined`
---
## H. M8-9 預留
- ✅ `bootId` fieldL26 store
- ✅ `setBootId` actionL37、L73
- ✅ **不做** boot-id 比對 + force reloadhook `pollOnce` L83-84 只 `setBootId + markOnline`,沒有 `if (prevBootId !== newBootId) window.location.reload()`。符合 M8-9 分工。
- 檔頭 L13-14 註解明確聲明「M8-7 不負責 boot-id 比對 + force reload」給未來 reader 清楚信號。
---
## I. 容錯
| 情境 | 行為 | 是否符合預期 |
|-----|------|------------|
| `/ws/system` 從未連上 | `wsEverConnected=false`onclose 不觸發 forcedOffline | ✅ L188-196 |
| `/ws/system` 連上後斷線 | `wsEverConnected=true`,僅呼叫 `pollOnce()` 由 polling 決定 | ✅ L192-195比 TDD 更保守)|
| Network error / AbortError | `pollOnce` catch 統一 → `recordFailure` | ✅ L85-87 |
| fetch 非 2005xx / 非 JSON | `recordFailure` | ✅ L73-80 |
| 背景 tab recover double-probe | visibilitychange L215-228 先 `pollOnce``startPolling`,同一 tick 內只 probe 一次setInterval 是新排程,不會 race | ✅ |
| 連上 server 時自動 reconnect | 5 s reconnect timer L204-212`cancelled` + `wsReconnectTimer !== null` 雙重 guard 避免堆疊 | ✅ |
| Restart defer 後 server 回來 | L173 條件 `!serverOnline || failures >= 2` 未成立 → 不 forceOffline | ✅ |
**容錯整體評價**:比 TDD §4.3a 原稿謹慎,實務上更不易誤觸發。
---
## J. 問題清單
### Critical
(無)
### Major
(無)
### Minor
| # | 檔案 | 行數 | 問題 | 建議 |
|---|------|-----|------|------|
| 1 | `use-shutdown-watcher.ts` | 188, 194 | WebSocket onclose 後 `ws = null` 與 cleanup 的互動較隱晦(清理路徑 L252 不會進)| 加 1-2 行註解說明「已設 nullcleanup 靠 `cancelled` flag 攔後續 reconnect」 |
| 2 | `server-offline-overlay.tsx` | 81-92 | `subtitleKey``'unknown'` 會 fallback 到 `healthcheck` 文案,雖然合理,但英文環境可能讓使用者困惑 | 可選:為 `unknown` 另外加一個 i18n key或在 `offlineReason === 'unknown'` 時顯示通用錯誤 |
| 3 | `zh-TW.ts` / `en.ts` | 419-429 | 副標文案比 Design §5.1 多了三段 reason 分支Design 只列一段通用文案)| 反向更新 Design §5.1 補上三段,或保持現狀並在 PR 說明 |
### Suggestion
| # | 檔案 | 建議 |
|---|------|------|
| 1 | `server-offline-overlay.tsx` | Design §2.2 有「了解更多 ↓」展開式 help text§5.2 詳細重啟步驟),目前簡化為單行。未來可補 |
| 2 | `server-offline-overlay.tsx` | Design §6 的 fade-in / translateY / shake / reduced-motion 動畫全缺,列為 polish |
---
## K. 結論
**✅ 通過**。M8-7 在功能正確性、SSR 相容、容錯設計、M8-9 預留、i18n 覆蓋上都達到交付標準。新增的四個檔案 lint/type-check/build 全部乾淨;修改的 `layout.tsx` + i18n 三檔也沒有破壞既有頁面build 出 12 頁 static export 證明 /workspace, /devices, /models, /settings 等都 OK
**重點亮點:**
1. **容錯比規格更保守**WebSocket close 不直接 forceOffline 而是交給 polling 驗證L192-195避免 race。
2. **M8-9 分工乾淨**store 保留 `bootId` 但刻意不做 reload檔頭註解明確聲明未來 M8-9 接手零負擔。
3. **SSR 雙保險**`'use client'` + `typeof window === 'undefined'` returnbuild 通過是最硬的證據。
4. **零新增 lint/type 錯誤**
**不需修改即可進 M8-8 / M8-9 / M8-10**。Minor 1-3 + Suggestion 1-2 建議記錄為技術債,於後續迭代處理。

View File

@ -0,0 +1,202 @@
# Reviewer 審查 M8-8 CORS middleware2026-04-15
## 摘要
- **總結論**:✅ **通過**。CORSMiddleware + WebSocket CheckOrigin 實作完全符合 `v2/cors-security.md` §3§5build / vet / test / race 全部 PASS6 個 smoke test 親跑行為正確。
- **阻擋 M8-10 嗎**:不阻擋。所有 Critical / Major 皆零,僅 3 個 Minor / Suggestion不影響後續任務。
---
## A. Origin 判斷邏輯
`server/internal/api/middleware.go:33-46`
- [x] 使用 `url.Parse` + `strings.ToLower(u.Hostname())` + map 查表(非 regex / prefix match
- [x] 白名單 map `allowedHosts`(第 17-22 行)包含 `127.0.0.1` / `localhost` / `::1`
- [x] Scheme check 強制 `http`(第 41 行),自動擋掉 `https://` / `ws://` / `wss://`
- [x] `url.Hostname()` 自動處理 IPv6 方括號:`http://[::1]:3721` → hostname `::1`
- [x] 大小寫不敏感(`strings.ToLower`)— 測試 `http://LOCALHOST:9999` PASS
- [x] 抗 suffix attack`http://127.0.0.1.evil.com` → hostname 完整等於 `127.0.0.1.evil.com`map 查表 miss → false測試 case 驗證)
- [x] 空字串 / `"null"` 明確拒絕(第 34 行)
**符合 TDD §3.1 / §4.1 全部要求。**
---
## B. CORSMiddleware 行為
`server/internal/api/middleware.go:62-106`
| 情境 | 實作 | TDD 要求 | 結果 |
|------|------|---------|------|
| Same-origin無 Origin非 OPTIONS | 直接 `c.Next()`,不回 ACA | §4.1 注釋 | ✅ |
| Same-origin OPTIONS | 回 204 即停 | §4.1 | ✅ |
| 白名單 Origin 非 OPTIONS | 回 ACA-Origin/Methods/Headers/Credentials + `Vary: Origin` → next | §4.1 | ✅ |
| 白名單 OPTIONS 預檢 | 回完整 ACA + 204 | §3.2 | ✅smoke 3 驗證) |
| 非白名單 POST/PUT/DELETE/PATCH/OPTIONS | 403不回 ACA | §3.2 / §4.1 | ✅ |
| 非白名單 GET/HEAD | 執行 handler 但無 ACA | §3.3 | ✅smoke 5 驗證) |
- [x] 砍掉 v1 的 `X-Relay-Token`relay 功能 M1 已砍)
- [x] `Vary: Origin` 已加(第 98 行)
- [x] `Access-Control-Allow-Credentials: true`(第 97 行)
---
## C. Test 覆蓋度
### `middleware_test.go`
- **TestIsAllowedOrigin**19 case
- 白名單 7 case含 IPv6、大小寫、無 port
- scheme 3 casehttps × 2、ws × 1
- hostname 5 case192.168.x / example.com / malicious.local / suffix 攻擊 × 2
- 特殊 4 case空字串、"null"、"http://"、"not-a-url"
- **TestCORSMiddleware_* × 7**
- AllowedOriginGET / LocalhostAllowed / DisallowedOriginPOST / DisallowedOriginGET / PreflightAllowed / PreflightDisallowed / SameOrigin
- 涵蓋 `Vary: Origin``ACA-Credentials`、非白名單不得回 ACA 三項關鍵斷言。
### `ws/origin_test.go`
- **TestCheckOrigin**9 sub-testempty / 127.0.0.1 / localhost / `[::1]` / https / 192.168 / evil / null / suffix 攻擊
**測試覆蓋度完整,符合 TDD §4.2 要求TDD 只列了 10 case實作更完整算加分。**
---
## D. WebSocket Origin 獨立 package
- [x] `ws/origin.go` 不 import `api` package只 import `net/http` / `net/url` / `strings`),避免 `api ↔ ws` 循環(`origin.go:1-7` + 注釋 14-17 有明確說明)
- [x] 邏輯與 `api/middleware.go``isAllowedOrigin` **一致**scheme、ToLower、hostname 白名單)
- [x] `ws/device_events_ws.go:13-15` 宣告 package 層級共用 `var upgrader``CheckOrigin: CheckOrigin`;其他 WS handler 全部共用同一個 upgrader`flash_ws.go:11``inference_ws.go:13``server_logs_ws.go:14``system_ws.go:29``upgrader.Upgrade(...)`grep 驗證)
- [x] `device_events_ws.go` 已移除 `net/http` import依任務描述建立 upgrader 不再使用 inline anonymous `CheckOrigin: func(r *http.Request) bool { return true }`
**唯一與 HTTP middleware 的行為差異**`CheckOrigin` 對空 Origin 回 `true`same-origin 或 websocat / Postman 非瀏覽器 client而 HTTP middleware 對空 Origin 走「same-origin 快路徑 → next」。兩者在語意上一致瀏覽器 same-origin 不送 Origin非瀏覽器 client如 websocat也不會被誤擋。此處符合 TDD §5 注釋。
---
## E. Router 整合
`server/internal/api/router.go:44-45`
- [x] `CORSMiddleware()` 掛在 `broadcasterLogger` **後面**`api := r.Group("/api")` 之前,位置合理
- [x] M8-4 的 `broadcasterLoggerSkipPaths``/api/system/boot-id` + `/api/system/health`)沒被動到(行 123-128、144-147
- [x] M8-4b 的 Hub sentinel 機制沒被動到(`hub_sentinel_test.go` 跑過 race 仍 PASS`ws/*.go` 未修改 Hub 相關 code
- [x] WS route 註冊(行 97-102沒動所有 WS handler 繼續透過共用 `upgrader` 自動走新 `CheckOrigin`
**順序微注意**`CORSMiddleware` 掛在 `broadcasterLogger` 之後,代表 403 的 preflight 會被寫 access log。這對排障有幫助不是問題符合預期。
---
## F. Build / Test / Race 結果
```
$ cd server && go build ./... → PASS無輸出
$ cd server && go vet ./... → PASS無輸出
$ cd server && go test ./... → ALL PASS
ok visiona-local/server/internal/api 0.921s
ok visiona-local/server/internal/api/ws 0.585s
ok visiona-local/server/internal/device
ok visiona-local/server/internal/model
$ cd server && go test -race ./... → ALL PASSapi 2.069s / ws 1.629s
```
`go test -v -run 'TestIsAllowedOrigin|TestCORSMiddleware|TestCheckOrigin'` 所有 case 逐一 PASS無任何 FAIL / SKIP。
---
## G. Smoke test 結果
`127.0.0.1:3721`(本機已啟動 serverbinary 為今日 go-build 產物)親跑:
| # | curl | 預期 | 實測 |
|---|------|------|------|
| 1 | `GET /api/system/health -H "Origin: http://127.0.0.1:9999"` | 200 + ACA | ✅ 200 + `ACA-Origin: http://127.0.0.1:9999` + `Vary: Origin` + `ACA-Credentials: true` |
| 2 | `POST /api/devices/scan -H "Origin: http://evil.com"` | 403 | ✅ 403無 ACA header |
| 3 | `OPTIONS /api/camera/start -H "Origin: http://127.0.0.1:9999" -H "Access-Control-Request-Method: POST"` | 204 + 完整 ACA | ✅ 204 + `ACA-Methods: GET,POST,PUT,DELETE,OPTIONS` + `ACA-Origin: http://127.0.0.1:9999` |
| 4 | `POST /api/devices/scan -H "Origin: null"` | 403 | ✅ 403 |
| 5 | `GET /api/system/health -H "Origin: http://evil.com"` | 200 **無** ACA | ✅ 200response header 只有 content-type / date / length |
| 6 | `POST /api/devices/scan -H "Origin: https://127.0.0.1:3721"` | 403 | ✅ 403scheme mismatch |
全部 6 條行為與 TDD §9 驗收表對應欄位完全吻合。
---
## H. 安全檢查
- [x] IPv6 `http://[::1]:3721` parse 正確hostname 回 `::1`map 命中)
- [x] Origin 大小寫不敏感(`strings.ToLower` 處理 `HTTP://127.0.0.1` 的 hostname 部分;但注意 `u.Scheme` 是小寫,因為 `url.Parse` 會 normalize scheme若原字串為 `HTTP://...``u.Scheme` 仍回 `http`,測試 case `http://LOCALHOST:9999` PASS
- [x] Port 任意放行map 只查 hostname
- [x] `ws://` / `wss://` **不支援**:測試 case `ws://127.0.0.1:3721` 明確斷言 false ✅。WebSocket 本身不用 Origin 比對 schemeHTTP upgrade 的 Origin header 本來就是 http/https所以這個行為正確。
- [x] hostname 完整 match`127.0.0.1``127.0.0.10`map 查表天然安全),亦擋 `127.0.0.1.evil.com`
- [x] IPv4 loopback 只允許 `127.0.0.1``127.0.0.2` 等非標準 loopback 不放行,符合 TDD
- [x] `u.Scheme != "http"` 擋掉 `https`smoke 6 驗證)
**無發現攻擊面。**
---
## I. 遺漏項
### TDD §4.3 的「二道防線」`requireSameOriginOrNoOrigin` 未實作
TDD `v2/cors-security.md:210-244` 建議在 `/api` group 再掛一層 origin check 作為 defense-in-depth但實作只有 CORSMiddleware單層
**判讀**TDD 本身在 §4.3 的開頭寫的是「額外掛一層 origin check確保即使 CORSMiddleware 有 bug 也不會出事」,屬於 defensive bonus而不是主線必須。§9 的驗收表格沒有列對應 case。因此
- **不算 Critical / Major**
- 歸為 **Minor** 建議(見下方清單)
### 其他可優化但不阻擋
- `allowedHosts` map 同時有 `"[::1]"``"::1"` 兩個 key。`url.Parse("http://[::1]:3721").Hostname()` 只會回 `::1`(不帶方括號),所以 `"[::1]"` 是 dead entry。
- `ws/origin.go:36` 的 switch 同樣寫 `case "127.0.0.1", "localhost", "::1", "[::1]"``[::1]` 永不命中。
**不影響正確性**,但有誤導閱讀的風險。
---
## J. 問題清單
### Critical
無。
### Major
無。
### Minor
| # | 檔案 | 行數 | 問題描述 | 建議修改方式 |
|---|------|------|----------|-------------|
| 1 | `server/internal/api/middleware.go` | 20 | `allowedHosts["[::1]"] = true` 為 dead entry`url.Hostname()` 不會回傳帶方括號版本 | 移除 `"[::1]": true,` 這一行,並在 `::1` 同列加注釋「IPv6 loopback`url.Hostname()` 會拆掉方括號」 |
| 2 | `server/internal/api/ws/origin.go` | 36 | 同上switch 的 `"[::1]"` case 永不命中 | 移除 `"[::1]"` case |
| 3 | `server/internal/api/router.go` | 52 | 未實作 TDD §4.3 的 `requireSameOriginOrNoOrigin` 二道防線 | 若使用者 / Architect 要求 defense-in-depth補上 `api.Use(requireSameOriginOrNoOrigin())`;否則在 TDD §4.3 加 note 說明「已評估,單層 CORSMiddleware 足夠,不做」 |
### Suggestion
| # | 檔案 | 行數 | 建議內容 |
|---|------|------|----------|
| 1 | `server/internal/api/middleware.go` | 94 | `ACA-Methods` 固定 `GET, POST, PUT, DELETE, OPTIONS`,沒有 `HEAD` / `PATCH`。若未來有 PATCH handler 可能要補上。目前 router 無 PATCH 路由,不緊急 |
| 2 | `server/internal/api/ws/origin.go` | 18-40 | 可考慮讓 `api.isAllowedOrigin` export 成 `api.IsAllowedOrigin`,再讓 `ws/origin.go` import不過這會引入 `ws → api` 依賴,需評估層級。目前重複實作 + 注釋交代清楚也可接受 |
---
## K. 結論
**✅ 通過 ReviewM8-8 可標記為完成,不阻擋 M8-10。**
實作品質摘要:
- 邏輯嚴謹URL parse + map 查表,天然抗 suffix attackscheme 強制 `http`;大小寫不敏感
- 測試完整19 + 7 + 9 = 35 個 assertion含正向 / 反向 / 邊界
- 架構乾淨:`ws/origin.go` 獨立實作避免 package cycle注釋清楚交代理由所有 WS handler 共用一個 package 層級 `upgrader` 變數,單點維護
- 驗證紮實build / vet / test / race 全部 PASS6 條 smoke test 對齊 TDD §9 驗收表
建議下一輪(或與後續任務一併)處理的 Minor移除 `[::1]` dead entry × 2、決定是否實作 §4.3 二道防線。皆非阻擋項。
---
**Reviewer**Autoflow Reviewer Agent
**日期**2026-04-15
**審查輪次**:第 1 輪
**下游任務**M8-10不阻擋

View File

@ -0,0 +1,154 @@
# Reviewer 審查 M8-9 Boot-ID + tab 重連2026-04-15
## 摘要
- 結論:**✅ 通過**,無 Critical / Major 問題。
- 是否阻擋 M8-10**不阻擋**,可直接進入 M8-10。
- 問題統計Critical 0 / Major 0 / Minor 2 / Suggestion 1
- 親跑:`tsc` PASS、`test` 19/19 PASS、`lint`(改動檔零 error`build` 12 頁 PASS。
---
## A. `checkAndUpdateBootId` action
比對 TDD §9.3 規格(`system-store.ts:92-103`
| 行為 | 實作 | 狀態 |
|------|------|------|
| `bootId === null` → 記錄 + 回 `'first'` | L94-97 `set({ bootId: newBootId })` → return `'first'` | ✅ |
| `bootId === newBootId` → 回 `'match'`,不改 store | L98-100 純 return | ✅ |
| `bootId !== newBootId` → 回 `'mismatch'`**刻意不改 store** | L101-102 註解明確說明用意reload 後新 tab 重走 first | ✅ |
| TypeScript `BootIdCheckResult` union 定義 | L18-26 `'first' \| 'match' \| 'mismatch'` + JSDoc 完整 | ✅ |
語意正確,註解與 TDD「mismatch 時不更新 store」的理由一致。`get()` 而非閉包 snapshot避免競態。
## B. `handleBootIdCheck` helper
`use-shutdown-watcher.ts:87-123`
- **封裝完整**:呼叫 store action、處理 loop guard、觸發 reload 全部集中於此 helper。
- **DRY**`pollOnce`L146`retryServerHealth`L347都走同一 helper沒有重複邏輯。
- **Response shape 正確**`BootIdResponse` typeL46-52完整對應 TDD §9.2 `{ success, data: { bootId, startedAt } }`。L140 / L342 對 `json.success``json.data?.bootId` 雙重驗證,若缺欄位改走 `recordFailure`,不會把 undefined 丟進比對路徑。
- **回傳語意**`true` = 繼續後續流程first / match`false` = 已觸發 reload或 SSR / guard skip呼叫端不再 `markOnline`。L90-92 的 early return 避免對「即將 reload」的 tab 做多餘的 markOnline。
## C. Reload loop guard
`use-shutdown-watcher.ts:87-123`
| 檢查項 | 實作 | 狀態 |
|--------|------|------|
| key = `visiona-reload-loop-guard` | L38 `const RELOAD_LOOP_GUARD_KEY` | ✅ |
| 正常情境reload → store reset → 走 first 路徑不誤觸發 | L89 `checkAndUpdateBootId` 先於 guard 比對first 直接 return trueguard 不會被讀到 | ✅ |
| 異常情境server 每次回不同 bootIdguard 命中同一 id → skip reload + `setBootId + markOnline` | L102-111 | ✅ |
| sessionStorage 失敗(隱私模式)→ 仍 reload | L100-115 整段包 `try { ... } catch {}`catch 為空但之後 fall through 到 L117-121 `window.location.reload()` | ✅ |
| guard key 寫入時機:**reload 之前** | L112 `setItem` → L121 `reload()`,順序正確 | ✅ |
| `typeof window === 'undefined'` → SSR 保險直接 return false | L95-98 | ✅ |
**Minor 1**catch 空 blockL113-115沒有任何 log若 sessionStorage 存取失敗(即便在隱私視窗)開發者將看不到訊號。建議加 `console.warn('[boot-id] sessionStorage unavailable, reload guard disabled')`,只是提醒用,不阻擋 reload。不影響行為不阻擋合併。
## D. 和 M8-7 的 integration
- `pollOnce` (L126-152):保留原流程,新增路徑僅是在 `res.ok + json.success + data.bootId` 全部成立後插入 `handleBootIdCheck`;失敗分支完全沿用 M8-7 的 `recordFailure`polling → overlay 行為不變。
- WebSocket `server:shutdown-imminent`L225-249、restart 10 s deferL232-242、visibility change probeL279-292、cleanupL303-323全部不動。
- Active retry 3 s / normal 10 s 切換邏輯L186-201不動boot-id 比對只發生在「成功取得 response」那條路徑不影響 retry 節奏。
- `retryServerHealth`L328-358M8-7 既有函式)被 patch 成走 `handleBootIdCheck`,對 overlay 的「手動 retry 按鈕」呼叫者透明。
既有測試M8-7、model-store、lib/api全數通過 → 零回歸。
## E. Test 品質
### Store tests`system-store.test.ts`)— 4 個
1. `first` call 情境 → 驗 return + `bootId` 被設L24-28
2. 相同 bootId → 驗 `'match'` + store 不動L30-35
3. 不同 bootId → 驗 `'mismatch'` + store 保留舊值L37-43
4. Reset 後再呼叫 → 重新 `'first'`,模擬 reload 後情境L45-52
TDD §12 要求的三路徑 + 邊界全數覆蓋。beforeEachL14-22確實 reset 所有欄位,隔離性 OK。
### Hook tests`use-shutdown-watcher.test.ts`)— 5 個
1. 首次 fetch → setBootId + markOnline無 reloadL65-74
2. 相同 id fetch → markOnline無 reloadL76-85
3. 異 id fetch → `window.location.reload` 被呼叫 + guard 寫入 + store 不更新L87-99
4. Loop guard 命中 → 不 reload + store 對齊新 id + `markOnline`L101-113
5. Fetch 失敗 → `recordFailure` + 無 reloadL115-124
`mockFetchBootId` helper 乾淨,`vi.stubGlobal('fetch', ...)` 正確。`Object.defineProperty(window, 'location', ...)` 是 jsdom 下替換 `location.reload` 的標準做法jsdom 的 `window.location` 不可直接賦值)。`afterEach` L61-63 `vi.restoreAllMocks()` 搭配 beforeEach 清除 `sessionStorage` + 重置 store測試間無耦合。
**Suggestion**SSR 情境(`typeof window === 'undefined'`)走 false pathL95-98目前 hook test 不覆蓋。因為 jsdom 下必定有 window要另寫 node env test。優先級低M8-9 可接受不補。
**Minor 2**task brief 提到「sessionStorage 失敗私密視窗catch 後仍 reload」的邊界 case但 hook test 5 個中沒有直接模擬 `sessionStorage.setItem` throw 的情境。實作正確L100-115 catch fall through 到 reload但無 regression guard。建議補一個 test`sessionStorage.setItem``vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error(); })` → 驗 `reloadSpy` 仍被呼叫。非阻擋性,可列入 M8-10 之後的測試補強清單。
## F. Build / Test / Lint 結果
| 指令 | 結果 |
|------|------|
| `pnpm tsc --noEmit` | ✅ 零錯誤(無任何輸出) |
| `pnpm test` | ✅ 4 test files / 19 tests 全綠(含本次新增 4+5 |
| `pnpm lint`(全 repo | ⚠️ 16 問題全部為 **pre-existing**`use-server-health.ts``use-websocket.ts` 等檔),**M8-9 改動的 4 個檔零 error / 零 warning** |
| `pnpm build` | ✅ Compiled successfully 5.5s / 12 頁 SSG 全數 prerender 完成 |
既有 repo lint 問題不在本次審查範疇,不阻擋 M8-9。
## G. SSR 相容
- `'use client'` directive 在 L1 正確標記。
- `window``sessionStorage``window.location.reload` 全部在 `useEffect`L155`handleBootIdCheck` 函式內使用,且 L95-98 有 `typeof window === 'undefined'` guard雙重保險因為 hook 只會在 client 執行但 `retryServerHealth` 可能被 server component 誤呼)。
- `retryServerHealth` 走同一 `handleBootIdCheck`SSR guard 也保護它。
- Next.js `pnpm build` 的 SSG prerender 12 頁全部成功,證實 build 階段不會觸發 `window is not defined`
## H. 完整 flow 驗證(讀 code 推論)
### 場景 1Restart Server
1. Wails `notifyShutdownImminent(reason=restart)` → WS broadcast。
2. Browser tab 收到(`use-shutdown-watcher.ts:225-249`)→ `restartDeferTimer` 啟動 10 s deferL234-241**不立刻** `forceOffline`
3. 每 10 snormal/ 3 sactive-retrypoll `/api/system/boot-id`。Restart 期間 server 約 3 s 起步fetch 連續 `ECONNREFUSED``recordFailure` 累計 → consecutiveFailures ≥ 2 → subscribe callbackL186-201自動切 active-retry。
4. Server re-spawn → 新 boot-id。
5. 下一次 `pollOnce``res.ok + json.success + data.bootId``handleBootIdCheck`store 裡舊 bootId ≠ 新 bootId → mismatch → guard 寫入 → `window.location.reload()`
6. 新 tab 載入 → Zustand store reset `bootId = null` → 首次 poll → `'first'``setBootId` + `markOnline` → 正常運作。
路徑正確 ✅
### 場景 2Quit
1. Wails `notifyShutdownImminent(reason=quit)` → WS broadcast。
2. Browser tab 收到 → `mapReason('quit') → 'quit'` → L245 `forceOffline('quit')` 立即執行(**沒有 10 s defer**defer 只在 `reason === 'restart'` 分支L232
3. Polling 持續失敗server 整組關掉)→ overlay 顯示「請重開 app」。
4. 每次 poll fail → `recordFailure`**不進入** `handleBootIdCheck`(整個 L146 block 要 `res.ok + success + data.bootId` 才會進)。
5. Server 沒回來,`reloadSpy` 永不會被呼叫。
路徑正確,不會誤觸發 reload ✅
### 場景 3網路 glitch
1. 單次 health check fail → `recordFailure`consecutiveFailures = 1尚未 ≥ 2 門檻 → **不** forceOffline、**不** 切模式。
2. 第二次失敗 → consecutiveFailures = 2 → subscribe callback 補上 `forceOffline('healthcheck-failed')`L191-197→ 切 active retry。
3. Active retry 3 s 後 → server 還在 → 拿到**同**一 boot-id → `handleBootIdCheck``'match'`(因為 store.bootId 沒被任何人改)→ L147 的 `if (!shouldContinue)` false → 往下到 L148 `markOnline()`(會同時 reset consecutiveFailures=0 + forcedOffline=false + offlineReason=null→ subscribe callback 偵測到 shouldActiveRetry=false下一次 polling tick 自動切回 normal 10 s。
4. `reloadSpy` 不會被呼叫。
路徑正確,不會誤 reload ✅
**額外確認**Restart 路徑下,如果 boot-id 變化先於 restartDeferTimer 10 s 到期 → `handleBootIdCheck` 會直接 reloadrestartDeferTimer 還在 running。`useEffect` cleanupL312-315會 clear timer但 reload 觸發後頁面立刻卸載timer 會被 jsdom / 瀏覽器自然清掉,無副作用。若 reload 被 SSR / guard skip極端情境restartDeferTimer 仍會 10 s 後執行,此時 `forceOffline('restart')` 的 guard 檢查 `!s.serverOnline || consecutiveFailures >= FAILURE_THRESHOLD`L237會判定為 falseguard skip 路徑裡 markOnline 已被呼叫)→ 不會誤顯示 overlay。健壯。
## I. 問題清單
### Critical
無。
### Major
無。
### Minor
| # | 檔案 | 行 | 問題 | 建議 |
|---|------|----|------|------|
| 1 | `frontend/src/hooks/use-shutdown-watcher.ts` | 113-115 | sessionStorage try/catch 吞 error 無 log異常發生時無法從 console 察覺 | catch 內加 `console.warn('[boot-id] sessionStorage unavailable, reload guard disabled')`,不改 control flow |
| 2 | `frontend/src/tests/hooks/use-shutdown-watcher.test.ts` | — | 無 test 覆蓋「sessionStorage.setItem throw → 仍 reload」邊界 | 用 `vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error(); })` 補 1 個 test`reloadSpy` 仍呼叫 1 次 |
### Suggestion
| # | 檔案 | 行 | 建議 |
|---|------|----|------|
| 1 | `frontend/src/hooks/use-shutdown-watcher.ts` | 95-98 | SSR guard 分支(`typeof window === 'undefined'`)無對應 testjsdom 下難模擬。可延後到專門的 SSR 測試環境補 |
## J. 結論
M8-9 Boot-ID + tab 自動重連實作完整、流程正確、測試充分。
- Store action `checkAndUpdateBootId` 三路徑語意與 TDD §9.3 一致,`mismatch` 刻意不改 store 的設計與 reload-後-新-tab 重走 first 路徑完美配合。
- Helper `handleBootIdCheck` DRY 封裝 guard + reload`pollOnce` / `retryServerHealth` 共用同一路徑。
- Reload loop guardsessionStorage在正常 / 異常 / 隱私視窗三種情境下都有正確 fall back不會卡 loop。
- M8-7 既有邏輯polling 模式切換、WebSocket、visibility、restart defer全數不變19 個 tests 全綠零回歸。
- 三個關鍵場景Restart / Quit / glitch讀 code 推論路徑全部正確,特別是 Quit 不會誤觸發 reload`handleBootIdCheck` 進入條件嚴格、glitch 同 bootId 會乾淨 recover。
- `tsc` / `build` / 改動檔 `lint` 全數 PASSpre-existing lint 問題不在本次範疇。
**結論:✅ 通過,不阻擋 M8-10**。2 個 Minor 與 1 個 Suggestion 可視為技術債,建議列入 M8-10 之後的測試補強清單處理,非必修。

View File

@ -0,0 +1,168 @@
# Reviewer 審查 MAJ-4 shutdown-imminent broadcast2026-04-15
## 摘要
- **結論**:✅ 通過。實作完整、測試涵蓋、race 乾淨、文件契約對齊、兩條 flowquit / restart邏輯正確。
- **阻擋 M8-10** 否。M8-4 遺留的 MAJ-4「shutdown-imminent 廣播」已補齊,可與 M8-5 patch、M8-7 / M8-8 / M8-9 一併收斂進 M8-10。
- 發現 1 個 Minorpayload reason 與 TDD §2.3 範例文字不完全一致1 個 InfoHub sentinel 行為變更),均不阻擋。
## A. ShutdownNotify handler
| 檢查 | 結果 | 依據 |
|------|------|------|
| `POST /api/system/shutdown-notify` 路徑 | ✅ | `router.go:64` |
| 接 query `reason` | ✅ | `system_handler.go:180` |
| 預設 reason | ✅ 歸類 `unknown` | `system_handler.go:181-186`(空值走 `default` 分支)|
| 驗證 reason | ✅ | 只認 `quit` / `restart`,其餘→ `unknown` 仍 200 |
| 呼叫 BroadcastToRoom | ✅ | `system_handler.go:194` |
| sleep 100ms 後回 200 | ✅ | `system_handler.go:26, 196-198, 201` |
| 無 client 仍 200 | ✅ | Hub `BroadcastToRoom` 空 room no-op`hub.go:129`handler 不判斷 client 數 |
| wsHub nil 不 panic | ✅ | `system_handler.go:188` nil guard + `TestShutdownNotify_NoHub` 覆蓋 |
**注意**:預設(空 reason不是直接對應到 `"quit"` 而是 `"unknown"`,與 prompt「預設 reason=quit」的文字描述不一致。但 **callerWails app永遠明確帶 `quit` 或 `restart`**`app.go:304``server_control.go:299`),預設值只在誤呼叫路徑生效,且 `"unknown"` 對前端而言透過 `mapReason` 仍會走 `'quit'` 分支立即顯示 overlay`use-shutdown-watcher.ts:70-71` 會 fallback → overlay 仍顯示)。**功能等價且更安全**(避免亂送的 reason 誤映射到 quit。可接受。
## B. shutdownNotifyBroadcaster interface
- `system_handler.go:18-20` 定義介面;`wsHub` 欄位以介面型別保存(`:36`, `:49-52`)。
- 單元測試用 `spyBroadcaster` 注入(`system_handler_test.go:29-57, 68-79`),完全脫離 real Hub goroutine。
- ✅ 解耦乾淨、便於測試。
## C. Hub.BroadcastToRoom
| 檢查 | 結果 | 依據 |
|------|------|------|
| 重用既有 API | ✅ | `hub.go:160-166`(既有方法,未新造)|
| Non-blocking | ✅ | `hub.go:131-136` select default → 滿 channel 直接 drop + `close` + `delete` |
| Room `system` 正確 | ✅ | `system_handler.go:194``system_ws.go:36` |
| 訊息格式 | ✅ | `system_handler.go:189-193``type`, `reason`, `ts` = `UnixMilli()`|
`TestHub_BroadcastToRoom_FullChannelDoesNotBlock` 驗證慢 client 不卡 hub goroutine第二次 broadcast 仍能送到 healthy client`hub_broadcast_test.go:73-132`)。✅
## D. /ws/system WebSocket endpoint
| 檢查 | 結果 | 依據 |
|------|------|------|
| router 註冊 | ✅ | `router.go:102` |
| client 加入 `system` room | ✅ | `system_ws.go:36-37``RegisterSync` 保證返回時已 in-room|
| 沿用 M8-8 Origin check | ✅ | 共用 `upgrader``device_events_ws.go:13-14``CheckOrigin: CheckOrigin` 自動繼承 |
| M8-4b sentinel 行為 | ⚠️ Info | 見下方說明 |
**M8-4b sentinel 互動Info非問題**Hub 的 `writeStartupSentinel``sync.Once` 保護,只要**任何** room 的第一個 client 連上就寫 `.first-ws-connected``hub.go:100-115`)。`/ws/system``BootIdWatcherMount` 在瀏覽器 tab 載入時自動連上(`use-shutdown-watcher.ts:298, connectWs`),實務上極可能**成為第一個**連上的 WS endpoint讓 startup pipeline 階段 6 由 `/ws/system` 觸發完成。這**不違反** `startup-pipeline.md §3 階段 6` 的語意定義是「Web UI 連上任何 WS」且 sentinel 只在第一次寫入、後續 no-op不會造成 race。✅
## E. notifyShutdownImminent helper
| 檢查 | 結果 | 依據 |
|------|------|------|
| 1 秒 timeout + 變數化 | ✅ | `shutdown_notify.go:26``notifyShutdownImminentTimeout`|
| Best-effort錯誤靜默| ✅ | `:49, :53` 兩個 err 分支直接 return不 log |
| `port <= 0` no-op | ✅ | `:38-40` |
| ctx nil 保護 | ✅ | `:41-43`fallback 到 `context.Background`|
| ctx timeout 包裹 | ✅ | `:44-45``context.WithTimeout`|
| 釋放 resp.Body | ✅ | `:57``resp.Body.Close`|
測試 6 個全部覆蓋zero port / POST 正確 / reason=restart / timeout 不卡 / 5xx 不 panic / connection refused 靜默)。`TestNotifyShutdownImminent_TimeoutDoesNotBlock` 實測 elapsed < 400ms`shutdown_notify_test.go:95-123`)。✅
## F. Wails app 端呼叫位置
| 檢查 | 結果 | 依據 |
|------|------|------|
| `shutdown()``ctrl.Stop()` 前呼叫 | ✅ | `app.go:302-309`順序notify → Stop|
| `Restart()``Stop` 前呼叫 | ✅ | `server_control.go:291-305`順序notify → Stop → StartWithPort|
| reason 正確 | ✅ | `app.go:304 = "quit"``server_control.go:299 = "restart"` |
| port 來源 | ✅ | shutdown 走 `snapshotStatus().Port``app.go:303`Restart 走 `c.proc.port` 鎖內複製成 `oldPort``server_control.go:284-289`|
兩處都有 nil guard`a.ctrl != nil` / `c.app != nil && oldPort > 0`),失敗路徑不阻塞主流程。✅
## G. 和 M8-7 前端對齊
`use-shutdown-watcher.ts`325 行):
| 檢查 | 結果 | 依據 |
|------|------|------|
| 訂閱 `/ws/system` | ✅ | `:211, 215``getWsBaseUrl() + '/ws/system'`|
| 解析 `server:shutdown-imminent` | ✅ | `:228` |
| `quit` 立即 `forceOffline` | ✅ | `:244-245``mapReason('quit') → 'quit' → forceOffline('quit')`|
| `restart` 延遲 10 秒 | ✅ | `:232-242``RESTART_DEFER_MS = 10_000`|
| `restart` 期間 polling 會先拿新 boot-id 觸發 reload | ✅ | `:186-201` polling 仍跑restart defer 10s 內新 server 起來 → `pollOnce → handleBootIdCheck → mismatch → reload``:86-123`|
`mapReason` 把 server 的 `quit` / `app-closing` / `manual-stop` 都 fold 到 `'quit'``:61-73`),所以後端改送 `"quit"` 而非 TDD 範例的 `"app-closing"`**對前端完全透明**(雙方都走立即 forceOffline 路徑)。✅
**與 TDD 範例的微差異**Minor`server-lifecycle.md:141` 範例寫 `payload: { reason: "app-closing" }`,實作是 `reason: "quit"`。兩者在前端都觸發立即 overlay但字面不一致。建議在 server-lifecycle.md §2.3 / §8 補註「實際 reason 由 caller 決定Wails shutdown=`quit`、Restart=`restart`」即可,不需改 code。
## H. Integration test
- `system_ws_integration_test.go:22-83``httptest.NewServer` + gin router 掛 `SystemEventsHandler`,真實 gorilla `websocket.DefaultDialer.Dial` 連上 → `hub.BroadcastToRoom("system", ...)``conn.ReadMessage()` 解析 JSON → 驗證 `type``reason`
- `:49-58` 等 Register 同步完成poll `hub.rooms["system"]` 非空best-effort 500ms。同 package 可存取 `hub.mu` 私有欄位,無需匯出。
- `httptest.Server``defer srv.Close()``conn.Close()``SetReadDeadline` 2s 保護,不會 leak。✅
## I. Test 品質15 個)
- **server/internal/api/handlers**5Quit / Restart / Invalid4 sub-case / NoHub / DefaultSleepPositive — 全部親跑通過。
- **server/internal/api/ws**4MultipleClients / EmptyRoom / FullChannelDoesNotBlock / SystemEventsHandler_ReceivesBroadcast — 全過。
- **visiona-local**6ZeroPort / SendsPostWithReason / SendsReasonRestart / Timeout / ServerError / ConnectionRefused — 全過。
- 合計 **15 個新 test 全部 PASS**(含 sub-test
- 使用 `withNoSleep` helper 把 `shutdownNotifySleepDuration` 歸零加速,`t.Cleanup` 還原 — 寫法乾淨。
- `TestShutdownNotify_DefaultSleepIsPositive` 特別保護生產常數不被誤改為 0 — 有心。
## J. 親跑驗證
```
cd server
go build ./... OK
go vet ./... OK
go test ./... OK含 api/handlers/ws 全綠)
go test -race -count=1 ./internal/api/...
api 2.057s / handlers 1.548s / ws 2.726s 全綠
cd visiona-local
go build . OK
go vet ./... OK
go test -run NotifyShutdown -v ./...
6 tests PASS (含 timeout 0.50s)
go test -race -count=1 ./... OK 8.930s(含 race detector
```
全部通過,無 race warning、無 build error。
## K. 完整 flow 驗證(讀 code 推論)
**Quit flow**(使用者關 Wails 視窗):
1. `OnBeforeClose``app.shutdown(ctx)``app.go:280`
2. `a.pipelineCancelFn()` / `a.watchCancel()` / IPC close / sentinel 清理(`:282-294`
3. `port := a.snapshotStatus().Port``notifyShutdownImminent(ctx, port, "quit")``:303-304`
4. server `ShutdownNotify` handler`BroadcastToRoom("system", {type, reason: "quit", ts})``time.Sleep(100ms)``200 OK`
5. `/ws/system` write pump 把 JSON 推到 TCP瀏覽器 `onmessage``mapReason('quit')='quit'``forceOffline('quit')` 立即顯示 overlay
6. `a.ctrl.Stop()``:309`)→ 7s grace → server 退出
7. 瀏覽器 polling 後續都 ECONNREFUSED但 overlay 已顯示,不重複觸發
✅ 正確。
**Restart flow**(使用者按 Restart
1. `ServerController.Restart()``server_control.go:282`
2. 鎖內複製 `oldPort := c.proc.port``:284-289`
3. `notifyShutdownImminent(ctx, oldPort, "restart")``:299`
4. server 廣播 `{reason: "restart"}` → 100ms sleep → 200
5. 瀏覽器 `onmessage``mapReason('restart')='restart'``restartDeferTimer = setTimeout(forceOffline('restart'), 10_000)``use-shutdown-watcher.ts:232-241`
6. `c.Stop()` → server 退出;`c.StartWithPort(oldPort)` → 新 server 起(新 boot-id
7. 瀏覽器 pollingnormal mode10s interval先打到新 server`handleBootIdCheck``'mismatch'``window.location.reload()``:95-123`
8. Reload 後新 page 初始化 → `pollOnce``'first'``markOnline` → normal 模式
9. 若 reload 發生在 10s defer 之前 → defer timer 被 unmount 時 `clearTimeout``:312-315`)→ overlay 沒機會顯示 ✅
10. 若 reload 發生在 10s 之後 → overlay 顯示後 reload 本身也會清除 → 正確復原
✅ 正確,兩條 flow 的時序都能正常收斂。
## L. 問題清單
### Major
(無)
### Minor
| # | 檔案:行 | 問題 | 建議 |
|---|---------|------|------|
| MIN-1 | server_handler.go:189-193 vs `server-lifecycle.md:141` | TDD 範例寫 `reason: "app-closing"`、實作送 `reason: "quit"`。前端 `mapReason` 雙向皆 fold 成 `'quit'`,功能等價,但文件字面不一致。 | 在 `server-lifecycle.md §2.3` 補一行:「實作中 reason 由 Wails caller 決定shutdown `quit`、Restart `restart`」。不必改 code。 |
| MIN-2 | system_handler.go:181-186 | 空 reason 被 fold 成 `"unknown"`prompt 審查項說「預設 reason=quit」。目前由 caller 永遠帶值,非實際風險。 | 如要嚴格對齊 prompt 可把 `default` 分支改成 `reason = "quit"`;或於 commit message 標註「預設 unknown 是刻意保守設計」。Reviewer 傾向維持現狀。 |
### Info非問題
- **Hub sentinel 與 /ws/system 交互**`/ws/system` 因 mount 時機早,實務上會成為第一個寫 `.first-ws-connected` 的 endpoint。符合 `startup-pipeline.md §3 階段 6` 的語意「Web UI 連上任何 WS」無 race無需調整。
- `TestSystemEventsHandler_ReceivesBroadcast` 直接存取 `hub.mu` / `hub.rooms`(私有)— 因為同 package 合法,但未來若搬到外部 integration package 會需要 exporter。目前 OK。
## M. 結論
MAJ-4 patch 品質達標:
1. 介面(`shutdownNotifyBroadcaster`乾淨解耦spy broadcaster 測試乾淨。
2. Hub.BroadcastToRoom 重用既有 API未新造non-blocking 行為有專屬 test 覆蓋。
3. `/ws/system` handler 沿用 M8-8 `upgrader` / `CheckOrigin`,無繞過安全機制。
4. notifyShutdownImminent helper best-effort 全面port<=0 / ctx nil / timeout / connection refused / 5xx 五種路徑都有測試)。
5. Wails 兩處呼叫點(`shutdown` / `Restart`)順序正確,都在 Stop 之前、port 來源正確。
6. 與 M8-7 前端 `use-shutdown-watcher.ts` 的 reason 映射、restart 10 秒 defer、M8-9 boot-id reload guard 完整對齊。
7. 15 個新 test 全綠,`go build / vet / test / test -race` server + visiona-local 均乾淨。
8. 兩條完整 flowQuit / Restart讀 code 推論時序正確overlay 不會 race、reload loop 有 guard。
**建議**:接受 patchMAJ-4 結案。Minor 1 / 2 不阻擋、留作文檔與 M8-10 final pass 時順手修正即可。M8-4 遺留 5 個 Major 至此MAJ-4已補齊可推進 M8-10。

View File

@ -1,9 +1,302 @@
# 專案進度 — visionA-local
## 目的:全新專案(從 edge-ai-platform 衍生的 local 版本)
## 當前階段:第二階段 — M7 Windows 實機 build + splash regression 修復
## 當前狀態Windows 端待驗證重 build 後 UI
## 最後更新2026-04-12
## 當前階段:🔴 **第一階段回溯** — L 級重大方向變更Wails 內嵌 → Wails 控制台 + 瀏覽器 Web UI
## 當前狀態:✅ 使用者決策全部收齊R5 第五輪決策),待三方產出正式 PRD v2 / Design Spec v2 / TDD v2
## 最後更新2026-04-14
## 🔴 2026-04-14 使用者提出 L 級重大方向變更
### 使用者原話
> 推論只需包含這三種 camera/image/上傳影片(avi, mpeg, mp4, 瀏覽器能吃的格式)
> 模型除了預設的幾種只能用上傳的
> 介面希望是用網頁而不是包在應用程式中
> 我想像中的是 visionA local 安裝完 啟動後 應用程式介面會有可以顯示 local server log 的地方
> 有可以啟動/停止 重啟 local server 的介面 有打開 localhost 網頁的介面
> 網頁上會有 scan/connect device 的介面 選模型/上傳模型 推論的介面
### 變更解讀
1. **推論來源範圍縮減**camera / image / 上傳影片,砍掉 URL 推論 + yt-dlp + YouTube/Vimeo
2. **模型管理縮減**:只保留「預設幾種 + 只能上傳」,砍掉任何 URL 下載 / Model Zoo 類功能
3. **介面架構巨變**Wails 桌面 app 退化為「Local Server 控制台」Log 面板 + Start/Stop/Restart + Open browser真正的使用介面在**瀏覽器**跑scan/connect/model/inference 全在 Web UI
### 影響範圍(初判)
- 砍 yt-dlp 打包M6 部分)→ 依賴瘦身 -35MB
- 砍 `ResolveWithYTDLP` / `ytdlpHosts` / `StartFromURL` yt-dlp 路徑 / 前端 URL tab
- Wails 控制台是**全新 UI**,和現有 splash + Next.js 完全不同
- 與第三輪決策 Q-A砍 tray、Q7關閉視窗=結束 app有潛在衝突可能要復議
- M1-M7 的工作**大部分仍可沿用**server / Next.js UI / 打包)只是 Wails 視窗內容要重寫
- 延伸的 yt-dlp 跳頁 bug 問題**自動消失**(功能直接砍)
### 第一輪三方分析狀態
- ✅ PM 分析完成:`01-requirements/pm-analysis-round2-refactor.md`419 行)
- ✅ Design 分析完成:`03-design/design-analysis-round2-refactor.md`537 行)
- ✅ Architect 分析完成:`04-architecture/architect-analysis-round2-refactor.md`798 行)
### 三方共識(無分歧)
1. **技術可行**,沿用率 85-95%,估 ~10 人天
2. **砍 yt-dlp**vendor 35MB + resolver + URL tabdmg 220→~135-185MB
3. **ffmpeg 保留**上傳影片仍需解碼GPL blocker 延續,可能 M8 切 LGPL
4. **Q-A 砍 tray 必須復議** — 新方向下 tray 價值從「可有可無」變「核心」
5. **Q7 關閉=結束必須復議** — 否則關 Wails 視窗 = SIGTERM server = 瀏覽器 tab ECONNREFUSED
6. **Next.js UI 幾乎零改動**80-90% 沿用,只砍 URL tab
7. **Wails 控制台走 vanilla HTML/JS/CSS**(不新 Next.js mini app
8. **CORS 要限制為 127.0.0.1/localhost**(瀏覽器模式新攻擊面)
9. **綁定維持 127.0.0.1**,不做 LAN mode
10. **watchServer 改為 Error state**,不 os.Exit
### 三方立場差異(待使用者裁決)
- **C1 動機問題**PM 堅持前置條件必須先知道使用者為什麼要改架構PM 列出 9 種可能動機
- **首次啟動是否自動開瀏覽器**Design 建議預設自動Ollama 式、PM 建議手動C6 選 A— 輕微分歧
- **First-Run 搬家策略**PM 建議留瀏覽器端C8 選 A、Design 沒強烈意見
### 下一步
- ✅ 使用者決策 R5 全部收齊見下方「R5 第五輪使用者決策」)
- ⏳ 三方依決策產出正式 PRD v2 / Design Spec v2 / TDD v2下一步
- ⏳ 三方互審 → 使用者確認 → 進開發
### R5 第五輪使用者決策2026-04-14重構方向變更
| # | 題目 | 使用者決定 | 備註 |
|---|------|----------|------|
| R5-1 | 重構動機 | **A + B + G**(多視窗便利 + 瀏覽器 devtools + 需求方就是這麼要求) | 純 127.0.0.1,無 LAN / 無背景 daemon 需求 |
| R5-2 | Wails 視窗關閉行為Q7 復議)| **維持關閉=結束 server**瀏覽器網頁顯示「local server 已離線」覆蓋層 | 不改原 Q7 決策但前端要新增「server 離線」UI |
| R5-3 | Tray 復議Q-A 復議)| **T1維持砍 tray** | 和 R5-2 一致,省 1.5 人天 |
| R5-4 | 首次啟動自動開瀏覽器 | **A首次自動開之後可設定** | Ollama 式零摩擦 |
| R5-5 | Wails 控制台 scope | 同意 PM 清單,**拿掉 Mock 模式切換** | |
| R5-5a | Mock 模式歸處 | **A完全砍掉 Mock 模式** | 使用者明確:「沒插硬體就讓它是空的,不用 demo」 |
| R5-6 | ffmpeg 授權 | **LGPL 方案 B混合** | Windows/Linux 用 BtbN 現成 LGPL binarymacOS 自 build |
| R5-6a | macOS build 規模 | **A最小 decoder-only build~20MB** | 只含 mp4/avi/mov/mpeg/mpg 五種解碼器 |
| R5-6b | macOS binary 存放 | **① commit 到 repo`vendor/ffmpeg/macos/`** | LGPL ffmpeg 幾乎不需更新,直接進 git |
| R5-6c | 是否打包 ffprobe | **一起包** | BtbN 本來就都有0 成本 |
| R5-7 | M7 Windows build | **先不管,做完再驗** | 跳過 M7-B3 baseline 驗證 |
### 三方共識全部採納(無須使用者裁決)
1. 技術可行,沿用率 85-95%~10 人天
2. 砍 yt-dlp 全套vendor 35MB + resolver + URL tab + handler
3. ffmpeg 保留(因 R5-6 走 LGPLGPL blocker 解除)
4. Next.js Web UI 80-90% 沿用,只砍 URL tab
5. Wails 控制台走 vanilla HTML/JS/CSS不新 Next.js mini app
6. CORS 限制為 127.0.0.1/localhost
7. 綁定維持 127.0.0.1(不做 LAN mode
8. watchServer 改為 Error state不再 `os.Exit(1)`
9. 預設模型維持 8 個 .nef「只能上傳」= 再次確認不做 Model Zoo
10. 批次影像上傳保留
11. 上傳影片副檔名:`.mp4 / .avi / .mov / .mpeg / .mpg`
12. Server port、資料目錄、版本號、清 log 等工具資訊住 Wails 控制台
13. 硬體偵測結果、上傳模型、Settings > 語言 住瀏覽器 Web UI
14. Restart 期間瀏覽器 tab 用 `boot-id` + retry 重連(雖然 R5-2 選關閉=結束,此邏輯仍需做以支援 Restart Server 按鈕)
### 三方正式 v2 文件(已產出)
- ✅ PRD v2.0`02-prd/PRD-v2.md`484 行)— PM 5 個懸念見 §11
- ✅ Design Spec v2.0`03-design/design-spec-v2.md`99 行索引)+ `03-design/v2/*.md`5 子檔)
- ✅ TDD v2.0`04-architecture/TDD-v2.md`136 行索引)+ `04-architecture/v2/*.md`8 子檔,~3738 行)
### 三方 v2.1 補丁(已產出,吸收 R5-D + R5-E + 互審發現)
- ✅ PRD v2.1(原地更新 PRD-v2.md500 行,卡在上限)
- ✅ Design Spec v2.1:索引 127 行 + settings-update 239 + control-panel 465 + **新檔 startup-progress 417**
- ✅ TDD v2.1:索引 162 行 + control-panel 830 + server-lifecycle 961 + web-ui-offline-overlay 更新 + deletions 更新 + milestone-plan 更新 + **新檔 startup-pipeline 518**
- **新工時預估**10 → **12 人天**+M8-4 +0.5 / +M8-4b +1 / +M8-7 +0.3 / +M8-10 +0.2),建議對外回報 ~13 人天含 buffer
### v2.1 新增懸而未決問題彙總
**Design 新增3 題)**
- D-Q120 秒 retry hint 文案「正在重試…」vs「正在處理中…」Design 建議前者)
- D-Q2WebSocket 被安全軟體擋的提示Design 建議不做特殊偵測)
- D-Q3Retry 按鈕語意「重置整個啟動」vs「重試當前階段」Design 建議重置,需 Architect 確認 RestartStartupSequence 可行)
**Architect 新增5 題)**
- A-Q1階段 6 WebSocket 首次連線實作方式 long-poll endpoint vs sentinel file交 M8-4b 執行者)
- A-Q2watcher goroutine 和使用者在 Starting 中按 Stop 的 raceaction bar 禁用M8-4b 實測)
- A-Q3shutdownGracePeriod 7s/6s 對齊若實測常被 SIGKILL 則改 9+1 秒
- A-Q4Linux notify-send 不存在時的 fallbackM8-10 實測 Ubuntu minimal
- A-Q5N-R4 CI/E2E 測試分層blocked on testing agent
**PM 保留**
- §11-4 N-R4 CI/E2E 測試分層(同 A-Q5
- §11-7 R5-E 6 階段中英雙語文案定稿Design 已定版,使用者最後可 override
### 第二輪三方互審結果2026-04-14— 🟢 全員通過
- ✅ **Design 審 PRD v2.1**通過3 Minor 不阻擋Error 按鈕命名 / Linux OFF 階段描述 / v2.0 歷史字樣)
- ✅ **PM 審 TDD v2.1**通過2 Minor 不阻擋code-reuse-v2.md:92 殘留 / milestone-plan.md:6 工時數字不同步)
- ✅ **Architect 審 Design Spec v2.1**通過3 Minor 全在 TDD 側skipped status 枚舉 / WS sentinel file 決案 / 階段 6 soft timeout skip + Retry 機制)
### 第二輪關鍵仲裁
- **Error 按鈕命名分歧**Architect 仲裁為**兩個獨立動作**
- Startup error60s timeout 或階段失敗)→ 按鈕「**Retry**」= 呼叫 `RestartStartupSequence()` 重置整個啟動流程
- Running 階段 watchServer 失敗 → 按鈕「**Restart Server**」= 重 spawn server既有行為
- **D-Q3 RestartStartupSequence 可行性**:✅ 可行,新增 function5 步驟實作細節已定)
- 停 watcher → ForceKill server → 重置 state machine → 重建 pipeline → 重跑 Start
- 階段 1 直接 Complete 不重跑
- sentinel file 必須先清
- Retry 情境下 port 允許 fallbackcold start 行為)
- **階段 6 WebSocket 就緒偵測方案**:採 **sentinel file** `<dataDir>/.first-ws-connected`(不用 long-poll endpoint
- **D-Q2 WebSocket 被擋偵測**:不可行,不做特殊偵測
- **D-Q1 20s retry hint 文案**不影響技術Design 自由定稿
### Architect 自補 TDD 清單M8-4b 前補完,估 1-2 小時,不啟動新 Agent
第一輪遺留 4 項 + 第二輪新增 3 項:
1. offline-overlay 10s/2 次/3s active polling 參數
2. Gin SkipPaths + crypto/rand boot-id
3. Restart 強制同 port 規則
4. ExportLog binding
5. `StartupProgressEvent.Status` 新增 `"skipped"` 枚舉值
6. 階段 6 WebSocket sentinel file 決案寫入
7. 階段 6 Toggle OFF 時跳過 soft timeout + 新增 §9「Retry 機制」小節(含 RestartStartupSequence
### v2.1 殘留 Minor不阻擋開發M8 過程中順手修)
- `04-architecture/v2/code-reuse-v2.md:92` 殘留「新增 autoOpenedThisSession 欄位」字樣(轉版漏改)
- `04-architecture/v2/milestone-plan.md:6` 摘要「~11.5 人天」和 L491 合計「12.0」不一致
- PRD v2.0 變更紀錄列殘留「首次自動開瀏覽器」(歷史紀錄,不修)
### M8 開發進度2026-04-15— 🟢 程式碼全部完成,只差 M8-10 交付
| Milestone | 狀態 | 備註 |
|-----------|------|------|
| Architect 自補 TDD 7 項 | ✅ 完成 | 7 項落地 + 意外發現FAILURE_THRESHOLD 同步、ForceKill 缺失提醒、hard timeout skip|
| M8-1 砍 yt-dlp | ✅ 完成 | +222/-555 行18 檔案5 項 build 全綠 |
| M8-2 砍 Mock | ✅ 完成 | -528 行15 檔案5 項 build 全綠smoke test 通過 |
| M8-3 ffmpeg LGPL | ✅ 完成 | ffmpeg 5.7MB + ffprobe 5.6MB(比 GPL 版省 85% 空間LGPL 合規build 2m44s |
| M8-1+M8-2 Reviewer | ✅ 通過 | 親自 build/test/smoke0 誤刪 0 殘留 |
| M8-3 Reviewer | ✅ 通過 | 18 項驗證全過1 Minor + 2 Suggestion + 3 交付前事項 |
| M8-4 ServerController + log ring buffer | ✅ + Review 通過 + 4 Major 補丁 | 20 unit test + race -count=2 全綠 |
| M8-4b 啟動階段管線 | ✅ + Review 通過 + 3 Major 補丁 | 14+3 testHasFailedStage / IsInColdStart helpers |
| M8-5 Wails 控制台 UI | ✅ + Review 通過 + 2 Critical 補丁 + Stage 6 CTA 補丁 | 9 檔 ~2012 行wails build PASS |
| M8-6 source-selector 副檔名擴充 | ✅ 完成(未 Review 改動太小)| 4 檔案 ~4 行 |
| M8-7 Offline Overlay | ✅ + Review 通過 | role=alertdialog + focus trap + wsEverConnected 容錯 |
| M8-8 CORS middleware | ✅ + Review 通過 | 127.0.0.1/localhost + suffix attack 防護 |
| MAJ-4 shutdown broadcast | ✅ + Review 通過 | server/ws + visiona-local/notify helper15 test |
| M8-9 Boot-ID + tab 重連 | ✅ + Review 通過 | 9 test + SSR 相容 + reload loop guard |
| **M8-10 端到端 smoke test + 三平台 build** | ⏳ **最後一哩** | 交付前事項見下 |
### M8-3 Reviewer 交付前必做事項M8-10 前)
1. **outer repo `visionA/.gitignore` 雖規則對,但 `vendor/ffmpeg/macos/` 內 4 檔未 `git add`** — 交付 commit 時記得一併 stageffmpeg / ffprobe / COPYING.LGPLv3 / BUILD.md
2. **`payload/darwin/bin/ffmpeg` 仍是舊 GPL 77MB binary** — M8-10 前必須重跑 `make payload-macos`
3. **`vendor/yt-dlp/` 殘留 87MB** — ✅ 已由 Orchestrator 順手清除
### M8-3 Minor + Suggestion低優先
- **Minor**BUILD.md §Verification §5 預期 `spctl --assess=accepted` 實測會被 reject改為 `codesign -v`
- **Suggestion 1**`vendor-ffmpeg` target 可補 sha256 對比防呆
- **Suggestion 2**`payload-windows` 授權檔 `skipifsourcedoesntexist` 若同時缺失會無授權交付
### 上一輪 Reviewer 提的 Minor已解決 / 懸而未決)
- ✅ `source-selector.tsx` accept 清單已擴充 mpeg/mpgM8-6 完成)
- ✅ `camera_handler.go` 後端副檔名白名單已擴充M8-6 完成)
- ⏳ `deps/checker.go` 未加 ffprobe 檢查 — M8-3 後可補
- ⏳ `api_e2e_test.go` 整檔刪後失去 HTTP 層 smoke — 建議 M8-10 前補一份不依賴 mock 的 read-only e2e
### M8-4 Reviewer 結果:⚠️ 需修 5 Major2026-04-15
**親跑驗證全綠**`go build/vet/test/test -race`、20 unit test、smoke test、SkipPaths 生效。
**5 個 Major**4 個 M8-4 Agent 回修、1 個留 M8-4b 包辦):
- **MAJ-1** `server_control.go:198-229 / 251-265` Stop/ForceKill 不 cancel watchCancel → 30s 後誤翻 Error + 發崩潰通知
- **MAJ-2** `server_control.go:269-291` handleWatchFailure 未取 txMu → 與 Stop race
- **MAJ-3** `server/main.go:166` shutdownFn timeout 仍 10sTDD §8.1 要求 6s破壞 7+1 modal UX
- **MAJ-4** 沒實作 `server:shutdown-imminent` WebSocket 廣播(阻擋 M8-9不阻擋 M8-4b/5/7→ **M8-4b 一起做**
- **MAJ-5** `server_control.go:579-608` logPump scanDone 不 drain lineCh → 丟最後 128 行崩潰 log
### M8-4 Reviewer 15 個 Minor技術債M8-5 後整理)
主要Snapshot 效率、ShouldEmit CAS micro-race、Restart 拆兩段 txMu、stopGraceful 與 logPump file handle race、scanner select default、notify timeout、v1/v2 重複碼
### v1/v2 並存策略
**合理但需立即標記砍除時程**。v1 路徑stopServer/stop()/kill()/watchServer/5s grace已 dead code 但仍存在易誤用 → **M8-5 完成後立即砍 v1**(含 MIN-10/11/12 併處理)
### 待使用者決策
- **commit 策略**Reviewer 建議分三個 commitM8-1 / M8-2 / M8-3或一個合併。使用者從未要求 commit保守做法是先不 commit 等使用者說。
### R5-Design 補充決策2026-04-14Design v2 產出後使用者回答)
| # | 題目 | 使用者決定 |
|---|------|----------|
| R5-D1 | Server 崩潰時除了控制台 Error banner 是否仍發 OS 原生通知 | **保留** OS 通知 |
| R5-D2 | Linux 預設「啟動時自動開瀏覽器」 toggle 狀態 | **預設 OFF**macOS/Windows 預設 ON避免 xdg-open 在極簡 WM 異常 |
| R5-D3 | R5-4 字面歧義「首次啟動」vs「每次啟動」 | **每次啟動**都自動開瀏覽器(修正 R5-4 原本「首次」的字面,實際意圖是「每次 Start Server 成功後」) |
### 三方交叉審閱階段(進行中)
- ⏳ PM 審 TDD v2驗證所有需求都有技術方案R5 / R5-D 全部落地
- ⏳ Design 審 PRD v2驗證體驗面沒遺漏R5-D1/D2/D3 有無落地
- ⏳ Architect 審 Design Spec v2驗證設計技術上可行
### 使用者授權
使用者已說「交互 review 完就進開發」— 審閱無衝突則直接進 M8不用另外確認。
### 三方互審結果2026-04-14
**Design 審 PRD v2**:❌ 不通過(`02-prd/reviews/design-review-of-prd-v2.md`
- Major 4 / Minor 4
- 核心問題R5-D1/D2/D3 都沒吸收PM 寫 PRD 時還不知道這三題)
- Major 4 auto-open toggle 位置分歧 → Design 仲裁「PRD 對(住 Wails 控制台Design Spec v2 settings-update.md 要自修
- §11-5 徽章決定:**不加**
- Architect Q6 Overlay close tab 決定:**不設**
**PM 審 TDD v2**:⚠️ 條件通過(`04-architecture/reviews/pm-review-of-tdd-v2.md`
- Major 4 / Minor 5
- 核心問題R5-D 三題 TDD 零匹配 + M8-9 驗收條件 `autoOpenedThisSession` flag 和 R5-D3 相反per-session-once 寫成成功條件Reviewer 會誤判)
- PM 自行回答 PM §11 技術懸念:
- **AC-1.3 10 秒預算不可達**(估 5.5-18 秒)→ 建議放寬到 **15 秒**
- **idle RAM ≤ 450MB 可達**(估 275-405MB
- **PM 對 Architect Q4 grace period 回答****7 秒 + 1 秒內顯示「停止中…」modal**(基於 Nielsen Norman 10 秒注意力臨界點)
- PM 判斷 M8-1/M8-2/M8-3 互不依賴,可在 Major 修復前先啟動(砍 yt-dlp / 砍 Mock / ffmpeg LGPL vendor
**Architect 審 Design Spec v2**:⚠️ 有條件通過(`03-design/reviews/architect-review-of-design-spec-v2.md`
- Major 2 / Minor 12
- Major 1Design settings-update.md §2.2 誤稱「走 Wails 既有 settings store」Wails v2 無此機制)+ 檔名不一致 → 採 TDD 的 `preferences.json @ <dataDir>/`
- Major 2R5-D2 Linux 預設 OFF 兩份 spec 都沒落地 → 新增 `DefaultPreferences()``runtime.GOOS`
- Architect 7 懸念自決:
- **Q1** grep 確認 `NewVideoSourceFromURL` 只有 `StartFromURL` + `videoIsURL`-guarded seek handler 呼叫 → 整組砍(含 `videoIsURL` field
- **Q3** `crypto/rand` 16 bytes → hex不引入 google/uuid
- **Q5** navigator.language fallbackzh* → zh-TW / en* → en-US / else → zh-TW
- **Q7** preferences JSON 用 write-rename atomic pattern
- **Architect 對 PM §11 回答**
- **§11-1** `preferences.json` @ `<dataDir>/`write-rename 原子寫fallback DefaultPreferences
- **§11-2** 樂觀 ~4s / 悲觀 ~8s 達標,但 **Windows + Defender 最壞 ~11s 可能超時**,建議 M8-10 實測,超時則 AC-1.3 放寬到 **12 秒**Architect 說 12PM 說 15差 3 秒)
- **§11-3** idle RAM 樂觀 ~370MB 達標,悲觀 ~500MB 超 50MB**建議 PRD clarify「450MB 不含 browser tab」**
- **關鍵發現 F-2**Restart Server port 保留 — TDD 允許 fallback 到 3722 會讓瀏覽器 tab URL 過期導致 Offline Overlay 永卡 → Restart 強制保留舊 port不可 fallback用不了就進 Error stateArchitect 自補)
- **關鍵發現 B-1**watchServer 改 Error state 時等於砍掉 OS 通知(違反 R5-D1→ 新增 `sendCrashNotification()` non-blocking toast新檔 `visiona-local/notify.go`Architect 自補)
### R5-E 追加決策2026-04-14互審結論後使用者追加
使用者把「AC-1.3 時間預算」問題從「要多快」翻轉成「**讓使用者感覺進度有在推動**」— 採 Nielsen Norman perceived performance 原則而非硬時間指標。
| # | 決定 |
|---|------|
| R5-E1 | **AC-1.3 時間上限放寬到 60 秒**(原 10 秒),原則是 perceived performance > 硬時間指標 |
| R5-E2 | **啟動全程必須有階段化進度顯示**:每個階段有編號 / 動作描述 / 視覺回饋 / 中英雙語文案 |
| R5-E3 | **任一階段卡超過 20 秒**要顯示「正在重試」類提示,不可白畫面 |
| R5-E4 | **超過 60 秒總上限仍未就緒** → 進 Error state和 watchServer 3 次失敗一致),顯示重試 / 回報 / 檢視 log 三按鈕 |
| R5-E5 | **階段文字由 Design Agent 決定**(使用者授權)— 使用者最後審 wireframe 時可以 override |
| R5-E6 | **瀏覽器就緒偵測採 WebSocket 連上訊號**(不做新 endpoint不做固定延遲WebSocket hub 收到第一個 client 連線視為第 6 階段「ready」 |
### 啟動階段建議6 階段Design 最終定版)
1. 初始化 Wails 控制台
2. 檢查 Python runtime + 驅動
3. 啟動本機伺服器port binding
4. 偵測 Kneron 裝置
5. 開啟瀏覽器
6. 瀏覽器就緒WebSocket 連上)
### 技術影響(三方 v2.1 補丁輪要吸收)
- 新增 Wails event`startup:progress {stage, label_zh, label_en, status}`
- 新增 Wails event`startup:stage-timeout {stage}`20 秒卡住觸發)
- `StartServer()` 改為階段化,每個階段 emit event
- Wails 控制台 vanilla JS 要訂閱 event 更新進度面板
- 新增啟動進度面板 UIDesign Spec v2.1 wireframe
- M8-4/M8-5 工時可能 +0.5-1 天
### 修正計畫v2.1 補丁輪)
- PM → PRD v2.1:補 R5-D1/D2/D3、Minor 1-4、AC-1.3 放寬到 12 秒、idle RAM 加註「不含 browser tab」
- Design → Design Spec v2.1
- settings-update.md 修 Major 1+2`preferences.json @ <dataDir>/` + `DefaultPreferences()` 平台差異)
- control-panel.md §4.4 log 1000→2000 / §6.2 補 OS notification + Report 按鈕 hold 註記 / §7.1 第 5 步「首次→每次」
- Architect → TDD v2.1
- R5-D1 sendCrashNotification 實作(新檔 notify.go
- R5-D2 DefaultPreferences 依 GOOS
- R5-D3 砍 autoOpenedThisSession flag每次 StartServer 都 trigger OpenInBrowser
- M8-9 驗收條件修正移除「Restart 不會二次開」條件)
- Restart 同 port 規則
- PM §11-1/2/3 寫入 TDD
- Q4 grace period 採 PM 7 秒 + 1 秒 modal 建議
- Q1/Q3/Q5/Q7 自決結果寫入
> 以下是 2026-04-12 之前的進度快照,保留備查。變更確認後需要全面更新。
## 🎉 M1 達成總結
- `dist/visiona-local.dmg` (70MB) 可雙擊安裝

View File

@ -12,9 +12,6 @@ env:
GO_VERSION: '1.26'
NODE_VERSION: '22'
PNPM_VERSION: '9'
# 暫時允許 GPL ffmpeg build 通過 vendor-sync 的授權檢查。
# 發佈前若 ffmpeg 來源改為 LGPL請移除此變數。
VISIONA_ALLOW_GPL_FFMPEG: '1'
jobs:
# ────────────────────────────────────────────────────────────────
@ -135,8 +132,7 @@ jobs:
run: |
make vendor-python-windows \
vendor-wheels-windows \
vendor-ffmpeg-windows \
vendor-ytdlp-windows
vendor-ffmpeg-windows
- name: Build server.exe
env:
@ -233,8 +229,7 @@ jobs:
run: |
make vendor-python-linux \
vendor-wheels-linux \
vendor-ffmpeg-linux \
vendor-ytdlp-linux
vendor-ffmpeg-linux
- name: Build server (Linux)
run: |

View File

@ -8,11 +8,18 @@
/vendor/**
!/vendor/.gitkeep
!/vendor/README.md
# R5-6bmacOS LGPL ffmpeg binary 進 git沒有現成 LGPL binary 來源,自 build 成本高,
# commit 後開發者 clone 即可用,不必每次重 build ~15 分鐘)
!/vendor/ffmpeg/
!/vendor/ffmpeg/macos/
!/vendor/ffmpeg/macos/**
# ── 建置產出 ──
/dist/
/visiona-local/build/bin/
/visiona-local/build/darwin/Resources/
# M8-3頂層 build/ffmpeg-macos/ 是 ffmpeg self-build 中間產物source + obj + install~180MB不進 git
/build/
/visiona-local/payload/
!/visiona-local/payload/.gitkeep
# M1-11頂層 payload/ 是 build 產物,不進 git除了 .gitkeep

View File

@ -19,16 +19,16 @@ DIST := dist
PAYLOAD := visiona-local/payload
.PHONY: help \
vendor-sync vendor-python vendor-wheels vendor-ffmpeg vendor-ytdlp \
vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows vendor-ytdlp-windows \
vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux vendor-ytdlp-linux \
vendor-sync vendor-python vendor-wheels vendor-ffmpeg vendor-ffmpeg-macos-build \
vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows \
vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux \
server build-server \
frontend build-frontend build-embed \
payload payload-macos payload-windows payload-linux \
stage-macos stage-windows \
wails-macos wails-windows wails-linux \
dmg exe exe-only _run-iscc appimage \
dev dev-mock test lint fmt \
dev test lint fmt \
clean clean-all clean-build-exe clean-build-dmg clean-build-appimage
# ── 幫助 ───────────────────────────────────────────────────────────
@ -36,7 +36,7 @@ help: ## 列出所有 make targets
@echo "visionA-local — available targets (M1-1 skeleton)"
@echo ""
@echo " 依賴同步:"
@echo " vendor-sync 下載 python-build-standalone / wheels / ffmpeg / yt-dlp → vendor/"
@echo " vendor-sync 下載 python-build-standalone / wheels / ffmpeg → vendor/"
@echo ""
@echo " Build單元"
@echo " server build Go server binary (→ dist/visiona-local-server)"
@ -67,10 +67,16 @@ PYTHON_VERSION := 3.12.9
PBS_RELEASE := 20250317
PBS_URL_DARWIN := https://github.com/astral-sh/python-build-standalone/releases/download/$(PBS_RELEASE)/cpython-$(PYTHON_VERSION)+$(PBS_RELEASE)-x86_64-apple-darwin-install_only.tar.gz
FFMPEG_URL_DARWIN := https://evermeet.cx/ffmpeg/getrelease/ffmpeg/zip
YTDLP_URL_DARWIN := https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos
# ── ffmpegLGPL v3方案 B 混合)──
# v2 TDD §2.2macOS 自 build decoder-only~20 MBcommit 到 vendor/ffmpeg/macos/
# Windows / Linux 用 BtbN 的 n7.1 LGPL build。
FFMPEG_VERSION := n7.1
FFMPEG_SRC_URL := https://github.com/FFmpeg/FFmpeg/archive/refs/tags/$(FFMPEG_VERSION).tar.gz
# sha256 於第一次 build 時由 `make vendor-ffmpeg-macos-build` 計算後填入 vendor/ffmpeg/macos/BUILD.md
# 之後每次 build 使用此值做 integrity check下方 Makefile 變數亦同步更新)。
FFMPEG_SRC_SHA256 := 7ddad2d992bd250a6c56053c26029f7e728bebf0f37f80cf3f8a0e6ec706431a
vendor-sync: vendor-python vendor-wheels vendor-ffmpeg vendor-ytdlp ## 下載所有第三方依賴到 vendor/(不進 git第三輪決策 Q-D=D2
vendor-sync: vendor-python vendor-wheels vendor-ffmpeg ## 下載所有第三方依賴到 vendor/(不進 git第三輪決策 Q-D=D2
@echo "==> vendor-sync 完成"
vendor-python: ## 下載 python-build-standalone tarball → vendor/python/darwin/
@ -103,45 +109,96 @@ vendor-wheels: ## 同步 wheels → vendor/wheels/darwin/(內部 wheel 從 vis
@ls -1 vendor/wheels/darwin/*.whl 2>/dev/null | wc -l | xargs -I{} echo " 共 {} 個 wheel"
@du -sh vendor/wheels/darwin
vendor-ffmpeg: ## 下載 ffmpeg static build (macOS x86_64) → vendor/ffmpeg/darwin/(預設要求 LGPL必要時可用 VISIONA_ALLOW_GPL_FFMPEG=1 暫時放行 GPL
@mkdir -p vendor/ffmpeg/darwin
@if [ -f vendor/ffmpeg/darwin/ffmpeg ]; then \
echo "==> ffmpeg 已存在,跳過 ($$(du -sh vendor/ffmpeg/darwin/ffmpeg | cut -f1))"; \
else \
echo "==> 下載 ffmpeg static build (macOS) from evermeet.cx..."; \
curl -fL -o /tmp/ffmpeg-latest.zip "$(FFMPEG_URL_DARWIN)"; \
unzip -o /tmp/ffmpeg-latest.zip -d vendor/ffmpeg/darwin/; \
rm -f /tmp/ffmpeg-latest.zip; \
chmod +x vendor/ffmpeg/darwin/ffmpeg; \
echo "==> ffmpeg 大小:$$(du -sh vendor/ffmpeg/darwin/ffmpeg | cut -f1)"; \
vendor/ffmpeg/darwin/ffmpeg -version 2>&1 | head -3; \
echo "==> 授權檢查:"; \
if vendor/ffmpeg/darwin/ffmpeg -version 2>&1 | grep -q -- '--enable-gpl'; then \
if [ "$${VISIONA_ALLOW_GPL_FFMPEG:-0}" = "1" ]; then \
echo " !! WARNING: 此 build 含 --enable-gplGPL build !!"; \
echo " !! VISIONA_ALLOW_GPL_FFMPEG=1 已啟用,暫時允許(僅限本地驗收,不可發佈) !!"; \
else \
echo "!! 錯誤:此 build 含 --enable-gpl違反 LGPL 策略 !!"; \
echo "!! macOS 暫無現成 LGPL binary 來源,需自行 build 或用 VISIONA_ALLOW_GPL_FFMPEG=1 暫時放行 !!"; \
rm -f vendor/ffmpeg/darwin/ffmpeg; \
vendor-ffmpeg: ## macOSLGPL v3 ffmpeg + ffprobe 已 commit 到 vendor/ffmpeg/macos/,本 target 只驗證存在 + LGPL 合規
@if [ ! -f vendor/ffmpeg/macos/ffmpeg ]; then \
echo "❌ vendor/ffmpeg/macos/ffmpeg 不存在。"; \
echo " 第一次 build 請執行make vendor-ffmpeg-macos-build"; \
echo " (只需要在升級 ffmpeg 版本時跑一次;平常 clone repo 後 binary 已在 git 內)"; \
exit 1; \
fi; \
else \
echo " OK: 未偵測到 --enable-gpl"; \
fi; \
fi
@if [ ! -f vendor/ffmpeg/macos/ffprobe ]; then \
echo "❌ vendor/ffmpeg/macos/ffprobe 不存在。"; \
echo " 請執行make vendor-ffmpeg-macos-build"; \
exit 1; \
fi
@echo "==> vendor/ffmpeg/macos/ffmpeg 存在:$$(du -h vendor/ffmpeg/macos/ffmpeg | cut -f1)"
@echo "==> vendor/ffmpeg/macos/ffprobe 存在:$$(du -h vendor/ffmpeg/macos/ffprobe | cut -f1)"
@if vendor/ffmpeg/macos/ffmpeg -version 2>&1 | grep -qE -- '--enable-gpl|libx264|libx265'; then \
echo "❌ LGPL 驗證失敗binary 含 --enable-gpl / libx264 / libx265"; \
exit 1; \
fi
@echo "==> LGPL 驗證通過(無 --enable-gpl / libx264 / libx265"
vendor-ytdlp: ## 下載 yt-dlp standalone (macOS) → vendor/yt-dlp/darwin/
@mkdir -p vendor/yt-dlp/darwin
@if [ ! -f vendor/yt-dlp/darwin/yt-dlp ]; then \
echo "==> 下載 yt-dlp (macOS)..."; \
curl -fL -o vendor/yt-dlp/darwin/yt-dlp "$(YTDLP_URL_DARWIN)"; \
chmod +x vendor/yt-dlp/darwin/yt-dlp; \
echo "==> yt-dlp 大小:$$(du -sh vendor/yt-dlp/darwin/yt-dlp | cut -f1)"; \
vendor/yt-dlp/darwin/yt-dlp --version 2>&1 | head -1; \
else \
echo "==> yt-dlp 已存在,跳過 ($$(du -sh vendor/yt-dlp/darwin/yt-dlp | cut -f1))"; \
# 只有升級 ffmpeg 版本時才跑此 targetbinary 產出後 commit 到 gitv2 TDD R5-6b
# 需要的系統依賴macOS
# brew install pkg-config nasm # 或 yasm
vendor-ffmpeg-macos-build: ## macOS從源碼 build LGPL v3 decoder-only ffmpeg + ffprobe升級時才跑~15 分鐘)
@if [ "$$(uname -s)" != "Darwin" ]; then \
echo "❌ vendor-ffmpeg-macos-build 只能在 macOS 上跑"; exit 1; \
fi
@command -v pkg-config >/dev/null 2>&1 || { echo "❌ 需要 pkg-configbrew install pkg-config"; exit 1; }
@command -v nasm >/dev/null 2>&1 || command -v yasm >/dev/null 2>&1 || { echo "❌ 需要 nasm 或 yasmbrew install nasm"; exit 1; }
@mkdir -p vendor/ffmpeg/macos build/ffmpeg-macos
@echo "==> 下載 ffmpeg source $(FFMPEG_VERSION)..."
curl -fL -o build/ffmpeg-macos/source.tar.gz "$(FFMPEG_SRC_URL)"
@echo "==> 驗證 source tarball sha256..."
@echo "$(FFMPEG_SRC_SHA256) build/ffmpeg-macos/source.tar.gz" | shasum -a 256 -c || { \
echo "❌ sha256 不符,請更新 Makefile 中的 FFMPEG_SRC_SHA256 或檢查來源"; \
echo " 實際 sha256$$(shasum -a 256 build/ffmpeg-macos/source.tar.gz | awk '{print $$1}')"; \
exit 1; }
@rm -rf build/ffmpeg-macos/src build/ffmpeg-macos/install
@mkdir -p build/ffmpeg-macos/src
tar xzf build/ffmpeg-macos/source.tar.gz -C build/ffmpeg-macos/src --strip-components=1
@echo "==> configuredecoder-only LGPL v3..."
cd build/ffmpeg-macos/src && ./configure \
--prefix="$$(pwd)/../install" \
--enable-version3 \
--disable-debug \
--disable-doc \
--disable-ffplay \
--disable-network \
--disable-autodetect \
--disable-shared \
--enable-static \
--disable-everything \
--enable-small \
--enable-protocol=file,pipe \
--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2 \
--enable-decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,mjpeg,prores,vp8,vp9,aac,mp2,mp3,pcm_s16le,pcm_s16be \
--enable-parser=h264,hevc,mpeg4video,mpegaudio,aac \
--enable-filter=scale,format,fps,null,anull \
--enable-muxer=image2pipe,image2,null \
--enable-encoder=mjpeg \
--enable-swscale \
--enable-swresample \
--extra-cflags="-arch x86_64 -mmacosx-version-min=10.15" \
--extra-ldflags="-arch x86_64 -mmacosx-version-min=10.15 -Wl,-search_paths_first" \
--arch=x86_64 \
--target-os=darwin \
--cc="clang -arch x86_64"
cd build/ffmpeg-macos/src && make -j$$(sysctl -n hw.ncpu)
cd build/ffmpeg-macos/src && make install
@echo "==> 複製 ffmpeg + ffprobe 到 vendor/ffmpeg/macos/..."
cp build/ffmpeg-macos/install/bin/ffmpeg vendor/ffmpeg/macos/ffmpeg
cp build/ffmpeg-macos/install/bin/ffprobe vendor/ffmpeg/macos/ffprobe
@strip -S -x vendor/ffmpeg/macos/ffmpeg
@strip -S -x vendor/ffmpeg/macos/ffprobe
@chmod +x vendor/ffmpeg/macos/ffmpeg vendor/ffmpeg/macos/ffprobe
@echo "==> ad-hoc 簽章..."
codesign --force --sign - vendor/ffmpeg/macos/ffmpeg
codesign --force --sign - vendor/ffmpeg/macos/ffprobe
@echo "==> 驗證授權configuration line 不含 --enable-gpl / libx264 / libx265..."
@if vendor/ffmpeg/macos/ffmpeg -version 2>&1 | grep -E -- '--enable-gpl|libx264|libx265'; then \
echo "❌ LGPL 驗證失敗build 不該出現 gpl / x264 / x265"; exit 1; \
fi
@echo "==> 複製 COPYING.LGPLv3 到 vendor/ffmpeg/macos/..."
cp build/ffmpeg-macos/src/COPYING.LGPLv3 vendor/ffmpeg/macos/COPYING.LGPLv3
@echo "==> ffmpeg 大小:$$(du -h vendor/ffmpeg/macos/ffmpeg | cut -f1)"
@echo "==> ffprobe 大小:$$(du -h vendor/ffmpeg/macos/ffprobe | cut -f1)"
@echo ""
@echo "✅ macOS LGPL ffmpeg build 完成。接下來請:"
@echo " 1) 更新 vendor/ffmpeg/macos/BUILD.md 的 Binary sha256 區塊"
@echo " 2) git add vendor/ffmpeg/macos/{ffmpeg,ffprobe,COPYING.LGPLv3,BUILD.md}"
# ── Build單元 ──────────────────────────────────────────────────
server: build-server ## alias for build-server
@ -179,14 +236,15 @@ build-embed: build-frontend ## 同步 frontend/out → server/web/out 供 go:emb
# ── Payload 準備 ───────────────────────────────────────────────────
payload: payload-$(OS) ## 依當前 OS 準備 payload
payload-macos: build-server vendor-python vendor-wheels vendor-ffmpeg vendor-ytdlp ## 準備 macOS payload → payload/darwin/(含 python runtime + wheels + ffmpeg + yt-dlp
@echo "==> 建立 macOS payload (binary + models + scripts + python + wheels + ffmpeg + yt-dlp)..."
payload-macos: build-server vendor-python vendor-wheels vendor-ffmpeg ## 準備 macOS payload → payload/darwin/(含 python runtime + wheels + ffmpeg
@echo "==> 建立 macOS payload (binary + models + scripts + python + wheels + ffmpeg + ffprobe)..."
rm -rf payload/darwin
mkdir -p payload/darwin/bin payload/darwin/data payload/darwin/scripts payload/darwin/python payload/darwin/wheels
cp dist/visiona-local-server payload/darwin/bin/
cp vendor/ffmpeg/darwin/ffmpeg payload/darwin/bin/
cp vendor/yt-dlp/darwin/yt-dlp payload/darwin/bin/
chmod +x payload/darwin/bin/ffmpeg payload/darwin/bin/yt-dlp
cp vendor/ffmpeg/macos/ffmpeg payload/darwin/bin/
cp vendor/ffmpeg/macos/ffprobe payload/darwin/bin/
cp vendor/ffmpeg/macos/COPYING.LGPLv3 payload/darwin/bin/ffmpeg-COPYING.LGPLv3
chmod +x payload/darwin/bin/ffmpeg payload/darwin/bin/ffprobe
cp -R server/data/* payload/darwin/data/
cp -R server/scripts/* payload/darwin/scripts/
cp vendor/python/darwin/python.tar.gz payload/darwin/python/
@ -215,8 +273,8 @@ stage-macos: payload-macos ## 將 payload/darwin/ 放到 Wails build/darwin/Reso
# payload-windows / vendor-*-windows 是 curl 下載跨平台可跑server.exe 步驟除外)。
PBS_URL_WINDOWS := https://github.com/astral-sh/python-build-standalone/releases/download/$(PBS_RELEASE)/cpython-$(PYTHON_VERSION)+$(PBS_RELEASE)-x86_64-pc-windows-msvc-install_only.tar.gz
FFMPEG_URL_WINDOWS := https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip
YTDLP_URL_WINDOWS := https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe
# LGPL v3 buildBtbN n7.1 穩定分支,含 LGPL-safe extra libs— v2 TDD §3
FFMPEG_URL_WINDOWS := https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-n7.1-latest-win64-lgpl-7.1.zip
vendor-python-windows: ## 下載 python-build-standalone Windows x86_64 → vendor/python/windows/
@mkdir -p vendor/python/windows
@ -258,13 +316,12 @@ vendor-wheels-windows: ## 同步 Windows wheels → vendor/wheels/windows/
@ls -1 vendor/wheels/windows/*.whl 2>/dev/null | wc -l | xargs -I{} echo " 共 {} 個 wheel"
@du -sh vendor/wheels/windows 2>/dev/null || true
vendor-ffmpeg-windows: ## 下載 ffmpeg Windows static build → vendor/ffmpeg/windows/
vendor-ffmpeg-windows: ## 下載 ffmpeg Windows LGPL v3 build (n7.1) → vendor/ffmpeg/windows/
@mkdir -p vendor/ffmpeg/windows
@if [ -f vendor/ffmpeg/windows/ffmpeg.exe ]; then \
echo "==> ffmpeg.exe 已存在,跳過"; \
@if [ -f vendor/ffmpeg/windows/ffmpeg.exe ] && [ -f vendor/ffmpeg/windows/ffprobe.exe ]; then \
echo "==> ffmpeg.exe + ffprobe.exe 已存在,跳過"; \
else \
echo "==> 下載 ffmpeg Windows build from BtbN..."; \
echo "!! WARNING: BtbN 為 GPL buildlicense 由 PM 最終確認 !!"; \
echo "==> 下載 BtbN LGPL ffmpeg (Windows, n7.1)..."; \
curl -fL -o vendor/ffmpeg/windows/ffmpeg-win.zip "$(FFMPEG_URL_WINDOWS)"; \
PY=""; \
for candidate in "$$VISIONA_PYTHON" "py -3" python3 python; do \
@ -274,29 +331,24 @@ vendor-ffmpeg-windows: ## 下載 ffmpeg Windows static build → vendor/ffmpeg/w
if $$candidate --version >/dev/null 2>&1; then PY="$$candidate"; break; fi; \
done; \
if [ -z "$$PY" ]; then echo "ERROR: 需要真實 python 來解壓 zipWindowsApps stub 無法使用)"; exit 1; fi; \
echo "==> 使用 $$PY 解壓 ffmpeg zip"; \
$$PY -c "import zipfile, shutil; z=zipfile.ZipFile('vendor/ffmpeg/windows/ffmpeg-win.zip'); \
members=[n for n in z.namelist() if n.endswith('/bin/ffmpeg.exe')]; \
assert members, 'ffmpeg.exe not found in zip'; \
src=z.open(members[0]); dst=open('vendor/ffmpeg/windows/ffmpeg.exe','wb'); \
shutil.copyfileobj(src, dst); src.close(); dst.close(); z.close()" || { echo "ERROR: python 解壓失敗"; exit 1; }; \
echo "==> 使用 $$PY 解壓 ffmpeg zip取出 ffmpeg.exe / ffprobe.exe / LICENSE.txt"; \
$$PY -c "import zipfile, os; z=zipfile.ZipFile('vendor/ffmpeg/windows/ffmpeg-win.zip'); \
wanted=['/bin/ffmpeg.exe','/bin/ffprobe.exe','/LICENSE.txt','/COPYING.LGPLv3']; \
members=[n for n in z.namelist() if any(n.endswith(w) for w in wanted)]; \
assert any(n.endswith('/bin/ffmpeg.exe') for n in members), 'ffmpeg.exe not found in zip'; \
assert any(n.endswith('/bin/ffprobe.exe') for n in members), 'ffprobe.exe not found in zip'; \
os.makedirs('vendor/ffmpeg/windows', exist_ok=True); \
[open('vendor/ffmpeg/windows/'+m.rsplit('/',1)[1],'wb').write(z.read(m)) for m in members]; \
print('extracted:', [m.rsplit('/',1)[1] for m in members])" || { echo "ERROR: python 解壓失敗"; exit 1; }; \
rm -f vendor/ffmpeg/windows/ffmpeg-win.zip; \
[ -f vendor/ffmpeg/windows/ffmpeg.exe ] || { echo "ERROR: ffmpeg.exe 沒被寫出"; exit 1; }; \
echo "==> ffmpeg.exe 大小:$$(du -sh vendor/ffmpeg/windows/ffmpeg.exe | cut -f1)"; \
[ -f vendor/ffmpeg/windows/ffprobe.exe ] || { echo "ERROR: ffprobe.exe 沒被寫出"; exit 1; }; \
echo "==> ffmpeg.exe 大小:$$(du -h vendor/ffmpeg/windows/ffmpeg.exe | cut -f1)"; \
echo "==> ffprobe.exe 大小:$$(du -h vendor/ffmpeg/windows/ffprobe.exe | cut -f1)"; \
fi
vendor-ytdlp-windows: ## 下載 yt-dlp.exe → vendor/yt-dlp/windows/
@mkdir -p vendor/yt-dlp/windows
@if [ ! -f vendor/yt-dlp/windows/yt-dlp.exe ]; then \
echo "==> 下載 yt-dlp.exe..."; \
curl -fL -o vendor/yt-dlp/windows/yt-dlp.exe "$(YTDLP_URL_WINDOWS)"; \
echo "==> yt-dlp.exe 大小:$$(du -sh vendor/yt-dlp/windows/yt-dlp.exe | cut -f1)"; \
else \
echo "==> yt-dlp.exe 已存在,跳過"; \
fi
payload-windows: build-server-windows vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows vendor-ytdlp-windows ## 準備 Windows payload → payload/windows/
@echo "==> 建立 Windows payload (binary + models + scripts + python + wheels + ffmpeg + yt-dlp)..."
payload-windows: build-server-windows vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows ## 準備 Windows payload → payload/windows/
@echo "==> 建立 Windows payload (binary + models + scripts + python + wheels + ffmpeg)..."
@# 注意:不 rm -rf payload/windows因為 build-server-windows 已先把 .exe 放進去
mkdir -p payload/windows/bin payload/windows/data payload/windows/scripts payload/windows/python payload/windows/wheels
@if [ ! -f payload/windows/bin/visiona-local-server.exe ]; then \
@ -304,7 +356,10 @@ payload-windows: build-server-windows vendor-python-windows vendor-wheels-window
exit 1; \
fi
cp vendor/ffmpeg/windows/ffmpeg.exe payload/windows/bin/
cp vendor/yt-dlp/windows/yt-dlp.exe payload/windows/bin/
cp vendor/ffmpeg/windows/ffprobe.exe payload/windows/bin/
@# LGPL 授權條款BtbN build 自帶 LICENSE.txtCOPYING.LGPLv3 不一定在壓縮檔內,失敗不致命)
@cp vendor/ffmpeg/windows/LICENSE.txt payload/windows/bin/ffmpeg-LICENSE.txt 2>/dev/null || true
@cp vendor/ffmpeg/windows/COPYING.LGPLv3 payload/windows/bin/ffmpeg-COPYING.LGPLv3 2>/dev/null || true
cp -R server/data/. payload/windows/data/
cp -R server/scripts/. payload/windows/scripts/
cp vendor/python/windows/python.tar.gz payload/windows/python/
@ -328,8 +383,8 @@ stage-windows: payload-windows ## 將 payload/windows/ 放到 Wails build/window
# payload-linux / vendor-*-linux 是 curl 下載跨平台可跑server 步驟除外)。
PBS_URL_LINUX := https://github.com/astral-sh/python-build-standalone/releases/download/$(PBS_RELEASE)/cpython-$(PYTHON_VERSION)+$(PBS_RELEASE)-x86_64-unknown-linux-gnu-install_only.tar.gz
FFMPEG_URL_LINUX := https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
YTDLP_URL_LINUX := https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux
# LGPL v3 buildBtbN n7.1 穩定分支)— v2 TDD §4
FFMPEG_URL_LINUX := https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-n7.1-latest-linux64-lgpl-7.1.tar.xz
vendor-python-linux: ## 下載 python-build-standalone Linux x86_64 → vendor/python/linux/
@mkdir -p vendor/python/linux
@ -359,38 +414,27 @@ vendor-wheels-linux: ## 同步 Linux wheels → vendor/wheels/linux/
@ls -1 vendor/wheels/linux/*.whl 2>/dev/null | wc -l | xargs -I{} echo " 共 {} 個 wheel"
@du -sh vendor/wheels/linux 2>/dev/null || true
vendor-ffmpeg-linux: ## 下載 ffmpeg Linux static build → vendor/ffmpeg/linux/
vendor-ffmpeg-linux: ## 下載 ffmpeg Linux LGPL v3 build (n7.1) → vendor/ffmpeg/linux/
@mkdir -p vendor/ffmpeg/linux
@if [ -f vendor/ffmpeg/linux/ffmpeg ]; then \
echo "==> ffmpeg (Linux) 已存在,跳過 ($$(du -sh vendor/ffmpeg/linux/ffmpeg | cut -f1))"; \
@if [ -f vendor/ffmpeg/linux/ffmpeg ] && [ -f vendor/ffmpeg/linux/ffprobe ]; then \
echo "==> ffmpeg + ffprobe (Linux) 已存在,跳過"; \
else \
echo "==> 下載 ffmpeg static build (Linux x86_64) from johnvansickle..."; \
echo "!! WARNING: johnvansickle build 為 GPL build正式發佈前需改用 LGPL 來源 !!"; \
echo "==> 下載 BtbN LGPL ffmpeg (Linux, n7.1)..."; \
curl -fL -o /tmp/ffmpeg-linux.tar.xz "$(FFMPEG_URL_LINUX)"; \
rm -rf /tmp/ffmpeg-linux-extract; \
mkdir -p /tmp/ffmpeg-linux-extract; \
tar xf /tmp/ffmpeg-linux.tar.xz -C /tmp/ffmpeg-linux-extract --strip-components=1; \
cp /tmp/ffmpeg-linux-extract/ffmpeg vendor/ffmpeg/linux/; \
chmod +x vendor/ffmpeg/linux/ffmpeg; \
cp /tmp/ffmpeg-linux-extract/bin/ffmpeg vendor/ffmpeg/linux/; \
cp /tmp/ffmpeg-linux-extract/bin/ffprobe vendor/ffmpeg/linux/; \
cp /tmp/ffmpeg-linux-extract/LICENSE.txt vendor/ffmpeg/linux/LICENSE.txt 2>/dev/null || true; \
chmod +x vendor/ffmpeg/linux/ffmpeg vendor/ffmpeg/linux/ffprobe; \
rm -rf /tmp/ffmpeg-linux* ; \
echo "==> ffmpeg (Linux) 大小:$$(du -sh vendor/ffmpeg/linux/ffmpeg | cut -f1)"; \
if [ "$${VISIONA_ALLOW_GPL_FFMPEG:-0}" != "1" ]; then \
echo " ⚠️ 提醒:此 build 為 GPL僅限本地驗收發佈前請改用 LGPL build"; \
fi; \
echo "==> ffmpeg (Linux) 大小:$$(du -h vendor/ffmpeg/linux/ffmpeg | cut -f1)"; \
echo "==> ffprobe (Linux) 大小:$$(du -h vendor/ffmpeg/linux/ffprobe | cut -f1)"; \
fi
vendor-ytdlp-linux: ## 下載 yt-dlp (Linux) → vendor/yt-dlp/linux/
@mkdir -p vendor/yt-dlp/linux
@if [ ! -f vendor/yt-dlp/linux/yt-dlp ]; then \
echo "==> 下載 yt-dlp (Linux)..."; \
curl -fL -o vendor/yt-dlp/linux/yt-dlp "$(YTDLP_URL_LINUX)"; \
chmod +x vendor/yt-dlp/linux/yt-dlp; \
echo "==> yt-dlp (Linux) 大小:$$(du -sh vendor/yt-dlp/linux/yt-dlp | cut -f1)"; \
else \
echo "==> yt-dlp (Linux) 已存在,跳過"; \
fi
payload-linux: build-server-linux vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux vendor-ytdlp-linux ## 準備 Linux payload → payload/linux/
@echo "==> 建立 Linux payload (binary + models + scripts + python + wheels + ffmpeg + yt-dlp)..."
payload-linux: build-server-linux vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux ## 準備 Linux payload → payload/linux/
@echo "==> 建立 Linux payload (binary + models + scripts + python + wheels + ffmpeg)..."
mkdir -p payload/linux/bin payload/linux/data payload/linux/scripts payload/linux/python payload/linux/wheels
@if [ ! -f payload/linux/bin/visiona-local-server ]; then \
echo "!! ERROR: payload/linux/bin/visiona-local-server 不存在build-server-linux 可能失敗 !!"; \
@ -398,7 +442,8 @@ payload-linux: build-server-linux vendor-python-linux vendor-wheels-linux vendor
fi
@chmod +x payload/linux/bin/visiona-local-server
@cp vendor/ffmpeg/linux/ffmpeg payload/linux/bin/ 2>/dev/null && chmod +x payload/linux/bin/ffmpeg || echo "!! WARN: ffmpeg 缺失"
@cp vendor/yt-dlp/linux/yt-dlp payload/linux/bin/ 2>/dev/null && chmod +x payload/linux/bin/yt-dlp || echo "!! WARN: yt-dlp 缺失"
@cp vendor/ffmpeg/linux/ffprobe payload/linux/bin/ 2>/dev/null && chmod +x payload/linux/bin/ffprobe || echo "!! WARN: ffprobe 缺失"
@cp vendor/ffmpeg/linux/LICENSE.txt payload/linux/bin/ffmpeg-LICENSE.txt 2>/dev/null || true
@if [ -d server/data ]; then cp -R server/data/. payload/linux/data/; fi
@if [ -d server/scripts ]; then cp -R server/scripts/. payload/linux/scripts/; fi
@cp vendor/python/linux/python.tar.gz payload/linux/python/ 2>/dev/null || echo "!! WARN: python tarball 缺失"
@ -532,9 +577,6 @@ appimage: wails-linux ## ⚠️ 必須在 Linux 上跑build-appimage.sh → d
dev:
@echo "TODO: make -j2 dev-server dev-frontend開發模式"
dev-mock:
@echo "TODO: make -j2 dev-mock-server dev-frontendMock 模式)"
test:
@echo "TODO: go test + pnpm test"

View File

@ -18,9 +18,9 @@ visionA-local 是 `edge-ai-platform`(原本要部署到 EC2 + Docker 的 Knero
三個核心承諾:
- 🎒 **零依賴**Python runtime、KneronPLUS SDK、ffmpeg、yt-dlp、預置 `.nef` 模型全部內嵌
- 🎒 **零依賴**Python runtime、KneronPLUS SDK、ffmpeg、預置 `.nef` 模型全部內嵌
- ✈️ **零網路**:下載一次後完全離線可用(適合客戶現場 IT 鎖得死緊的場景)
- 🖱️ **零學習成本**:雙擊安裝 → 開啟 → Mock 模式 30 秒內跑出第一幀推論
- 🖱️ **零學習成本**:雙擊安裝 → 開啟 → 插上 Kneron 裝置 30 秒內跑出第一幀推論
對標產品Docker Desktop、Ollama。
@ -70,10 +70,9 @@ visionA-local 是 `edge-ai-platform`(原本要部署到 EC2 + Docker 的 Knero
- **裝置管理**USB 自動偵測 Kneron KL520 / KL72010 秒內連線
- **攝影機推論**MJPEG 串流 + 即時 overlay首次延遲 ≤ 250ms穩定後 ≤ 150ms
- **Mock 模式**零硬體入口產品經理、SA 也能拿來說故事
- **模型管理**8 個預置 `.nef` 模型(分類 / 偵測 / 臉辨)+ 自上傳切換
- **核心推論引擎**image classification、object detection、face recognition
- **媒體推論**:支援圖片、影片檔、URL內嵌 yt-dlp
- **媒體推論**:支援圖片與影片檔本機上傳R5 決策後不支援 URL 推論
- **中英雙語**,跟隨系統 Dark Mode
### ❌ 不做的事(明確排除)
@ -126,10 +125,10 @@ make help
| Target | 作用 |
|--------|------|
| `vendor-sync` | 下載 python-build-standalone、wheels、ffmpeg、yt-dlp |
| `vendor-sync` | 下載 python-build-standalone、wheels、ffmpeg |
| `build-server` | 編譯 Go server binary先 build frontend + embed |
| `build-frontend` | pnpm build Next.js 靜態產物 |
| `payload-macos` | 準備 macOS payloadbinary + python + wheels + ffmpeg + yt-dlp + 模型) |
| `payload-macos` | 準備 macOS payloadbinary + python + wheels + ffmpeg + 模型) |
| `wails-macos` | Wails build + ad-hoc codesign |
| `dmg` | 產出 `dist/visiona-local.dmg` |
| `exe` | Windows installer需在 Windows runner 執行) |
@ -161,7 +160,6 @@ make help
## 已知限制與 TODO
- 🔴 **ffmpeg 目前是 GPL build**(含 `--enable-gpl --enable-libx264`),由 `VISIONA_ALLOW_GPL_FFMPEG=1` flag 放行本地驗收,**發佈前等法務 review**
- 🟡 **Kneron 預置模型 re-distribution 授權**:開發階段假設可用,正式發佈前需與 Kneron 官方確認
- 🟡 **Windows / Linux 安裝檔**build script 就緒,等 CI runner 齊備
- 🟡 **Apple Silicon** 未經測試(理論上 Rosetta 2 可跑)
@ -179,8 +177,7 @@ make help
| 元件 | 授權 | 備註 |
|------|------|------|
| ffmpeg | **GPL**(目前使用 GPL build | ⚠️ 法務 review pending |
| yt-dlp | Unlicense | — |
| ffmpeg | **LGPL v3**(方案 B 混合macOS 自 build decoder-only / Windows & Linux 用 BtbN n7.1 LGPL | v2 TDD §2.2 |
| KneronPLUS SDK | Kneron 商用條款 | 再次確認 re-distribution 權利 |
| python-build-standalone | MPL 2.0 / PSFL | — |
| Python 標準函式庫 | PSFL | — |

View File

@ -8,6 +8,8 @@ import { ThemeSync } from "@/components/theme-sync";
import { LangSync } from "@/components/lang-sync";
import { StoreHydration } from "@/components/store-hydration";
import { GuidedTour } from "@/components/guided-tour";
import { ServerOfflineOverlay } from "@/components/system/server-offline-overlay";
import { ShutdownWatcherMount } from "@/components/system/shutdown-watcher-mount";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -53,6 +55,8 @@ export default function RootLayout({
<ThemeSync />
<LangSync />
<GuidedTour />
<ShutdownWatcherMount />
<ServerOfflineOverlay />
<Toaster richColors position="bottom-right" />
</body>
</html>

View File

@ -138,25 +138,6 @@ export default function SettingsPage() {
<CardTitle className="text-base">{t('settings.hardware.title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>{t('settings.hardware.runtimeMode')}</Label>
{/* TODO: 連接 backend GET /api/system/config 讀實際 mode現階段只顯示預設值 real */}
<Select value="real" disabled>
<SelectTrigger className="w-[420px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mock">{t('settings.hardware.runtimeModeMock')}</SelectItem>
<SelectItem value="real">{t('settings.hardware.runtimeModeReal')}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t('settings.hardware.runtimeModeHint')}
</p>
</div>
<Separator />
<div className="space-y-2">
<Label>{t('settings.hardware.pythonMode')}</Label>
<Input value={BUNDLED_PYTHON_PLACEHOLDER} readOnly className="bg-muted w-80" />

View File

@ -19,7 +19,13 @@ export function CameraControls({ deviceId }: CameraControlsProps) {
{t('camera.stopCamera')}
</Button>
) : (
<Button onClick={() => startPipeline(cameras[0]?.id ?? 'mock-cam-0', deviceId)}>
<Button
disabled={cameras.length === 0}
onClick={() => {
const firstCam = cameras[0]?.id;
if (firstCam) startPipeline(firstCam, deviceId);
}}
>
{t('camera.startCamera')}
</Button>
)}

View File

@ -2,7 +2,6 @@
import { useState, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useCameraStore } from '@/stores/camera-store';
import { useTranslation } from '@/lib/i18n';
@ -25,7 +24,6 @@ export function SourceSelector({ deviceId }: SourceSelectorProps) {
uploadImage,
uploadVideo,
uploadBatchImages,
startFromUrl,
} = useCameraStore();
const [mounted, setMounted] = useState(false);
@ -48,8 +46,6 @@ export function SourceSelector({ deviceId }: SourceSelectorProps) {
setCameraDisabled(false);
}
}, [hasCameras, activeTab]);
const [videoMode, setVideoMode] = useState<'file' | 'url'>('file');
const [videoUrl, setVideoUrl] = useState('');
const [isDragging, setIsDragging] = useState(false);
const imageFileRef = useRef<HTMLInputElement>(null);
const videoFileRef = useRef<HTMLInputElement>(null);
@ -91,12 +87,6 @@ export function SourceSelector({ deviceId }: SourceSelectorProps) {
if (videoFileRef.current) videoFileRef.current.value = '';
};
const handleUrlSubmit = async () => {
if (!videoUrl.trim()) return;
await startFromUrl(videoUrl.trim(), deviceId);
setVideoUrl('');
};
const sourceLabel =
sourceType === 'camera' ? t('camera.camera') : sourceType === 'image' ? t('camera.image') : sourceType === 'batch_image' ? t('camera.batchImages') : t('camera.video');
@ -183,25 +173,6 @@ export function SourceSelector({ deviceId }: SourceSelectorProps) {
)}
{activeTab === 'video' && (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2">
<Button
variant={videoMode === 'file' ? 'default' : 'outline'}
size="sm"
onClick={() => setVideoMode('file')}
>
{t('camera.uploadFile')}
</Button>
<Button
variant={videoMode === 'url' ? 'default' : 'outline'}
size="sm"
onClick={() => setVideoMode('url')}
>
{t('camera.pasteUrl')}
</Button>
</div>
{videoMode === 'file' ? (
<div className="flex items-center gap-3">
<Button
onClick={() => videoFileRef.current?.click()}
@ -215,42 +186,11 @@ export function SourceSelector({ deviceId }: SourceSelectorProps) {
<input
ref={videoFileRef}
type="file"
accept=".mp4,.avi,.mov"
accept=".mp4,.avi,.mov,.mpeg,.mpg"
className="hidden"
onChange={handleVideoSelect}
/>
</div>
) : (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<Input
placeholder={t('camera.urlPlaceholder')}
value={videoUrl}
onChange={(e) => setVideoUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleUrlSubmit();
}}
className="flex-1"
/>
<Button
onClick={handleUrlSubmit}
disabled={isUploading || !videoUrl.trim()}
>
{isUploading ? t('common.loading') : t('common.start')}
</Button>
</div>
{isUploading ? (
<p className="text-xs text-amber-600 animate-pulse">
YouTube 10-30 ...
</p>
) : (
<p className="text-xs text-muted-foreground">
{t('camera.urlHelpText')}
</p>
)}
</div>
)}
</div>
)}
</>
)}

View File

@ -0,0 +1,162 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { AlertTriangle, Loader2 } from 'lucide-react';
import { useSystemStore, FAILURE_THRESHOLD } from '@/stores/system-store';
import { useTranslation, type TranslationKey } from '@/lib/i18n';
import { Button } from '@/components/ui/button';
import { retryServerHealth } from '@/hooks/use-shutdown-watcher';
import { cn } from '@/lib/utils';
/**
* <ServerOfflineOverlay>
*
* TDD v2 §2.6M8-7+ Design v2 §2-§9server-offline-overlay.md
*
*
* - +
* - role="alertdialog" + aria-modal + aria-live="assertive" + focus trap
* - offlineReason quit / restart / healthcheck-failed / unknown
* - window.close() tab help text
* - health check
* - Active retry use-shutdown-watcher 3 s boot-id
*/
export function ServerOfflineOverlay() {
const forcedOffline = useSystemStore((s) => s.forcedOffline);
const failures = useSystemStore((s) => s.consecutiveFailures);
const offlineReason = useSystemStore((s) => s.offlineReason);
const { t } = useTranslation();
const show = forcedOffline || failures >= FAILURE_THRESHOLD;
const [retrying, setRetrying] = useState(false);
const retryButtonRef = useRef<HTMLButtonElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
// Focus trapoverlay 顯示時把焦點推到重試按鈕上。
useEffect(() => {
if (!show) return;
retryButtonRef.current?.focus();
}, [show]);
// Focus trapTab 鍵循環只在卡片內元素之間移動。
useEffect(() => {
if (!show) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const card = cardRef.current;
if (!card) return;
const focusable = card.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [show]);
if (!show) return null;
const handleRetry = async () => {
if (retrying) return;
setRetrying(true);
try {
await retryServerHealth();
} finally {
// 給使用者一點點視覺反饋,避免 spinner 一閃而過
setTimeout(() => setRetrying(false), 300);
}
};
// 依 reason 決定副標
const subtitleKey: TranslationKey = ((): TranslationKey => {
switch (offlineReason) {
case 'quit':
return 'offline.subtitle.quit';
case 'restart':
return 'offline.subtitle.restart';
case 'healthcheck-failed':
return 'offline.subtitle.healthcheck';
default:
return 'offline.subtitle.healthcheck';
}
})();
return (
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="server-offline-title"
aria-describedby="server-offline-subtitle"
aria-live="assertive"
className={cn(
'fixed inset-0 z-[9999] flex items-center justify-center',
'bg-black/55 backdrop-blur-sm',
'dark:bg-black/70',
)}
data-testid="server-offline-overlay"
>
<div
ref={cardRef}
className={cn(
'mx-4 w-full max-w-md rounded-2xl border bg-background p-10 shadow-2xl',
'flex flex-col items-center text-center',
)}
>
{/* Icon */}
<div className="mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-destructive/10 dark:bg-destructive/15">
<AlertTriangle className="h-10 w-10 text-destructive" aria-hidden="true" />
</div>
{/* 標題 */}
<h1
id="server-offline-title"
className="mb-3 text-2xl font-bold text-foreground"
>
{t('offline.title')}
</h1>
{/* 副標 — 動態 reason */}
<p
id="server-offline-subtitle"
className="mb-6 text-[15px] leading-relaxed text-muted-foreground"
>
{t(subtitleKey)}
</p>
{/* 重試按鈕 */}
<Button
ref={retryButtonRef}
size="lg"
className="mb-4 w-60"
onClick={handleRetry}
disabled={retrying}
data-testid="server-offline-retry"
>
{retrying ? (
<>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
{t('offline.retrying')}
</>
) : (
<>{t('offline.retryButton')}</>
)}
</Button>
{/* Help text — 取代 close buttonwindow.close() 對主動開的 tab 無效)*/}
<p className="mt-2 text-xs text-muted-foreground">
{t('offline.helpText')}
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,16 @@
'use client';
import { useShutdownWatcher } from '@/hooks/use-shutdown-watcher';
/**
* useShutdownWatcher root layout client wrapper
*
* useShutdownWatcher React hook server componentlayout.tsx
* 'use client' wrapper render DOM
*
* TDD v2 §2.6M8-7
*/
export function ShutdownWatcherMount() {
useShutdownWatcher();
return null;
}

View File

@ -0,0 +1,358 @@
'use client';
import { useEffect } from 'react';
import { useSystemStore, FAILURE_THRESHOLD, type OfflineReason } from '@/stores/system-store';
import { getApiBaseUrl, getWsBaseUrl, getRelayToken } from '@/lib/constants';
import { getRelayHeaders, ensureRelayToken } from '@/lib/api';
/**
* use-shutdown-watcher
*
* TDD v2 §2.6M8-7+ §2.6.2aMinor 4 WebSocket + §12M8-9 Boot-ID reload
*
* forcedOffline
* 1. WebSocket `server:shutdown-imminent` reason
* 2. WebSocket onclose clean close healthcheck-failed
* 3. polling fallbackhealth check 2 healthcheck-failed
*
*
* - normal10 s pollingserver
* - active retry3 s pollingoverlay server
*
* Page Visibility tab polling probe
*
* server `/ws/system` endpoint M8-4b
* WebSocket forcedOffline polling fallback
*
* Boot-ID reloadM8-9
* - /system/boot-id response.bootId store.checkAndUpdateBootId
* first bootIdmarkOnline
* match bootId markOnline
* mismatch bootId server force `window.location.reload()`
* - reload sessionStorage bootId reloadflag
* poll bootId bug reload loop
* reload store reset bootId=null first
*/
/** sessionStorage key記錄上次針對哪個 bootId 觸發過 reload防 reload loop。 */
const RELOAD_LOOP_GUARD_KEY = 'visiona-reload-loop-guard';
const POLL_INTERVAL_NORMAL_MS = 10_000;
const POLL_INTERVAL_ACTIVE_RETRY_MS = 3_000;
const FETCH_TIMEOUT_MS = 3_000;
const WS_RECONNECT_DELAY_MS = 5_000;
const RESTART_DEFER_MS = 10_000;
interface BootIdResponse {
success: boolean;
data?: {
bootId: string;
startedAt: number;
};
}
interface ShutdownImminentMessage {
type: 'server:shutdown-imminent';
reason: 'app-closing' | 'manual-stop' | 'restart' | 'quit';
ts?: number;
}
/** 把 server WebSocket reason 對應到本地 store 的 OfflineReason。 */
function mapReason(serverReason: ShutdownImminentMessage['reason']): OfflineReason {
switch (serverReason) {
case 'quit':
case 'app-closing':
return 'quit';
case 'manual-stop':
return 'quit';
case 'restart':
return 'restart';
default:
return 'unknown';
}
}
/**
* boot-id + force reloadM8-9
*
*
* - `true` first / match
* - `false` reload
*
* reload loop guard sessionStorage bootId reload
* - reload store.bootId=null first reload
* - server bootIdsessionStorage
* reload markOnline reload loop 使
*/
function handleBootIdCheck(newBootId: string): boolean {
const store = useSystemStore.getState();
const result = store.checkAndUpdateBootId(newBootId);
if (result === 'first' || result === 'match') {
return true;
}
// mismatchserver 重啟force reload。
if (typeof window === 'undefined') {
// SSR / 測試環境保險:不做 reload只標示不再繼續處理。
return false;
}
try {
const lastGuard = window.sessionStorage.getItem(RELOAD_LOOP_GUARD_KEY);
if (lastGuard === newBootId) {
// 同一個 bootId 已經觸發過 reload避免 loop改為接受這個 bootId + markOnline。
console.warn(
'[boot-id] skip reload: already reloaded for this bootId this session',
newBootId,
);
store.setBootId(newBootId);
store.markOnline();
return false;
}
window.sessionStorage.setItem(RELOAD_LOOP_GUARD_KEY, newBootId);
} catch {
// sessionStorage 可能在隱私模式等情境下失敗;失敗時仍然執行 reload。
}
console.info('[boot-id] mismatch detected, force reloading tab', {
oldBootId: store.bootId,
newBootId,
});
window.location.reload();
return false;
}
/** 打一次 boot-id成功 → 比對 bootId可能觸發 reload+ markOnline失敗 → recordFailure。 */
async function pollOnce(): Promise<void> {
const store = useSystemStore.getState();
try {
await ensureRelayToken();
const res = await fetch(`${getApiBaseUrl()}/system/boot-id`, {
cache: 'no-store',
headers: getRelayHeaders(),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) {
store.recordFailure();
return;
}
const json = (await res.json()) as BootIdResponse;
if (!json.success || !json.data?.bootId) {
store.recordFailure();
return;
}
// 成功:先比對 bootIdM8-9若 mismatch → reload直接結束
// first / match → markOnline。
const shouldContinue = handleBootIdCheck(json.data.bootId);
if (!shouldContinue) return;
store.markOnline();
} catch {
store.recordFailure();
}
}
export function useShutdownWatcher(): void {
useEffect(() => {
if (typeof window === 'undefined') return;
let intervalHandle: ReturnType<typeof setInterval> | null = null;
let currentMode: 'normal' | 'active-retry' = 'normal';
let ws: WebSocket | null = null;
let wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let wsEverConnected = false; // 只有曾經連上才把 onclose 視為「server 斷了」
let cancelled = false;
let restartDeferTimer: ReturnType<typeof setTimeout> | null = null;
const intervalFor = (mode: 'normal' | 'active-retry') =>
mode === 'active-retry' ? POLL_INTERVAL_ACTIVE_RETRY_MS : POLL_INTERVAL_NORMAL_MS;
const startPolling = (mode: 'normal' | 'active-retry') => {
if (intervalHandle !== null && currentMode === mode) return;
if (intervalHandle !== null) clearInterval(intervalHandle);
currentMode = mode;
intervalHandle = setInterval(() => {
void pollOnce();
}, intervalFor(mode));
};
const stopPolling = () => {
if (intervalHandle !== null) {
clearInterval(intervalHandle);
intervalHandle = null;
}
};
// 訂閱 store當 forcedOffline / consecutiveFailures 改變時,自動切 polling 模式
const unsubscribe = useSystemStore.subscribe((state) => {
const shouldActiveRetry =
state.forcedOffline || state.consecutiveFailures >= FAILURE_THRESHOLD;
const targetMode: 'normal' | 'active-retry' = shouldActiveRetry ? 'active-retry' : 'normal';
// 連續失敗達到門檻但尚未 forcedOffline → 由 hook 補上 forceOffline
if (
!state.forcedOffline &&
state.consecutiveFailures >= FAILURE_THRESHOLD &&
state.serverOnline !== false
) {
useSystemStore.getState().forceOffline('healthcheck-failed');
}
if (intervalHandle !== null && targetMode !== currentMode) {
startPolling(targetMode);
}
});
// ─── WebSocket 訂閱 ───────────────────────────────────────────
// server 端 `/ws/system` 可能尚未實作M8-4b此處需容錯
// - 連不上 → onerror / onclosewsEverConnected=false→ 不觸發 forcedOffline
// - 連上後再斷 → 視為 server 真的掛了 → 觸發 forcedOffline
const connectWs = () => {
if (cancelled) return;
try {
const token = getRelayToken();
let wsUrl = `${getWsBaseUrl()}/ws/system`;
if (token) {
wsUrl += `?token=${encodeURIComponent(token)}`;
}
ws = new WebSocket(wsUrl);
} catch {
scheduleWsReconnect();
return;
}
ws.onopen = () => {
wsEverConnected = true;
};
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(String(ev.data)) as Partial<ShutdownImminentMessage>;
if (msg?.type !== 'server:shutdown-imminent') return;
const serverReason = (msg as ShutdownImminentMessage).reason;
const reason = mapReason(serverReason);
if (reason === 'restart') {
// restart延遲 10 s 才顯示 overlay期間若 polling 成功bootId 變)→ M8-9 reload
if (restartDeferTimer !== null) clearTimeout(restartDeferTimer);
restartDeferTimer = setTimeout(() => {
const s = useSystemStore.getState();
if (!s.serverOnline || s.consecutiveFailures >= FAILURE_THRESHOLD) {
s.forceOffline('restart');
}
}, RESTART_DEFER_MS);
return;
}
// quit / unknown立即顯示
useSystemStore.getState().forceOffline(reason);
} catch {
// 非 JSON / 格式錯誤 → 忽略
}
};
ws.onclose = (ev) => {
const wasEver = wsEverConnected;
ws = null;
// 只有「曾經連上後又斷」才視為 server 真的掛了;
// 若從未連上server 端尚未實作 /ws/system不觸發 forcedOffline。
if (wasEver && !ev.wasClean) {
// 立即觸發一次 polling 確認,若失敗才會被 polling 機制處理
void pollOnce();
}
scheduleWsReconnect();
};
ws.onerror = () => {
// 忽略 — onclose 會接續處理
};
};
const scheduleWsReconnect = () => {
if (cancelled || wsReconnectTimer !== null) return;
wsReconnectTimer = setTimeout(() => {
wsReconnectTimer = null;
if (!cancelled && document.visibilityState === 'visible') {
connectWs();
}
}, WS_RECONNECT_DELAY_MS);
};
// ─── Page Visibility ───────────────────────────────────────────
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void pollOnce();
const s = useSystemStore.getState();
const mode: 'normal' | 'active-retry' =
s.forcedOffline || s.consecutiveFailures >= FAILURE_THRESHOLD ? 'active-retry' : 'normal';
startPolling(mode);
if (ws === null && wsReconnectTimer === null) {
connectWs();
}
} else {
stopPolling();
}
};
// ─── 初始啟動 ───────────────────────────────────────────────
void pollOnce();
if (document.visibilityState === 'visible') {
startPolling('normal');
connectWs();
}
document.addEventListener('visibilitychange', onVisibilityChange);
// ─── 清理 ───────────────────────────────────────────────
return () => {
cancelled = true;
document.removeEventListener('visibilitychange', onVisibilityChange);
unsubscribe();
stopPolling();
if (wsReconnectTimer !== null) {
clearTimeout(wsReconnectTimer);
wsReconnectTimer = null;
}
if (restartDeferTimer !== null) {
clearTimeout(restartDeferTimer);
restartDeferTimer = null;
}
if (ws) {
ws.onclose = null;
ws.onerror = null;
ws.onmessage = null;
ws.close();
ws = null;
}
};
}, []);
}
/** 匯出供 overlay 元件呼叫,手動 retry 按鈕用。 */
export async function retryServerHealth(): Promise<boolean> {
const store = useSystemStore.getState();
try {
await ensureRelayToken();
const res = await fetch(`${getApiBaseUrl()}/system/boot-id`, {
cache: 'no-store',
headers: getRelayHeaders(),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) {
store.recordFailure();
return false;
}
const json = (await res.json()) as BootIdResponse;
if (!json.success || !json.data?.bootId) {
store.recordFailure();
return false;
}
// M8-9手動 retry 也要比對 bootIdmismatch → reload。
const shouldContinue = handleBootIdCheck(json.data.bootId);
if (!shouldContinue) {
// 已觸發 reload或在 SSR/guard 情境下被 skip回傳 true 讓 overlay 視為成功。
return true;
}
store.markOnline();
return true;
} catch {
store.recordFailure();
return false;
}
}

View File

@ -67,7 +67,7 @@ export const en: TranslationDict = {
subtitle: 'Manage your edge AI devices',
scan: 'Scan Devices',
scanning: 'Scanning...',
noDevices: 'No devices detected. Make sure mock mode is enabled or connect a device.',
noDevices: 'No Kneron devices detected. Please connect a KL520 / KL720 device and click "Scan".',
type: 'Type',
firmware: 'Firmware',
flashedModel: 'Flashed Model',
@ -208,13 +208,9 @@ export const en: TranslationDict = {
selectImage: 'Select Image',
selectImages: 'Select Images',
selectVideo: 'Select Video',
uploadFile: 'Upload File',
pasteUrl: 'Paste URL',
urlPlaceholder: 'https://example.com/video.mp4',
urlHelpText: 'Supports YouTube, direct video URL (.mp4 etc.), and RTSP stream.',
jpgPng: 'JPG, PNG',
jpgPngMultiple: 'JPG, PNG (multiple)',
mp4AviMov: 'MP4, AVI, MOV',
mp4AviMov: 'MP4 / AVI / MOV / MPEG / MPG',
noInputSource: 'No input source',
selectSourceHint: 'Select a camera, image, or video above',
uploadedImage: 'Uploaded Image',
@ -271,10 +267,6 @@ export const en: TranslationDict = {
},
hardware: {
title: 'Hardware',
runtimeMode: 'Runtime Mode',
runtimeModeMock: 'Mock (simulated devices, no hardware required — for development/testing)',
runtimeModeReal: 'Real Hardware (default — connects to actual Kneron devices)',
runtimeModeHint: 'Default is Real Hardware mode. To force Mock mode for development, set environment variable VISIONA_MOCK=1 before launching. Runtime switching will be available in a future release.',
pythonMode: 'Python Runtime',
pythonModeAuto: 'Auto (prefer bundled)',
pythonModeBundled: 'Bundled (recommended)',
@ -421,8 +413,18 @@ export const en: TranslationDict = {
cannotStopStream: 'Cannot stop stream',
imageUploadFailed: 'Image upload failed',
videoUploadFailed: 'Video upload failed',
cannotOpenVideoUrl: 'Cannot open video URL',
batchUploadFailed: 'Batch image upload failed',
unexpectedError: 'An unexpected error occurred',
},
offline: {
title: 'Local Server Offline',
subtitle: {
quit: 'visionA Local has stopped. Please relaunch the application.',
restart: 'Server is restarting, please wait…',
healthcheck: 'The server is not responding. Reconnecting…',
},
retryButton: 'Retry connection',
retrying: 'Retrying…',
helpText: 'To leave this page, simply close this browser tab.',
},
};

View File

@ -206,10 +206,6 @@ export interface TranslationDict {
selectImage: string;
selectImages: string;
selectVideo: string;
uploadFile: string;
pasteUrl: string;
urlPlaceholder: string;
urlHelpText: string;
jpgPng: string;
jpgPngMultiple: string;
mp4AviMov: string;
@ -269,10 +265,6 @@ export interface TranslationDict {
};
hardware: {
title: string;
runtimeMode: string;
runtimeModeMock: string;
runtimeModeReal: string;
runtimeModeHint: string;
pythonMode: string;
pythonModeAuto: string;
pythonModeBundled: string;
@ -419,10 +411,20 @@ export interface TranslationDict {
cannotStopStream: string;
imageUploadFailed: string;
videoUploadFailed: string;
cannotOpenVideoUrl: string;
batchUploadFailed: string;
unexpectedError: string;
};
offline: {
title: string;
subtitle: {
quit: string;
restart: string;
healthcheck: string;
};
retryButton: string;
retrying: string;
helpText: string;
};
}
type Join<K, P> = K extends string

View File

@ -67,7 +67,7 @@ export const zhTW: TranslationDict = {
subtitle: '管理你的 Edge AI 裝置',
scan: '掃描裝置',
scanning: '掃描中...',
noDevices: '未偵測到裝置。請確認已啟用 Mock 模式或連接裝置。',
noDevices: '未偵測到 Kneron 裝置。請連接 KL520 / KL720 後按「掃描」。',
type: '類型',
firmware: '韌體',
flashedModel: '已燒錄模型',
@ -208,13 +208,9 @@ export const zhTW: TranslationDict = {
selectImage: '選擇圖片',
selectImages: '選擇圖片',
selectVideo: '選擇影片',
uploadFile: '上傳檔案',
pasteUrl: '貼上連結',
urlPlaceholder: 'https://example.com/video.mp4',
urlHelpText: '支援 YouTube、直接影片 URL.mp4 等)及 RTSP 串流。',
jpgPng: 'JPG, PNG',
jpgPngMultiple: 'JPG, PNG支援多選',
mp4AviMov: 'MP4, AVI, MOV',
mp4AviMov: 'MP4 / AVI / MOV / MPEG / MPG',
noInputSource: '無輸入來源',
selectSourceHint: '請在上方選擇攝影機、圖片或影片',
uploadedImage: '已上傳圖片',
@ -271,10 +267,6 @@ export const zhTW: TranslationDict = {
},
hardware: {
title: '硬體',
runtimeMode: '執行模式',
runtimeModeMock: 'Mock模擬裝置不需真實硬體 — 開發 / 測試用)',
runtimeModeReal: '真實硬體(預設 — 連接實體 Kneron 裝置)',
runtimeModeHint: '預設為真實硬體模式。若要強制使用 Mock 模式進行開發,啟動前設定環境變數 VISIONA_MOCK=1。未來版本會加入 UI 切換功能。',
pythonMode: 'Python 執行模式',
pythonModeAuto: '自動(優先內嵌)',
pythonModeBundled: '內嵌(推薦)',
@ -421,8 +413,18 @@ export const zhTW: TranslationDict = {
cannotStopStream: '無法停止串流',
imageUploadFailed: '圖片上傳失敗',
videoUploadFailed: '影片上傳失敗',
cannotOpenVideoUrl: '無法開啟影片連結',
batchUploadFailed: '批次圖片上傳失敗',
unexpectedError: '發生未預期的錯誤',
},
offline: {
title: 'Local Server 已離線',
subtitle: {
quit: 'visionA Local 已結束,請重新開啟應用程式。',
restart: 'Server 正在重新啟動,請稍候…',
healthcheck: '偵測到 Server 無回應,正在重試連線…',
},
retryButton: '重試連線',
retrying: '正在重試中…',
helpText: '如要離開本頁,請直接關閉此分頁。',
},
};

View File

@ -29,7 +29,6 @@ interface CameraState {
uploadImage: (file: File, deviceId: string) => Promise<void>;
uploadVideo: (file: File, deviceId: string) => Promise<void>;
uploadBatchImages: (files: File[], deviceId: string) => Promise<void>;
startFromUrl: (url: string, deviceId: string) => Promise<void>;
setBatchSelectedIndex: (index: number) => void;
setBatchProgress: (count: number) => void;
setVideoProgress: (frameIndex: number) => void;
@ -164,37 +163,6 @@ export const useCameraStore = create<CameraState>((set) => ({
}
},
startFromUrl: async (url, deviceId) => {
set({ isUploading: true });
try {
const res = await api.post<{ streamUrl: string; filename: string }>('/media/url', {
url,
deviceId,
});
if (res.success && res.data) {
const rawUrl = res.data.streamUrl.startsWith('http')
? res.data.streamUrl
: `${getBackendUrl()}${res.data.streamUrl}`;
const data = res.data as Record<string, unknown>;
set({
isStreaming: true,
streamUrl: appendRelayToken(rawUrl),
sourceType: 'video',
sourceFilename: res.data.filename || url,
videoTotalFrames: (data.totalFrames as number) || 0,
videoDurationSeconds: (data.durationSeconds as number) || 0,
videoFrameIndex: 0,
});
} else {
showApiError(res.error);
}
} catch {
showError(getTranslation().t('errors.cannotOpenVideoUrl'));
} finally {
set({ isUploading: false });
}
},
uploadBatchImages: async (files, deviceId) => {
set({ isUploading: true });
try {

View File

@ -0,0 +1,104 @@
import { create } from 'zustand';
/**
* System store
*
* TDD v2 §2.6M8-7 Web UI Server-Offline Overlay+ §9M8-9 boot-id reload
*
* store server ++ bootId
* hookuse-shutdown-watcher pollingWebSocket
* componentserver-offline-overlay
*
* M8-9 `checkAndUpdateBootId` action hook health check
* bootId server hook force reload
*/
export type OfflineReason = 'quit' | 'restart' | 'healthcheck-failed' | 'unknown';
/**
* boot-id
* - `first` : store bootId health check
* - `match` : bootId server
* - `mismatch` : bootId server M8-9 force reload
* mismatch store bootId reload tab
* `first`
*/
export type BootIdCheckResult = 'first' | 'match' | 'mismatch';
interface SystemState {
/** server 目前是否在線(由 polling / WebSocket 共同判定)。 */
serverOnline: boolean;
/** 是否被 server 主動 broadcast `shutdown-imminent` 強制標記為離線。 */
forcedOffline: boolean;
/** 為什麼離線;用於 overlay 動態文案。 */
offlineReason: OfflineReason | null;
/** server 啟動時產生的 bootId給 M8-9 比對用,本 milestone 只負責保存。 */
bootId: string | null;
/** 連續 health check 失敗次數;達到門檻才顯示 overlay避免抖動誤觸發。 */
consecutiveFailures: number;
/** 把 server 標記為離線(由 polling 連續失敗 / WebSocket close / shutdown-imminent 觸發)。 */
forceOffline: (reason: OfflineReason) => void;
/** 把 server 標記為在線active retry / 手動重試成功觸發)。 */
markOnline: () => void;
/** 累計一次 health check 失敗。 */
recordFailure: () => void;
/** 設定 / 更新 bootId。 */
setBootId: (id: string) => void;
/**
* bootIdM8-9
* - store.bootId === null 'first'
* - 'match'
* - 'mismatch' store reload
*/
checkAndUpdateBootId: (newBootId: string) => BootIdCheckResult;
}
/**
* v2.1 = 2 20 s
* hook `consecutiveFailures >= FAILURE_THRESHOLD` forceOffline
*/
export const FAILURE_THRESHOLD = 2;
export const useSystemStore = create<SystemState>((set, get) => ({
serverOnline: true,
forcedOffline: false,
offlineReason: null,
bootId: null,
consecutiveFailures: 0,
forceOffline: (reason) =>
set({
serverOnline: false,
forcedOffline: true,
offlineReason: reason,
}),
markOnline: () =>
set({
serverOnline: true,
forcedOffline: false,
offlineReason: null,
consecutiveFailures: 0,
}),
recordFailure: () =>
set((s) => ({
consecutiveFailures: s.consecutiveFailures + 1,
})),
setBootId: (id) => set({ bootId: id }),
checkAndUpdateBootId: (newBootId) => {
const current = get().bootId;
if (current === null) {
set({ bootId: newBootId });
return 'first';
}
if (current === newBootId) {
return 'match';
}
// mismatch刻意不更新 store由呼叫端負責 force reload。
return 'mismatch';
},
}));

View File

@ -0,0 +1,125 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { useSystemStore } from '@/stores/system-store';
/**
* M8-9use-shutdown-watcher boot-id reload
*
* retryServerHealth handleBootIdCheck
* - fetch setBootIdmarkOnline
* - bootId markOnline reload
* - bootId window.location.reload()
* - reloadsessionStorage guard bootId reload
*/
vi.mock('@/lib/api', () => ({
getRelayHeaders: () => ({}),
ensureRelayToken: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('@/lib/constants', () => ({
getApiBaseUrl: () => 'http://localhost:3000/api',
getWsBaseUrl: () => 'ws://localhost:3000',
getRelayToken: () => 'test-token',
}));
import { retryServerHealth } from '@/hooks/use-shutdown-watcher';
const RELOAD_LOOP_GUARD_KEY = 'visiona-reload-loop-guard';
function mockFetchBootId(bootId: string) {
return vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
data: { bootId, startedAt: Date.now() },
}),
});
}
describe('use-shutdown-watcher — boot-id reload (M8-9)', () => {
const reloadSpy = vi.fn();
beforeEach(() => {
useSystemStore.setState({
serverOnline: true,
forcedOffline: false,
offlineReason: null,
bootId: null,
consecutiveFailures: 0,
});
window.sessionStorage.clear();
reloadSpy.mockClear();
// 替換 window.location.reload 為 spyjsdom 的 location 非 configurable
// 用 defineProperty 套一層替代物件)。
Object.defineProperty(window, 'location', {
configurable: true,
value: { ...window.location, reload: reloadSpy },
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('first fetch should persist bootId and mark online without reload', async () => {
const fetchMock = mockFetchBootId('boot-1');
vi.stubGlobal('fetch', fetchMock);
const ok = await retryServerHealth();
expect(ok).toBe(true);
expect(useSystemStore.getState().bootId).toBe('boot-1');
expect(useSystemStore.getState().serverOnline).toBe(true);
expect(reloadSpy).not.toHaveBeenCalled();
});
it('second fetch with same bootId should just mark online', async () => {
useSystemStore.setState({ bootId: 'boot-1' });
const fetchMock = mockFetchBootId('boot-1');
vi.stubGlobal('fetch', fetchMock);
const ok = await retryServerHealth();
expect(ok).toBe(true);
expect(useSystemStore.getState().bootId).toBe('boot-1');
expect(reloadSpy).not.toHaveBeenCalled();
});
it('fetch with different bootId should trigger window.location.reload', async () => {
useSystemStore.setState({ bootId: 'boot-1' });
const fetchMock = mockFetchBootId('boot-2');
vi.stubGlobal('fetch', fetchMock);
const ok = await retryServerHealth();
expect(ok).toBe(true);
expect(reloadSpy).toHaveBeenCalledTimes(1);
// mismatch 不該更新 store 的 bootId等 reload 後重走 first 路徑)
expect(useSystemStore.getState().bootId).toBe('boot-1');
// guard 應被設為新的 bootId
expect(window.sessionStorage.getItem(RELOAD_LOOP_GUARD_KEY)).toBe('boot-2');
});
it('should not reload twice for the same new bootId (loop guard)', async () => {
useSystemStore.setState({ bootId: 'boot-1' });
window.sessionStorage.setItem(RELOAD_LOOP_GUARD_KEY, 'boot-2');
const fetchMock = mockFetchBootId('boot-2');
vi.stubGlobal('fetch', fetchMock);
const ok = await retryServerHealth();
expect(ok).toBe(true);
expect(reloadSpy).not.toHaveBeenCalled();
// guard 命中時應該把 store 對齊新 bootId 並 markOnline避免卡在假離線
expect(useSystemStore.getState().bootId).toBe('boot-2');
expect(useSystemStore.getState().serverOnline).toBe(true);
});
it('fetch failure should record failure and not reload', async () => {
useSystemStore.setState({ bootId: 'boot-1' });
const fetchMock = vi.fn().mockResolvedValue({ ok: false });
vi.stubGlobal('fetch', fetchMock);
const ok = await retryServerHealth();
expect(ok).toBe(false);
expect(reloadSpy).not.toHaveBeenCalled();
expect(useSystemStore.getState().consecutiveFailures).toBe(1);
});
});

View File

@ -0,0 +1,53 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useSystemStore } from '@/stores/system-store';
/**
* M8-9system-store boot-id
*
*
* - checkAndUpdateBootId 'first' bootId
* - bootId 'match'store
* - bootId 'mismatch'store bootId
*/
describe('SystemStore — boot-id check (M8-9)', () => {
beforeEach(() => {
useSystemStore.setState({
serverOnline: true,
forcedOffline: false,
offlineReason: null,
bootId: null,
consecutiveFailures: 0,
});
});
it('should return "first" and persist bootId when store is empty', () => {
const result = useSystemStore.getState().checkAndUpdateBootId('boot-aaa');
expect(result).toBe('first');
expect(useSystemStore.getState().bootId).toBe('boot-aaa');
});
it('should return "match" when the same bootId is seen again', () => {
useSystemStore.getState().setBootId('boot-aaa');
const result = useSystemStore.getState().checkAndUpdateBootId('boot-aaa');
expect(result).toBe('match');
expect(useSystemStore.getState().bootId).toBe('boot-aaa');
});
it('should return "mismatch" and NOT update bootId when a new one arrives', () => {
useSystemStore.getState().setBootId('boot-aaa');
const result = useSystemStore.getState().checkAndUpdateBootId('boot-bbb');
expect(result).toBe('mismatch');
// 關鍵mismatch 時 store 不該更新,等 reload 後新 tab 走 'first' 路徑。
expect(useSystemStore.getState().bootId).toBe('boot-aaa');
});
it('should treat second fresh call after reset as "first" again', () => {
useSystemStore.getState().checkAndUpdateBootId('boot-aaa');
// 模擬 reload 後 store 被 reset
useSystemStore.setState({ bootId: null });
const result = useSystemStore.getState().checkAndUpdateBootId('boot-bbb');
expect(result).toBe('first');
expect(useSystemStore.getState().bootId).toBe('boot-bbb');
});
});

View File

@ -58,8 +58,9 @@ else
echo "⚠️ payload/linux/bin/visiona-local-server 不存在(需要在 Linux 上 go build server"
fi
# ffmpeg / yt-dlp
for tool in ffmpeg yt-dlp; do
# ffmpeg / ffprobe
# ffmpeg 為 LGPL v3 buildBtbN n7.1v2 TDD §4
for tool in ffmpeg ffprobe; do
if [ -f "$PAYLOAD_LINUX/bin/$tool" ]; then
cp "$PAYLOAD_LINUX/bin/$tool" "$APPDIR/usr/bin/$tool"
else
@ -69,6 +70,12 @@ done
chmod +x "$APPDIR/usr/bin/"* 2>/dev/null || true
# 把 ffmpeg 授權條款放到 AppImage 的 share/doc/
mkdir -p "$APPDIR/usr/share/doc/visiona-local"
if [ -f "$PAYLOAD_LINUX/bin/ffmpeg-LICENSE.txt" ]; then
cp "$PAYLOAD_LINUX/bin/ffmpeg-LICENSE.txt" "$APPDIR/usr/share/doc/visiona-local/ffmpeg-LICENSE.txt"
fi
echo "==> 複製資料、腳本、Python runtime、wheels"
[ -d "$PAYLOAD_LINUX/data" ] && cp -R "$PAYLOAD_LINUX/data/." "$APPDIR/usr/lib/visiona-local/data/" || true
[ -d "$PAYLOAD_LINUX/scripts" ] && cp -R "$PAYLOAD_LINUX/scripts/." "$APPDIR/usr/lib/visiona-local/scripts/" || true

View File

@ -71,9 +71,12 @@ Source: "..\..\visiona-local\build\bin\visiona-local.exe"; DestDir: "{app}"; Fla
; ── Server binaryGo build必須在 Windows runner 上 GOOS=windows go build──
Source: "..\..\payload\windows\bin\visiona-local-server.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
; ── ffmpeg + yt-dlp ───────────────────────────────────────────────
; ── ffmpeg + ffprobe ─────────────────────────────────────────────
; ffmpeg 為 LGPL v3 buildBtbN n7.1v2 TDD §3附上 LGPL 授權條款
Source: "..\..\payload\windows\bin\ffmpeg.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
Source: "..\..\payload\windows\bin\yt-dlp.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
Source: "..\..\payload\windows\bin\ffprobe.exe"; DestDir: "{app}\bin"; Flags: ignoreversion
Source: "..\..\payload\windows\bin\ffmpeg-LICENSE.txt"; DestDir: "{app}\bin"; Flags: ignoreversion skipifsourcedoesntexist
Source: "..\..\payload\windows\bin\ffmpeg-COPYING.LGPLv3"; DestDir: "{app}\bin"; Flags: ignoreversion skipifsourcedoesntexist
; ── Python runtime tarball + wheels ───────────────────────────────
Source: "..\..\payload\windows\python\python.tar.gz"; DestDir: "{app}\python"; Flags: ignoreversion

View File

@ -55,10 +55,9 @@ fi
wails doctor || log "wails doctor 有警告,繼續"
log "[5/5] 開始 buildtarget=$TARGET"
log "⚠️ ffmpeg 使用 GPL build需設定 VISIONA_ALLOW_GPL_FFMPEG=1"
export VISIONA_ALLOW_GPL_FFMPEG=1
log "ffmpeg 使用 LGPL v3 buildv2 TDD §4BtbN n7.1 LGPL"
make vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux vendor-ytdlp-linux
make vendor-python-linux vendor-wheels-linux vendor-ffmpeg-linux
make payload-linux
case "$TARGET" in
payload-linux) ;;

View File

@ -68,7 +68,7 @@ if (-not (Test-Path 'C:\msys64\usr\bin\make.exe')) {
}
Log "[4/4] 開始 buildtarget=$Target"
Log '⚠️ ffmpeg 使用 GPL build需設定 VISIONA_ALLOW_GPL_FFMPEG=1'
Log 'ffmpeg 使用 LGPL v3 buildv2 TDD §3BtbN n7.1 LGPL'
# 讓 MSYS2 bash 繼承 Windows PATH才找得到 go / pnpm / python / wails
$env:MSYS2_PATH_TYPE = 'inherit'
@ -176,7 +176,6 @@ $msysPath = '/' + $projectPath.Substring(0,1).ToLower() + '/' + `
$bashParts = @(
"cd '$msysPath'",
'export VISIONA_ALLOW_GPL_FFMPEG=1',
"export VISIONA_PYTHON='$msysPython'"
)
if ($msysIsccDir) {
@ -188,7 +187,7 @@ if ($msysIsccExe) {
# Build 模式:
# VISIONA_FAST=1 → 前置產物齊全時跳過 vendor/payload/wails只重跑 isccdebug iteration 用)
# 預設 → 每次 clean buildwails build / server binary / frontend embed 全重做)
# 保留 vendor/ 快取Python runtime / wheels / ffmpeg / yt-dlp)以免重下 200MB
# 保留 vendor/ 快取Python runtime / wheels / ffmpeg)以免重下
$fastPath = (Test-Path 'visiona-local\build\bin\visiona-local.exe') -and `
(Test-Path 'payload\windows\bin\visiona-local-server.exe') -and `
(Test-Path 'payload\windows\bin\ffmpeg.exe') -and `
@ -199,12 +198,12 @@ if ($env:VISIONA_FAST -eq '1' -and $fastPath -and ($Target -eq 'exe' -or -not $e
$bashParts += 'make exe-only'
} else {
Log '預設 clean build每次重做 wails + server binary + frontend embedvendor cache 保留)'
# 清 wails / frontend / server/web/out但不清 vendor/(避免重下 Python / wheels / ffmpeg / yt-dlp 200MB+
# 清 wails / frontend / server/web/out但不清 vendor/(避免重下 Python / wheels / ffmpeg
$bashParts += 'rm -rf visiona-local/build/bin visiona-local/build/windows/Resources'
$bashParts += 'rm -rf frontend/out frontend/.next server/web/out'
$bashParts += 'rm -rf payload/windows/bin/visiona-local-server.exe'
$bashParts += 'rm -rf dist/visiona-local-*-windows-x64.exe'
$bashParts += 'make vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows vendor-ytdlp-windows'
$bashParts += 'make vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows'
$bashParts += 'make payload-windows'
switch ($Target) {
'payload-windows' { }

View File

@ -1,247 +0,0 @@
package api_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"visiona-local/server/internal/api"
"visiona-local/server/internal/api/handlers"
"visiona-local/server/internal/api/ws"
"visiona-local/server/internal/camera"
"visiona-local/server/internal/device"
"visiona-local/server/internal/inference"
"visiona-local/server/internal/model"
"visiona-local/server/pkg/logger"
)
// apiResponse is the standard JSON envelope for all API responses.
type apiResponse struct {
Success bool `json:"success"`
Data json.RawMessage `json:"data,omitempty"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
// setupTestServer creates a fully wired Gin router with mock devices.
func setupTestServer(t *testing.T, mockCount int) *httptest.Server {
t.Helper()
registry := device.NewRegistry()
deviceMgr := device.NewManager(registry, true, mockCount, "")
// Drain event bus to prevent blocking
go func() {
for range deviceMgr.Events() {
}
}()
modelRepo := model.NewRepository("")
modelStore := model.NewModelStore(t.TempDir())
cameraMgr := camera.NewManager(true)
inferenceSvc := inference.NewService(deviceMgr)
wsHub := ws.NewHub()
logBroadcaster := logger.NewBroadcaster(100, nil)
sysHandler := handlers.NewSystemHandler("test", "now", nil)
router := api.NewRouter(
modelRepo, modelStore, deviceMgr, cameraMgr,
inferenceSvc, wsHub, nil, logBroadcaster, sysHandler,
)
return httptest.NewServer(router)
}
func getJSON(t *testing.T, ts *httptest.Server, path string) apiResponse {
t.Helper()
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
defer resp.Body.Close()
var r apiResponse
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
t.Fatalf("GET %s: decode error: %v", path, err)
}
return r
}
func postJSON(t *testing.T, ts *httptest.Server, path string, body string) apiResponse {
t.Helper()
resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body))
if err != nil {
t.Fatalf("POST %s: %v", path, err)
}
defer resp.Body.Close()
var r apiResponse
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
t.Fatalf("POST %s: decode error: %v", path, err)
}
return r
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
func TestHealthCheck(t *testing.T) {
ts := setupTestServer(t, 0)
defer ts.Close()
resp, err := http.Get(ts.URL + "/api/system/health")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("health check status = %d, want 200", resp.StatusCode)
}
var data map[string]string
json.NewDecoder(resp.Body).Decode(&data)
if data["status"] != "ok" {
t.Fatalf("health check status = %q, want 'ok'", data["status"])
}
}
func TestDeviceWorkflow_MockMode(t *testing.T) {
ts := setupTestServer(t, 2)
defer ts.Close()
// 1. List devices — should have 2 mock devices
t.Run("list devices", func(t *testing.T) {
r := getJSON(t, ts, "/api/devices")
if !r.Success {
t.Fatal("list devices should succeed")
}
var data struct {
Devices []json.RawMessage `json:"devices"`
}
json.Unmarshal(r.Data, &data)
if len(data.Devices) != 2 {
t.Fatalf("expected 2 devices, got %d", len(data.Devices))
}
})
// 2. Get single device
t.Run("get device", func(t *testing.T) {
r := getJSON(t, ts, "/api/devices/mock-device-1")
if !r.Success {
t.Fatal("get device should succeed")
}
})
// 3. Get non-existing device → 404
t.Run("get device not found", func(t *testing.T) {
r := getJSON(t, ts, "/api/devices/nonexistent")
if r.Success {
t.Fatal("should fail for non-existing device")
}
if r.Error == nil || r.Error.Code != "DEVICE_NOT_FOUND" {
t.Fatalf("expected DEVICE_NOT_FOUND error, got %+v", r.Error)
}
})
// 4. Connect device
t.Run("connect device", func(t *testing.T) {
r := postJSON(t, ts, "/api/devices/mock-device-1/connect", "")
if !r.Success {
t.Fatalf("connect should succeed: %+v", r.Error)
}
// Verify status changed to connected
r2 := getJSON(t, ts, "/api/devices/mock-device-1")
var info struct {
Status string `json:"status"`
}
json.Unmarshal(r2.Data, &info)
if info.Status != "connected" {
t.Fatalf("expected status 'connected', got '%s'", info.Status)
}
})
// 5. Start inference
t.Run("start inference", func(t *testing.T) {
r := postJSON(t, ts, "/api/devices/mock-device-1/inference/start", "")
if !r.Success {
t.Fatalf("start inference should succeed: %+v", r.Error)
}
})
// 6. Stop inference
t.Run("stop inference", func(t *testing.T) {
r := postJSON(t, ts, "/api/devices/mock-device-1/inference/stop", "")
if !r.Success {
t.Fatalf("stop inference should succeed: %+v", r.Error)
}
})
// 7. Disconnect device
t.Run("disconnect device", func(t *testing.T) {
r := postJSON(t, ts, "/api/devices/mock-device-1/disconnect", "")
if !r.Success {
t.Fatalf("disconnect should succeed: %+v", r.Error)
}
})
}
func TestDeviceScan_MockMode(t *testing.T) {
ts := setupTestServer(t, 1)
defer ts.Close()
// In mock mode, scan just returns existing mock devices
r := postJSON(t, ts, "/api/devices/scan", "")
if !r.Success {
t.Fatalf("scan should succeed: %+v", r.Error)
}
var data struct {
Devices []json.RawMessage `json:"devices"`
}
json.Unmarshal(r.Data, &data)
if len(data.Devices) != 1 {
t.Fatalf("expected 1 device after scan, got %d", len(data.Devices))
}
}
func TestModelList(t *testing.T) {
ts := setupTestServer(t, 0)
defer ts.Close()
r := getJSON(t, ts, "/api/models")
if !r.Success {
t.Fatal("list models should succeed")
}
}
func TestConnectNonExistentDevice(t *testing.T) {
ts := setupTestServer(t, 1)
defer ts.Close()
r := postJSON(t, ts, "/api/devices/nonexistent/connect", "")
if r.Success {
t.Fatal("connect nonexistent device should fail")
}
}
func TestMultiDeviceIsolation(t *testing.T) {
ts := setupTestServer(t, 3)
defer ts.Close()
// Connect device 1 only
r := postJSON(t, ts, "/api/devices/mock-device-1/connect", "")
if !r.Success {
t.Fatalf("connect device 1 should succeed: %+v", r.Error)
}
// Device 2 should still be detected (not connected)
r2 := getJSON(t, ts, "/api/devices/mock-device-2")
var info struct {
Status string `json:"status"`
}
json.Unmarshal(r2.Data, &info)
if info.Status != "detected" {
t.Fatalf("device 2 should be 'detected', got '%s'", info.Status)
}
}

View File

@ -3,7 +3,6 @@ package handlers
import (
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
@ -30,8 +29,7 @@ type CameraHandler struct {
sourceType camera.SourceType
// Video seek state — preserved across seek operations
videoPath string // original file path or resolved URL
videoIsURL bool // true if source is a URL
videoPath string // original file path
videoFPS float64 // target FPS
videoInfo camera.VideoInfo // duration, total frames
activeDeviceID string // device ID for current video session
@ -248,8 +246,8 @@ func (h *CameraHandler) UploadVideo(c *gin.Context) {
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext != ".mp4" && ext != ".avi" && ext != ".mov" {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "only MP4/AVI/MOV files are supported"}})
if ext != ".mp4" && ext != ".avi" && ext != ".mov" && ext != ".mpeg" && ext != ".mpg" {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "only MP4/AVI/MOV/MPEG/MPG files are supported"}})
return
}
@ -302,7 +300,6 @@ func (h *CameraHandler) UploadVideo(c *gin.Context) {
h.activeSource = videoSource
h.sourceType = camera.SourceVideo
h.videoPath = tmpFile.Name()
h.videoIsURL = false
h.videoFPS = 15
h.videoInfo = videoInfo
h.activeDeviceID = deviceID
@ -338,164 +335,6 @@ func (h *CameraHandler) UploadVideo(c *gin.Context) {
})
}
// ytdlpHosts lists hostnames where yt-dlp should be used to resolve the actual
// video stream URL before passing to ffmpeg.
var ytdlpHosts = map[string]bool{
"youtube.com": true, "www.youtube.com": true, "youtu.be": true, "m.youtube.com": true,
"vimeo.com": true, "www.vimeo.com": true,
"dailymotion.com": true, "www.dailymotion.com": true,
"twitch.tv": true, "www.twitch.tv": true,
"bilibili.com": true, "www.bilibili.com": true,
"tiktok.com": true, "www.tiktok.com": true,
"facebook.com": true, "www.facebook.com": true, "fb.watch": true,
"instagram.com": true, "www.instagram.com": true,
"twitter.com": true, "x.com": true,
}
type urlKind int
const (
urlDirect urlKind = iota // direct video file or RTSP, pass to ffmpeg directly
urlYTDLP // needs yt-dlp to resolve first
urlBad // invalid or unsupported
)
// classifyVideoURL determines how to handle the given URL.
func classifyVideoURL(rawURL string) (urlKind, string) {
parsed, err := url.Parse(rawURL)
if err != nil {
return urlBad, "Invalid URL format"
}
scheme := strings.ToLower(parsed.Scheme)
host := strings.ToLower(parsed.Hostname())
// RTSP streams — direct to ffmpeg
if scheme == "rtsp" || scheme == "rtsps" {
return urlDirect, ""
}
// Must be http or https
if scheme != "http" && scheme != "https" {
return urlBad, "Unsupported protocol: " + scheme + ". Use http, https, or rtsp."
}
// Known video platforms — use yt-dlp
if ytdlpHosts[host] {
return urlYTDLP, ""
}
// Everything else — pass directly to ffmpeg
return urlDirect, ""
}
// StartFromURL handles video/stream inference from a URL (HTTP, HTTPS, RTSP).
func (h *CameraHandler) StartFromURL(c *gin.Context) {
var req struct {
URL string `json:"url"`
DeviceID string `json:"deviceId"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": err.Error()}})
return
}
if req.URL == "" {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "url is required"}})
return
}
if req.DeviceID == "" {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "BAD_REQUEST", "message": "deviceId is required"}})
return
}
// Classify the URL
kind, reason := classifyVideoURL(req.URL)
if kind == urlBad {
c.JSON(400, gin.H{"success": false, "error": gin.H{"code": "UNSUPPORTED_URL", "message": reason}})
return
}
// For video platforms (YouTube, etc.), resolve actual stream URL via yt-dlp
videoURL := req.URL
if kind == urlYTDLP {
resolved, err := camera.ResolveWithYTDLP(req.URL)
if err != nil {
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "URL_RESOLVE_FAILED", "message": "無法解析影片連結: " + err.Error()}})
return
}
videoURL = resolved
}
h.stopActivePipeline()
// Probe video info (duration, frame count) - may be slow for remote URLs
videoInfo := camera.ProbeVideoInfo(videoURL, 15)
// Create VideoSource from URL (ffmpeg supports HTTP/HTTPS/RTSP natively)
videoSource, err := camera.NewVideoSourceFromURL(videoURL, 15)
if err != nil {
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "URL_OPEN_FAILED", "message": err.Error()}})
return
}
if videoInfo.TotalFrames > 0 {
videoSource.SetTotalFrames(videoInfo.TotalFrames)
}
// Get device driver
session, err := h.deviceMgr.GetDevice(req.DeviceID)
if err != nil {
videoSource.Close()
c.JSON(404, gin.H{"success": false, "error": gin.H{"code": "DEVICE_NOT_FOUND", "message": err.Error()}})
return
}
resultCh := make(chan *driver.InferenceResult, 10)
go func() {
room := "inference:" + req.DeviceID
for result := range resultCh {
h.wsHub.BroadcastToRoom(room, result)
}
}()
h.activeSource = videoSource
h.sourceType = camera.SourceVideo
h.videoPath = videoURL
h.videoIsURL = true
h.videoFPS = 15
h.videoInfo = videoInfo
h.activeDeviceID = req.DeviceID
h.pipeline = camera.NewInferencePipeline(
videoSource,
camera.SourceVideo,
session.Driver,
h.streamer.FrameChannel(),
resultCh,
)
h.pipeline.Start()
go func() {
<-h.pipeline.Done()
close(resultCh)
h.wsHub.BroadcastToRoom("inference:"+req.DeviceID, map[string]interface{}{
"type": "pipeline_complete",
"sourceType": "video",
})
}()
streamURL := "/api/camera/stream"
c.JSON(200, gin.H{
"success": true,
"data": gin.H{
"streamUrl": streamURL,
"sourceType": "video",
"filename": req.URL,
"totalFrames": videoInfo.TotalFrames,
"durationSeconds": videoInfo.DurationSec,
},
})
}
// UploadBatchImages handles multiple image files for sequential batch inference.
func (h *CameraHandler) UploadBatchImages(c *gin.Context) {
h.stopActivePipeline()
@ -694,7 +533,6 @@ func (h *CameraHandler) stopActivePipeline() {
h.activeSource = nil
h.sourceType = ""
h.videoPath = ""
h.videoIsURL = false
h.activeDeviceID = ""
}
@ -725,13 +563,7 @@ func (h *CameraHandler) SeekVideo(c *gin.Context) {
h.stopPipelineForSeek()
// Create new VideoSource with seek position
var videoSource *camera.VideoSource
var err error
if h.videoIsURL {
videoSource, err = camera.NewVideoSourceFromURLWithSeek(h.videoPath, h.videoFPS, req.TimeSeconds)
} else {
videoSource, err = camera.NewVideoSourceWithSeek(h.videoPath, h.videoFPS, req.TimeSeconds)
}
videoSource, err := camera.NewVideoSourceWithSeek(h.videoPath, h.videoFPS, req.TimeSeconds)
if err != nil {
c.JSON(500, gin.H{"success": false, "error": gin.H{"code": "SEEK_FAILED", "message": err.Error()}})
return

View File

@ -1,15 +1,30 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"net/http"
"runtime"
"time"
"visiona-local/server/internal/api/ws"
"visiona-local/server/internal/deps"
"github.com/gin-gonic/gin"
)
// shutdownNotifyBroadcaster 是 SystemHandler 呼叫 Hub.BroadcastToRoom 的抽象,
// 方便單元測試用 spy 注入。預設由 *ws.Hub 滿足。
type shutdownNotifyBroadcaster interface {
BroadcastToRoom(room string, data interface{})
}
// shutdownNotifySleepDuration 是 ShutdownNotify 廣播後等 client 收訊息的時間。
// 對應 TDD v2/server-lifecycle.md §2.3Minor 4的 "best-effort" 設計:
// 我們不等待實際送達 ACK只 sleep 固定時間,讓 write pump 有機會把 byte 推出去。
// 單元測試可 override 成 0 加速。
var shutdownNotifySleepDuration = 100 * time.Millisecond
type SystemHandler struct {
startTime time.Time
version string
@ -17,9 +32,24 @@ type SystemHandler struct {
pythonBin string // 由 main.go 傳入InstallDriver 會用到
shutdownFn func()
depsCache []deps.Dependency
bootID string // M8-4server 啟動時產生的 boot-id32 字元 hex
wsHub shutdownNotifyBroadcaster // MAJ-4 補丁:用於 shutdown-imminent 廣播
}
func NewSystemHandler(version, buildTime, pythonBin string, shutdownFn func()) *SystemHandler {
// newBootID 產生 32 字元 hex 字串16 bytes 隨機)。
// 對應 TDD v2/server-lifecycle.md §9.1:用純標準庫 crypto/rand不引 google/uuid。
func newBootID() string {
b := make([]byte, 16)
// crypto/rand.Read 在實務上不會失敗;即使失敗(回 zero bytes仍然可用
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func NewSystemHandler(version, buildTime, pythonBin string, shutdownFn func(), wsHub *ws.Hub) *SystemHandler {
var b shutdownNotifyBroadcaster
if wsHub != nil {
b = wsHub
}
return &SystemHandler{
startTime: time.Now(),
version: version,
@ -27,6 +57,8 @@ func NewSystemHandler(version, buildTime, pythonBin string, shutdownFn func()) *
pythonBin: pythonBin,
shutdownFn: shutdownFn,
depsCache: deps.CheckAll(),
bootID: newBootID(),
wsHub: b,
}
}
@ -34,6 +66,19 @@ func (h *SystemHandler) HealthCheck(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
}
// BootID 回傳此 server process 啟動時產生的 boot-id。
// 瀏覽器 tab 每 10 秒 poll 一次,用於偵測 server 重啟 → 觸發 window.location.reload()。
// 對應 TDD v2/server-lifecycle.md §9。
func (h *SystemHandler) BootID(c *gin.Context) {
c.JSON(200, gin.H{
"success": true,
"data": gin.H{
"bootId": h.bootID,
"startedAt": h.startTime.UnixMilli(),
},
})
}
func (h *SystemHandler) Info(c *gin.Context) {
c.JSON(200, gin.H{
"success": true,
@ -115,6 +160,47 @@ func (h *SystemHandler) InstallDriver(c *gin.Context) {
})
}
// ShutdownNotify 發送 server:shutdown-imminent 廣播到 /ws/system 的所有 client。
//
// 路由POST /api/system/shutdown-notify?reason=quit|restart
//
// 對應 TDD v2/server-lifecycle.md §2.3Minor 4與 v2/web-ui-offline-overlay.md §3.2a
// Wails 在 ctrl.Stop() SIGTERM 之前先呼叫這個 endpoint讓瀏覽器 tab 的 Offline Overlay
// 立即顯示,避免只靠 health polling 導致的 15 秒延遲 + race condition。
//
// 設計原則best-effort
// - 沒有 WebSocket client 也回 200失敗情境也視為正常
// - reason 非 quit / restart 時視為 "unknown"(仍 broadcast 讓前端決定怎麼處理)
// - broadcast 完固定 sleep 100 ms給 write pump 時間把 byte 真的推到 TCP socket
// (不等 ACKserver 馬上就要 SIGTERM 了)
//
// Request: POST /api/system/shutdown-notify?reason=quit
// Response 200: {"ok": true, "reason": "quit"}
func (h *SystemHandler) ShutdownNotify(c *gin.Context) {
reason := c.Query("reason")
switch reason {
case "quit", "restart":
// 正常路徑
default:
reason = "unknown"
}
if h.wsHub != nil {
payload := gin.H{
"type": "server:shutdown-imminent",
"reason": reason,
"ts": time.Now().UnixMilli(),
}
h.wsHub.BroadcastToRoom("system", payload)
// 等 client 有時間把訊息 flush 出去
if shutdownNotifySleepDuration > 0 {
time.Sleep(shutdownNotifySleepDuration)
}
}
c.JSON(http.StatusOK, gin.H{"ok": true, "reason": reason})
}
func (h *SystemHandler) Restart(c *gin.Context) {
c.JSON(200, gin.H{"success": true, "data": gin.H{"message": "restarting"}})
if f, ok := c.Writer.(http.Flusher); ok {

View File

@ -0,0 +1,231 @@
package handlers
// system_handler_test.go — MAJ-4 補丁shutdown-notify endpoint 單元測試
//
// 驗證 POST /api/system/shutdown-notify 的行為:
// 1. reason=quit → 200 + 廣播 payload.reason = "quit"
// 2. reason=restart → 200 + 廣播 payload.reason = "restart"
// 3. reason=invalid / 空 → 200 + 廣播 payload.reason = "unknown"
// 4. wsHub = nil → 仍回 200不 panic
//
// 用 spy broadcaster 代替 real *ws.Hub避免測試需要真的 goroutine / channel。
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func init() {
gin.SetMode(gin.TestMode)
}
// spyBroadcaster 實作 shutdownNotifyBroadcaster把每次呼叫紀錄起來供斷言。
type spyBroadcaster struct {
mu sync.Mutex
calls []spyCall
}
type spyCall struct {
room string
data map[string]interface{}
}
func (s *spyBroadcaster) BroadcastToRoom(room string, data interface{}) {
s.mu.Lock()
defer s.mu.Unlock()
// 把 gin.H 轉成 map[string]interface{} 方便比對
m := map[string]interface{}{}
switch v := data.(type) {
case gin.H:
for k, val := range v {
m[k] = val
}
case map[string]interface{}:
m = v
default:
// 最後一招:透過 JSON round-trip
b, _ := json.Marshal(data)
_ = json.Unmarshal(b, &m)
}
s.calls = append(s.calls, spyCall{room: room, data: m})
}
func (s *spyBroadcaster) snapshot() []spyCall {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]spyCall, len(s.calls))
copy(out, s.calls)
return out
}
// newTestHandler 組一個 SystemHandler 但用 spy broadcaster 替代 real Hub。
func newTestHandler(spy *spyBroadcaster) *SystemHandler {
h := &SystemHandler{
startTime: time.Now(),
version: "test",
buildTime: "test-build",
bootID: "test-boot-id",
}
if spy != nil {
h.wsHub = spy
}
return h
}
// newTestRouter 建一個只掛 shutdown-notify 的最小 router。
func newTestRouter(h *SystemHandler) *gin.Engine {
r := gin.New()
r.POST("/api/system/shutdown-notify", h.ShutdownNotify)
return r
}
// 整組測試前把 sleep 時間歸零,避免拖慢 test suite。
func withNoSleep(t *testing.T) {
t.Helper()
orig := shutdownNotifySleepDuration
shutdownNotifySleepDuration = 0
t.Cleanup(func() {
shutdownNotifySleepDuration = orig
})
}
func TestShutdownNotify_ReasonQuit(t *testing.T) {
withNoSleep(t)
spy := &spyBroadcaster{}
h := newTestHandler(spy)
r := newTestRouter(h)
req := httptest.NewRequest(http.MethodPost, "/api/system/shutdown-notify?reason=quit", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d; body=%s", w.Code, w.Body.String())
}
var body struct {
OK bool `json:"ok"`
Reason string `json:"reason"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("bad json response: %v", err)
}
if !body.OK || body.Reason != "quit" {
t.Errorf("response fields wrong: %+v", body)
}
calls := spy.snapshot()
if len(calls) != 1 {
t.Fatalf("want 1 broadcast, got %d", len(calls))
}
if calls[0].room != "system" {
t.Errorf("want room=system, got %q", calls[0].room)
}
if calls[0].data["type"] != "server:shutdown-imminent" {
t.Errorf("want type=server:shutdown-imminent, got %v", calls[0].data["type"])
}
if calls[0].data["reason"] != "quit" {
t.Errorf("want reason=quit, got %v", calls[0].data["reason"])
}
if _, ok := calls[0].data["ts"]; !ok {
t.Errorf("payload missing ts")
}
}
func TestShutdownNotify_ReasonRestart(t *testing.T) {
withNoSleep(t)
spy := &spyBroadcaster{}
h := newTestHandler(spy)
r := newTestRouter(h)
req := httptest.NewRequest(http.MethodPost, "/api/system/shutdown-notify?reason=restart", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d", w.Code)
}
calls := spy.snapshot()
if len(calls) != 1 || calls[0].data["reason"] != "restart" {
t.Fatalf("want 1 broadcast reason=restart, got %+v", calls)
}
}
func TestShutdownNotify_ReasonInvalid(t *testing.T) {
withNoSleep(t)
cases := []string{"", "halt", "kill9", "QUIT" /* 大小寫不同應視為 unknown */}
for _, reasonQuery := range cases {
name := reasonQuery
if name == "" {
name = "empty"
}
t.Run("reason_"+name, func(t *testing.T) {
spy := &spyBroadcaster{}
h := newTestHandler(spy)
r := newTestRouter(h)
url := "/api/system/shutdown-notify"
if reasonQuery != "" {
url += "?reason=" + reasonQuery
}
req := httptest.NewRequest(http.MethodPost, url, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200, got %d", w.Code)
}
var body struct {
OK bool `json:"ok"`
Reason string `json:"reason"`
}
_ = json.Unmarshal(w.Body.Bytes(), &body)
if body.Reason != "unknown" {
t.Errorf("want response.reason=unknown, got %q", body.Reason)
}
calls := spy.snapshot()
if len(calls) != 1 {
t.Fatalf("want 1 broadcast, got %d", len(calls))
}
if calls[0].data["reason"] != "unknown" {
t.Errorf("want payload.reason=unknown, got %v", calls[0].data["reason"])
}
})
}
}
func TestShutdownNotify_NoHub(t *testing.T) {
withNoSleep(t)
// wsHub = nil 代表單元測試或啟動失敗情境。handler 不應 panic必須回 200。
h := newTestHandler(nil)
r := newTestRouter(h)
req := httptest.NewRequest(http.MethodPost, "/api/system/shutdown-notify?reason=quit", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("want 200 even without hub, got %d", w.Code)
}
}
// TestShutdownNotify_DefaultSleepIsPositive 保護常數不被誤改為 0 在生產路徑上。
// 單元測試透過 withNoSleep 暫時設為 0這裡只驗證原始預設值。
func TestShutdownNotify_DefaultSleepIsPositive(t *testing.T) {
if shutdownNotifySleepDuration <= 0 {
t.Errorf("shutdownNotifySleepDuration should be > 0 in production, got %v", shutdownNotifySleepDuration)
}
}

View File

@ -2,28 +2,105 @@ package api
import (
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
)
// allowedHosts 定義 CORS 白名單的 hostname。
// 任何 port 都允許scheme 只允許 http本機不可能是 https
//
// M8-8TDD v2/cors-security.md §3.1
// v2 模式下 UI 改在使用者瀏覽器中跑server 同時暴露給其他瀏覽器分頁,
// 必須限定 cross-origin 來源在本機 loopback避免惡意網站透過 CORS 攻擊。
var allowedHosts = map[string]bool{
"127.0.0.1": true,
"localhost": true,
"[::1]": true,
"::1": true,
}
// isAllowedOrigin 判斷 Origin header 是否屬於白名單。
//
// 合法例http://127.0.0.1:3721 / http://localhost:3721 / http://[::1]:3721
// 不合法例https://127.0.0.1:3721 / http://evil.com / null / http://192.168.1.5:3721
//
// 注意:
// - 空字串視為非白名單(呼叫端會自行決定 same-origin 路徑)。
// - "null"local file、某些 sandboxed iframe一律拒絕。
// - 只允許 http scheme本機不會有 https。
func isAllowedOrigin(origin string) bool {
if origin == "" || origin == "null" {
return false
}
u, err := url.Parse(origin)
if err != nil {
return false
}
if u.Scheme != "http" {
return false
}
host := strings.ToLower(u.Hostname())
return allowedHosts[host]
}
// CORSMiddleware 僅允許 127.0.0.1/localhost/::1 任意 port 的跨來源請求。
//
// 行為M8-8 / TDD v2/cors-security.md §4.1
//
// 1. Origin header 為空 → same-origin瀏覽器 same-origin 不送 Origin→ 直接放行;
// 若是 OPTIONS 預檢則回 204 即停(避免帶 ACA* 給沒人看的請求)。
// 2. Origin 在白名單 → 回完整 ACA* headersOPTIONS → 204其他方法 → 繼續執行 handler。
// 3. Origin 不在白名單:
// - state-changing 方法POST/PUT/DELETE/PATCH/OPTIONS→ 403 Forbidden不回 ACA*。
// - 簡單讀取GET/HEAD→ 執行 handler 但不回 ACA*,瀏覽器 JS 讀不到 body。
//
// 為什麼 GET/HEAD 不直接擋CORS 的設計就是讓 GET 可以執行(畢竟 `<img>`、`<script>` tag
// 也會送 GET擋掉反而可能影響 same-origin 的 sub-resource。瀏覽器層的保護
// 是「不讓 JS 讀回應」,已足夠。對副作用操作我們強制走 POST + 不在白名單時 403。
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" {
// In production, frontend is same-origin so browsers don't send Origin header.
// In dev, Next.js on :3000 needs CORS to reach Go on :3721.
// Allow all origins since this is a local-first application.
c.Header("Access-Control-Allow-Origin", origin)
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Relay-Token")
c.Header("Access-Control-Allow-Credentials", "true")
method := c.Request.Method
if c.Request.Method == http.MethodOptions {
// Same-origin 請求:瀏覽器 same-origin 不送 Origin這條走最快路徑。
if origin == "" {
if method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
return
}
if !isAllowedOrigin(origin) {
// 非白名單 Origin
// - state-changing 方法 → 403嚴格擋
// - GET/HEAD → 執行但不回 ACA*(瀏覽器層擋)
if method == http.MethodOptions ||
method == http.MethodPost ||
method == http.MethodPut ||
method == http.MethodDelete ||
method == http.MethodPatch {
c.AbortWithStatus(http.StatusForbidden)
return
}
c.Next()
return
}
// 白名單 Origin回完整 ACA* headers
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Vary", "Origin")
if method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}

View File

@ -0,0 +1,201 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func init() {
gin.SetMode(gin.TestMode)
}
// TestIsAllowedOrigin 驗證 CORS 白名單判斷邏輯M8-8 / TDD §4.2)。
func TestIsAllowedOrigin(t *testing.T) {
cases := []struct {
origin string
want bool
}{
// 白名單合法情境
{"http://127.0.0.1:3721", true},
{"http://127.0.0.1", true},
{"http://localhost:3000", true},
{"http://localhost:8080", true},
{"http://localhost", true},
{"http://[::1]:3721", true},
{"http://LOCALHOST:9999", true}, // hostname 應大小寫不敏感
// scheme 不對
{"https://127.0.0.1:3721", false},
{"https://localhost:3000", false},
{"ws://127.0.0.1:3721", false},
// hostname 不在白名單
{"http://192.168.1.5:3721", false},
{"http://example.com", false},
{"http://malicious.local", false},
{"http://127.0.0.1.evil.com", false}, // suffix 攻擊
{"http://evil-127.0.0.1.com", false},
// 特殊情境
{"", false},
{"null", false},
{"http://", false},
{"not-a-url", false},
}
for _, tc := range cases {
got := isAllowedOrigin(tc.origin)
if got != tc.want {
t.Errorf("isAllowedOrigin(%q) = %v, want %v", tc.origin, got, tc.want)
}
}
}
// newTestRouter 建一台只掛 CORSMiddleware 的最小 router用於測試 middleware 行為。
func newTestRouter() *gin.Engine {
r := gin.New()
r.Use(CORSMiddleware())
r.GET("/api/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
r.POST("/api/do", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
return r
}
// TestCORSMiddleware_AllowedOriginGET白名單 Origin 的 GET 應回 200 且帶 ACA header。
func TestCORSMiddleware_AllowedOriginGET(t *testing.T) {
r := newTestRouter()
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
req.Header.Set("Origin", "http://127.0.0.1:3000")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://127.0.0.1:3000" {
t.Errorf("ACA-Origin = %q, want http://127.0.0.1:3000", got)
}
if got := w.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
t.Errorf("ACA-Credentials = %q, want true", got)
}
if got := w.Header().Get("Vary"); got != "Origin" {
t.Errorf("Vary = %q, want Origin", got)
}
}
// TestCORSMiddleware_LocalhostAllowedlocalhost 任意 port 都應放行。
func TestCORSMiddleware_LocalhostAllowed(t *testing.T) {
r := newTestRouter()
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
req.Header.Set("Origin", "http://localhost:8080")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:8080" {
t.Errorf("ACA-Origin = %q, want http://localhost:8080", got)
}
}
// TestCORSMiddleware_DisallowedOriginPOST非白名單 Origin 的 POST 必須 403。
func TestCORSMiddleware_DisallowedOriginPOST(t *testing.T) {
r := newTestRouter()
req := httptest.NewRequest(http.MethodPost, "/api/do", nil)
req.Header.Set("Origin", "https://example.com")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", w.Code)
}
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("非白名單不應回 ACA-Origingot %q", got)
}
}
// TestCORSMiddleware_DisallowedOriginGET非白名單 GET 應該執行 handler 但不回 ACA。
func TestCORSMiddleware_DisallowedOriginGET(t *testing.T) {
r := newTestRouter()
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
req.Header.Set("Origin", "http://malicious.local")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (handler 仍執行,瀏覽器層擋讀取)", w.Code)
}
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("非白名單不應回 ACA-Origingot %q", got)
}
}
// TestCORSMiddleware_PreflightAllowed白名單 Origin 的 OPTIONS preflight 應回 204 + 完整 headers。
func TestCORSMiddleware_PreflightAllowed(t *testing.T) {
r := newTestRouter()
req := httptest.NewRequest(http.MethodOptions, "/api/do", nil)
req.Header.Set("Origin", "http://127.0.0.1:9999")
req.Header.Set("Access-Control-Request-Method", "POST")
req.Header.Set("Access-Control-Request-Headers", "Content-Type")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", w.Code)
}
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "http://127.0.0.1:9999" {
t.Errorf("ACA-Origin = %q, want http://127.0.0.1:9999", got)
}
if got := w.Header().Get("Access-Control-Allow-Methods"); got == "" {
t.Errorf("ACA-Methods 不應為空")
}
if got := w.Header().Get("Access-Control-Allow-Headers"); got == "" {
t.Errorf("ACA-Headers 不應為空")
}
}
// TestCORSMiddleware_PreflightDisallowed非白名單 OPTIONS preflight 應 403不回 ACA。
func TestCORSMiddleware_PreflightDisallowed(t *testing.T) {
r := newTestRouter()
req := httptest.NewRequest(http.MethodOptions, "/api/do", nil)
req.Header.Set("Origin", "http://evil.com")
req.Header.Set("Access-Control-Request-Method", "POST")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", w.Code)
}
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("非白名單不應回 ACA-Origingot %q", got)
}
}
// TestCORSMiddleware_SameOrigin沒帶 Originsame-origin應放行。
func TestCORSMiddleware_SameOrigin(t *testing.T) {
r := newTestRouter()
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("same-origin 不應回 ACA-Origingot %q", got)
}
}

View File

@ -35,6 +35,12 @@ func NewRouter(
// with one that also pushes to the WebSocket broadcaster.
r := gin.New()
r.Use(gin.Recovery())
// M8-4跳過高頻輪詢 endpoint 的 access logTDD v2/server-lifecycle.md §9.1a)。
// 瀏覽器每 10s poll 一次 boot-idbusiness code 也會定期輪詢 health
// 若每次都寫 access log 會把 business log 淹沒。
//
// 注意broadcasterLogger 是我們自製的 middleware不會直接套用 gin.LoggerConfig
// 因此 skip 邏輯在 broadcasterLogger 內部手動實作(見下方)。
r.Use(broadcasterLogger(logBroadcaster))
r.Use(CORSMiddleware())
@ -50,8 +56,12 @@ func NewRouter(
api.GET("/system/info", systemHandler.Info)
api.GET("/system/metrics", systemHandler.Metrics)
api.GET("/system/deps", systemHandler.Deps)
api.GET("/system/boot-id", systemHandler.BootID) // M8-4瀏覽器 tab 用於偵測 server 重啟
api.POST("/system/restart", systemHandler.Restart)
api.POST("/system/install-driver", systemHandler.InstallDriver)
// MAJ-4 補丁Wails shutdown / Restart 前廣播 server:shutdown-imminent
// 到 /ws/system讓瀏覽器 tab 立即顯示 Offline Overlay。
api.POST("/system/shutdown-notify", systemHandler.ShutdownNotify)
// Models
api.GET("/models", modelHandler.ListModels)
@ -80,7 +90,6 @@ func NewRouter(
api.POST("/media/upload/video", cameraHandler.UploadVideo)
api.POST("/media/upload/batch-images", cameraHandler.UploadBatchImages)
api.GET("/media/batch-images/:index", cameraHandler.GetBatchImageFrame)
api.POST("/media/url", cameraHandler.StartFromURL)
api.POST("/media/seek", cameraHandler.SeekVideo)
}
@ -89,6 +98,8 @@ func NewRouter(
r.GET("/ws/devices/:id/flash-progress", ws.FlashProgressHandler(wsHub))
r.GET("/ws/devices/:id/inference", ws.InferenceHandler(wsHub, inferenceSvc))
r.GET("/ws/server-logs", ws.ServerLogsHandler(wsHub, logBroadcaster))
// MAJ-4 補丁:/ws/system — server:shutdown-imminent 事件訂閱
r.GET("/ws/system", ws.SystemEventsHandler(wsHub))
// Embedded frontend static file serving (production mode)
if staticFS != nil {
@ -109,9 +120,19 @@ func NewRouter(
return r
}
// broadcasterLoggerSkipPaths 列出不寫 access log 的 endpointM8-4 TDD §9.1a)。
// 這些 endpoint 被瀏覽器或業務 code 高頻輪詢,每次都寫 log 會把 log 噴滿。
var broadcasterLoggerSkipPaths = map[string]struct{}{
"/api/system/boot-id": {},
"/api/system/health": {},
}
// broadcasterLogger is a Gin middleware that logs HTTP requests to both
// stdout (like gin.Logger) and the WebSocket log broadcaster so that
// request logs are visible in the frontend Settings page.
//
// M8-4對 broadcasterLoggerSkipPaths 裡列出的 endpoint 不寫 log
// 避免把 access log 淹沒(瀏覽器每 10s poll boot-idhealth 被業務 code 高頻輪詢)。
func broadcasterLogger(b *logger.Broadcaster) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
@ -120,6 +141,11 @@ func broadcasterLogger(b *logger.Broadcaster) gin.HandlerFunc {
c.Next()
// M8-4跳過高頻輪詢 endpoint比對只看 path不含 query
if _, skip := broadcasterLoggerSkipPaths[path]; skip {
return
}
latency := time.Since(start)
status := c.Writer.Status()
method := c.Request.Method

View File

@ -1,16 +1,17 @@
package ws
import (
"net/http"
"visiona-local/server/internal/device"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
// upgrader 是所有 WS handler 共用的 gorilla upgrader。
// M8-8CheckOrigin 改為 ws.CheckOriginloopback 白名單),
// 不再接受任意 Originv1 模式是 wails:// 才放行所有 origin
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
CheckOrigin: CheckOrigin,
}
func DeviceEventsHandler(hub *Hub, deviceMgr *device.Manager) gin.HandlerFunc {

View File

@ -2,7 +2,11 @@ package ws
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/gorilla/websocket"
)
@ -23,12 +27,25 @@ type RoomMessage struct {
Message []byte
}
// Hub 管理 WebSocket client 訂閱與訊息廣播。
//
// M8-4bHub 額外負責「第一個 client 連上時寫 sentinel file」
// 讓 Wails 端的 StartupPipeline 知道階段 6Wait for Web UI WebSocket已完成。
// 詳細設計見 .autoflow/04-architecture/v2/startup-pipeline.md §3。
//
// dataDir 由 main.go 在初始化 Hub 後透過 SetStartupSentinel(dataDir) 注入。
// 若 dataDir 為空sentinel 寫入會被跳過(單元測試或缺少資料目錄時的安全行為)。
type Hub struct {
rooms map[string]map[*Client]bool
register chan *Subscription
unregister chan *Subscription
broadcast chan *RoomMessage
mu sync.RWMutex
// M8-4b: 啟動 sentinel file
sentinelDataDir string // <dataDir>,由 SetStartupSentinel 設定
sentinelOnce sync.Once // 確保只在「第一個」client 連上時寫一次
bootID string // 寫入 sentinel 內容供 debug
}
func NewHub() *Hub {
@ -37,9 +54,49 @@ func NewHub() *Hub {
register: make(chan *Subscription, 10),
unregister: make(chan *Subscription, 10),
broadcast: make(chan *RoomMessage, 100),
bootID: fmt.Sprintf("boot-%d", time.Now().UnixNano()),
}
}
// SetStartupSentinel 設定 sentinel file 的根目錄。
// main.go 在 NewHub() 之後、Run() 之前呼叫一次dataDir 應為完整路徑。
//
// 寫入路徑:<dataDir>/.first-ws-connected
// 內容boot-id + timestamp用於 debug內容對 Wails 端的判斷沒有意義,存在即可)
//
// dataDir 為空字串時 sentinel 機制完全停用。
func (h *Hub) SetStartupSentinel(dataDir string) {
h.mu.Lock()
h.sentinelDataDir = dataDir
h.mu.Unlock()
}
// writeStartupSentinel 在第一個 WebSocket client 連上時呼叫一次。
// 由 sentinelOnce 確保只執行一次;後續連線完全 no-op。
//
// 寫入失敗不會 panic 也不會回 errorsentinel 是 best-effort 機制,
// 若 disk 滿/權限錯Wails 端會走 hard timeout 路徑進 Error state。
func (h *Hub) writeStartupSentinel() {
h.sentinelOnce.Do(func() {
h.mu.RLock()
dir := h.sentinelDataDir
bootID := h.bootID
h.mu.RUnlock()
if dir == "" {
return
}
path := filepath.Join(dir, ".first-ws-connected")
// 確保父目錄存在dataDir 通常已存在,但保險起見)
_ = os.MkdirAll(dir, 0o755)
f, err := os.Create(path)
if err != nil {
return
}
_, _ = fmt.Fprintf(f, "bootId=%s\nts=%d\n", bootID, time.Now().UnixMilli())
_ = f.Close()
})
}
func (h *Hub) Run() {
for {
select {
@ -50,6 +107,9 @@ func (h *Hub) Run() {
}
h.rooms[sub.Room][sub.Client] = true
h.mu.Unlock()
// M8-4b第一次有 client 加入任何 room → 寫 sentinel file
// sync.Once 保證後續呼叫 no-op
h.writeStartupSentinel()
if sub.done != nil {
close(sub.done)
}

View File

@ -0,0 +1,132 @@
package ws
// hub_broadcast_test.go — MAJ-4 補丁:驗證 Hub.BroadcastToRoom 在 system room 的行為
//
// 涵蓋:
// 1. 多個 client 都收到同一則訊息
// 2. 空 room無 client不 panic、不 block
// 3. client send channel 滿時不 block hub會 drop 該 client
import (
"encoding/json"
"testing"
"time"
)
// makeRegisteredClient 註冊一個 buffered send channel 的 dummy client 到指定 room。
func makeRegisteredClient(h *Hub, room string, bufSize int) *Client {
c := &Client{Send: make(chan []byte, bufSize)}
h.RegisterSync(&Subscription{Client: c, Room: room})
return c
}
func TestHub_BroadcastToRoom_MultipleClients(t *testing.T) {
hub := NewHub()
go hub.Run()
c1 := makeRegisteredClient(hub, "system", 4)
c2 := makeRegisteredClient(hub, "system", 4)
c3 := makeRegisteredClient(hub, "system", 4)
payload := map[string]interface{}{
"type": "server:shutdown-imminent",
"reason": "quit",
"ts": int64(1234567890),
}
hub.BroadcastToRoom("system", payload)
for i, c := range []*Client{c1, c2, c3} {
select {
case msg := <-c.Send:
var got map[string]interface{}
if err := json.Unmarshal(msg, &got); err != nil {
t.Fatalf("client %d bad json: %v", i, err)
}
if got["type"] != "server:shutdown-imminent" || got["reason"] != "quit" {
t.Errorf("client %d wrong payload: %+v", i, got)
}
case <-time.After(500 * time.Millisecond):
t.Fatalf("client %d did not receive broadcast within 500ms", i)
}
}
}
func TestHub_BroadcastToRoom_EmptyRoom(t *testing.T) {
hub := NewHub()
go hub.Run()
// 無 client 註冊在 "system" room → BroadcastToRoom 應該 no-op 且不 panic
done := make(chan struct{})
go func() {
defer close(done)
hub.BroadcastToRoom("system", map[string]string{"type": "server:shutdown-imminent"})
}()
select {
case <-done:
// OK
case <-time.After(500 * time.Millisecond):
t.Fatalf("BroadcastToRoom with empty room blocked > 500ms")
}
}
func TestHub_BroadcastToRoom_FullChannelDoesNotBlock(t *testing.T) {
hub := NewHub()
go hub.Run()
// buffer = 1 的 slow client先塞滿 → hub 接著 broadcast 時會打到 default case
// select 非 blocking sendhub goroutine 必須仍能繼續處理後續訊息。
//
// 同時註冊一個 healthy client 觀察hub 沒被卡住的證據 = healthy client 仍能收到訊息。
slow := makeRegisteredClient(hub, "system", 1)
slow.Send <- []byte("pre-existing") // 塞滿 slow 的 buffer
healthy := makeRegisteredClient(hub, "system", 4)
// 先量 broadcast 的時間,若 hub 被 slow client 卡住,這行會 block 直到 test timeout
done := make(chan struct{})
go func() {
defer close(done)
hub.BroadcastToRoom("system", map[string]string{"type": "server:shutdown-imminent"})
}()
select {
case <-done:
// OK — broadcast 沒 block
case <-time.After(500 * time.Millisecond):
t.Fatalf("BroadcastToRoom blocked — slow client 未被 drop")
}
// healthy client 必須收到訊息(證明 hub goroutine 沒被 slow client 卡住)
select {
case msg := <-healthy.Send:
var got map[string]interface{}
if err := json.Unmarshal(msg, &got); err != nil {
t.Fatalf("healthy client bad json: %v", err)
}
if got["type"] != "server:shutdown-imminent" {
t.Errorf("healthy client wrong payload: %+v", got)
}
case <-time.After(500 * time.Millisecond):
t.Fatalf("healthy client did not receive broadcast — hub 被 slow client 卡住")
}
// 再次 broadcast 驗證 hub 持續工作slow 已被 dropbroadcast 仍該回來)
done2 := make(chan struct{})
go func() {
defer close(done2)
hub.BroadcastToRoom("system", map[string]string{"type": "server:shutdown-imminent", "n": "2"})
}()
select {
case <-done2:
// OK
case <-time.After(500 * time.Millisecond):
t.Fatalf("second BroadcastToRoom blocked")
}
// healthy 應收到第二則
select {
case <-healthy.Send:
case <-time.After(500 * time.Millisecond):
t.Fatalf("healthy client 未收到第二則")
}
}

View File

@ -0,0 +1,85 @@
package ws
// hub_sentinel_test.go — M8-4b驗證 Hub 在第一個 client 連上時寫 sentinel file
//
// 測試對應 .autoflow/04-architecture/v2/startup-pipeline.md §3 階段 6。
import (
"os"
"path/filepath"
"testing"
"time"
)
// 模擬一個 dummy client不需要真的 WebSocket 連線Hub.Run 只關心 *Client pointer
func dummyClient() *Client {
return &Client{Send: make(chan []byte, 1)}
}
func TestHub_StartupSentinel_WrittenOnFirstRegister(t *testing.T) {
dir, err := os.MkdirTemp("", "ws-hub-sentinel-*")
if err != nil {
t.Fatalf("mkdtemp: %v", err)
}
defer os.RemoveAll(dir)
hub := NewHub()
hub.SetStartupSentinel(dir)
go hub.Run()
// 第一個 client 加入任意 room
hub.RegisterSync(&Subscription{Client: dummyClient(), Room: "test-room"})
// sentinel 應該在 dir 下出現
path := filepath.Join(dir, ".first-ws-connected")
if _, err := os.Stat(path); err != nil {
t.Fatalf("sentinel file 應該被寫入:%v", err)
}
}
func TestHub_StartupSentinel_WrittenOnlyOnce(t *testing.T) {
dir, err := os.MkdirTemp("", "ws-hub-sentinel-once-*")
if err != nil {
t.Fatalf("mkdtemp: %v", err)
}
defer os.RemoveAll(dir)
hub := NewHub()
hub.SetStartupSentinel(dir)
go hub.Run()
// 第一個 client
hub.RegisterSync(&Subscription{Client: dummyClient(), Room: "test-room"})
path := filepath.Join(dir, ".first-ws-connected")
info1, err := os.Stat(path)
if err != nil {
t.Fatalf("first sentinel: %v", err)
}
// 等一點時間確保 mtime 會不同(如果有寫第二次)
time.Sleep(50 * time.Millisecond)
// 第二、三個 client 加入
hub.RegisterSync(&Subscription{Client: dummyClient(), Room: "test-room"})
hub.RegisterSync(&Subscription{Client: dummyClient(), Room: "another-room"})
info2, err := os.Stat(path)
if err != nil {
t.Fatalf("second stat: %v", err)
}
if !info1.ModTime().Equal(info2.ModTime()) {
t.Fatalf("sentinel file 不應該被重寫mtime1=%v mtime2=%v", info1.ModTime(), info2.ModTime())
}
}
func TestHub_StartupSentinel_DisabledWhenDataDirEmpty(t *testing.T) {
hub := NewHub()
// 不呼叫 SetStartupSentinel → sentinelDataDir 為空
go hub.Run()
// 加 client 不該 panic、也不該寫任何檔案
hub.RegisterSync(&Subscription{Client: dummyClient(), Room: "test-room"})
// 沒有路徑可以驗證,這個測試主要驗證「不 panic」即可
}

View File

@ -0,0 +1,40 @@
package ws
import (
"net/http"
"net/url"
"strings"
)
// CheckOrigin 決定 WebSocket upgrade 是否允許。
//
// M8-8 / TDD v2/cors-security.md §5
// 與 HTTP CORS 白名單一致,只允許本機 loopback 來源:
// - http://127.0.0.1:* / http://localhost:* / http://[::1]:*
// - same-originOrigin header 為空gorilla 預設行為)
//
// 注意:這個 helper 故意與 api package 的 isAllowedOrigin 重複實作(不直接 import
// 避免 ws → api 反向 import會造成 cycle。兩邊邏輯保持一致。
func CheckOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
// same-origin 或非瀏覽器 clientwebsocat、Postman 等)
return true
}
if origin == "null" {
return false
}
u, err := url.Parse(origin)
if err != nil {
return false
}
if u.Scheme != "http" {
return false
}
host := strings.ToLower(u.Hostname())
switch host {
case "127.0.0.1", "localhost", "::1", "[::1]":
return true
}
return false
}

View File

@ -0,0 +1,38 @@
package ws
import (
"net/http"
"testing"
)
// TestCheckOrigin 驗證 WebSocket upgrade 的 origin 白名單M8-8 / TDD §5
func TestCheckOrigin(t *testing.T) {
cases := []struct {
name string
origin string
want bool
}{
{"empty same-origin", "", true},
{"loopback 127.0.0.1", "http://127.0.0.1:3721", true},
{"loopback localhost", "http://localhost:3000", true},
{"loopback ipv6", "http://[::1]:3721", true},
{"https 不允許", "https://127.0.0.1:3721", false},
{"非 loopback hostname", "http://192.168.1.5:3721", false},
{"惡意網站", "http://evil.com", false},
{"null origin", "null", false},
{"suffix 攻擊", "http://127.0.0.1.evil.com", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, "http://127.0.0.1:3721/ws/devices/events", nil)
if tc.origin != "" {
req.Header.Set("Origin", tc.origin)
}
got := CheckOrigin(req)
if got != tc.want {
t.Errorf("CheckOrigin(%q) = %v, want %v", tc.origin, got, tc.want)
}
})
}
}

View File

@ -0,0 +1,58 @@
package ws
// system_ws.go — MAJ-4 補丁:/ws/system WebSocket endpoint
//
// 對應 TDD v2/server-lifecycle.md §2.3Minor 4與 v2/web-ui-offline-overlay.md §3.2a。
//
// 用途:讓瀏覽器 tab 訂閱 server 的 `server:shutdown-imminent` 廣播,
// 以便在 Wails 關閉 / Restart 流程 SIGTERM 前立即顯示 Offline Overlay
// 避免只靠 health polling 導致的 15 秒延遲與 race。
//
// 室room名稱`system`
// - Hub.BroadcastToRoom("system", payload) 由 system_handler.ShutdownNotify 呼叫
// - 目前只支援單一類型事件server:shutdown-imminent未來若有其他 system 事件可共用此 room
//
// 設計注意:
// - 不向新 client 送任何歷史訊息shutdown event 必須「當下」收到才有意義,
// 補送一個過期的 shutdown 訊息會誤導前端)。
// - Read pump 只用於偵測 client 主動斷線,不處理任何 client → server 訊息。
// - 沿用 upgrader + CheckOriginloopback 白名單)。
import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
// SystemEventsHandler 處理 /ws/system 的 WebSocket 升級與訂閱。
func SystemEventsHandler(hub *Hub) gin.HandlerFunc {
return func(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
client := &Client{Conn: conn, Send: make(chan []byte, 16)}
sub := &Subscription{Client: client, Room: "system"}
hub.RegisterSync(sub)
defer hub.Unregister(sub)
// Read pump — 唯一目的:偵測 client 斷線close frame / socket error→ 觸發 conn.Close
// 使得 write pump 的 range client.Send 結束後能乾淨退出。
go func() {
defer conn.Close()
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}()
// Write pump — 把 Hub 塞進 client.Send 的訊息送出去。
for msg := range client.Send {
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
}
}
}

View File

@ -0,0 +1,83 @@
package ws
// system_ws_integration_test.go — MAJ-4 補丁:/ws/system 整合 smoke test
//
// 啟一個 httptest server 掛 SystemEventsHandler真的用 gorilla WebSocket client
// 連進去,然後呼叫 hub.BroadcastToRoom("system", ...),驗證 client 收到訊息。
//
// 這個測試取代外部 websocat / wscat 的需求,讓 smoke test 在 CI 就能跑。
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
func TestSystemEventsHandler_ReceivesBroadcast(t *testing.T) {
gin.SetMode(gin.TestMode)
hub := NewHub()
go hub.Run()
r := gin.New()
r.GET("/ws/system", SystemEventsHandler(hub))
srv := httptest.NewServer(r)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/system"
dialer := websocket.DefaultDialer
conn, _, err := dialer.Dial(wsURL, http.Header{})
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
// 給 Hub.register channel 一點時間處理 client 加入 room
// RegisterSync 會同步等完,但我們這邊是透過 HTTP upgrade 流程,
// client 先連上後才 RegisterSync — 需要等 handler 執行到那一行)
// 改用 poll持續廣播直到收到或 timeout。
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
// 等 hub 吸收 Register最多 500 ms
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
hub.mu.RLock()
n := len(hub.rooms["system"])
hub.mu.RUnlock()
if n > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
// 廣播 shutdown-imminent
hub.BroadcastToRoom("system", map[string]interface{}{
"type": "server:shutdown-imminent",
"reason": "quit",
"ts": time.Now().UnixMilli(),
})
// Read 第一則訊息
_, data, err := conn.ReadMessage()
if err != nil {
t.Fatalf("read: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("json: %v; raw=%s", err, string(data))
}
if got["type"] != "server:shutdown-imminent" {
t.Errorf("wrong type: %+v", got)
}
if got["reason"] != "quit" {
t.Errorf("wrong reason: %+v", got)
}
}

View File

@ -14,24 +14,16 @@ type CameraInfo struct {
}
type Manager struct {
mockMode bool
mockCamera *MockCamera
ffmpegCam *FFmpegCamera
isOpen bool
mu sync.Mutex
}
func NewManager(mockMode bool) *Manager {
return &Manager{mockMode: mockMode}
func NewManager() *Manager {
return &Manager{}
}
func (m *Manager) ListCameras() []CameraInfo {
if m.mockMode {
return []CameraInfo{
{ID: "mock-cam-0", Name: "Mock Camera 0", Index: 0, Width: 640, Height: 480},
}
}
// Try to detect real cameras via ffmpeg (auto-detects OS)
devices := ListFFmpegDevices()
if len(devices) > 0 {
@ -50,12 +42,6 @@ func (m *Manager) Open(index, width, height int) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.mockMode {
m.mockCamera = NewMockCamera(width, height)
m.isOpen = true
return nil
}
// Try real camera via ffmpeg
if !DetectFFmpeg() {
return fmt.Errorf("ffmpeg not found — install with: brew install ffmpeg (macOS) or winget install ffmpeg (Windows)")
@ -79,7 +65,6 @@ func (m *Manager) Close() error {
_ = m.ffmpegCam.Close()
m.ffmpegCam = nil
}
m.mockCamera = nil
m.isOpen = false
return nil
}
@ -94,9 +79,6 @@ func (m *Manager) ReadFrame() ([]byte, error) {
if m.ffmpegCam != nil {
return m.ffmpegCam.ReadFrame()
}
if m.mockCamera != nil {
return m.mockCamera.ReadFrame()
}
return nil, fmt.Errorf("no camera available")
}

View File

@ -1,95 +0,0 @@
package camera
import (
"bytes"
"fmt"
"image"
"image/color"
"image/jpeg"
"time"
)
type MockCamera struct {
width int
height int
frameCount int
}
func NewMockCamera(width, height int) *MockCamera {
return &MockCamera{width: width, height: height}
}
func (mc *MockCamera) ReadFrame() ([]byte, error) {
mc.frameCount++
return mc.generateTestCard()
}
func (mc *MockCamera) generateTestCard() ([]byte, error) {
img := image.NewRGBA(image.Rect(0, 0, mc.width, mc.height))
offset := mc.frameCount % mc.width
for y := 0; y < mc.height; y++ {
for x := 0; x < mc.width; x++ {
pos := (x + offset) % mc.width
ratio := float64(pos) / float64(mc.width)
var r, g, b uint8
if ratio < 0.33 {
r = uint8(255 * (1 - ratio/0.33))
g = uint8(255 * ratio / 0.33)
} else if ratio < 0.66 {
g = uint8(255 * (1 - (ratio-0.33)/0.33))
b = uint8(255 * (ratio - 0.33) / 0.33)
} else {
b = uint8(255 * (1 - (ratio-0.66)/0.34))
r = uint8(255 * (ratio - 0.66) / 0.34)
}
img.SetRGBA(x, y, color.RGBA{R: r, G: g, B: b, A: 255})
}
}
// Draw dark overlay bar at top for text area
for y := 0; y < 40; y++ {
for x := 0; x < mc.width; x++ {
img.SetRGBA(x, y, color.RGBA{R: 0, G: 0, B: 0, A: 180})
}
}
// Draw "MOCK CAMERA" text block and frame counter using simple rectangles
drawTextBlock(img, 10, 10, fmt.Sprintf("MOCK CAMERA | Frame: %d | %s", mc.frameCount, time.Now().Format("15:04:05")))
// Draw center crosshair
cx, cy := mc.width/2, mc.height/2
for i := -20; i <= 20; i++ {
if cx+i >= 0 && cx+i < mc.width {
img.SetRGBA(cx+i, cy, color.RGBA{R: 255, G: 255, B: 255, A: 200})
}
if cy+i >= 0 && cy+i < mc.height {
img.SetRGBA(cx, cy+i, color.RGBA{R: 255, G: 255, B: 255, A: 200})
}
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 75}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func drawTextBlock(img *image.RGBA, x, y int, text string) {
white := color.RGBA{R: 255, G: 255, B: 255, A: 255}
// Simple pixel-based text rendering: each character is a 5x7 block
for i, ch := range text {
if ch == ' ' {
continue
}
px := x + i*6
// Draw a small white dot for each character position
for dy := 0; dy < 5; dy++ {
for dx := 0; dx < 4; dx++ {
if px+dx < img.Bounds().Max.X && y+dy < img.Bounds().Max.Y {
img.SetRGBA(px+dx, y+dy, white)
}
}
}
}
}

View File

@ -60,8 +60,7 @@ type VideoSource struct {
done chan struct{}
finished bool
err error
filePath string // local file path (empty for URL sources)
isURL bool // true when source is a URL, skip file cleanup
filePath string // local file path
totalFrames int64 // 0 means unknown
frameCount int64 // atomic counter incremented in readLoop
}
@ -69,77 +68,15 @@ type VideoSource struct {
// NewVideoSource starts an ffmpeg process that decodes a video file
// and outputs MJPEG frames to stdout at the specified FPS.
func NewVideoSource(filePath string, fps float64) (*VideoSource, error) {
return newVideoSource(filePath, fps, false, 0)
return newVideoSource(filePath, fps, 0)
}
// NewVideoSourceWithSeek starts ffmpeg from a specific position (in seconds).
func NewVideoSourceWithSeek(filePath string, fps float64, seekSeconds float64) (*VideoSource, error) {
return newVideoSource(filePath, fps, false, seekSeconds)
return newVideoSource(filePath, fps, seekSeconds)
}
// NewVideoSourceFromURL starts an ffmpeg process that reads from a URL
// (HTTP, HTTPS, RTSP, etc.) and outputs MJPEG frames to stdout.
func NewVideoSourceFromURL(rawURL string, fps float64) (*VideoSource, error) {
return newVideoSource(rawURL, fps, true, 0)
}
// NewVideoSourceFromURLWithSeek starts ffmpeg from a URL at a specific position.
func NewVideoSourceFromURLWithSeek(rawURL string, fps float64, seekSeconds float64) (*VideoSource, error) {
return newVideoSource(rawURL, fps, true, seekSeconds)
}
// ResolveWithYTDLP uses yt-dlp to extract the direct video stream URL
// from platforms like YouTube, Vimeo, etc.
// Returns the resolved direct URL or an error.
func ResolveWithYTDLP(rawURL string) (string, error) {
cmd := exec.Command("yt-dlp", "-f", "best[ext=mp4]/best", "--get-url", rawURL)
out, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
stderr := strings.TrimSpace(string(exitErr.Stderr))
return "", fmt.Errorf("%s\n%s", friendlyYTDLPError(stderr), stderr)
}
return "", fmt.Errorf("yt-dlp 未安裝或無法執行: %w", err)
}
resolved := strings.TrimSpace(string(out))
if resolved == "" {
return "", fmt.Errorf("yt-dlp 無法取得影片下載連結(影片可能有地區限制或需要登入)")
}
if idx := strings.Index(resolved, "\n"); idx > 0 {
resolved = resolved[:idx]
}
return resolved, nil
}
// friendlyYTDLPError 把 yt-dlp 的技術性錯誤訊息轉成使用者能理解的提示。
func friendlyYTDLPError(stderr string) string {
s := strings.ToLower(stderr)
switch {
case strings.Contains(s, "sign in") || strings.Contains(s, "age"):
return "此影片需要登入 YouTube 帳號才能觀看(例如年齡限制),無法直接使用"
case strings.Contains(s, "private"):
return "此影片為私人影片,無法存取"
case strings.Contains(s, "unavailable") || strings.Contains(s, "not available"):
return "此影片無法取得(可能已被移除或在你的地區不可用)"
case strings.Contains(s, "copyright"):
return "此影片因版權限制無法下載"
case strings.Contains(s, "live"):
return "此影片為直播串流,目前不支援直播推論"
case strings.Contains(s, "premiere"):
return "此影片為首播Premiere尚未開始或格式不支援"
case strings.Contains(s, "drm") || strings.Contains(s, "protected"):
return "此影片有 DRM 保護,無法下載"
case strings.Contains(s, "rate limit") || strings.Contains(s, "429"):
return "YouTube 暫時限制了請求頻率,請稍後再試"
case strings.Contains(s, "no video formats"):
return "找不到可用的影片格式"
default:
return "無法解析此影片連結,請確認 URL 是否正確且影片為公開狀態"
}
}
func newVideoSource(input string, fps float64, isURL bool, seekSeconds float64) (*VideoSource, error) {
func newVideoSource(filePath string, fps float64, seekSeconds float64) (*VideoSource, error) {
if fps <= 0 {
fps = 15
}
@ -149,7 +86,7 @@ func newVideoSource(input string, fps float64, isURL bool, seekSeconds float64)
args = append(args, "-ss", fmt.Sprintf("%.3f", seekSeconds))
}
args = append(args,
"-i", input,
"-i", filePath,
"-vf", fmt.Sprintf("fps=%g", fps),
"-f", "image2pipe",
"-vcodec", "mjpeg",
@ -169,18 +106,12 @@ func newVideoSource(input string, fps float64, isURL bool, seekSeconds float64)
return nil, fmt.Errorf("failed to start ffmpeg: %w", err)
}
filePath := ""
if !isURL {
filePath = input
}
vs := &VideoSource{
cmd: cmd,
stdout: stdout,
frameCh: make(chan []byte, 30), // buffer up to 30 frames
done: make(chan struct{}),
filePath: filePath,
isURL: isURL,
}
go vs.readLoop()
@ -295,8 +226,8 @@ func (v *VideoSource) Close() error {
for range v.frameCh {
}
<-v.done
// Only remove temp files, not URL sources
if !v.isURL && v.filePath != "" {
// Remove temp file if present
if v.filePath != "" {
_ = os.Remove(v.filePath)
}
return nil

View File

@ -18,9 +18,6 @@ const (
type Config struct {
Port int
Host string
MockMode bool
MockCamera bool
MockDeviceCount int
LogLevel string
DevMode bool
DataDir string
@ -36,9 +33,6 @@ func Load() *Config {
flag.IntVar(&cfg.Port, "port", 3721, "Server port")
// 強制 localhost-only即使使用者傳入其他 hostLoad() 結束前會覆寫回 127.0.0.1
flag.StringVar(&cfg.Host, "host", "127.0.0.1", "Server host (forced to 127.0.0.1 for local-only use)")
flag.BoolVar(&cfg.MockMode, "mock", false, "Enable mock device driver")
flag.BoolVar(&cfg.MockCamera, "mock-camera", false, "Enable mock camera")
flag.IntVar(&cfg.MockDeviceCount, "mock-devices", 1, "Number of mock devices")
flag.StringVar(&cfg.LogLevel, "log-level", "info", "Log level (debug/info/warn/error)")
flag.BoolVar(&cfg.DevMode, "dev", false, "Dev mode: disable embedded static file serving")
flag.StringVar(&cfg.DataDir, "data-dir", "", "Override data directory (default: <binary>/data)")

View File

@ -27,9 +27,9 @@ func CheckAll() []Dependency {
check("ffmpeg", false,
"macOS: brew install ffmpeg | Windows: winget install Gyan.FFmpeg",
"-version"),
check("yt-dlp", false,
"macOS: brew install yt-dlp | Windows: winget install yt-dlp",
"--version"),
check("ffprobe", false,
"macOS: brew install ffmpeg | Windows: winget install Gyan.FFmpeg",
"-version"),
check("python3", false,
"Required only for Kneron KL720 hardware. macOS: brew install python3",
"--version"),
@ -66,8 +66,8 @@ func check(name string, required bool, hint string, args ...string) Dependency {
d.Available = true
d.Path = path
// 效能bundle 內的 binary(尤其是 yt-dlp PyInstaller 單檔冷啟動可能需 20 秒
// 會阻塞 server startup。bundle binary 已知良好,跳過 version 查詢以加速啟動。
// 效能bundle 內的 binary 冷啟動可能較慢(尤其 PyInstaller
// bundle binary 已知良好,跳過 version 查詢以加速啟動。
// 若之後需要版本字串handler 可 lazy 再打一次。
if strings.HasPrefix(path, strings.TrimSpace(os.Getenv("VISIONA_BUNDLE_BIN_DIR"))) &&
os.Getenv("VISIONA_BUNDLE_BIN_DIR") != "" {

View File

@ -7,7 +7,6 @@ import (
"visiona-local/server/internal/driver"
"visiona-local/server/internal/driver/kneron"
mockdriver "visiona-local/server/internal/driver/mock"
"visiona-local/server/pkg/logger"
)
@ -15,28 +14,18 @@ type Manager struct {
registry *DriverRegistry
sessions map[string]*DeviceSession
eventBus chan DeviceEvent
mockMode bool
scriptPath string
logBroadcaster *logger.Broadcaster
mu sync.RWMutex
}
func NewManager(registry *DriverRegistry, mockMode bool, mockCount int, scriptPath string) *Manager {
m := &Manager{
func NewManager(registry *DriverRegistry, scriptPath string) *Manager {
return &Manager{
registry: registry,
sessions: make(map[string]*DeviceSession),
eventBus: make(chan DeviceEvent, 100),
mockMode: mockMode,
scriptPath: scriptPath,
}
if mockMode {
for i := 0; i < mockCount; i++ {
id := fmt.Sprintf("mock-device-%d", i+1)
d := mockdriver.Factory(id, i)
m.sessions[id] = NewSession(d)
}
}
return m
}
// SetLogBroadcaster attaches a log broadcaster so that Kneron driver
@ -54,10 +43,6 @@ func (m *Manager) SetLogBroadcaster(b *logger.Broadcaster) {
}
func (m *Manager) Start() {
if m.mockMode {
return
}
// Detect real Kneron devices (KL520, KL720, etc.) via Python bridge.
devices := kneron.DetectDevices(m.scriptPath)
if len(devices) == 0 {
@ -80,10 +65,6 @@ func (m *Manager) Start() {
// Rescan re-detects connected Kneron devices. New devices are registered,
// removed devices are cleaned up, and existing devices are left untouched.
func (m *Manager) Rescan() []driver.DeviceInfo {
if m.mockMode {
return m.ListDevices()
}
detected := kneron.DetectDevices(m.scriptPath)
// Build a set of detected device IDs.

View File

@ -22,19 +22,9 @@ func (d *testDriver) ReadInference() (*driver.InferenceResult, error) { return n
func (d *testDriver) RunInference(_ []byte) (*driver.InferenceResult, error) { return nil, nil }
func (d *testDriver) GetModelInfo() (*driver.ModelInfo, error) { return nil, nil }
func TestNewManager_MockMode(t *testing.T) {
registry := NewRegistry()
mgr := NewManager(registry, true, 2, "")
devices := mgr.ListDevices()
if len(devices) != 2 {
t.Errorf("NewManager mock mode: got %d devices, want 2", len(devices))
}
}
func TestManager_ListDevices(t *testing.T) {
registry := NewRegistry()
mgr := NewManager(registry, false, 0, "")
mgr := NewManager(registry, "")
mgr.sessions["test-1"] = NewSession(&testDriver{
info: driver.DeviceInfo{ID: "test-1", Name: "Test Device", Type: "KL720", Status: driver.StatusDetected},
@ -46,9 +36,20 @@ func TestManager_ListDevices(t *testing.T) {
}
}
func TestManager_ListDevices_Empty(t *testing.T) {
registry := NewRegistry()
mgr := NewManager(registry, "")
// 無硬體時應回傳空 listR5-5a沒插硬體就空白不給 Mock 資料)
devices := mgr.ListDevices()
if len(devices) != 0 {
t.Errorf("ListDevices() with no hardware = %d, want 0", len(devices))
}
}
func TestManager_GetDevice(t *testing.T) {
registry := NewRegistry()
mgr := NewManager(registry, false, 0, "")
mgr := NewManager(registry, "")
mgr.sessions["test-1"] = NewSession(&testDriver{
info: driver.DeviceInfo{ID: "test-1"},
})
@ -73,7 +74,7 @@ func TestManager_GetDevice(t *testing.T) {
func TestManager_Connect(t *testing.T) {
registry := NewRegistry()
mgr := NewManager(registry, false, 0, "")
mgr := NewManager(registry, "")
td := &testDriver{info: driver.DeviceInfo{ID: "test-1", Status: driver.StatusDetected}}
mgr.sessions["test-1"] = NewSession(td)

View File

@ -1,183 +0,0 @@
package mock
import (
"fmt"
"math/rand"
"sync"
"time"
"visiona-local/server/internal/driver"
)
var mockLabels = []string{"person", "car", "bicycle", "dog", "cat", "chair", "bottle", "phone"}
type MockDriver struct {
info driver.DeviceInfo
connected bool
inferring bool
modelLoaded string
mu sync.Mutex
}
func NewMockDriver(info driver.DeviceInfo) *MockDriver {
return &MockDriver{info: info}
}
func Factory(id string, index int) driver.DeviceDriver {
info := driver.DeviceInfo{
ID: id,
Name: fmt.Sprintf("Kneron KL720 (Mock #%d)", index+1),
Type: "kneron_kl720",
Port: fmt.Sprintf("/dev/ttyMOCK%d", index),
Status: driver.StatusDetected,
FirmwareVer: "2.2.0-mock",
}
return NewMockDriver(info)
}
func (d *MockDriver) Info() driver.DeviceInfo {
d.mu.Lock()
defer d.mu.Unlock()
return d.info
}
func (d *MockDriver) Connect() error {
d.mu.Lock()
defer d.mu.Unlock()
time.Sleep(200 * time.Millisecond)
d.connected = true
d.info.Status = driver.StatusConnected
return nil
}
func (d *MockDriver) Disconnect() error {
d.mu.Lock()
defer d.mu.Unlock()
d.connected = false
d.inferring = false
d.info.Status = driver.StatusDisconnected
return nil
}
func (d *MockDriver) IsConnected() bool {
d.mu.Lock()
defer d.mu.Unlock()
return d.connected
}
func (d *MockDriver) Flash(modelPath string, progressCh chan<- driver.FlashProgress) error {
d.mu.Lock()
d.info.Status = driver.StatusFlashing
d.mu.Unlock()
type stage struct {
name string
duration time.Duration
startPct int
endPct int
}
stages := []stage{
{"preparing", 1 * time.Second, 0, 10},
{"transferring", 6 * time.Second, 10, 80},
{"verifying", 2 * time.Second, 80, 95},
{"rebooting", 1 * time.Second, 95, 99},
}
for _, s := range stages {
steps := (s.endPct - s.startPct) / 5
if steps < 1 {
steps = 1
}
interval := s.duration / time.Duration(steps)
for i := 0; i <= steps; i++ {
pct := s.startPct + (s.endPct-s.startPct)*i/steps
progressCh <- driver.FlashProgress{
Percent: pct,
Stage: s.name,
Message: fmt.Sprintf("%s... %d%%", s.name, pct),
}
time.Sleep(interval)
}
}
d.mu.Lock()
d.modelLoaded = modelPath
d.info.FlashedModel = modelPath
d.info.Status = driver.StatusConnected
d.mu.Unlock()
progressCh <- driver.FlashProgress{Percent: 100, Stage: "done", Message: "Flash complete"}
return nil
}
func (d *MockDriver) StartInference() error {
d.mu.Lock()
defer d.mu.Unlock()
d.inferring = true
d.info.Status = driver.StatusInferencing
return nil
}
func (d *MockDriver) StopInference() error {
d.mu.Lock()
defer d.mu.Unlock()
d.inferring = false
d.info.Status = driver.StatusConnected
return nil
}
func (d *MockDriver) ReadInference() (*driver.InferenceResult, error) {
return d.RunInference(nil)
}
func (d *MockDriver) RunInference(imageData []byte) (*driver.InferenceResult, error) {
time.Sleep(30 * time.Millisecond)
numDetections := rand.Intn(3) + 1
detections := make([]driver.DetectionResult, numDetections)
for i := 0; i < numDetections; i++ {
w := 0.1 + rand.Float64()*0.3
h := 0.1 + rand.Float64()*0.3
detections[i] = driver.DetectionResult{
Label: mockLabels[rand.Intn(len(mockLabels))],
Confidence: 0.3 + rand.Float64()*0.7,
BBox: driver.BBox{
X: rand.Float64() * (1 - w),
Y: rand.Float64() * (1 - h),
Width: w,
Height: h,
},
}
}
classifications := []driver.ClassResult{
{Label: "person", Confidence: 0.5 + rand.Float64()*0.5},
{Label: "car", Confidence: rand.Float64() * 0.5},
{Label: "dog", Confidence: rand.Float64() * 0.3},
{Label: "cat", Confidence: rand.Float64() * 0.2},
{Label: "bicycle", Confidence: rand.Float64() * 0.15},
}
return &driver.InferenceResult{
TaskType: "detection",
Timestamp: time.Now().UnixMilli(),
LatencyMs: 20 + rand.Float64()*30,
Detections: detections,
Classifications: classifications,
}, nil
}
func (d *MockDriver) GetModelInfo() (*driver.ModelInfo, error) {
d.mu.Lock()
defer d.mu.Unlock()
if d.modelLoaded == "" {
return nil, fmt.Errorf("no model loaded")
}
return &driver.ModelInfo{
ID: d.modelLoaded,
Name: d.modelLoaded,
LoadedAt: time.Now(),
}, nil
}

View File

@ -83,10 +83,9 @@ func main() {
logger := pkglogger.New(cfg.LogLevel)
logger.Info("Starting visionA-local Server %s (built: %s)", Version, BuildTime)
logger.Info("Mock mode: %v, Mock camera: %v, Dev mode: %v, Python mode: %s",
cfg.MockMode, cfg.MockCamera, cfg.DevMode, cfg.PythonMode)
logger.Info("Dev mode: %v, Python mode: %s", cfg.DevMode, cfg.PythonMode)
// 把 VISIONA_BUNDLE_BIN_DIR 加到 PATH讓 exec.Command("yt-dlp") / exec.Command("ffmpeg")
// 把 VISIONA_BUNDLE_BIN_DIR 加到 PATH讓 exec.Command("ffmpeg") / exec.Command("ffprobe")
// 能透過 LookPath 找到 bundle 內的 binaryGo 1.19+ Windows 不再搜 cwd
if bundleBin := os.Getenv("VISIONA_BUNDLE_BIN_DIR"); bundleBin != "" {
sep := string(os.PathListSeparator)
@ -127,6 +126,11 @@ func main() {
// Initialize WebSocket hub (before device manager so log broadcaster is ready)
wsHub := ws.NewHub()
// M8-4b注入 dataDir 給 Hub第一個 WebSocket client 連上時會在
// <dataDir>/.first-ws-connected 寫 sentinel file讓 Wails 端的
// StartupPipeline 知道階段 6Wait for Web UI WebSocket已完成。
// 詳見 .autoflow/04-architecture/v2/startup-pipeline.md §3 階段 6。
wsHub.SetStartupSentinel(dataDir)
go wsHub.Run()
// Initialize log broadcaster for real-time log streaming
@ -139,12 +143,12 @@ func main() {
registry := device.NewRegistry()
bridgeScript := resolveBridgeScript(base)
logger.Info("Kneron bridge script: %s", bridgeScript)
deviceMgr := device.NewManager(registry, cfg.MockMode, cfg.MockDeviceCount, bridgeScript)
deviceMgr := device.NewManager(registry, bridgeScript)
deviceMgr.SetLogBroadcaster(logBroadcaster)
deviceMgr.Start()
// Initialize camera manager
cameraMgr := camera.NewManager(cfg.MockCamera)
cameraMgr := camera.NewManager()
// Initialize services
flashSvc := flash.NewService(deviceMgr, modelRepo, dataDir)
@ -164,7 +168,10 @@ func main() {
restartRequested := make(chan struct{}, 1)
shutdownFn := func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
// MAJ-3 修復timeout 必須 ≤ Wails shutdownGracePeriod (7s),留 1s buffer。
// TDD §8.1Wails 端 7s grace + 1s modalserver 端 6s 內必須完成清理,
// 否則 Wails 在第 7s SIGKILL 時 server 還在 sync 檔案會被打斷。
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
inferenceSvc.StopAll()
cameraMgr.Close()
@ -190,7 +197,7 @@ func main() {
}
// Create system handler with injected version and restart function
systemHandler := handlers.NewSystemHandler(Version, BuildTime, pythonBinForSystem, restartFn)
systemHandler := handlers.NewSystemHandler(Version, BuildTime, pythonBinForSystem, restartFn, wsHub)
// Create router
r := api.NewRouter(modelRepo, modelStore, deviceMgr, cameraMgr, flashSvc, inferenceSvc, wsHub, staticFS, logBroadcaster, systemHandler)

0
local-tool/vendor/.gitkeep vendored Normal file
View File

294
local-tool/vendor/ffmpeg/macos/BUILD.md vendored Normal file
View File

@ -0,0 +1,294 @@
# macOS LGPL ffmpeg build record
此目錄存放 visionA-local 的 macOS x86_64 專用 ffmpeg + ffprobe binary。
依 v2 TDD §2`/.autoflow/04-architecture/v2/ffmpeg-lgpl.md`決策macOS 沒有現成的
LGPL static build 來源,採「自 build decoder-only」策略binary 直接 commit 到 git
R5-6b— 開發者 clone repo 即可使用,不必每次重 build~15 分鐘)。
---
## Reproducibility
| 項目 | 值 |
|------|---|
| ffmpeg release | `n7.1` |
| Source tarball | <https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n7.1.tar.gz> |
| Source sha256 | `7ddad2d992bd250a6c56053c26029f7e728bebf0f37f80cf3f8a0e6ec706431a` |
| Build host | macOS 14.7.6 (Sonoma, x86_64) |
| Toolchain | Apple clang 16.0.0 (clang-1600.0.26.6), Command Line Tools |
| Assembler | nasm 3.01Homebrew bottlecompiled 2025-10-11 |
| Homebrew | 5.1.6 |
| Build date | 2026-04-15 |
| Build flags | 見下方 Configure flags 區塊(與 `Makefile``vendor-ffmpeg-macos-build` target 一致) |
## Binary sha256
| 檔案 | sha256 |
|------|--------|
| `ffmpeg` | `c3cb9f1dad66730267c12fca92c6344d2f8939ab227889caac33005f8947992c` |
| `ffprobe` | `bd388fb4372ed5f7e44ee331a51be6383d702fb2c067bf562cabbdfbdd8b0c5e` |
| `COPYING.LGPLv3` | `da7eabb7bafdf7d3ae5e9f223aa5bdc1eece45ac569dc21b3b037520b4464768` |
計算指令:
```bash
shasum -a 256 vendor/ffmpeg/macos/ffmpeg vendor/ffmpeg/macos/ffprobe
```
## Binary 大小實測strip 後)
| 檔案 | Bytes | 人類可讀 |
|------|-------|---------|
| `ffmpeg` | 6,007,520 | 5.7 MB |
| `ffprobe` | 5,865,568 | 5.6 MB |
實測比 TDD 原估 1015 MB 小一半,因為 `--disable-everything` + 白名單僅啟用必要 decoder/demuxer/filter無 GPL 元件。
### Build 實測耗時
- **2 分 44 秒**`make vendor-ffmpeg-macos-build``time` 量測)
- user: 559.60ssystem: 56.03swall-clock: 164.56s
- CPU 使用率:~374%macOS x86_648 核 Intel
- 比 TDD 原估 1020 分鐘快很多,因為 `--disable-everything` 大幅削減編譯單元數量
## License
**LGPL v3**`--enable-version3` 未加 `--enable-gpl`)。完整授權條款見同目錄的
`COPYING.LGPLv3`build 後由 Makefile 自動從 source tarball 複製過來)。
build 不 link 以下 GPL-only 元件:
- 無 `libx264`H.264 encoderGPL
- 無 `libx265`H.265 encoderGPL
- 無 `libxavs` / `libxvid`GPL
- 無 `libfaac`non-free
僅使用 libavcodec 內建的 LGPL native decoderh264 / hevc / mpeg1video / mpeg2video /
mpeg4 / mjpeg / prores / vp8 / vp9 / aac / mp2 / mp3 / pcm_*)。
---
## Configure flags完整複製
```
./configure \
--prefix="<build_dir>/install" \
--enable-version3 \
--disable-debug \
--disable-doc \
--disable-ffplay \
--disable-network \
--disable-autodetect \
--disable-shared \
--enable-static \
--disable-everything \
--enable-small \
--enable-protocol=file,pipe \
--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2 \
--enable-decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,mjpeg,prores,vp8,vp9,aac,mp2,mp3,pcm_s16le,pcm_s16be \
--enable-parser=h264,hevc,mpeg4video,mpegaudio,aac \
--enable-filter=scale,format,fps,null,anull \
--enable-muxer=image2pipe,image2,null \
--enable-encoder=mjpeg \
--enable-swscale \
--enable-swresample \
--extra-cflags="-arch x86_64 -mmacosx-version-min=10.15" \
--extra-ldflags="-arch x86_64 -mmacosx-version-min=10.15 -Wl,-search_paths_first" \
--arch=x86_64 \
--target-os=darwin \
--cc="clang -arch x86_64"
```
### 為什麼是這些 flag
| flag | 理由 |
|------|-----|
| `--enable-version3` | 使用 LGPL v3非 v2.1),與 BtbN Windows/Linux build 對齊 |
| `--disable-debug` / `--disable-doc` | 縮 binary 體積 |
| `--disable-network` | 我們只處理本地檔案,不需要 http/rtsp/rtmp 協議 |
| `--disable-autodetect` | 不自動偵測系統上的外部 lib`libopus` / `libvpx`LGPL 合規稽核時更乾淨 |
| `--disable-shared --enable-static` | 產出 self-contained binary不依賴 macOS 上任何外部 dylib |
| `--disable-everything` | 先關全部,白名單 enable確保不額外 link 任何 GPL 元件 |
| `--enable-small` | 最佳化體積而非速度 |
| `--enable-protocol=file,pipe` | 只開 file:// 和 pipeffmpeg 內部 stdin/stdout |
| `--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2` | 對齊 PRD v2 支援的上傳格式 `.mp4 / .avi / .mov / .mpeg / .mpg` |
| `--enable-decoder=h264,hevc,...` | 涵蓋常見 codecH.264 / H.265 / MPEG1/2/4 / mjpeg / prores / vp8/9 / AAC / MP2/3 / PCM |
| `--enable-parser=...` | 必要,否則某些 decoder 會在碼流切分階段 fail |
| `--enable-muxer=image2pipe,image2,null` | 輸出單張 JPEG 或 NULL測試用 |
| `--enable-encoder=mjpeg` | `-f image2pipe -vcodec mjpeg` 需要 mjpeg encoderLGPL-safe |
| `--enable-swscale` / `--enable-swresample` | pixel format / sample rate 轉換 |
| `-mmacosx-version-min=10.15` | 相容 macOS 10.15 Catalina 以上 |
---
## How to rebuild
**僅在升級 ffmpeg 版本時才需要執行。平常 clone repo 後直接使用 git 內的 binary。**
### 前置系統依賴
```bash
brew install pkg-config nasm # 或 yasm擇一
```
### 執行 build
```bash
cd /path/to/local-tool
make vendor-ffmpeg-macos-build
```
target 會:
1. 從 GitHub 下載 ffmpeg source tarball版本由 `Makefile``FFMPEG_VERSION` 變數控制)
2. 驗證 sha256不符則 fail
3. 解壓到 `build/ffmpeg-macos/src/`
4. `./configure`(只啟用 decoder/demuxer/filter 白名單,不 link 任何 GPL 元件)
5. `make -j$(sysctl -n hw.ncpu)`
6. `make install``build/ffmpeg-macos/install/`
7. 複製 `ffmpeg` + `ffprobe``vendor/ffmpeg/macos/`
8. `strip -S -x`(去除 debug symbol 與 local symbol
9. ad-hoc `codesign`(無 Apple Developer ID 也能在 Gatekeeper 下跑)
10. 驗證 `ffmpeg -version` 不含 `--enable-gpl` / `libx264` / `libx265`
11. 複製 `COPYING.LGPLv3` 到同目錄
Build 完成後請手動更新本檔的「Build date / Binary sha256 / Binary 大小 / Build 實測耗時」
區塊,然後:
```bash
git add vendor/ffmpeg/macos/ffmpeg \
vendor/ffmpeg/macos/ffprobe \
vendor/ffmpeg/macos/COPYING.LGPLv3 \
vendor/ffmpeg/macos/BUILD.md
git commit -m "chore(vendor): rebuild macOS ffmpeg LGPL binary (n<version>)"
```
**注意**:不要 commit `build/` 目錄下的中間產物(已在 `.gitignore`)。
---
## Verification
Build 完成後的自動驗證:
```bash
# 1. 確認 LGPL 合規(不含 GPL 元件)
vendor/ffmpeg/macos/ffmpeg -version 2>&1 | grep -E -- '--enable-gpl|libx264|libx265'
# 預期:無輸出
# 2. 確認可執行
vendor/ffmpeg/macos/ffmpeg -version | head -3
vendor/ffmpeg/macos/ffprobe -version | head -3
# 3. 確認 decoder 完整
vendor/ffmpeg/macos/ffmpeg -hide_banner -decoders 2>/dev/null | grep -E ' (h264|hevc|aac|mpeg2video|mpeg4|mjpeg|prores|vp8|vp9|mp3) '
# 4. 確認 demuxer 完整
vendor/ffmpeg/macos/ffmpeg -hide_banner -formats 2>/dev/null | grep -E ' (mov|avi|mpeg|matroska)'
# 5. 確認 Gatekeeper 可過ad-hoc signed
codesign -v vendor/ffmpeg/macos/ffmpeg
codesign -v vendor/ffmpeg/macos/ffprobe
# 預期無輸出exit 0
# 6. 實際解一支 mp4 影片
vendor/ffmpeg/macos/ffmpeg -hide_banner -i <some-sample>.mp4 -f image2pipe -vcodec mjpeg -frames:v 1 -q:v 5 /tmp/test.jpg
file /tmp/test.jpg
# 預期JPEG image data
```
---
## 實測驗證輸出(本次 build
### 1. LGPL 合規ffmpeg -version 擷取)
```
ffmpeg version a6b71ea Copyright (c) 2000-2024 the FFmpeg developers
built with Apple clang version 16.0.0 (clang-1600.0.26.6)
configuration: --prefix=.../install --enable-version3 --disable-debug --disable-doc
--disable-ffplay --disable-network --disable-autodetect --disable-shared --enable-static
--disable-everything --enable-small --enable-protocol=file,pipe
--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2
--enable-decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,mjpeg,prores,vp8,vp9,aac,mp2,mp3,pcm_s16le,pcm_s16be
--enable-parser=h264,hevc,mpeg4video,mpegaudio,aac
--enable-filter=scale,format,fps,null,anull
--enable-muxer=image2pipe,image2,null --enable-encoder=mjpeg
--enable-swscale --enable-swresample ...
libavutil 59. 39.100 / 59. 39.100
libavcodec 61. 19.100 / 61. 19.100
libavformat 61. 7.100 / 61. 7.100
```
- ✅ 無 `--enable-gpl`
- ✅ 無 `libx264`
- ✅ 無 `libx265`
- ✅ 有 `--enable-version3`LGPL v3
### 2. Decoder 驗證
```
$ vendor/ffmpeg/macos/ffmpeg -hide_banner -decoders 2>&1 \
| grep -E ' h264 | hevc | aac | mpeg2video | mpeg4 '
VFS..D h264
VFS..D hevc
V.S.BD mpeg2video
VF..BD mpeg4
A....D aac
```
五個必要 decoder 全數通過。
### 3. Demuxer / Format 驗證
```
$ vendor/ffmpeg/macos/ffmpeg -hide_banner -formats 2>&1 \
| grep -iE 'mov|mp4|avi|mpeg|matroska'
D avi
D matroska,webm
D mov,mp4,m4a,3gp,3g2,mj2
D mpeg
D mpegts
```
- `mov,mp4,m4a,3gp,3g2,mj2` — 涵蓋 mp4 / mov
- `avi` — ok
- `mpeg` — 對應 mpegpsMPEG Program Stream
- `mpegts` — MPEG Transport Stream
- `matroska,webm` — ok
### 4. Dynamic dependencies (`otool -L`)
```
vendor/ffmpeg/macos/ffmpeg:
/usr/lib/libSystem.B.dylib
/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
/System/Library/Frameworks/CoreVideo.framework/Versions/A/CoreVideo
/System/Library/Frameworks/CoreMedia.framework/Versions/A/CoreMedia
vendor/ffmpeg/macos/ffprobe:
(同上四個 macOS system framework
```
- ✅ 只依賴 macOS 系統內建 framework`libSystem`, `CoreFoundation`, `CoreVideo`, `CoreMedia`
- ✅ **無任何第三方 dylib**`libx264`, `libx265`, `libvpx`, `libopus`... 都不存在)
- ✅ 等同於 self-contained binary搬到任一台 macOS 10.15+ x86_64 都能跑
### 5. Code signing
```
$ codesign -v vendor/ffmpeg/macos/ffmpeg # exit 0, no output
$ codesign -v vendor/ffmpeg/macos/ffprobe # exit 0, no output
```
ad-hoc simbol signing okGatekeeper 可過。
---
## Commit 清單(只允許這四個檔進 git
為了防呆,`.gitignore` 設定成「`vendor/ffmpeg/macos/**` 全部 un-ignore」
因此任何意外丟進此目錄的檔案都會被 git 看見。code review 時請嚴格檢查
這個目錄下**只有**以下四個檔:
- `ffmpeg`binary
- `ffprobe`binary
- `COPYING.LGPLv3`(授權條款)
- `BUILD.md`(本檔)

View File

@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.

BIN
local-tool/vendor/ffmpeg/macos/ffmpeg vendored Executable file

Binary file not shown.

BIN
local-tool/vendor/ffmpeg/macos/ffprobe vendored Executable file

Binary file not shown.

View File

@ -68,11 +68,13 @@ type ServerStatus struct {
}
// ServerProcess 包裝子行程控制。
// v2 新增 app 反向指標,讓 ServerProcess.stopGraceful() 能 emit Wails event。
type ServerProcess struct {
cmd *exec.Cmd
port int
stdoutLog *os.File
stderrLog *os.File
app *App
}
// App 是 Wails 綁定的主結構。
@ -80,16 +82,16 @@ type App struct {
ctx context.Context
dataDir string
pythonMode PythonMode
mockMode bool
mu sync.Mutex
server *ServerProcess
server *ServerProcess // v1 相容保留v2 已改用 ctrl.proc
pythonBin string
pythonModeR PythonMode // 實際使用的 modeauto resolved 之後)
lastError string
releaseLock func()
// 啟動進度訊息 — 供 splash page 透過 GetBootstrapStatus() binding 輪詢顯示
// M8-4啟動進度訊息v1 splash 殘留v2 已由 startup-pipeline event 取代)。
// 目前仍保留供開發 log 使用,最終在 M8-4b 整個流程改寫後會被拿掉。
bootstrapStatus string
// L-1server 健康偵測 goroutine 控制
@ -98,6 +100,23 @@ type App struct {
// L-3Wails 自己的 IPC server收 /ipc/raise
ipcPort int
ipcListener net.Listener
// M8-4v2 新增欄位
ctrl *ServerController // state machine 控制器
logBuf *LogBuffer // ring buffer2000 行)
prefs Preferences // 控制台偏好in-memory持久化至 preferences.json
// M8-4b6 階段啟動進度 pipeline
// startupPipeline 由 startup() 建立shutdown() 與 RestartStartupSequence() 重置。
// pipelineCancelFn 是 watcher goroutine 的 cancel func存在 App 上方便 RestartStartupSequence 操作。
startupPipeline *StartupPipeline
pipelineCancelFn context.CancelFunc
// M8-4b 補丁M-3 修復RestartStartupSequence 的 Step 6呼叫 ctrl.Start
// 在單元測試裡會真的 spawn python server無法測。提供 test hook 讓測試能替換
// 成 no-op 或 spy。生產環境 restartStartFn == nil → 走預設的 a.ctrl.Start()。
// test-only正式環境不應設定。
restartStartFn func() error
}
// NewApp 建立 App 實例。
@ -115,12 +134,9 @@ func NewApp() *App {
mode = PythonModeAuto
}
}
// M7預設真實硬體模式使用者決策 Q8
// 若要強制 mock 模式(無 Kneron 裝置環境下 debug設環境變數 VISIONA_MOCK=1
mock := os.Getenv("VISIONA_MOCK") == "1"
// R5-5a沒插硬體就顯示空白狀態由 UI 處理),一律真實硬體路徑
return &App{
pythonMode: mode,
mockMode: mock,
}
}
@ -129,10 +145,25 @@ func NewApp() *App {
// -----------------------------------------------------------------------
// startup 由 Wails 在 app 啟動時呼叫。
//
// M8-4b整合 6 階段啟動 pipeline。流程
// stage 1init Wails console→ seedUserDataDir 完成後 CompleteStage(1)
// stage 2Python runtime
// stage 3spawn server ├─ 由 startServerV2 內部 hook
// stage 4device probe
// stage 5open browser → ctrl.Start() return 後處理
// stage 6wait WebSocket → watcher goroutine poll sentinel file
//
// pipeline 失敗時不再呼叫 reportFatal會直接結束程式而是進 Error state
// 讓使用者看到 Wails 控制台的 Retry 按鈕。
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
ensureGUIPath()
// M8-4初始化 ring buffer 與 state machine controller
a.logBuf = NewLogBuffer()
a.ctrl = NewServerController(a)
dataDir := platformDataDir()
a.dataDir = dataDir
@ -141,6 +172,18 @@ func (a *App) startup(ctx context.Context) {
return
}
// M8-4載入 preferences.json讀取失敗 → 用 DefaultPreferences 預設)
a.prefs = LoadPreferences(dataDir)
// M8-4b建立 startup pipeline 並啟動emit stage=1 running
// 必須在 prefs 載入後,因為 watcher 會讀 prefs.AutoOpenBrowser 判斷階段 5/6 是否 skip。
// 同時清掉殘留的 sentinel file前次 crash 留下的舊檔會讓階段 6 瞬間完成造成假象)
removeSentinelFile(dataDir)
a.startupPipeline = NewStartupPipeline(a)
pipelineCtx, cancel := context.WithCancel(ctx)
a.pipelineCancelFn = cancel
a.startupPipeline.Start(pipelineCtx)
// 1. 舊資料目錄遷移(必須在 lock 之前,因為 lock 檔會寫到新路徑)
migrateOldDataDirs(dataDir)
@ -183,15 +226,63 @@ func (a *App) startup(ctx context.Context) {
fmt.Fprintln(os.Stderr, "[visiona-local] seed user data dir failed:", err)
}
// 4. 啟動 server 子行程
if err := a.startServer(); err != nil {
a.reportFatal("server start failed", err)
// M8-4b階段 1初始化 Wails 控制台)完成 → 自動進入階段 2 running
a.startupPipeline.CompleteStage(1)
// 4. M8-4走 ServerController 啟動v2 路徑)。
// 冷啟動允許 port fallbackStartWithPort(0) 即為冷啟動)。
// startServerV2 內部會 hook 階段 2/3/4 的 pipeline.CompleteStage()。
if err := a.ctrl.Start(); err != nil {
// pipeline.FailStage 已經由 startServerV2 → ctrl.startInternal 觸發失敗時 emit error +
// 切到 Error state這裡不需要呼叫 reportFatal讓使用者看到 Retry 按鈕)
fmt.Fprintln(os.Stderr, "[visiona-local] startup pipeline: server start failed:", err)
return
}
// 階段 5開瀏覽器或 skip
a.runStartupStage5()
// 階段 6 由 watcher goroutine poll sentinel file 觸發 → CompleteStage(6) → markReady
}
// runStartupStage5 處理 R5-E 階段 5開瀏覽器。
// AutoOpenBrowser=false → SkipStage 進入階段 6也會被 skip-timeout 規則處理)
// AutoOpenBrowser=true → 呼叫 openBrowser 並 CompleteStage(5)
func (a *App) runStartupStage5() {
if a.startupPipeline == nil {
return
}
if !a.prefs.AutoOpenBrowser {
a.startupPipeline.SkipStage(5)
return
}
// 取得 server URL
url := ""
if a.ctrl != nil {
a.ctrl.mu.Lock()
if a.ctrl.proc != nil && a.ctrl.proc.port > 0 {
url = fmt.Sprintf("http://127.0.0.1:%d", a.ctrl.proc.port)
}
a.ctrl.mu.Unlock()
}
if url != "" {
// 不等瀏覽器真的開(只等命令 return失敗記 log 不擋流程
if err := openBrowser(url); err != nil {
fmt.Fprintf(os.Stderr, "[visiona-local] startup stage 5: open browser failed: %v\n", err)
}
}
a.startupPipeline.CompleteStage(5)
}
// shutdown 由 Wails 在 app 結束時呼叫。
//
// M8-4改為呼叫 ctrl.Stop() 走 7 秒 grace + 1 秒 modal 流程。
// M8-4b停 startup pipeline watcher + 清 sentinel file。
// 細節對應 TDD v2/server-lifecycle.md §8 + v2/startup-pipeline.md §3。
func (a *App) shutdown(ctx context.Context) {
// M8-4b停 startup pipeline watcher避免 Stop 期間 watcher 還在 poll sentinel
if a.pipelineCancelFn != nil {
a.pipelineCancelFn()
a.pipelineCancelFn = nil
}
// 停 watch goroutine
if a.watchCancel != nil {
a.watchCancel()
@ -202,7 +293,28 @@ func (a *App) shutdown(ctx context.Context) {
}
removeWailsIPCPort(a.dataDir)
// MAJ-4 補丁:先通知瀏覽器 tab「server 要關了」→ 立即顯示 Offline Overlay
// 這一步必須在 ctrl.Stop() 之前,因為 Stop 會馬上 SIGTERM server之後瀏覽器
// 只會看到 ECONNREFUSED要等 15 s polling 失敗才顯示 overlay。
//
// best-effort失敗server 沒起來、endpoint 掛了、1 s timeout全部忽略
// 不阻塞 shutdown 流程。
if a.ctrl != nil {
port := a.snapshotStatus().Port
notifyShutdownImminent(ctx, port, "quit")
}
// M8-4由 ServerController 執行 Stop含 7 秒 grace + 1 秒 modal
if a.ctrl != nil {
_ = a.ctrl.Stop()
} else {
// v1 fallback理論上 M8-4 後不會走到)
a.stopServer()
}
// M8-4b清 sentinel file正常關機的清理下次啟動才不會誤判階段 6 已完成)
removeSentinelFile(a.dataDir)
if a.releaseLock != nil {
a.releaseLock()
}
@ -426,8 +538,7 @@ func (a *App) startServer() error {
// 1. 決定 python runtime
a.setBootstrapStatus("正在初始化 Python 環境...")
pyBin, pyMode, err := a.ensurePythonRuntime(a.pythonMode)
if err != nil && !a.mockMode {
// Mock 模式下沒有 python 仍可啟動server 不 spawn sidecar
if err != nil {
return fmt.Errorf("python runtime unavailable: %w", err)
}
a.mu.Lock()
@ -438,7 +549,7 @@ func (a *App) startServer() error {
// 1.5. 首次啟動自動安裝 Kneron WinUSB driverWindows onlymacOS/Linux no-op
// 失敗不擋 server 啟動 —— 使用者之後可手動點「安裝 USB Driver」按鈕重試。
// 用 .driver-installed 記號檔避免每次都跑。
if !a.mockMode && pyBin != "" {
if pyBin != "" {
if err := a.ensureDriverInstalled(pyBin); err != nil {
fmt.Fprintln(os.Stderr, "[visiona-local] driver auto-install failed (非致命,可於 UI 手動重試):", err)
}
@ -459,24 +570,15 @@ func (a *App) startServer() error {
}
// 4. 組參數
//
// Mock 模式下 server 根本不需要 python sidecar因此
// - 不傳 --python-mode讓 server 用預設 auto
// - 不傳 --python
// 這樣可避免在沒有對應 flag 的舊版 server 上誤殺,也避免誤導。
args := []string{
"--host", "127.0.0.1",
"--port", strconv.Itoa(port),
"--data-dir", a.dataDir,
"--python-mode", string(pyMode),
}
if a.mockMode {
args = append(args, "--mock")
} else {
args = append(args, "--python-mode", string(pyMode))
if pyBin != "" {
args = append(args, "--python", pyBin)
}
}
// 5. 開 log 檔
logsDir := filepath.Join(a.dataDir, "logs")
@ -491,7 +593,7 @@ func (a *App) startServer() error {
cmd.Dir = filepath.Dir(binPath)
configureSysProcAttr(cmd) // Windows: CREATE_NO_WINDOW 藏掉 server 小黑窗
// 注入 bundle bin dir 給 server 偵測 ffmpeg / yt-dlpM6
// 注入 bundle bin dir 給 server 偵測 ffmpeg / ffprobeM6
env := os.Environ()
if binDir, err := locateBundleBinDir(); err == nil {
env = append(env, "VISIONA_BUNDLE_BIN_DIR="+binDir)
@ -499,7 +601,7 @@ func (a *App) startServer() error {
}
// 注入 python interpreter 路徑給 serverkneron detector 會讀 VISIONA_PYTHON
// 避免 detector 自己去 resolve 又走不同的路徑邏輯造成不一致。
if !a.mockMode && pyBin != "" {
if pyBin != "" {
env = append(env, "VISIONA_PYTHON="+pyBin)
fmt.Fprintln(os.Stderr, "[visiona-local] python interpreter:", pyBin)
}
@ -683,7 +785,7 @@ func (p *ServerProcess) stop() {
// - PythonModeAuto先試 system失敗才走 bundled
// - PythonModeBundledplaceholder回錯誤M2 才實作)
//
// M1 預設 mock 模式,所以 python 失敗不會擋啟動(由 caller 決定)。
// R5-5a 之後python 失敗直接擋啟動(沒有模擬回退)。
func (a *App) ensurePythonRuntime(mode PythonMode) (string, PythonMode, error) {
switch mode {
case PythonModeAuto:
@ -859,7 +961,7 @@ func locateBundledPythonAssets() (tarball, wheelsDir string, err error) {
return "", "", fmt.Errorf("bundled python assets not found (tried .app Resources + same-dir + payload/%s)", runtime.GOOS)
}
// locateBundleBinDir 找 bundle 內的 bin 目錄(含 ffmpeg / yt-dlp / visiona-local-server
// locateBundleBinDir 找 bundle 內的 bin 目錄(含 ffmpeg / ffprobe / visiona-local-server
//
// 順序:
// 1. macOS .app bundleContents/Resources/bin
@ -882,7 +984,7 @@ func locateBundleBinDir() (string, error) {
return abs, nil
}
// 或與執行檔同目錄server binary 本身就在這裡時)
if fileExists(filepath.Join(exeDir, "ffmpeg")) || fileExists(filepath.Join(exeDir, "yt-dlp")) {
if fileExists(filepath.Join(exeDir, "ffmpeg")) || fileExists(filepath.Join(exeDir, "ffprobe")) {
return exeDir, nil
}
}
@ -1572,7 +1674,14 @@ func ensureGUIPath() {
}
// openBrowser 用系統預設瀏覽器開啟 URL。
func openBrowser(url string) error {
//
// 設計為 package-level var 是為了方便單元測試攔截呼叫次數
// M8-4b 補丁 M-2 的 regression test 需要驗證冷啟動時 openBrowser 只被呼叫一次)。
// 正式執行環境由 init預設值 openBrowserExec提供實作。
var openBrowser = openBrowserExec
// openBrowserExec 是 openBrowser 的實際系統呼叫實作。
func openBrowserExec(url string) error {
switch runtime.GOOS {
case "darwin":
return exec.Command("open", url).Start()

View File

@ -1,74 +1,353 @@
// visionA Local — splash / bootstrap
// 職責:顯示 app 啟動進度 → server 就緒後跳轉到 Next.js 主 UI
// visionA-local 控制台 main entry
// - 負責 bindings / events 註冊、初始化 UI
// - M8-5 Wails 控制台 UI對齊 Design Spec v2.1
import { GetServerStatus, GetServerURL, GetBootstrapStatus } from './wailsjs/go/main/App.js';
import {
StartServer,
StopServer,
RestartServer,
ForceKillServer,
GetServerStatusV2,
GetRecentLogs,
ClearLogs,
GetSystemInfo,
OpenInBrowser,
RevealLogsFolder,
ExportLog,
GetPreferences,
SetPreferences,
RestartStartupSequence,
} from './wailsjs/go/main/App.js';
const statusEl = document.getElementById('status');
const errorEl = document.getElementById('error');
import { EventsOn } from './wailsjs/runtime/runtime.js';
import { t, applyI18n, setLocale, getLocale, detectLocale, LOCALES } from './i18n.js';
import {
setServerState,
updateServerMeta,
updatePrimaryControls,
showErrorBanner,
hideErrorBanner,
showToast,
initHeaderClock,
setPrimaryCTAPulse,
STATE_ERROR,
} from './control-panel.js';
import {
showStartupPanel,
hideStartupPanel,
updateStage,
markStageTimeout,
showStartupError,
renderStages,
onManualModeChange,
} from './startup-panel.js';
import {
initLogPanel,
appendLogs,
clearLog,
flashLastError,
applyLogFilter,
} from './log-panel.js';
import { initSettingsPanel, openSettings } from './settings-panel.js';
const POLL_INTERVAL_MS = 400;
// 首次啟動最長容忍時間venv 解壓(10s) + 建 venv(5s) + pip install wheels(30-60s) +
// libwdi driver install with UAC(15-30s) + server spawn(3s) + health check(2s) ≈ 60-110s
// 給到 240 秒以涵蓋慢速硬碟 / UAC 被使用者拖延的情況
const MAX_WAIT_MS = 240_000;
const startTime = Date.now();
let lastStatus = '';
async function poll() {
const elapsed = Date.now() - startTime;
if (elapsed > MAX_WAIT_MS) {
showError(
`啟動逾時(${Math.round(MAX_WAIT_MS / 1000)} 秒)。\n` +
'請關閉此視窗並重新啟動應用程式,或查看 log\n' +
'%APPDATA%\\visiona-local\\logs\\wails.log'
);
return;
}
// ---------- 全域狀態 ----------
const state = {
server: null, // ServerStatusV2
prefs: null, // Preferences
sysInfo: null, // SystemInfo
starting: false, // 啟動進度面板是否顯示
};
// ---------- 初始化 ----------
async function init() {
// 1. 讀 preferences → 決定 locale
try {
// 先更新 bootstrap 進度文字venv / pip / driver / server...
const bootstrapMsg = await GetBootstrapStatus();
if (bootstrapMsg && bootstrapMsg !== lastStatus) {
statusEl.textContent = bootstrapMsg;
lastStatus = bootstrapMsg;
}
// 檢查 server 是否已就緒
const status = await GetServerStatus();
if (status && status.lastError) {
showError('伺服器啟動失敗:' + status.lastError);
return;
}
if (status && status.running && status.url) {
statusEl.textContent = '載入主介面...';
window.location.replace(status.url + '/');
return;
}
// 備用:直接問 URL
const url = await GetServerURL();
if (url) {
statusEl.textContent = '載入主介面...';
window.location.replace(url + '/');
return;
}
state.prefs = await GetPreferences();
} catch (e) {
// binding 尚未就緒時會 throw繼續輪詢
console.warn('GetPreferences failed:', e);
state.prefs = { autoOpenBrowser: true, locale: '' };
}
const locale = detectLocale(state.prefs && state.prefs.locale);
setLocale(locale);
// 2. 讀系統資訊
try {
state.sysInfo = await GetSystemInfo();
} catch (e) {
console.warn('GetSystemInfo failed:', e);
}
setTimeout(poll, POLL_INTERVAL_MS);
// 3. 套 i18n 到 DOM
applyI18n();
document.title = t('control.title');
if (state.sysInfo && state.sysInfo.appVersion) {
document.getElementById('app-version').textContent = state.sysInfo.appVersion;
}
// 4. render 6 階段 UI skeleton
renderStages();
// 5. 初始化 log panel拉既有 buffer
try {
const existingLogs = await GetRecentLogs(2000);
initLogPanel(existingLogs || []);
} catch (e) {
console.warn('GetRecentLogs failed:', e);
initLogPanel([]);
}
// 6. 綁按鈕 handlers
bindHandlers();
// 7. 訂閱 Wails events
subscribeEvents();
// 8. 初始 state query
try {
const status = await GetServerStatusV2();
handleServerStatus(status);
} catch (e) {
console.warn('GetServerStatusV2 failed:', e);
}
// 9. 初始化 settings panel
initSettingsPanel({
prefs: state.prefs,
sysInfo: state.sysInfo,
getPreferences: GetPreferences,
setPreferences: SetPreferences,
onLocaleChange: (newLoc) => {
setLocale(newLoc || detectLocale(''));
applyI18n();
document.title = t('control.title');
renderStages();
// re-render current state text
if (state.server) setServerState(state.server);
},
});
// 10. header uptime 時鐘
initHeaderClock(() => state.server);
// 11. Manual mode 監聽stage 5 skipped 時引導使用者點 Open in Browser
// 對齊 Design Spec §4.1
onManualModeChange((enabled) => {
setPrimaryCTAPulse(enabled);
});
}
function showError(msg) {
statusEl.hidden = true;
errorEl.textContent = msg;
errorEl.hidden = false;
// ---------- 處理 server status ----------
function handleServerStatus(status) {
if (!status) return;
state.server = status;
setServerState(status);
updateServerMeta(status);
updatePrimaryControls(status);
// Error state runtime banner非 startup error
if (status.state === STATE_ERROR && status.lastError && !state.starting) {
showErrorBanner(status.lastError);
} else if (status.state !== STATE_ERROR) {
hideErrorBanner();
}
}
// 等 Wails runtime 就緒再開始輪詢
if (window.runtime) {
poll();
// ---------- 按鈕 handlers ----------
function bindHandlers() {
const $ = (id) => document.getElementById(id);
// Open in Browser
$('btn-open-browser').addEventListener('click', async () => {
try {
await OpenInBrowser('');
} catch (e) {
showToast('Failed to open browser: ' + e);
}
});
// Start
$('btn-start').addEventListener('click', async () => {
try {
await StartServer();
} catch (e) {
showToast('Start failed: ' + e);
}
});
// Manage menu
const manageBtn = $('btn-manage');
const manageMenu = $('manage-menu');
manageBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
const hidden = manageMenu.hasAttribute('hidden');
if (hidden) {
manageMenu.removeAttribute('hidden');
manageBtn.setAttribute('aria-expanded', 'true');
} else {
manageMenu.setAttribute('hidden', '');
manageBtn.setAttribute('aria-expanded', 'false');
}
});
document.addEventListener('click', () => {
if (!manageMenu.hasAttribute('hidden')) {
manageMenu.setAttribute('hidden', '');
manageBtn.setAttribute('aria-expanded', 'false');
}
});
$('mi-stop').addEventListener('click', async () => {
try {
await StopServer();
} catch (e) {
showToast('Stop failed: ' + e);
}
});
$('mi-restart').addEventListener('click', async () => {
try {
await RestartServer();
} catch (e) {
showToast('Restart failed: ' + e);
}
});
$('mi-open-folder').addEventListener('click', async () => {
try {
await RevealLogsFolder();
} catch (e) {
showToast('Open folder failed: ' + e);
}
});
// Log actions
$('btn-clear-log').addEventListener('click', async () => {
try {
await ClearLogs();
clearLog();
showToast(t('control.log.clearToast'));
} catch (e) {
showToast('Clear failed: ' + e);
}
});
$('btn-copy-log').addEventListener('click', async () => {
const output = document.getElementById('log-output');
try {
await navigator.clipboard.writeText(output.innerText || '');
showToast(t('control.log.copied'));
} catch (e) {
showToast('Copy failed: ' + e);
}
});
$('btn-export-log').addEventListener('click', async () => {
try {
const path = await ExportLog();
showToast(t('control.log.exported', { path }));
} catch (e) {
showToast('Export failed: ' + e);
}
});
$('btn-open-folder').addEventListener('click', async () => {
try {
await RevealLogsFolder();
} catch (e) {
showToast('Open folder failed: ' + e);
}
});
// Filter
$('filter-input').addEventListener('input', (e) => applyLogFilter({ text: e.target.value }));
$('level-filter').addEventListener('change', (e) => applyLogFilter({ level: e.target.value }));
// Settings
$('btn-settings').addEventListener('click', openSettings);
// Startup error actions
$('btn-retry').addEventListener('click', async () => {
try {
await RestartStartupSequence();
} catch (e) {
showToast('Retry failed: ' + e);
}
});
$('btn-view-log').addEventListener('click', () => {
hideStartupPanel();
flashLastError();
});
// Report 按鈕 disabledcoming soon
// Error banner
$('banner-restart').addEventListener('click', async () => {
try {
await RestartServer();
} catch (e) {
showToast('Restart failed: ' + e);
}
});
$('banner-view').addEventListener('click', () => {
flashLastError();
});
// 鍵盤快捷鍵⌘F / Ctrl+F focus filter
document.addEventListener('keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
e.preventDefault();
$('filter-input').focus();
}
});
}
// ---------- 訂閱 Wails events ----------
function subscribeEvents() {
// server state / error / recovered
EventsOn('server:state-change', (payload) => handleServerStatus(payload));
EventsOn('server:error', (payload) => {
// Go 端 server_control.go L184/L361 emit payload key 為 "reason"(不是 "error"
if (payload && payload.reason) showErrorBanner(payload.reason);
});
EventsOn('server:recovered', () => {
hideErrorBanner();
showToast('Server recovered');
});
// log stream
EventsOn('log:append', (entries) => {
if (Array.isArray(entries)) appendLogs(entries);
else if (entries) appendLogs([entries]);
});
EventsOn('log:clear', () => clearLog());
// startup pipeline
EventsOn('startup:progress', (ev) => {
if (!state.starting) {
state.starting = true;
showStartupPanel();
}
updateStage(ev);
});
EventsOn('startup:stage-timeout', (ev) => {
markStageTimeout(ev);
});
EventsOn('startup:error', (ev) => {
showStartupError(ev);
});
EventsOn('startup:ready', () => {
state.starting = false;
hideStartupPanel();
});
// shutdown modalM8-4 1 秒後顯示)
EventsOn('shutdown:modal-show', () => {
const m = document.getElementById('shutdown-modal');
if (m) m.removeAttribute('hidden');
});
// app level fatal保留相容
EventsOn('app:error', (msg) => {
showErrorBanner(typeof msg === 'string' ? msg : JSON.stringify(msg));
});
}
// ---------- 啟動 ----------
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
window.addEventListener('load', () => setTimeout(poll, 200));
init();
}

View File

@ -0,0 +1,126 @@
// control-panel.js — Header / Status / Primary controls / Error banner / Toast
import { t } from './i18n.js';
// ---------- Server state 常數 ----------
// 必須與 Go 端 server_control.go §ServerState 常數對齊(全部 lowercase
// 千萬不要改成 PascalCase會讓 state 比對全面失敗。
export const STATE_IDLE = 'idle';
export const STATE_STARTING = 'starting';
export const STATE_RUNNING = 'running';
export const STATE_STOPPING = 'stopping';
export const STATE_STOPPED = 'stopped';
export const STATE_ERROR = 'error';
// ---------- Server state ----------
export function setServerState(status) {
const root = document.getElementById('app');
const dot = document.getElementById('status-dot');
const textEl = document.getElementById('status-text');
if (!status) return;
const s = status.state || STATE_IDLE;
root.setAttribute('data-state', s);
dot.className = 'status-dot state-' + s;
let text;
switch (s) {
case STATE_IDLE: text = t('control.status.idle'); break;
case STATE_STARTING: text = t('control.status.starting'); break;
case STATE_RUNNING:
text = t('control.status.running');
if (status.port) text += ' · :' + status.port;
break;
case STATE_STOPPING: text = t('control.status.stopping'); break;
case STATE_STOPPED: text = t('control.status.stopped'); break;
case STATE_ERROR: text = t('control.status.error', { reason: (status.lastError || '').slice(0, 80) }); break;
default: text = s;
}
textEl.textContent = text;
}
// ---------- Server meta ----------
let headerClockTimer = null;
export function updateServerMeta(status) {
const portEl = document.getElementById('meta-port');
const pidEl = document.getElementById('meta-pid');
portEl.textContent = status && status.port ? String(status.port) : '—';
pidEl.textContent = status && status.pid ? String(status.pid) : '—';
// uptime 由 initHeaderClock 定時刷新
}
export function initHeaderClock(getServer) {
if (headerClockTimer) clearInterval(headerClockTimer);
const uptimeEl = document.getElementById('meta-uptime');
headerClockTimer = setInterval(() => {
const s = getServer();
if (!s || !s.startedAt || s.state !== STATE_RUNNING) {
uptimeEl.textContent = '—';
return;
}
const ms = Date.now() - s.startedAt;
uptimeEl.textContent = formatUptime(ms);
}, 1000);
}
function formatUptime(ms) {
if (ms < 0) ms = 0;
const s = Math.floor(ms / 1000);
const hh = String(Math.floor(s / 3600)).padStart(2, '0');
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
const ss = String(s % 60).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
// ---------- Primary controls enable/disable ----------
export function updatePrimaryControls(status) {
const s = status && status.state;
const openBtn = document.getElementById('btn-open-browser');
const startBtn = document.getElementById('btn-start');
const manageBtn = document.getElementById('btn-manage');
const miStop = document.getElementById('mi-stop');
const miRestart = document.getElementById('mi-restart');
openBtn.disabled = s !== STATE_RUNNING;
startBtn.disabled = !(s === STATE_STOPPED || s === STATE_IDLE || s === STATE_ERROR);
manageBtn.disabled = !(s === STATE_RUNNING || s === STATE_ERROR);
miStop.disabled = s !== STATE_RUNNING;
miRestart.disabled = !(s === STATE_RUNNING || s === STATE_ERROR);
}
// ---------- Error banner ----------
export function showErrorBanner(errorMsg) {
const banner = document.getElementById('error-banner');
const desc = document.getElementById('banner-desc');
desc.textContent = errorMsg || '';
banner.removeAttribute('hidden');
}
export function hideErrorBanner() {
const banner = document.getElementById('error-banner');
banner.setAttribute('hidden', '');
}
// ---------- Primary CTA pulse引導使用者點擊 Open in Browser ----------
// 對齊 Design Spec v2.1 startup-progress.md §4.1stage 6 manual hint mode
// 於 stage 5 skipped 時由 app.js 主動開啟,成功建立 WS 連線或 panel 關閉時取消
export function setPrimaryCTAPulse(enabled) {
const openBtn = document.getElementById('btn-open-browser');
if (!openBtn) return;
if (enabled) {
openBtn.classList.add('pulse-cta');
} else {
openBtn.classList.remove('pulse-cta');
}
}
// ---------- Toast ----------
let toastTimer = null;
export function showToast(message, duration = 3000) {
const el = document.getElementById('toast');
if (!el) return;
el.textContent = message;
el.removeAttribute('hidden');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
el.setAttribute('hidden', '');
}, duration);
}

View File

@ -0,0 +1,222 @@
// visionA-local 控制台 i18n
// namespace: desktop-control
// 對齊 Design Spec v2.1 control-panel.md §9 + startup-progress.md §7
const dict = {
'zh-TW': {
'control.title': 'visionA-local · 伺服器控制台',
'control.status.idle': '閒置',
'control.status.starting': '啟動中...',
'control.status.running': '執行中',
'control.status.runningBrowserOpened': '執行中 · 已開啟瀏覽器',
'control.status.stopping': '停止中...',
'control.status.stopped': '已停止',
'control.status.error': '錯誤:{reason}',
'control.meta.port': '連接埠',
'control.meta.uptime': '執行時間',
'control.meta.pid': '程序 ID',
'control.meta.version': '版本',
'control.action.openBrowser': '在瀏覽器開啟',
'control.action.start': '啟動',
'control.action.stop': '停止',
'control.action.restart': '重新啟動',
'control.action.manage': '管理',
'control.action.stopServer': '停止伺服器',
'control.action.restartServer': '重新啟動伺服器',
'control.log.followTail': '自動跟隨最新',
'control.log.showTimestamps': '顯示時間戳',
'control.log.filterPlaceholder': '過濾 log...',
'control.log.jumpToLatest': '跳到最新',
'control.log.clear': '清空',
'control.log.clearToast': '已清空 log',
'control.log.copy': '複製',
'control.log.copied': '已複製到剪貼簿',
'control.log.copyPrivacyHint': 'Log 可能包含檔名與裝置資訊,請注意分享對象',
'control.log.export': '匯出 log',
'control.log.exported': '已匯出到 {path}',
'control.log.openFolder': '開啟 log 資料夾',
'control.log.lines': '行數:{current} / {max}',
'control.footer.closeWarning': '⚠ 關閉此視窗會停止伺服器',
'control.error.title': '伺服器無法啟動',
'control.error.description': '{reason}',
'control.error.restartButton': '重新啟動伺服器',
'control.error.viewLogDetails': '檢視 log 詳情',
'control.error.reportButton': '回報問題...',
'control.shutdown.stopping': '正在停止伺服器…',
// startup progress
'startup.panel.title': '正在啟動 visionA-local',
'startup.panel.ariaLabel': '啟動進度:階段 {current} / {max}',
'startup.progressLabel': '進度 {current} / {max}',
'startup.progressWithElapsed': '進度 {current} / {max} · 已等待 {elapsed} 秒',
'startup.stage.1.label': '初始化控制台',
'startup.stage.1.description': '準備 visionA-local 桌面環境',
'startup.stage.2.label': '檢查 Python 執行環境',
'startup.stage.2.description': '首次啟動可能需要較長時間',
'startup.stage.3.label': '啟動本機伺服器',
'startup.stage.3.description': '在 127.0.0.1:3721 啟動服務',
'startup.stage.4.label': '偵測 Kneron 裝置',
'startup.stage.4.description': '掃描已連接的硬體',
'startup.stage.5.label': '開啟瀏覽器',
'startup.stage.5.description': '在預設瀏覽器開啟 Web UI',
'startup.stage.5.skipped.label': '跳過(依偏好設定)',
'startup.stage.6.label': '等待 Web UI 連線',
'startup.stage.6.description': '正在與瀏覽器建立即時連線',
'startup.stage.6.manualHint': '請點擊控制台的「在瀏覽器開啟」按鈕',
'startup.status.pending': '等待中',
'startup.status.running': '進行中',
'startup.status.done': '完成',
'startup.status.failed': '失敗',
'startup.status.skipped': '跳過(依偏好設定)',
'startup.timeout.message': '這個步驟花的時間比預期久,正在重試...',
'startup.error.title': '啟動失敗',
'startup.error.description.timeout': '啟動時間超過 60 秒,可能是系統環境異常或網路中斷。',
'startup.error.description.stageFailed': '階段「{stageLabel}」執行失敗。',
'startup.error.failedStage': '失敗階段:{n} · {label}',
'startup.error.retry': '重試',
'startup.error.viewLog': '檢視 log',
'startup.error.report': '回報問題',
// settings
'settings.title': '設定',
'settings.autoOpenBrowser.label': '啟動時自動開啟瀏覽器',
'settings.autoOpenBrowser.hintLinux': 'Linux 桌面環境差異大,預設關閉',
'settings.language.label': '語言',
'settings.about.title': '關於',
},
'en': {
'control.title': 'visionA-local · Server Control',
'control.status.idle': 'Idle',
'control.status.starting': 'Starting...',
'control.status.running': 'Running',
'control.status.runningBrowserOpened': 'Running · Browser opened',
'control.status.stopping': 'Stopping...',
'control.status.stopped': 'Stopped',
'control.status.error': 'Error: {reason}',
'control.meta.port': 'Port',
'control.meta.uptime': 'Uptime',
'control.meta.pid': 'PID',
'control.meta.version': 'Version',
'control.action.openBrowser': 'Open in Browser',
'control.action.start': 'Start',
'control.action.stop': 'Stop',
'control.action.restart': 'Restart',
'control.action.manage': 'Manage',
'control.action.stopServer': 'Stop server',
'control.action.restartServer': 'Restart server',
'control.log.followTail': 'Follow tail',
'control.log.showTimestamps': 'Show timestamps',
'control.log.filterPlaceholder': 'Filter...',
'control.log.jumpToLatest': 'Jump to latest',
'control.log.clear': 'Clear',
'control.log.clearToast': 'Log cleared',
'control.log.copy': 'Copy',
'control.log.copied': 'Copied to clipboard',
'control.log.copyPrivacyHint': 'Log may contain filenames and device info. Share with care.',
'control.log.export': 'Export log',
'control.log.exported': 'Exported to {path}',
'control.log.openFolder': 'Open log folder',
'control.log.lines': 'Lines: {current} / {max}',
'control.footer.closeWarning': '⚠ Closing this window will stop the server',
'control.error.title': 'Server failed to start',
'control.error.description': '{reason}',
'control.error.restartButton': 'Restart Server',
'control.error.viewLogDetails': 'View log details',
'control.error.reportButton': 'Report...',
'control.shutdown.stopping': 'Stopping server…',
// startup progress
'startup.panel.title': 'Starting visionA-local',
'startup.panel.ariaLabel': 'Startup progress: stage {current} / {max}',
'startup.progressLabel': 'Progress {current} / {max}',
'startup.progressWithElapsed': 'Progress {current} / {max} · {elapsed}s elapsed',
'startup.stage.1.label': 'Initializing control panel',
'startup.stage.1.description': 'Preparing visionA-local desktop',
'startup.stage.2.label': 'Checking Python runtime',
'startup.stage.2.description': 'First launch may take longer',
'startup.stage.3.label': 'Starting local server',
'startup.stage.3.description': 'Starting service on 127.0.0.1:3721',
'startup.stage.4.label': 'Detecting Kneron devices',
'startup.stage.4.description': 'Scanning connected hardware',
'startup.stage.5.label': 'Opening browser',
'startup.stage.5.description': 'Opening the Web UI in your default browser',
'startup.stage.5.skipped.label': 'Skipped (per preference)',
'startup.stage.6.label': 'Waiting for Web UI to connect',
'startup.stage.6.description': 'Establishing realtime connection with the browser',
'startup.stage.6.manualHint': 'Please click "Open in Browser" in the Control Panel',
'startup.status.pending': 'Waiting',
'startup.status.running': 'Running',
'startup.status.done': 'Done',
'startup.status.failed': 'Failed',
'startup.status.skipped': 'Skipped (per preference)',
'startup.timeout.message': 'This step is taking longer than expected, retrying...',
'startup.error.title': 'Startup failed',
'startup.error.description.timeout': 'Startup exceeded 60 seconds. Your environment may have issues or the network is interrupted.',
'startup.error.description.stageFailed': 'Stage "{stageLabel}" failed.',
'startup.error.failedStage': 'Failed stage: {n} · {label}',
'startup.error.retry': 'Retry',
'startup.error.viewLog': 'View Log',
'startup.error.report': 'Report Issue',
// settings
'settings.title': 'Settings',
'settings.autoOpenBrowser.label': 'Auto-open browser on startup',
'settings.autoOpenBrowser.hintLinux': 'Linux desktop environments vary, disabled by default',
'settings.language.label': 'Language',
'settings.about.title': 'About',
},
};
let currentLocale = 'zh-TW';
// 根據 navigator.language 或儲存值決定 locale
export function detectLocale(preferredLocale) {
// 1. 使用者明確指定preferences
if (preferredLocale && dict[preferredLocale]) return preferredLocale;
// 2. localStorage
const stored = localStorage.getItem('vl-locale');
if (stored && dict[stored]) return stored;
// 3. navigator.language
const lang = (navigator.language || navigator.userLanguage || '').toLowerCase();
if (lang.startsWith('zh')) return 'zh-TW';
if (lang.startsWith('en')) return 'en';
// 4. fallback
return 'zh-TW';
}
export function setLocale(locale) {
if (dict[locale]) {
currentLocale = locale;
localStorage.setItem('vl-locale', locale);
}
}
export function getLocale() {
return currentLocale;
}
// t('key', {param: value}) → 翻譯字串並替換 {param}
export function t(key, params) {
let s = (dict[currentLocale] && dict[currentLocale][key]);
if (s === undefined) {
// fallback en
s = (dict.en && dict.en[key]) || key;
}
if (params) {
for (const k in params) {
s = s.replace(new RegExp('\\{' + k + '\\}', 'g'), params[k]);
}
}
return s;
}
// 對 DOM 中所有 [data-i18n] / [data-i18n-placeholder] 執行翻譯
export function applyI18n(root) {
const scope = root || document;
scope.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
scope.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
el.setAttribute('placeholder', t(key));
});
}
export const LOCALES = Object.keys(dict);

View File

@ -1,25 +1,191 @@
<!DOCTYPE html>
<html lang="en">
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>visionA Local</title>
<title>visionA-local · Server Control</title>
<link rel="icon" type="image/png" href="icon.png">
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<div class="splash">
<img class="logo-icon" src="icon.png" alt="visionA Local">
<div class="brand">
<div class="brand-name">visionA <span class="brand-accent">Local</span></div>
<div class="brand-tagline">Edge AI Workspace</div>
<div id="app" class="control-panel" data-state="idle">
<!-- Header -->
<header class="header">
<img class="brand-logo" src="icon.png" alt="visionA-local">
<div class="brand-info">
<h1 class="brand-name">visionA-local</h1>
<div class="status-line">
<span class="status-dot" id="status-dot" aria-hidden="true"></span>
<span class="status-text" id="status-text" role="status" aria-live="polite">Idle</span>
</div>
<div class="spinner"></div>
<div class="status" id="status">正在啟動伺服器...</div>
<div class="error" id="error" hidden></div>
<dl class="server-meta" id="server-meta">
<div class="meta-item"><dt data-i18n="control.meta.port">Port</dt><dd id="meta-port"></dd></div>
<div class="meta-item"><dt data-i18n="control.meta.uptime">Uptime</dt><dd id="meta-uptime"></dd></div>
<div class="meta-item"><dt data-i18n="control.meta.pid">PID</dt><dd id="meta-pid"></dd></div>
</dl>
</div>
<div class="brand-version">
<span id="app-version">v0.1.0</span>
<button class="icon-btn" id="btn-settings" type="button" title="Settings" aria-label="Settings"></button>
</div>
</header>
<!-- Primary Controls -->
<section class="primary-controls" aria-label="Server controls">
<button class="btn btn-primary" id="btn-open-browser" type="button" disabled>
<span aria-hidden="true">🌐</span>
<span data-i18n="control.action.openBrowser">Open in Browser</span>
</button>
<button class="btn btn-outline" id="btn-start" type="button">
<span aria-hidden="true"></span>
<span data-i18n="control.action.start">Start</span>
</button>
<div class="manage-wrapper">
<button class="btn btn-outline" id="btn-manage" type="button" disabled aria-haspopup="menu" aria-expanded="false">
<span data-i18n="control.action.manage">Manage</span> <span aria-hidden="true"></span>
</button>
<div class="manage-menu" id="manage-menu" role="menu" hidden>
<button role="menuitem" id="mi-stop" class="menu-item menu-item-danger">
<span data-i18n="control.action.stopServer">Stop server</span>
</button>
<button role="menuitem" id="mi-restart" class="menu-item">
<span data-i18n="control.action.restartServer">Restart server</span>
</button>
<div class="menu-divider"></div>
<button role="menuitem" id="mi-open-folder" class="menu-item">
<span data-i18n="control.log.openFolder">Open log folder</span>
</button>
</div>
</div>
</section>
<!-- Log controls -->
<section class="log-controls" aria-label="Log controls">
<label class="checkbox"><input type="checkbox" id="cb-follow-tail" checked> <span data-i18n="control.log.followTail">Follow tail</span></label>
<label class="checkbox"><input type="checkbox" id="cb-show-ts" checked> <span data-i18n="control.log.showTimestamps">Show timestamps</span></label>
<label class="filter-wrapper">
<span aria-hidden="true">🔍</span>
<input type="search" id="filter-input" data-i18n-placeholder="control.log.filterPlaceholder" placeholder="Filter..." aria-label="Filter logs">
</label>
<select id="level-filter" aria-label="Level filter">
<option value="">All</option>
<option value="debug">DEBUG</option>
<option value="info">INFO</option>
<option value="warn">WARN</option>
<option value="error">ERROR</option>
</select>
</section>
<!-- Startup progress panel (顯示於 Starting state) -->
<section class="startup-panel" id="startup-panel" role="progressbar" aria-valuemin="0" aria-valuemax="6" aria-valuenow="0" hidden>
<h2 class="startup-title" id="startup-title" data-i18n="startup.panel.title">Starting visionA-local</h2>
<div class="stages" id="stages"></div>
<div class="progress-row">
<div class="progress-bar" id="progress-bar" aria-hidden="true"></div>
<span class="progress-text" id="progress-text"></span>
</div>
<div class="sr-only" id="startup-live" aria-live="polite" aria-atomic="true"></div>
<!-- Error mode -->
<div class="startup-error" id="startup-error" hidden>
<h3 class="error-title" data-i18n="startup.error.title">Startup failed</h3>
<p class="error-desc" id="error-desc"></p>
<p class="error-stage" id="error-stage"></p>
<div class="error-actions">
<button class="btn btn-primary" id="btn-retry" type="button">
<span aria-hidden="true">🔄</span>
<span data-i18n="startup.error.retry">Retry</span>
</button>
<button class="btn btn-ghost" id="btn-view-log" type="button">
<span aria-hidden="true">📋</span>
<span data-i18n="startup.error.viewLog">View Log</span>
</button>
<button class="btn btn-ghost" id="btn-report" type="button" disabled title="Coming soon">
<span aria-hidden="true">🐞</span>
<span data-i18n="startup.error.report">Report Issue</span>
</button>
</div>
</div>
</section>
<!-- Error banner (for runtime server errors) -->
<section class="error-banner" id="error-banner" role="alert" hidden>
<span class="banner-icon" aria-hidden="true"></span>
<div class="banner-content">
<strong class="banner-title" data-i18n="control.error.title">Server failed to start</strong>
<p class="banner-desc" id="banner-desc"></p>
<div class="banner-actions">
<button class="btn btn-primary btn-sm" id="banner-restart" data-i18n="control.error.restartButton">Restart Server</button>
<button class="btn btn-ghost btn-sm" id="banner-view" data-i18n="control.error.viewLogDetails">View log details</button>
</div>
</div>
</section>
<!-- Log panel -->
<section class="log-panel" id="log-panel" aria-label="Log output">
<output id="log-output" aria-live="polite" aria-atomic="false"></output>
<button class="jump-latest" id="btn-jump-latest" type="button" hidden data-i18n="control.log.jumpToLatest">Jump to latest</button>
</section>
<!-- Log actions -->
<section class="log-actions">
<button class="btn btn-ghost btn-sm" id="btn-clear-log" data-i18n="control.log.clear">Clear</button>
<button class="btn btn-ghost btn-sm" id="btn-copy-log" data-i18n="control.log.copy">Copy</button>
<button class="btn btn-ghost btn-sm" id="btn-export-log" data-i18n="control.log.export">Export log</button>
<button class="btn btn-ghost btn-sm" id="btn-open-folder" data-i18n="control.log.openFolder">Open log folder</button>
</section>
<!-- Footer -->
<footer class="footer">
<span class="footer-left" id="footer-lines">Lines: 0 / 2000</span>
<span class="footer-right" id="footer-warning" data-i18n="control.footer.closeWarning">⚠ Closing this window will stop the server</span>
</footer>
<!-- Settings modal -->
<div class="modal-backdrop" id="settings-modal" hidden>
<div class="modal" role="dialog" aria-labelledby="settings-title" aria-modal="true">
<header class="modal-header">
<h2 id="settings-title">Settings</h2>
<button class="icon-btn" id="btn-close-settings" aria-label="Close"></button>
</header>
<div class="modal-body">
<label class="setting-row">
<div class="setting-text">
<div class="setting-label" data-i18n="settings.autoOpenBrowser.label">Auto-open browser on startup</div>
<div class="setting-hint" id="auto-open-hint"></div>
</div>
<input type="checkbox" id="pref-auto-open">
</label>
<label class="setting-row">
<div class="setting-text">
<div class="setting-label" data-i18n="settings.language.label">Language</div>
</div>
<select id="pref-locale">
<option value="">Auto</option>
<option value="zh-TW">繁體中文</option>
<option value="en">English</option>
</select>
</label>
<div class="about-section">
<h3 data-i18n="settings.about.title">About</h3>
<dl class="about-list" id="about-list"></dl>
</div>
</div>
</div>
</div>
<!-- Shutdown modal -->
<div class="modal-backdrop shutdown-modal" id="shutdown-modal" hidden>
<div class="modal shutdown-dialog" role="alert">
<div class="spinner-lg" aria-hidden="true"></div>
<p data-i18n="control.shutdown.stopping">Stopping server…</p>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast" hidden></div>
</div>
<script type="module" src="app.js"></script>
</body>
</html>

View File

@ -0,0 +1,175 @@
// log-panel.js — Log ring buffer DOM rendering, filter, auto-scroll
// 對齊 Design Spec v2.1 control-panel.md §4.4-4.5
const MAX_LINES = 2000;
const buffer = []; // [{ts, level, stream, line}]
let autoScroll = true;
let filterText = '';
let filterLevel = '';
// ---------- init ----------
export function initLogPanel(existing) {
buffer.length = 0;
if (Array.isArray(existing)) {
for (const line of existing) buffer.push(line);
}
renderAll();
updateLineCount();
const output = document.getElementById('log-output');
if (output) {
output.addEventListener('scroll', () => {
const nearBottom = output.scrollTop + output.clientHeight >= output.scrollHeight - 30;
if (!nearBottom) {
autoScroll = false;
const jumpBtn = document.getElementById('btn-jump-latest');
if (jumpBtn) jumpBtn.removeAttribute('hidden');
} else {
autoScroll = true;
const jumpBtn = document.getElementById('btn-jump-latest');
if (jumpBtn) jumpBtn.setAttribute('hidden', '');
}
});
}
const jumpBtn = document.getElementById('btn-jump-latest');
if (jumpBtn) {
jumpBtn.addEventListener('click', () => {
autoScroll = true;
const el = document.getElementById('log-output');
if (el) el.scrollTop = el.scrollHeight;
jumpBtn.setAttribute('hidden', '');
});
}
const followCb = document.getElementById('cb-follow-tail');
if (followCb) {
followCb.addEventListener('change', (e) => {
autoScroll = e.target.checked;
if (autoScroll) scrollToBottom();
});
}
const tsCb = document.getElementById('cb-show-ts');
if (tsCb) {
tsCb.addEventListener('change', () => renderAll());
}
}
// ---------- append from event ----------
export function appendLogs(entries) {
for (const e of entries) {
buffer.push(e);
}
// ring buffer 裁切
if (buffer.length > MAX_LINES) {
buffer.splice(0, buffer.length - MAX_LINES);
}
renderAppend(entries);
updateLineCount();
if (autoScroll) scrollToBottom();
}
export function clearLog() {
buffer.length = 0;
const output = document.getElementById('log-output');
if (output) output.innerHTML = '';
updateLineCount();
}
// ---------- filter ----------
export function applyLogFilter(opts) {
if (opts.text !== undefined) filterText = opts.text.toLowerCase();
if (opts.level !== undefined) filterLevel = opts.level.toLowerCase();
renderAll();
}
function matches(entry) {
if (filterLevel && (entry.level || '').toLowerCase() !== filterLevel) return false;
if (filterText && !((entry.line || '').toLowerCase().includes(filterText))) return false;
return true;
}
// ---------- render ----------
function renderAll() {
const output = document.getElementById('log-output');
if (!output) return;
output.innerHTML = '';
const showTs = document.getElementById('cb-show-ts')?.checked !== false;
const frag = document.createDocumentFragment();
for (const e of buffer) {
if (!matches(e)) continue;
frag.appendChild(buildLine(e, showTs));
}
output.appendChild(frag);
if (autoScroll) scrollToBottom();
}
function renderAppend(entries) {
const output = document.getElementById('log-output');
if (!output) return;
const showTs = document.getElementById('cb-show-ts')?.checked !== false;
const frag = document.createDocumentFragment();
for (const e of entries) {
if (!matches(e)) continue;
frag.appendChild(buildLine(e, showTs));
}
output.appendChild(frag);
// DOM 行數裁切(跟 ring buffer 同步)
while (output.childElementCount > MAX_LINES) {
output.removeChild(output.firstChild);
}
}
function buildLine(entry, showTs) {
const row = document.createElement('div');
const level = (entry.level || '').toLowerCase();
row.className = 'log-line level-' + (level || 'plain');
let html = '';
if (showTs && entry.ts) {
const d = new Date(entry.ts);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
html += `<span class="log-ts">${hh}:${mm}:${ss}</span> `;
}
if (entry.level) {
html += `<span class="log-level">${entry.level.toUpperCase().padEnd(5)}</span> `;
}
html += `<span class="log-msg"></span>`;
row.innerHTML = html;
row.querySelector('.log-msg').textContent = entry.line || '';
return row;
}
function scrollToBottom() {
const el = document.getElementById('log-output');
if (el) el.scrollTop = el.scrollHeight;
}
function updateLineCount() {
const el = document.getElementById('footer-lines');
if (!el) return;
el.textContent = `Lines: ${buffer.length} / ${MAX_LINES}`;
}
// ---------- 閃爍最後一條 ERROR ----------
export function flashLastError() {
const output = document.getElementById('log-output');
if (!output) return;
// 找最後一條 ERROR row
const errors = output.querySelectorAll('.log-line.level-error');
if (errors.length === 0) {
// fallback: 捲到最底
scrollToBottom();
return;
}
const target = errors[errors.length - 1];
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
// flash 2 次
target.classList.remove('flash');
void target.offsetWidth;
target.classList.add('flash');
setTimeout(() => target.classList.remove('flash'), 1500);
}

View File

@ -0,0 +1,111 @@
// settings-panel.js — Settings modal (Auto-open browser / Language / About)
import { t, getLocale } from './i18n.js';
let ctx = null;
export function initSettingsPanel(context) {
ctx = context;
const backdrop = document.getElementById('settings-modal');
const closeBtn = document.getElementById('btn-close-settings');
closeBtn.addEventListener('click', closeSettings);
backdrop.addEventListener('click', (e) => {
if (e.target === backdrop) closeSettings();
});
// Auto-open browser toggle
const autoOpenCb = document.getElementById('pref-auto-open');
autoOpenCb.addEventListener('change', async () => {
try {
const newPrefs = {
...(ctx.prefs || {}),
autoOpenBrowser: autoOpenCb.checked,
};
await ctx.setPreferences(newPrefs);
ctx.prefs = newPrefs;
} catch (e) {
console.error('SetPreferences failed:', e);
// revert
autoOpenCb.checked = !autoOpenCb.checked;
}
});
// Locale
const localeSel = document.getElementById('pref-locale');
localeSel.addEventListener('change', async () => {
const val = localeSel.value;
try {
const newPrefs = {
...(ctx.prefs || {}),
locale: val,
};
await ctx.setPreferences(newPrefs);
ctx.prefs = newPrefs;
if (ctx.onLocaleChange) ctx.onLocaleChange(val);
paintAboutList();
} catch (e) {
console.error('SetPreferences locale failed:', e);
}
});
// ESC 關
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !backdrop.hasAttribute('hidden')) {
closeSettings();
}
});
}
export function openSettings() {
if (!ctx) return;
const backdrop = document.getElementById('settings-modal');
const autoOpenCb = document.getElementById('pref-auto-open');
const localeSel = document.getElementById('pref-locale');
const hintEl = document.getElementById('auto-open-hint');
// 同步 prefs 到 UI
const prefs = ctx.prefs || {};
autoOpenCb.checked = !!prefs.autoOpenBrowser;
localeSel.value = prefs.locale || '';
// Linux 提示
const plat = (ctx.sysInfo && ctx.sysInfo.platform) || '';
if (plat.startsWith('linux')) {
hintEl.textContent = t('settings.autoOpenBrowser.hintLinux');
} else {
hintEl.textContent = '';
}
paintAboutList();
// 標題 i18n
document.getElementById('settings-title').textContent = t('settings.title');
backdrop.removeAttribute('hidden');
}
function closeSettings() {
const backdrop = document.getElementById('settings-modal');
if (backdrop) backdrop.setAttribute('hidden', '');
}
function paintAboutList() {
const about = document.getElementById('about-list');
if (!about || !ctx || !ctx.sysInfo) return;
const s = ctx.sysInfo;
about.innerHTML = '';
const rows = [
[t('control.meta.version'), s.appVersion || '—'],
['Build', s.buildTime || '—'],
['Platform', s.platform || '—'],
['Data dir', s.dataDir || '—'],
['Logs dir', s.logsDir || '—'],
];
for (const [k, v] of rows) {
const dt = document.createElement('dt');
dt.textContent = k;
const dd = document.createElement('dd');
dd.textContent = v;
about.appendChild(dt);
about.appendChild(dd);
}
}

View File

@ -0,0 +1,281 @@
// startup-panel.js — 6 階段啟動進度面板
// 對齊 Design Spec v2.1 startup-progress.md
import { t } from './i18n.js';
const TOTAL_STAGES = 6;
// 本地狀態stages[1..6] = {status, startedAt, slow, manualHint}
const stages = {};
for (let i = 1; i <= TOTAL_STAGES; i++) {
stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false };
}
// 啟動流程旗標stage 5 skipped → stage 6 進入「manual hint」模式
// 對齊 Design Spec §4.1「階段 6 Settings OFF 情境」
let manualMode = false;
// 觀察者:當進入 manual mode 時通知外層app.js加 pulse / enable primary CTA
const manualModeListeners = new Set();
export function onManualModeChange(fn) {
manualModeListeners.add(fn);
return () => manualModeListeners.delete(fn);
}
function emitManualMode(enabled) {
manualModeListeners.forEach((fn) => {
try { fn(enabled); } catch (e) { console.warn('manualMode listener error:', e); }
});
}
// ---------- 渲染 stage 列 skeleton ----------
export function renderStages() {
const container = document.getElementById('stages');
if (!container) return;
container.innerHTML = '';
for (let i = 1; i <= TOTAL_STAGES; i++) {
const row = document.createElement('div');
row.className = 'stage-item';
row.dataset.stage = String(i);
row.dataset.state = stages[i].status;
row.innerHTML = `
<span class="stage-icon" aria-hidden="true"></span>
<span class="stage-number">${i} ·</span>
<div class="stage-label">
<div class="stage-label-primary"></div>
<div class="stage-label-secondary"></div>
</div>
<span class="stage-status"></span>
<div class="stage-hint" hidden></div>
`;
container.appendChild(row);
paintStageRow(i);
}
// 更新標題
const titleEl = document.getElementById('startup-title');
if (titleEl) titleEl.textContent = t('startup.panel.title');
paintProgressBar();
}
function paintStageRow(stage) {
const row = document.querySelector(`.stage-item[data-stage="${stage}"]`);
if (!row) return;
const st = stages[stage];
row.dataset.state = st.slow && st.status === 'running' ? 'running-slow' : st.status;
const iconEl = row.querySelector('.stage-icon');
const labelPrimary = row.querySelector('.stage-label-primary');
const labelSecondary = row.querySelector('.stage-label-secondary');
const statusEl = row.querySelector('.stage-status');
const hintEl = row.querySelector('.stage-hint');
// icon
let icon = '○';
switch (st.status) {
case 'pending': icon = '○'; break;
case 'running': icon = '<span class="spinner-sm"></span>'; break;
case 'completed':
case 'done': icon = '✓'; break;
case 'failed': icon = '✕'; break;
case 'skipped': icon = '⏭'; break;
}
iconEl.innerHTML = icon;
// label
labelPrimary.textContent = t(`startup.stage.${stage}.label`);
// Stage 5 skipped → label secondary 顯示 skipped 原因
// Stage 6 manual mode → description 改顯示 manual hint
if (stage === 5 && st.status === 'skipped') {
labelSecondary.textContent = t('startup.stage.5.skipped.label');
} else if (stage === 6 && st.manualHint) {
labelSecondary.textContent = t('startup.stage.6.manualHint');
} else {
labelSecondary.textContent = t(`startup.stage.${stage}.description`);
}
// status text
const statusMap = {
pending: 'startup.status.pending',
running: 'startup.status.running',
completed: 'startup.status.done',
done: 'startup.status.done',
failed: 'startup.status.failed',
skipped: 'startup.status.skipped',
};
statusEl.textContent = t(statusMap[st.status] || 'startup.status.pending');
// slow hint line
// Design Spec §4.1stage 6 manual mode 不套 20 秒 retry hint等待人為動作
if (st.slow && st.status === 'running' && !st.manualHint) {
hintEl.textContent = t('startup.timeout.message');
hintEl.removeAttribute('hidden');
} else {
hintEl.setAttribute('hidden', '');
}
}
function paintProgressBar() {
const bar = document.getElementById('progress-bar');
const textEl = document.getElementById('progress-text');
if (!bar || !textEl) return;
bar.innerHTML = '';
let completed = 0;
let current = 0;
for (let i = 1; i <= TOTAL_STAGES; i++) {
const cell = document.createElement('span');
cell.className = 'progress-cell state-' + stages[i].status;
bar.appendChild(cell);
if (stages[i].status === 'completed' || stages[i].status === 'done' || stages[i].status === 'skipped') completed++;
if (stages[i].status === 'running') current = i;
}
const progressNum = current || completed;
// 若有 slow 狀態,顯示 elapsed
let elapsedText = '';
for (let i = 1; i <= TOTAL_STAGES; i++) {
if (stages[i].slow && stages[i].status === 'running' && stages[i].startedAt) {
const elapsed = Math.floor((Date.now() - stages[i].startedAt) / 1000);
elapsedText = t('startup.progressWithElapsed', { current: progressNum, max: TOTAL_STAGES, elapsed });
break;
}
}
textEl.textContent = elapsedText || t('startup.progressLabel', { current: progressNum, max: TOTAL_STAGES });
// aria
const panel = document.getElementById('startup-panel');
if (panel) panel.setAttribute('aria-valuenow', String(progressNum));
}
// ---------- Panel 顯示 / 隱藏 ----------
export function showStartupPanel() {
const panel = document.getElementById('startup-panel');
if (!panel) return;
panel.removeAttribute('hidden');
// Error mode 預設隱藏
document.getElementById('startup-error').setAttribute('hidden', '');
}
export function hideStartupPanel() {
const panel = document.getElementById('startup-panel');
if (!panel) return;
panel.setAttribute('hidden', '');
// reset
for (let i = 1; i <= TOTAL_STAGES; i++) {
stages[i] = { status: 'pending', startedAt: 0, slow: false, manualHint: false };
}
if (manualMode) {
manualMode = false;
emitManualMode(false);
}
renderStages();
}
// ---------- 更新階段(收到 startup:progress----------
export function updateStage(ev) {
if (!ev || !ev.stage) return;
const n = ev.stage;
if (n < 1 || n > TOTAL_STAGES) return;
stages[n].status = ev.status || 'pending';
if (ev.startedAt) stages[n].startedAt = ev.startedAt;
if (stages[n].status !== 'running') stages[n].slow = false;
// Design Spec §4.1stage 5 skipped → 立即推 stage 6 進入 manual hint mode
// (此時 Go 層也會送 stage 6 running但這裡先行設定 manualHint 旗標,
// 以便收到後續 stage 6 progress 時仍能維持 hint 文案。)
if (n === 5 && stages[5].status === 'skipped') {
enterManualMode();
}
// stage 6 收到 done → 離開 manual mode使用者已成功點擊並建立連線
if (n === 6 && (stages[6].status === 'completed' || stages[6].status === 'done')) {
if (manualMode) {
manualMode = false;
stages[6].manualHint = false;
emitManualMode(false);
}
}
// running 狀態下,把其他仍 pending 的顯示維持
paintStageRow(n);
paintProgressBar();
// 無障礙 live region
const live = document.getElementById('startup-live');
if (live) {
const statusKey = {
pending: 'startup.status.pending',
running: 'startup.status.running',
completed: 'startup.status.done',
failed: 'startup.status.failed',
skipped: 'startup.status.skipped',
}[stages[n].status] || 'startup.status.pending';
live.textContent = `${n} · ${t(`startup.stage.${n}.label`)} · ${t(statusKey)}`;
}
}
// ---------- 進入 Manual Hint 模式stage 5 skipped → stage 6 等待人為動作)----------
// 對齊 Design Spec §4.1「階段 6 Settings OFF 情境」
function enterManualMode() {
if (manualMode) return;
manualMode = true;
// 把 stage 6 標成 running + manualHint若 Go 層還沒送 stage 6 running這裡主動 paint
if (stages[6].status === 'pending') {
stages[6].status = 'running';
stages[6].startedAt = Date.now();
}
stages[6].manualHint = true;
// 不套 soft timeout因為是等人為動作
stages[6].slow = false;
paintStageRow(6);
paintProgressBar();
emitManualMode(true);
}
// 外部呼叫:查詢是否處於 manual mode
export function isManualMode() {
return manualMode;
}
// ---------- 階段 soft timeout ----------
export function markStageTimeout(ev) {
if (!ev || !ev.stage) return;
const n = ev.stage;
if (!stages[n]) return;
// Design Spec §4.1stage 6 manual mode 不套 20 秒 retry hint
if (n === 6 && stages[6].manualHint) return;
stages[n].slow = true;
if (!stages[n].startedAt) stages[n].startedAt = Date.now();
paintStageRow(n);
paintProgressBar();
}
// ---------- Error mode ----------
export function showStartupError(ev) {
const errorBlock = document.getElementById('startup-error');
const descEl = document.getElementById('error-desc');
const stageEl = document.getElementById('error-stage');
if (!errorBlock) return;
const cause = ev && ev.cause || 'stage-failure';
const failedStage = ev && ev.stage || 0;
if (cause === 'total-timeout') {
descEl.textContent = t('startup.error.description.timeout');
} else {
const stageLabel = failedStage ? t(`startup.stage.${failedStage}.label`) : '';
descEl.textContent = t('startup.error.description.stageFailed', { stageLabel });
}
if (failedStage) {
stageEl.textContent = t('startup.error.failedStage', {
n: failedStage,
label: t(`startup.stage.${failedStage}.label`),
});
// 標記該階段為 failed
stages[failedStage].status = 'failed';
paintStageRow(failedStage);
paintProgressBar();
}
errorBlock.removeAttribute('hidden');
// 確保 panel 顯示
const panel = document.getElementById('startup-panel');
if (panel) panel.removeAttribute('hidden');
}

View File

@ -1,112 +1,683 @@
/* visionA Local — splash screen */
* { margin: 0; padding: 0; box-sizing: border-box; }
/* visionA-local 控制台 Design Spec v2.1 對齊
* 設計 tokens 參考 shadcn oklch tokens近似 */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
/* ---------- Design Tokens ---------- */
:root {
--brand-bg-top: #1A1F36;
--brand-bg-bottom: #0E1222;
--brand-primary: #4F7EFF;
--brand-primary-light: #6EA8FF;
--brand-mint: #6EF3C5;
--brand-white: #FFFFFF;
--brand-muted: #8890B0;
--brand-danger: #FF6B6B;
--bg: #ffffff;
--surface-1: #fafafa;
--surface-2: #f4f4f5;
--fg: #111827;
--fg-muted: #6b7280;
--border: #e5e7eb;
--border-strong: #d1d5db;
--primary: #2563eb;
--primary-fg: #ffffff;
--primary-hover: #1d4ed8;
--success: #16a34a;
--warning: #b45309;
--destructive: #b91c1c;
--destructive-soft:#fef2f2;
--focus-ring: rgba(37, 99, 235, 0.35);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft JhengHei',
'PingFang TC', 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'SF Mono', 'Menlo', 'Consolas', 'Roboto Mono', monospace;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f0f10;
--surface-1: #17181a;
--surface-2: #1f2022;
--fg: #e5e7eb;
--fg-muted: #9ca3af;
--border: #2a2b2e;
--border-strong: #3a3b3e;
--primary: #3b82f6;
--primary-fg: #ffffff;
--primary-hover: #2563eb;
--success: #22c55e;
--warning: #fbbf24;
--destructive: #f87171;
--destructive-soft:#2a1414;
--focus-ring: rgba(59, 130, 246, 0.45);
--shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
--shadow-md: 0 4px 16px rgba(0,0,0,0.55);
}
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft JhengHei',
'PingFang TC', 'Helvetica Neue', Arial, sans-serif;
background: linear-gradient(180deg, var(--brand-bg-top) 0%, var(--brand-bg-bottom) 100%);
color: #E6E8F0;
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
font-size: 14px;
-webkit-font-smoothing: antialiased;
height: 100vh;
overflow: hidden;
}
#app {
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
border: 0;
}
/* ---------- Layout ---------- */
.control-panel {
display: flex;
flex-direction: column;
height: 100vh;
min-width: 560px;
}
/* ---------- Header ---------- */
.header {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.brand-logo {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.brand-info { flex: 1; min-width: 0; }
.brand-name {
margin: 0;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.01em;
}
.status-line {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
font-size: 14px;
}
.status-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--fg-muted);
transition: background 300ms ease-out;
}
.status-dot.state-starting { background: var(--warning); animation: pulse 1s ease-in-out infinite; }
.status-dot.state-running { background: var(--success); }
.status-dot.state-stopping { background: var(--warning); animation: pulse 1s ease-in-out infinite; }
.status-dot.state-stopped { background: var(--fg-muted); }
.status-dot.state-idle { background: var(--fg-muted); }
.status-dot.state-error { background: var(--destructive); }
.status-text {
font-weight: 500;
color: var(--fg);
}
.server-meta {
display: flex;
gap: 16px;
margin: 6px 0 0;
padding: 0;
font-size: 12px;
color: var(--fg-muted);
}
.server-meta .meta-item { display: flex; gap: 4px; }
.server-meta dt { display: inline; }
.server-meta dt::after { content: ':'; margin-right: 2px; }
.server-meta dd { display: inline; margin: 0; font-variant-numeric: tabular-nums; }
.brand-version {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
font-size: 12px;
color: var(--fg-muted);
}
.icon-btn {
background: transparent;
border: 1px solid transparent;
padding: 4px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--fg-muted);
font-size: 16px;
}
.icon-btn:hover { background: var(--surface-2); color: var(--fg); }
/* ---------- Primary controls ---------- */
.primary-controls {
display: flex;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
align-items: center;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: var(--radius-md);
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
line-height: 1;
min-height: 36px;
transition: background 120ms, border-color 120ms, color 120ms;
}
.btn:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.btn[disabled] { cursor: not-allowed; opacity: 0.45; }
.btn-sm { padding: 4px 10px; font-size: 12px; min-height: 28px; }
.btn-primary {
background: var(--primary);
color: var(--primary-fg);
border-color: var(--primary);
}
.btn-primary:hover:not([disabled]) { background: var(--primary-hover); border-color: var(--primary-hover); }
/* Stage 6 manual hint 引導使用者點擊 Open in Browser
* 對齊 Design Spec v2.1 startup-progress.md §4.1 */
.btn.pulse-cta:not([disabled]) {
animation: ctaPulse 1.8s ease-in-out infinite;
box-shadow: 0 0 0 0 var(--focus-ring);
}
@keyframes ctaPulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.55);
}
50% {
box-shadow: 0 0 0 8px rgba(37, 99, 235, 0);
}
}
@media (prefers-color-scheme: dark) {
@keyframes ctaPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.65); }
50% { box-shadow: 0 0 0 8px rgba(59, 130, 246, 0); }
}
}
/* Reduced motion改為靜態高亮外框不做動畫 */
@media (prefers-reduced-motion: reduce) {
.btn.pulse-cta:not([disabled]) {
animation: none;
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
}
.btn-outline {
background: transparent;
color: var(--fg);
border-color: var(--border-strong);
}
.btn-outline:hover:not([disabled]) { background: var(--surface-2); }
.btn-ghost {
background: transparent;
color: var(--fg);
border-color: transparent;
}
.btn-ghost:hover:not([disabled]) { background: var(--surface-2); }
/* Manage dropdown */
.manage-wrapper { position: relative; }
.manage-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 200px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 4px;
box-shadow: var(--shadow-md);
z-index: 10;
}
.menu-item {
display: block;
width: 100%;
text-align: left;
padding: 8px 12px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 13px;
color: var(--fg);
cursor: pointer;
}
.menu-item:hover:not([disabled]) { background: var(--surface-2); }
.menu-item-danger { color: var(--destructive); }
.menu-item[disabled] { opacity: 0.4; cursor: not-allowed; }
.menu-divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}
/* ---------- Log controls ---------- */
.log-controls {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 16px;
border-bottom: 1px solid var(--border);
font-size: 12px;
color: var(--fg-muted);
}
.checkbox {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.filter-wrapper {
flex: 1;
display: flex;
align-items: center;
gap: 4px;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 4px 8px;
}
.filter-wrapper input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--fg);
font-family: inherit;
font-size: 12px;
}
#level-filter {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 4px 8px;
color: var(--fg);
font-family: inherit;
font-size: 12px;
}
/* ---------- Startup panel ---------- */
.startup-panel {
margin: 12px 16px 0;
padding: 16px;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
animation: fadeIn 200ms ease-out;
}
.startup-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
}
.stages {
display: flex;
flex-direction: column;
gap: 8px;
}
.stage-item {
display: grid;
grid-template-columns: 24px 24px 1fr auto;
column-gap: 8px;
row-gap: 2px;
align-items: center;
padding: 4px 0;
transition: opacity 200ms;
}
.stage-item[data-state="pending"] { opacity: 0.6; }
.stage-item[data-state="completed"] { opacity: 0.75; }
.stage-item[data-state="failed"] { background: rgba(185, 28, 28, 0.06); border-radius: var(--radius-sm); padding: 6px 8px; }
.stage-icon {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
width: 20px;
height: 20px;
font-size: 14px;
color: var(--fg-muted);
}
.stage-item[data-state="running"] .stage-icon,
.stage-item[data-state="running-slow"] .stage-icon { color: var(--primary); }
.stage-item[data-state="completed"] .stage-icon { color: var(--success); }
.stage-item[data-state="failed"] .stage-icon { color: var(--destructive); }
.splash {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 48px;
animation: fadeIn 0.4s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.logo-icon {
width: 96px;
height: 96px;
border-radius: 22px;
box-shadow: 0 16px 64px rgba(79, 126, 255, 0.25),
0 0 0 1px rgba(110, 168, 255, 0.12);
}
.brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
margin-top: 4px;
}
.brand-name {
font-size: 28px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--brand-white);
}
.brand-accent {
color: var(--brand-primary-light);
font-weight: 400;
}
.brand-tagline {
font-size: 12px;
.stage-number {
font-size: 13px;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--brand-muted);
color: var(--fg-muted);
}
.stage-label-primary {
font-size: 13px;
font-weight: 500;
color: var(--fg);
}
.stage-item[data-state="completed"] .stage-label-primary { color: var(--fg-muted); }
.stage-item[data-state="failed"] .stage-label-primary { color: var(--destructive); font-weight: 600; }
.stage-label-secondary {
font-size: 11px;
color: var(--fg-muted);
}
.stage-status {
font-size: 12px;
color: var(--fg-muted);
justify-self: end;
}
.stage-item[data-state="running"] .stage-status { color: var(--primary); }
.stage-item[data-state="completed"] .stage-status { color: var(--success); }
.stage-item[data-state="failed"] .stage-status { color: var(--destructive); }
.stage-hint {
grid-column: 3 / 5;
font-size: 11px;
color: var(--warning);
margin-top: 2px;
}
.stage-hint::before { content: '⚠ '; }
.spinner {
width: 32px;
height: 32px;
border: 2.5px solid rgba(255, 255, 255, 0.08);
border-top-color: var(--brand-primary-light);
/* Spinner */
.spinner-sm {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(0,0,0,0.1);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
.spinner-lg {
display: inline-block;
width: 32px;
height: 32px;
border: 3px solid rgba(0,0,0,0.1);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@media (prefers-color-scheme: dark) {
.spinner-sm, .spinner-lg { border-color: rgba(255,255,255,0.12); border-top-color: var(--primary); }
}
.progress-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
}
.progress-bar {
flex: 1;
display: flex;
gap: 2px;
height: 6px;
}
.progress-cell {
flex: 1;
height: 6px;
background: var(--border);
border-radius: 2px;
transition: background 250ms;
}
.progress-cell.state-completed, .progress-cell.state-done, .progress-cell.state-skipped {
background: var(--success);
}
.progress-cell.state-running {
background: var(--primary);
animation: pulse 1.5s ease-in-out infinite;
}
.progress-cell.state-failed { background: var(--destructive); }
.progress-text {
font-size: 11px;
color: var(--fg-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Startup error mode */
.startup-error {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.error-title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: var(--destructive);
}
.error-desc, .error-stage {
margin: 6px 0;
font-size: 12px;
color: var(--fg);
}
.error-actions {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
@keyframes spin {
to { transform: rotate(360deg); }
/* ---------- Runtime Error banner ---------- */
.error-banner {
display: flex;
gap: 12px;
margin: 12px 16px 0;
padding: 14px 16px;
background: var(--destructive-soft);
border: 1px solid var(--destructive);
border-radius: var(--radius-md);
animation: fadeIn 200ms ease-out;
}
.banner-icon { font-size: 20px; color: var(--destructive); }
.banner-content { flex: 1; }
.banner-title { display: block; font-size: 14px; color: var(--destructive); }
.banner-desc { margin: 4px 0 8px; font-size: 13px; color: var(--fg); }
.banner-actions { display: flex; gap: 8px; }
/* ---------- Log panel ---------- */
.log-panel {
flex: 1;
min-height: 100px;
margin: 12px 16px 0;
position: relative;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
#log-output {
display: block;
height: 100%;
overflow-y: auto;
padding: 8px 12px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
color: var(--fg);
white-space: pre;
}
.log-line {
display: block;
white-space: pre-wrap;
word-break: break-all;
padding: 1px 0;
border-radius: 2px;
transition: background 200ms;
}
.log-line.flash { background: rgba(185, 28, 28, 0.2); }
.log-ts {
color: var(--fg-muted);
margin-right: 4px;
}
.log-level {
display: inline-block;
font-weight: 600;
width: 48px;
}
.log-line.level-debug .log-level, .log-line.level-debug .log-msg { color: #60a5fa; }
.log-line.level-info .log-level { color: var(--fg-muted); }
.log-line.level-warn .log-level, .log-line.level-warn .log-msg { color: var(--warning); }
.log-line.level-error .log-level, .log-line.level-error .log-msg { color: var(--destructive); }
.log-line.level-plain .log-level { display: none; }
.jump-latest {
position: absolute;
bottom: 12px;
right: 12px;
padding: 6px 12px;
font-size: 11px;
background: var(--primary);
color: var(--primary-fg);
border: none;
border-radius: 16px;
cursor: pointer;
box-shadow: var(--shadow-md);
}
.status {
font-size: 13px;
color: var(--brand-muted);
letter-spacing: 0.02em;
/* ---------- Log actions ---------- */
.log-actions {
display: flex;
gap: 4px;
padding: 8px 16px;
}
.error {
/* ---------- Footer ---------- */
.footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 16px 8px;
font-size: 11px;
color: var(--fg-muted);
border-top: 1px solid var(--border);
}
.footer-right { color: var(--warning); }
/* ---------- Modal ---------- */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fadeIn 150ms ease-out;
}
.modal {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
min-width: 380px;
max-width: 560px;
padding: 14px 18px;
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.35);
border-radius: 10px;
color: #FFB5B5;
font-size: 13px;
line-height: 1.55;
text-align: center;
box-shadow: var(--shadow-md);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.modal-header h2 { margin: 0; font-size: 15px; font-weight: 600; }
.modal-body { padding: 16px; }
.setting-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 10px 0;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.setting-row:last-of-type { border-bottom: none; }
.setting-label { font-size: 13px; font-weight: 500; }
.setting-hint { font-size: 11px; color: var(--fg-muted); margin-top: 2px; }
.setting-row input[type="checkbox"], .setting-row select {
font-family: inherit;
}
.about-section {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.about-section h3 { margin: 0 0 8px; font-size: 12px; font-weight: 600; color: var(--fg-muted); }
.about-list {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
font-size: 11px;
margin: 0;
}
.about-list dt { color: var(--fg-muted); }
.about-list dd { margin: 0; color: var(--fg); word-break: break-all; }
/* Shutdown modal */
.shutdown-modal .modal.shutdown-dialog {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 24px 40px;
min-width: 280px;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
bottom: 56px;
left: 50%;
transform: translateX(-50%);
padding: 10px 16px;
background: rgba(17, 24, 39, 0.92);
color: #fff;
border-radius: var(--radius-md);
font-size: 12px;
box-shadow: var(--shadow-md);
z-index: 200;
max-width: 80%;
word-break: break-all;
animation: fadeIn 150ms ease-out;
}
@media (prefers-color-scheme: dark) {
.toast { background: rgba(240, 240, 240, 0.95); color: #111; }
}
/* ---------- Animations ---------- */
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.spinner-sm, .spinner-lg { border: 2px solid var(--primary); border-radius: 50%; }
}

View File

@ -1,6 +1,9 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// This file is automatically generated by wails build. DO NOT EDIT
//
// M8-5: 本檔案會在下次 `wails build` / `wails dev` 執行時被 Wails 工具鏈自動重新生成。
// 目前手動維護以涵蓋 M8-4 / M8-4b 新增的 bindings讓 frontend 在生成前能正常載入。
export function GetServerStatus() {
return window['go']['main']['App']['GetServerStatus']();
@ -21,3 +24,63 @@ export function InstallKneronDriver() {
export function OpenBrowser(arg1) {
return window['go']['main']['App']['OpenBrowser'](arg1);
}
// --- M8-4 Server control bindings ---
export function StartServer() {
return window['go']['main']['App']['StartServer']();
}
export function StopServer() {
return window['go']['main']['App']['StopServer']();
}
export function RestartServer() {
return window['go']['main']['App']['RestartServer']();
}
export function ForceKillServer() {
return window['go']['main']['App']['ForceKillServer']();
}
export function GetServerStatusV2() {
return window['go']['main']['App']['GetServerStatusV2']();
}
export function GetRecentLogs(arg1) {
return window['go']['main']['App']['GetRecentLogs'](arg1);
}
export function ClearLogs() {
return window['go']['main']['App']['ClearLogs']();
}
export function GetSystemInfo() {
return window['go']['main']['App']['GetSystemInfo']();
}
export function OpenInBrowser(arg1) {
return window['go']['main']['App']['OpenInBrowser'](arg1);
}
export function RevealLogsFolder() {
return window['go']['main']['App']['RevealLogsFolder']();
}
export function ExportLog() {
return window['go']['main']['App']['ExportLog']();
}
export function GetPreferences() {
return window['go']['main']['App']['GetPreferences']();
}
export function SetPreferences(arg1) {
return window['go']['main']['App']['SetPreferences'](arg1);
}
// --- M8-4b Startup pipeline binding ---
export function RestartStartupSequence() {
return window['go']['main']['App']['RestartStartupSequence']();
}

View File

@ -0,0 +1,205 @@
package main
// log_buffer.go — M8-4Wails 控制台 log ring buffer
//
// 負責:
// 1. 2000 行 thread-safe ring buffer
// 2. LogLine 結構(含 level 解析)
// 3. Snapshot取最後 n 行(複製,不回傳 view
// 4. Rate limit1 秒 > 200 條時退化為「只寫檔不 emit」
//
// TDD ground truth
// - .autoflow/04-architecture/v2/control-panel.md §4.3§4.5
// - .autoflow/04-architecture/v2/server-lifecycle.md §4stdout/stderr pipe 捕捉)
//
// 本檔只做「資料結構」+「level 解析」+「rate limit 判定」。
// 實際的 logPump goroutine 與 Wails event emit 放在 server_control.go。
import (
"strings"
"sync"
"sync/atomic"
"time"
)
const (
// logBufferCap 是 ring buffer 行數上限。TDD 定版 2000。
logBufferCap = 2000
// logRateLimitWindow 是 rate limit 的視窗長度。
logRateLimitWindow = 1 * time.Second
// logRateLimitBurst 是視窗內允許 emit 的最大行數。超過就只寫檔不 emit。
logRateLimitBurst = 200
)
// LogLine 是 ring buffer 中的單一條目。
//
// 對應 TDD v2/control-panel.md §4.3 的 LogLine struct。
type LogLine struct {
// Ts 為 Unix 毫秒(方便前端 JS 直接 new Date(ts))。
Ts int64 `json:"ts"`
// Stream 為來源stdout / stderr / wails
Stream string `json:"stream"`
// Line 為原始那一行文字。
Line string `json:"line"`
// Level 為解析後的等級info / warn / error / debug解析不到則為 ""。
Level string `json:"level,omitempty"`
}
// LogBuffer 是 thread-safe 的 ring buffer。
type LogBuffer struct {
mu sync.Mutex
lines [logBufferCap]LogLine
head int // 下一筆寫入位置
size int // 目前已存筆數(最多 logBufferCap
// dropped 紀錄被覆寫的最舊條目總數(給 debug / 未來統計用)。
dropped uint64
// rateLimit 相關:用原子計數維持最小鎖定範圍
rlWindowStart atomic.Int64 // 視窗起點Unix nano
rlCount atomic.Int64 // 視窗內已 emit 的行數
}
// NewLogBuffer 建立一個新的 ring buffer。
func NewLogBuffer() *LogBuffer {
return &LogBuffer{}
}
// Append 在 buffer 最末端追加一行。buffer 滿時會覆寫最舊行。
//
// 這個方法只動 ring bufferrate limit 判定是獨立的 ShouldEmit。
func (b *LogBuffer) Append(l LogLine) {
b.mu.Lock()
defer b.mu.Unlock()
b.lines[b.head] = l
b.head = (b.head + 1) % logBufferCap
if b.size < logBufferCap {
b.size++
} else {
b.dropped++
}
}
// Snapshot 依插入順序回傳最後 n 行(複製,安全外流)。
// n <= 0 或 n > size → 回傳全部。
func (b *LogBuffer) Snapshot(n int) []LogLine {
b.mu.Lock()
defer b.mu.Unlock()
if b.size == 0 {
return []LogLine{}
}
if n <= 0 || n > b.size {
n = b.size
}
out := make([]LogLine, 0, n)
// start = 最舊條目的 index
start := (b.head - b.size + logBufferCap) % logBufferCap
skip := b.size - n
for i := 0; i < b.size; i++ {
if i < skip {
continue
}
idx := (start + i) % logBufferCap
out = append(out, b.lines[idx])
}
return out
}
// Reset 清空 ring bufferClearLogs binding 用)。
// 不動磁碟檔;也不重置 rate limit counter。
func (b *LogBuffer) Reset() {
b.mu.Lock()
defer b.mu.Unlock()
b.head = 0
b.size = 0
}
// Size 回傳目前存了多少行thread-safe
func (b *LogBuffer) Size() int {
b.mu.Lock()
defer b.mu.Unlock()
return b.size
}
// Dropped 回傳被覆寫掉的最舊條目總數。
func (b *LogBuffer) Dropped() uint64 {
b.mu.Lock()
defer b.mu.Unlock()
return b.dropped
}
// ShouldEmit 判定此刻是否可以 emit Wails event。
//
// 規則:每 1 秒視窗內最多 emit logRateLimitBurst (=200) 行;超過 → 回 false
// 呼叫端只寫檔 + append buffer不做 EventsEmit。
//
// 實作採「固定視窗」而非「sliding window」夠簡單且效果可接受。
func (b *LogBuffer) ShouldEmit() bool {
now := time.Now().UnixNano()
windowStart := b.rlWindowStart.Load()
if now-windowStart >= int64(logRateLimitWindow) {
// 新視窗:把 windowStart CAS 為 now成功者負責 reset count
if b.rlWindowStart.CompareAndSwap(windowStart, now) {
b.rlCount.Store(0)
}
}
// 在目前視窗內 +1超過 burst 就退回 false
n := b.rlCount.Add(1)
return n <= logRateLimitBurst
}
// parseLogLevel 從一行 log 抽出 levelbest-effort
//
// 支援格式(大小寫不敏感):
// - `... [INFO] ...` / `... [WARN] ...` / `... [ERROR] ...` / `... [DEBUG] ...`
// - `INFO:` / `WARN:` / `ERROR:` / `DEBUG:`
// - `[GIN] 200 | ...` → info< 400
// - `[GIN] 4xx | ...` → warn
// - `[GIN] 5xx | ...` → error
// - 其他 → 空字串(前端當 info 顯示)
//
// 解析失敗不是錯誤level 為空字串即可。
func parseLogLevel(line string) string {
if line == "" {
return ""
}
upper := strings.ToUpper(line)
// 明示 bracket 標記(優先)
switch {
case strings.Contains(upper, "[ERROR]"), strings.Contains(upper, " ERROR:"), strings.HasPrefix(upper, "ERROR:"):
return "error"
case strings.Contains(upper, "[WARN]"), strings.Contains(upper, "[WARNING]"), strings.Contains(upper, " WARN:"), strings.HasPrefix(upper, "WARN:"):
return "warn"
case strings.Contains(upper, "[INFO]"), strings.Contains(upper, " INFO:"), strings.HasPrefix(upper, "INFO:"):
return "info"
case strings.Contains(upper, "[DEBUG]"), strings.Contains(upper, " DEBUG:"), strings.HasPrefix(upper, "DEBUG:"):
return "debug"
}
// Gin access log`[GIN] 200 | 1.2ms | GET /xxx`
if strings.HasPrefix(line, "[GIN]") {
// 找 3 位數 status
for i := 0; i+3 <= len(line); i++ {
if line[i] >= '0' && line[i] <= '9' &&
i+2 < len(line) &&
line[i+1] >= '0' && line[i+1] <= '9' &&
line[i+2] >= '0' && line[i+2] <= '9' {
code := (int(line[i]-'0'))*100 + int(line[i+1]-'0')*10 + int(line[i+2]-'0')
switch {
case code >= 500:
return "error"
case code >= 400:
return "warn"
default:
return "info"
}
}
}
}
return ""
}

Some files were not shown because too many files have changed in this diff Show More